Code Coverage

Cobertura Coverage Report > osm_nbi >

authconn_internal.py

Trend

Classes100%
 
Lines16%
   
Conditionals100%
 

File Coverage summary

NameClassesLinesConditionals
authconn_internal.py
100%
1/1
16%
39/247
100%
0/0

Coverage Breakdown by Class

NameLinesConditionals
authconn_internal.py
16%
39/247
N/A

Source

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 1 """
23 AuthconnInternal implements implements the connector for
24 OSM Internal Authentication Backend and leverages the RBAC model
25 """
26
27 1 __author__ = (
28     "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, "
29     "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
30 )
31 1 __date__ = "$06-jun-2019 11:16:08$"
32
33 1 import logging
34 1 import re
35
36 1 from osm_nbi.authconn import Authconn, AuthException, AuthconnConflictException  # , AuthconnOperationException
37 1 from osm_common.dbbase import DbException
38 1 from osm_nbi.base_topic import BaseTopic
39 1 from osm_nbi.validation import is_valid_uuid
40 1 from time import time, sleep
41 1 from http import HTTPStatus
42 1 from uuid import uuid4
43 1 from hashlib import sha256
44 1 from copy import deepcopy
45 1 from random import choice as random_choice
46
47
48 1 class AuthconnInternal(Authconn):
49 1     token_time_window = 2  # seconds
50 1     token_delay = 1  # seconds to wait upon second request within time window
51
52 1     users_collection = "users"
53 1     roles_collection = "roles"
54 1     projects_collection = "projects"
55 1     tokens_collection = "tokens"
56
57 1     def __init__(self, config, db, role_permissions):
58 0         Authconn.__init__(self, config, db, role_permissions)
59 0         self.logger = logging.getLogger("nbi.authenticator.internal")
60
61 0         self.db = db
62         # self.msg = msg
63         # self.token_cache = token_cache
64
65         # To be Confirmed
66 0         self.sess = None
67
68 1     def validate_token(self, token):
69         """
70         Check if the token is valid.
71
72         :param token: token to validate
73         :return: dictionary with information associated with the token:
74             "_id": token id
75             "project_id": project id
76             "project_name": project name
77             "user_id": user id
78             "username": user name
79             "roles": list with dict containing {name, id}
80             "expires": expiration date
81         If the token is not valid an exception is raised.
82         """
83
84 0         try:
85 0             if not token:
86 0                 raise AuthException(
87                     "Needed a token or Authorization HTTP header",
88                     http_code=HTTPStatus.UNAUTHORIZED,
89                 )
90
91 0             now = time()
92
93             # get from database if not in cache
94             # if not token_info:
95 0             token_info = self.db.get_one(self.tokens_collection, {"_id": token})
96 0             if token_info["expires"] < now:
97 0                 raise AuthException(
98                     "Expired Token or Authorization HTTP header",
99                     http_code=HTTPStatus.UNAUTHORIZED,
100                 )
101
102 0             return token_info
103
104 0         except DbException as e:
105 0             if e.http_code == HTTPStatus.NOT_FOUND:
106 0                 raise AuthException(
107                     "Invalid Token or Authorization HTTP header",
108                     http_code=HTTPStatus.UNAUTHORIZED,
109                 )
110             else:
111 0                 raise
112 0         except AuthException:
113 0             raise
114 0         except Exception:
115 0             self.logger.exception(
116                 "Error during token validation using internal backend"
117             )
118 0             raise AuthException(
119                 "Error during token validation using internal backend",
120                 http_code=HTTPStatus.UNAUTHORIZED,
121             )
122
123 1     def revoke_token(self, token):
124         """
125         Invalidate a token.
126
127         :param token: token to be revoked
128         """
129 0         try:
130             # self.token_cache.pop(token, None)
131 0             self.db.del_one(self.tokens_collection, {"_id": token})
132 0             return True
133 0         except DbException as e:
134 0             if e.http_code == HTTPStatus.NOT_FOUND:
135 0                 raise AuthException(
136                     "Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND
137                 )
138             else:
139                 # raise
140 0                 exmsg = "Error during token revocation using internal backend"
141 0                 self.logger.exception(exmsg)
142 0                 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
143
144 1     def validate_user(self, user, password):
145         """
146         Validate username and password via appropriate backend.
147         :param user: username of the user.
148         :param password: password to be validated.
149         """
150 0         user_rows = self.db.get_list(
151             self.users_collection, {BaseTopic.id_field("users", user): user}
152         )
153 0         user_content = None
154 0         if user_rows:
155 0             user_content = user_rows[0]
156 0             salt = user_content["_admin"]["salt"]
157 0             shadow_password = sha256(
158                 password.encode("utf-8") + salt.encode("utf-8")
159             ).hexdigest()
160 0             if shadow_password != user_content["password"]:
161 0                 user_content = None
162 0         return user_content
163
164 1     def authenticate(self, credentials, token_info=None):
165         """
166         Authenticate a user using username/password or previous token_info plus project; its creates a new token
167
168         :param credentials: dictionary that contains:
169             username: name, id or None
170             password: password or None
171             project_id: name, id, or None. If None first found project will be used to get an scope token
172             other items are allowed and ignored
173         :param token_info: previous token_info to obtain authorization
174         :return: the scoped token info or raises an exception. The token is a dictionary with:
175             _id:  token string id,
176             username: username,
177             project_id: scoped_token project_id,
178             project_name: scoped_token project_name,
179             expires: epoch time when it expires,
180         """
181
182 0         now = time()
183 0         user_content = None
184 0         user = credentials.get("username")
185 0         password = credentials.get("password")
186 0         project = credentials.get("project_id")
187
188         # Try using username/password
189 0         if user:
190 0             user_content = self.validate_user(user, password)
191 0             if not user_content:
192 0                 raise AuthException(
193                     "Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED
194                 )
195 0             if not user_content.get("_admin", None):
196 0                 raise AuthException(
197                     "No default project for this user.",
198                     http_code=HTTPStatus.UNAUTHORIZED,
199                 )
200 0         elif token_info:
201 0             user_rows = self.db.get_list(
202                 self.users_collection, {"username": token_info["username"]}
203             )
204 0             if user_rows:
205 0                 user_content = user_rows[0]
206             else:
207 0                 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
208         else:
209 0             raise AuthException(
210                 "Provide credentials: username/password or Authorization Bearer token",
211                 http_code=HTTPStatus.UNAUTHORIZED,
212             )
213         # Delay upon second request within time window
214 0         if (
215             now - user_content["_admin"].get("last_token_time", 0)
216             < self.token_time_window
217         ):
218 0             sleep(self.token_delay)
219         # user_content["_admin"]["last_token_time"] = now
220         # self.db.replace("users", user_content["_id"], user_content)   # might cause race conditions
221 0         self.db.set_one(
222             self.users_collection,
223             {"_id": user_content["_id"]},
224             {"_admin.last_token_time": now},
225         )
226
227 0         token_id = "".join(
228             random_choice(
229                 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
230             )
231             for _ in range(0, 32)
232         )
233
234         # projects = user_content.get("projects", [])
235 0         prm_list = user_content.get("project_role_mappings", [])
236
237 0         if not project:
238 0             project = prm_list[0]["project"] if prm_list else None
239 0         if not project:
240 0             raise AuthException(
241                 "can't find a default project for this user",
242                 http_code=HTTPStatus.UNAUTHORIZED,
243             )
244
245 0         projects = [prm["project"] for prm in prm_list]
246
247 0         proj = self.db.get_one(
248             self.projects_collection, {BaseTopic.id_field("projects", project): project}
249         )
250 0         project_name = proj["name"]
251 0         project_id = proj["_id"]
252 0         if project_name not in projects and project_id not in projects:
253 0             raise AuthException(
254                 "project {} not allowed for this user".format(project),
255                 http_code=HTTPStatus.UNAUTHORIZED,
256             )
257
258         # TODO remove admin, this vill be used by roles RBAC
259 0         if project_name == "admin":
260 0             token_admin = True
261         else:
262 0             token_admin = proj.get("admin", False)
263
264         # add token roles
265 0         roles = []
266 0         roles_list = []
267 0         for prm in prm_list:
268 0             if prm["project"] in [project_id, project_name]:
269 0                 role = self.db.get_one(
270                     self.roles_collection,
271                     {BaseTopic.id_field("roles", prm["role"]): prm["role"]},
272                 )
273 0                 rid = role["_id"]
274 0                 if rid not in roles:
275 0                     rnm = role["name"]
276 0                     roles.append(rid)
277 0                     roles_list.append({"name": rnm, "id": rid})
278 0         if not roles_list:
279 0             rid = self.db.get_one(self.roles_collection, {"name": "project_admin"})[
280                 "_id"
281             ]
282 0             roles_list = [{"name": "project_admin", "id": rid}]
283
284 0         new_token = {
285             "issued_at": now,
286             "expires": now + 3600,
287             "_id": token_id,
288             "id": token_id,
289             "project_id": proj["_id"],
290             "project_name": proj["name"],
291             "username": user_content["username"],
292             "user_id": user_content["_id"],
293             "admin": token_admin,
294             "roles": roles_list,
295         }
296
297 0         self.db.create(self.tokens_collection, new_token)
298 0         return deepcopy(new_token)
299
300 1     def get_role_list(self, filter_q={}):
301         """
302         Get role list.
303
304         :return: returns the list of roles.
305         """
306 0         return self.db.get_list(self.roles_collection, filter_q)
307
308 1     def create_role(self, role_info):
309         """
310         Create a role.
311
312         :param role_info: full role info.
313         :return: returns the role id.
314         :raises AuthconnOperationException: if role creation failed.
315         """
316         # TODO: Check that role name does not exist ?
317 0         rid = str(uuid4())
318 0         role_info["_id"] = rid
319 0         rid = self.db.create(self.roles_collection, role_info)
320 0         return rid
321
322 1     def delete_role(self, role_id):
323         """
324         Delete a role.
325
326         :param role_id: role identifier.
327         :raises AuthconnOperationException: if role deletion failed.
328         """
329 0         rc = self.db.del_one(self.roles_collection, {"_id": role_id})
330 0         self.db.del_list(self.tokens_collection, {"roles.id": role_id})
331 0         return rc
332
333 1     def update_role(self, role_info):
334         """
335         Update a role.
336
337         :param role_info: full role info.
338         :return: returns the role name and id.
339         :raises AuthconnOperationException: if user creation failed.
340         """
341 0         rid = role_info["_id"]
342 0         self.db.set_one(self.roles_collection, {"_id": rid}, role_info)
343 0         return {"_id": rid, "name": role_info["name"]}
344
345 1     def create_user(self, user_info):
346         """
347         Create a user.
348
349         :param user_info: full user info.
350         :return: returns the username and id of the user.
351         """
352 0         BaseTopic.format_on_new(user_info, make_public=False)
353 0         salt = uuid4().hex
354 0         user_info["_admin"]["salt"] = salt
355 0         present = time()
356 0         if not user_info["username"] == "admin":
357 0             if self.config.get("pwd_expiry_check"):
358 0                 user_info["_admin"]["modified_time"] = present
359 0                 user_info["_admin"]["expire_time"] = present
360 0         if "password" in user_info:
361 0             user_info["password"] = sha256(
362                 user_info["password"].encode("utf-8") + salt.encode("utf-8")
363             ).hexdigest()
364         # "projects" are not stored any more
365 0         if "projects" in user_info:
366 0             del user_info["projects"]
367 0         self.db.create(self.users_collection, user_info)
368 0         return {"username": user_info["username"], "_id": user_info["_id"]}
369
370 1     def update_user(self, user_info):
371         """
372         Change the user name and/or password.
373
374         :param user_info: user info modifications
375         """
376 0         uid = user_info["_id"]
377 0         old_pwd = user_info.get("old_password")
378 0         user_data = self.db.get_one(
379             self.users_collection, {BaseTopic.id_field("users", uid): uid}
380         )
381 0         if old_pwd:
382 0             salt = user_data["_admin"]["salt"]
383 0             shadow_password = sha256(old_pwd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
384 0             if shadow_password != user_data["password"]:
385 0                 raise AuthconnConflictException(
386                     "Incorrect password",
387                     http_code=HTTPStatus.CONFLICT
388                 )
389 0         BaseTopic.format_on_edit(user_data, user_info)
390         # User Name
391 0         usnm = user_info.get("username")
392 0         if usnm:
393 0             user_data["username"] = usnm
394         # If password is given and is not already encripted
395 0         pswd = user_info.get("password")
396 0         if pswd and (
397             len(pswd) != 64 or not re.match("[a-fA-F0-9]*", pswd)
398         ):  # TODO: Improve check?
399 0             salt = uuid4().hex
400 0             if "_admin" not in user_data:
401 0                 user_data["_admin"] = {}
402 0             user_data["_admin"]["salt"] = salt
403 0             user_data["password"] = sha256(
404                 pswd.encode("utf-8") + salt.encode("utf-8")
405             ).hexdigest()
406 0             if not user_data["username"] == "admin":
407 0                 if self.config.get("pwd_expiry_check"):
408 0                     present = time()
409 0                     if self.config.get("days"):
410 0                         expire = present + 86400 * self.config.get("days")
411 0                         user_data["_admin"]["modified_time"] = present
412 0                         user_data["_admin"]["expire_time"] = expire
413         # Project-Role Mappings
414         # TODO: Check that user_info NEVER includes "project_role_mappings"
415 0         if "project_role_mappings" not in user_data:
416 0             user_data["project_role_mappings"] = []
417 0         for prm in user_info.get("add_project_role_mappings", []):
418 0             user_data["project_role_mappings"].append(prm)
419 0         for prm in user_info.get("remove_project_role_mappings", []):
420 0             for pidf in ["project", "project_name"]:
421 0                 for ridf in ["role", "role_name"]:
422 0                     try:
423 0                         user_data["project_role_mappings"].remove(
424                             {"role": prm[ridf], "project": prm[pidf]}
425                         )
426 0                     except KeyError:
427 0                         pass
428 0                     except ValueError:
429 0                         pass
430 0         idf = BaseTopic.id_field("users", uid)
431 0         self.db.set_one(self.users_collection, {idf: uid}, user_data)
432 0         if user_info.get("remove_project_role_mappings"):
433 0             idf = "user_id" if idf == "_id" else idf
434 0             self.db.del_list(self.tokens_collection, {idf: uid})
435
436 1     def delete_user(self, user_id):
437         """
438         Delete user.
439
440         :param user_id: user identifier.
441         :raises AuthconnOperationException: if user deletion failed.
442         """
443 0         self.db.del_one(self.users_collection, {"_id": user_id})
444 0         self.db.del_list(self.tokens_collection, {"user_id": user_id})
445 0         return True
446
447 1     def get_user_list(self, filter_q=None):
448         """
449         Get user list.
450
451         :param filter_q: dictionary to filter user list by:
452             name (username is also admitted).  If a user id is equal to the filter name, it is also provided
453             other
454         :return: returns a list of users.
455         """
456 0         filt = filter_q or {}
457 0         if "name" in filt:  # backward compatibility
458 0             filt["username"] = filt.pop("name")
459 0         if filt.get("username") and is_valid_uuid(filt["username"]):
460             # username cannot be a uuid. If this is the case, change from username to _id
461 0             filt["_id"] = filt.pop("username")
462 0         users = self.db.get_list(self.users_collection, filt)
463 0         project_id_name = {}
464 0         role_id_name = {}
465 0         for user in users:
466 0             prms = user.get("project_role_mappings")
467 0             projects = user.get("projects")
468 0             if prms:
469 0                 projects = []
470                 # add project_name and role_name. Generate projects for backward compatibility
471 0                 for prm in prms:
472 0                     project_id = prm["project"]
473 0                     if project_id not in project_id_name:
474 0                         pr = self.db.get_one(
475                             self.projects_collection,
476                             {BaseTopic.id_field("projects", project_id): project_id},
477                             fail_on_empty=False,
478                         )
479 0                         project_id_name[project_id] = pr["name"] if pr else None
480 0                     prm["project_name"] = project_id_name[project_id]
481 0                     if prm["project_name"] not in projects:
482 0                         projects.append(prm["project_name"])
483
484 0                     role_id = prm["role"]
485 0                     if role_id not in role_id_name:
486 0                         role = self.db.get_one(
487                             self.roles_collection,
488                             {BaseTopic.id_field("roles", role_id): role_id},
489                             fail_on_empty=False,
490                         )
491 0                         role_id_name[role_id] = role["name"] if role else None
492 0                     prm["role_name"] = role_id_name[role_id]
493 0                 user["projects"] = projects  # for backward compatibility
494 0             elif projects:
495                 # user created with an old version. Create a project_role mapping with role project_admin
496 0                 user["project_role_mappings"] = []
497 0                 role = self.db.get_one(
498                     self.roles_collection,
499                     {BaseTopic.id_field("roles", "project_admin"): "project_admin"},
500                 )
501 0                 for p_id_name in projects:
502 0                     pr = self.db.get_one(
503                         self.projects_collection,
504                         {BaseTopic.id_field("projects", p_id_name): p_id_name},
505                     )
506 0                     prm = {
507                         "project": pr["_id"],
508                         "project_name": pr["name"],
509                         "role_name": "project_admin",
510                         "role": role["_id"],
511                     }
512 0                     user["project_role_mappings"].append(prm)
513             else:
514 0                 user["projects"] = []
515 0                 user["project_role_mappings"] = []
516
517 0         return users
518
519 1     def get_project_list(self, filter_q={}):
520         """
521         Get role list.
522
523         :return: returns the list of projects.
524         """
525 0         return self.db.get_list(self.projects_collection, filter_q)
526
527 1     def create_project(self, project_info):
528         """
529         Create a project.
530
531         :param project: full project info.
532         :return: the internal id of the created project
533         :raises AuthconnOperationException: if project creation failed.
534         """
535 0         pid = self.db.create(self.projects_collection, project_info)
536 0         return pid
537
538 1     def delete_project(self, project_id):
539         """
540         Delete a project.
541
542         :param project_id: project identifier.
543         :raises AuthconnOperationException: if project deletion failed.
544         """
545 0         idf = BaseTopic.id_field("projects", project_id)
546 0         r = self.db.del_one(self.projects_collection, {idf: project_id})
547 0         idf = "project_id" if idf == "_id" else "project_name"
548 0         self.db.del_list(self.tokens_collection, {idf: project_id})
549 0         return r
550
551 1     def update_project(self, project_id, project_info):
552         """
553         Change the name of a project
554
555         :param project_id: project to be changed
556         :param project_info: full project info
557         :return: None
558         :raises AuthconnOperationException: if project update failed.
559         """
560 0         self.db.set_one(
561             self.projects_collection,
562             {BaseTopic.id_field("projects", project_id): project_id},
563             project_info,
564         )