RBAC with internal authentication backend - Phase 2
[osm/NBI.git] / osm_nbi / authconn_internal.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Telefonica S.A.
4 # Copyright 2018 ALTRAN InnovaciĆ³n S.L.
5 #
6 # Licensed under the Apache License, Version 2.0 (the "License"); you may
7 # not use this file except in compliance with the License. You may obtain
8 # a copy of the License at
9 #
10 # http://www.apache.org/licenses/LICENSE-2.0
11 #
12 # Unless required by applicable law or agreed to in writing, software
13 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15 # License for the specific language governing permissions and limitations
16 # under the License.
17 #
18 # For those usages not covered by the Apache License, Version 2.0 please
19 # contact: esousa@whitestack.com or glavado@whitestack.com
20 ##
21
22 """
23 AuthconnInternal implements implements the connector for
24 OSM Internal Authentication Backend and leverages the RBAC model
25 """
26
27 __author__ = "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>"
28 __date__ = "$06-jun-2019 11:16:08$"
29
30 from authconn import Authconn, AuthException # , AuthconnOperationException
31 from osm_common.dbbase import DbException
32 from base_topic import BaseTopic
33
34 import logging
35 import re
36 from time import time
37 from http import HTTPStatus
38 from uuid import uuid4
39 from hashlib import sha256
40 from copy import deepcopy
41 from random import choice as random_choice
42
43
44 class AuthconnInternal(Authconn):
45 def __init__(self, config, db, token_cache):
46 Authconn.__init__(self, config, db, token_cache)
47
48 self.logger = logging.getLogger("nbi.authenticator.internal")
49
50 # Get Configuration
51 # self.xxx = config.get("xxx", "default")
52
53 self.db = db
54 self.token_cache = token_cache
55
56 # To be Confirmed
57 self.auth = None
58 self.sess = None
59
60 def validate_token(self, token):
61 """
62 Check if the token is valid.
63
64 :param token: token to validate
65 :return: dictionary with information associated with the token:
66 "_id": token id
67 "project_id": project id
68 "project_name": project name
69 "user_id": user id
70 "username": user name
71 "roles": list with dict containing {name, id}
72 "expires": expiration date
73 If the token is not valid an exception is raised.
74 """
75
76 try:
77 if not token:
78 raise AuthException("Needed a token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
79
80 # try to get from cache first
81 now = time()
82 token_info = self.token_cache.get(token)
83 if token_info and token_info["expires"] < now:
84 # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del
85 self.token_cache.pop(token, None)
86 token_info = None
87
88 # get from database if not in cache
89 if not token_info:
90 token_info = self.db.get_one("tokens", {"_id": token})
91 if token_info["expires"] < now:
92 raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
93
94 return token_info
95
96 except DbException as e:
97 if e.http_code == HTTPStatus.NOT_FOUND:
98 raise AuthException("Invalid Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
99 else:
100 raise
101 except AuthException:
102 if self.config["global"].get("test.user_not_authorized"):
103 return {"id": "fake-token-id-for-test",
104 "project_id": self.config["global"].get("test.project_not_authorized", "admin"),
105 "username": self.config["global"]["test.user_not_authorized"], "admin": True}
106 else:
107 raise
108 except Exception:
109 self.logger.exception("Error during token validation using internal backend")
110 raise AuthException("Error during token validation using internal backend",
111 http_code=HTTPStatus.UNAUTHORIZED)
112
113 def revoke_token(self, token):
114 """
115 Invalidate a token.
116
117 :param token: token to be revoked
118 """
119 try:
120 self.token_cache.pop(token, None)
121 self.db.del_one("tokens", {"_id": token})
122 return True
123 except DbException as e:
124 if e.http_code == HTTPStatus.NOT_FOUND:
125 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
126 else:
127 # raise
128 msg = "Error during token revocation using internal backend"
129 self.logger.exception(msg)
130 raise AuthException(msg, http_code=HTTPStatus.UNAUTHORIZED)
131
132 def authenticate(self, user, password, project=None, token_info=None):
133 """
134 Authenticate a user using username/password or previous token_info plus project; its creates a new token
135
136 :param user: user: name, id or None
137 :param password: password or None
138 :param project: name, id, or None. If None first found project will be used to get an scope token
139 :param token_info: previous token_info to obtain authorization
140 :param remote: remote host information
141 :return: the scoped token info or raises an exception. The token is a dictionary with:
142 _id: token string id,
143 username: username,
144 project_id: scoped_token project_id,
145 project_name: scoped_token project_name,
146 expires: epoch time when it expires,
147 """
148
149 now = time()
150 user_content = None
151
152 # Try using username/password
153 if user:
154 user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user})
155 if user_rows:
156 user_content = user_rows[0]
157 salt = user_content["_admin"]["salt"]
158 shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest()
159 if shadow_password != user_content["password"]:
160 user_content = None
161 if not user_content:
162 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
163 elif token_info:
164 user_rows = self.db.get_list("users", {"username": token_info["username"]})
165 if user_rows:
166 user_content = user_rows[0]
167 else:
168 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
169 else:
170 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
171 http_code=HTTPStatus.UNAUTHORIZED)
172
173 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
174 for _ in range(0, 32))
175
176 # projects = user_content.get("projects", [])
177 prm_list = user_content.get("project_role_mappings", [])
178
179 if not project:
180 project = prm_list[0]["project"] if prm_list else None
181 if not project:
182 raise AuthException("can't find a default project for this user", http_code=HTTPStatus.UNAUTHORIZED)
183
184 projects = [prm["project"] for prm in prm_list]
185
186 proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
187 project_name = proj["name"]
188 project_id = proj["_id"]
189 if project_name not in projects and project_id not in projects:
190 raise AuthException("project {} not allowed for this user".format(project),
191 http_code=HTTPStatus.UNAUTHORIZED)
192
193 # TODO remove admin, this vill be used by roles RBAC
194 if project_name == "admin":
195 token_admin = True
196 else:
197 token_admin = proj.get("admin", False)
198
199 # add token roles
200 roles = []
201 roles_list = []
202 for prm in prm_list:
203 if prm["project"] in [project_id, project_name]:
204 role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]})
205 rid = role["_id"]
206 if rid not in roles:
207 rnm = role["name"]
208 roles.append(rid)
209 roles_list.append({"name": rnm, "id": rid})
210 if not roles_list:
211 rid = self.db.get_one("roles", {"name": "project_admin"})["_id"]
212 roles_list = [{"name": "project_admin", "id": rid}]
213
214 new_token = {"issued_at": now,
215 "expires": now + 3600,
216 "_id": token_id,
217 "id": token_id,
218 "project_id": proj["_id"],
219 "project_name": proj["name"],
220 "username": user_content["username"],
221 "user_id": user_content["_id"],
222 "admin": token_admin,
223 "roles": roles_list,
224 }
225
226 self.token_cache[token_id] = new_token
227 self.db.create("tokens", new_token)
228 return deepcopy(new_token)
229
230 def get_role_list(self, filter_q={}):
231 """
232 Get role list.
233
234 :return: returns the list of roles.
235 """
236 return self.db.get_list("roles", filter_q)
237
238 def create_role(self, role_info):
239 """
240 Create a role.
241
242 :param role_info: full role info.
243 :return: returns the role id.
244 :raises AuthconnOperationException: if role creation failed.
245 """
246 # TODO: Check that role name does not exist ?
247 rid = str(uuid4())
248 role_info["_id"] = rid
249 rid = self.db.create("roles", role_info)
250 return rid
251
252 def delete_role(self, role_id):
253 """
254 Delete a role.
255
256 :param role_id: role identifier.
257 :raises AuthconnOperationException: if role deletion failed.
258 """
259 return self.db.del_one("roles", {"_id": role_id})
260
261 def update_role(self, role_info):
262 """
263 Update a role.
264
265 :param role_info: full role info.
266 :return: returns the role name and id.
267 :raises AuthconnOperationException: if user creation failed.
268 """
269 rid = role_info["_id"]
270 self.db.set_one("roles", {"_id": rid}, role_info) # CONFIRM
271 return {"_id": rid, "name": role_info["name"]}
272
273 def create_user(self, user_info):
274 """
275 Create a user.
276
277 :param user_info: full user info.
278 :return: returns the username and id of the user.
279 """
280 BaseTopic.format_on_new(user_info, make_public=False)
281 salt = uuid4().hex
282 user_info["_admin"]["salt"] = salt
283 if "password" in user_info:
284 user_info["password"] = sha256(user_info["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest()
285 # "projects" are not stored any more
286 if "projects" in user_info:
287 del user_info["projects"]
288 self.db.create("users", user_info)
289 return {"username": user_info["username"], "_id": user_info["_id"]}
290
291 def update_user(self, user_info):
292 """
293 Change the user name and/or password.
294
295 :param user_info: user info modifications
296 """
297 uid = user_info["_id"]
298 user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid})
299 BaseTopic.format_on_edit(user_data, user_info)
300 # User Name
301 usnm = user_info.get("username")
302 if usnm:
303 user_data["username"] = usnm
304 # If password is given and is not already encripted
305 pswd = user_info.get("password")
306 if pswd and (len(pswd) != 64 or not re.match('[a-fA-F0-9]*', pswd)): # TODO: Improve check?
307 salt = uuid4().hex
308 if "_admin" not in user_data:
309 user_data["_admin"] = {}
310 user_data["_admin"]["salt"] = salt
311 user_data["password"] = sha256(pswd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
312 # Project-Role Mappings
313 # TODO: Check that user_info NEVER includes "project_role_mappings"
314 if "project_role_mappings" not in user_data:
315 user_data["project_role_mappings"] = []
316 for prm in user_info.get("add_project_role_mappings", []):
317 user_data["project_role_mappings"].append(prm)
318 for prm in user_info.get("remove_project_role_mappings", []):
319 for pidf in ["project", "project_name"]:
320 for ridf in ["role", "role_name"]:
321 try:
322 user_data["project_role_mappings"].remove({"role": prm[ridf], "project": prm[pidf]})
323 except KeyError:
324 pass
325 except ValueError:
326 pass
327 self.db.set_one("users", {BaseTopic.id_field("users", uid): uid}, user_data) # CONFIRM
328
329 def delete_user(self, user_id):
330 """
331 Delete user.
332
333 :param user_id: user identifier.
334 :raises AuthconnOperationException: if user deletion failed.
335 """
336 self.db.del_one("users", {"_id": user_id})
337 return True
338
339 def get_user_list(self, filter_q=None):
340 """
341 Get user list.
342
343 :param filter_q: dictionary to filter user list by name (username is also admited) and/or _id
344 :return: returns a list of users.
345 """
346 filt = filter_q or {}
347 if "name" in filt:
348 filt["username"] = filt["name"]
349 del filt["name"]
350 users = self.db.get_list("users", filt)
351 for user in users:
352 projects = []
353 projs_with_roles = []
354 prms = user.get("project_role_mappings", [])
355 for prm in prms:
356 if prm["project"] not in projects:
357 projects.append(prm["project"])
358 for project in projects:
359 roles = []
360 roles_for_proj = []
361 for prm in prms:
362 if prm["project"] == project and prm["role"] not in roles:
363 role = prm["role"]
364 roles.append(role)
365 rl = self.db.get_one("roles", {BaseTopic.id_field("roles", role): role})
366 roles_for_proj.append({"name": rl["name"], "_id": rl["_id"], "id": rl["_id"]})
367 try:
368 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
369 projs_with_roles.append({"name": pr["name"], "_id": pr["_id"], "id": pr["_id"],
370 "roles": roles_for_proj})
371 except Exception as e:
372 self.logger.exception("Error during user listing using internal backend: {}".format(e))
373 user["projects"] = projs_with_roles
374 if "project_role_mappings" in user:
375 del user["project_role_mappings"]
376 return users
377
378 def get_project_list(self, filter_q={}):
379 """
380 Get role list.
381
382 :return: returns the list of projects.
383 """
384 return self.db.get_list("projects", filter_q)
385
386 def create_project(self, project_info):
387 """
388 Create a project.
389
390 :param project: full project info.
391 :return: the internal id of the created project
392 :raises AuthconnOperationException: if project creation failed.
393 """
394 pid = self.db.create("projects", project_info)
395 return pid
396
397 def delete_project(self, project_id):
398 """
399 Delete a project.
400
401 :param project_id: project identifier.
402 :raises AuthconnOperationException: if project deletion failed.
403 """
404 filter_q = {BaseTopic.id_field("projects", project_id): project_id}
405 r = self.db.del_one("projects", filter_q)
406 return r
407
408 def update_project(self, project_id, project_info):
409 """
410 Change the name of a project
411
412 :param project_id: project to be changed
413 :param project_info: full project info
414 :return: None
415 :raises AuthconnOperationException: if project update failed.
416 """
417 self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info)