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