Code Coverage

Cobertura Coverage Report > osm_nbi >

authconn_internal.py

Trend

Classes100%
 
Lines17%
   
Conditionals100%
 

File Coverage summary

NameClassesLinesConditionals
authconn_internal.py
100%
1/1
17%
39/229
100%
0/0

Coverage Breakdown by Class

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