Updating python dependencies
[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__ = (
28 "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, "
29 "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
30 )
31 __date__ = "$06-jun-2019 11:16:08$"
32
33 import logging
34 import re
35
36 from osm_nbi.authconn import Authconn, AuthException, AuthconnConflictException # , AuthconnOperationException
37 from osm_common.dbbase import DbException
38 from osm_nbi.base_topic import BaseTopic
39 from osm_nbi.validation import is_valid_uuid
40 from time import time, sleep
41 from http import HTTPStatus
42 from uuid import uuid4
43 from hashlib import sha256
44 from copy import deepcopy
45 from random import choice as random_choice
46
47
48 class AuthconnInternal(Authconn):
49 token_time_window = 2 # seconds
50 token_delay = 1 # seconds to wait upon second request within time window
51
52 users_collection = "users"
53 roles_collection = "roles"
54 projects_collection = "projects"
55 tokens_collection = "tokens"
56
57 def __init__(self, config, db, role_permissions):
58 Authconn.__init__(self, config, db, role_permissions)
59 self.logger = logging.getLogger("nbi.authenticator.internal")
60
61 self.db = db
62 # self.msg = msg
63 # self.token_cache = token_cache
64
65 # To be Confirmed
66 self.sess = None
67
68 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 try:
85 if not token:
86 raise AuthException(
87 "Needed a token or Authorization HTTP header",
88 http_code=HTTPStatus.UNAUTHORIZED,
89 )
90
91 now = time()
92
93 # get from database if not in cache
94 # if not token_info:
95 token_info = self.db.get_one(self.tokens_collection, {"_id": token})
96 if token_info["expires"] < now:
97 raise AuthException(
98 "Expired Token or Authorization HTTP header",
99 http_code=HTTPStatus.UNAUTHORIZED,
100 )
101
102 return token_info
103
104 except DbException as e:
105 if e.http_code == HTTPStatus.NOT_FOUND:
106 raise AuthException(
107 "Invalid Token or Authorization HTTP header",
108 http_code=HTTPStatus.UNAUTHORIZED,
109 )
110 else:
111 raise
112 except AuthException:
113 raise
114 except Exception:
115 self.logger.exception(
116 "Error during token validation using internal backend"
117 )
118 raise AuthException(
119 "Error during token validation using internal backend",
120 http_code=HTTPStatus.UNAUTHORIZED,
121 )
122
123 def revoke_token(self, token):
124 """
125 Invalidate a token.
126
127 :param token: token to be revoked
128 """
129 try:
130 # self.token_cache.pop(token, None)
131 self.db.del_one(self.tokens_collection, {"_id": token})
132 return True
133 except DbException as e:
134 if e.http_code == HTTPStatus.NOT_FOUND:
135 raise AuthException(
136 "Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND
137 )
138 else:
139 # raise
140 exmsg = "Error during token revocation using internal backend"
141 self.logger.exception(exmsg)
142 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
143
144 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 user_rows = self.db.get_list(
151 self.users_collection, {BaseTopic.id_field("users", user): user}
152 )
153 user_content = None
154 if user_rows:
155 user_content = user_rows[0]
156 salt = user_content["_admin"]["salt"]
157 shadow_password = sha256(
158 password.encode("utf-8") + salt.encode("utf-8")
159 ).hexdigest()
160 if shadow_password != user_content["password"]:
161 user_content = None
162 return user_content
163
164 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 now = time()
183 user_content = None
184 user = credentials.get("username")
185 password = credentials.get("password")
186 project = credentials.get("project_id")
187
188 # Try using username/password
189 if user:
190 user_content = self.validate_user(user, password)
191 if not user_content:
192 raise AuthException(
193 "Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED
194 )
195 if not user_content.get("_admin", None):
196 raise AuthException(
197 "No default project for this user.",
198 http_code=HTTPStatus.UNAUTHORIZED,
199 )
200 elif token_info:
201 user_rows = self.db.get_list(
202 self.users_collection, {"username": token_info["username"]}
203 )
204 if user_rows:
205 user_content = user_rows[0]
206 else:
207 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
208 else:
209 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 if (
215 now - user_content["_admin"].get("last_token_time", 0)
216 < self.token_time_window
217 ):
218 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 self.db.set_one(
222 self.users_collection,
223 {"_id": user_content["_id"]},
224 {"_admin.last_token_time": now},
225 )
226
227 token_id = "".join(
228 random_choice(
229 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
230 )
231 for _ in range(0, 32)
232 )
233
234 # projects = user_content.get("projects", [])
235 prm_list = user_content.get("project_role_mappings", [])
236
237 if not project:
238 project = prm_list[0]["project"] if prm_list else None
239 if not project:
240 raise AuthException(
241 "can't find a default project for this user",
242 http_code=HTTPStatus.UNAUTHORIZED,
243 )
244
245 projects = [prm["project"] for prm in prm_list]
246
247 proj = self.db.get_one(
248 self.projects_collection, {BaseTopic.id_field("projects", project): project}
249 )
250 project_name = proj["name"]
251 project_id = proj["_id"]
252 if project_name not in projects and project_id not in projects:
253 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 if project_name == "admin":
260 token_admin = True
261 else:
262 token_admin = proj.get("admin", False)
263
264 # add token roles
265 roles = []
266 roles_list = []
267 for prm in prm_list:
268 if prm["project"] in [project_id, project_name]:
269 role = self.db.get_one(
270 self.roles_collection,
271 {BaseTopic.id_field("roles", prm["role"]): prm["role"]},
272 )
273 rid = role["_id"]
274 if rid not in roles:
275 rnm = role["name"]
276 roles.append(rid)
277 roles_list.append({"name": rnm, "id": rid})
278 if not roles_list:
279 rid = self.db.get_one(self.roles_collection, {"name": "project_admin"})[
280 "_id"
281 ]
282 roles_list = [{"name": "project_admin", "id": rid}]
283
284 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 self.db.create(self.tokens_collection, new_token)
298 return deepcopy(new_token)
299
300 def get_role_list(self, filter_q={}):
301 """
302 Get role list.
303
304 :return: returns the list of roles.
305 """
306 return self.db.get_list(self.roles_collection, filter_q)
307
308 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 rid = str(uuid4())
318 role_info["_id"] = rid
319 rid = self.db.create(self.roles_collection, role_info)
320 return rid
321
322 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 rc = self.db.del_one(self.roles_collection, {"_id": role_id})
330 self.db.del_list(self.tokens_collection, {"roles.id": role_id})
331 return rc
332
333 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 rid = role_info["_id"]
342 self.db.set_one(self.roles_collection, {"_id": rid}, role_info)
343 return {"_id": rid, "name": role_info["name"]}
344
345 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 BaseTopic.format_on_new(user_info, make_public=False)
353 salt = uuid4().hex
354 user_info["_admin"]["salt"] = salt
355 present = time()
356 if not user_info["username"] == "admin":
357 if self.config.get("pwd_expiry_check"):
358 user_info["_admin"]["modified_time"] = present
359 user_info["_admin"]["expire_time"] = present
360 if "password" in user_info:
361 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 if "projects" in user_info:
366 del user_info["projects"]
367 self.db.create(self.users_collection, user_info)
368 return {"username": user_info["username"], "_id": user_info["_id"]}
369
370 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 uid = user_info["_id"]
377 old_pwd = user_info.get("old_password")
378 user_data = self.db.get_one(
379 self.users_collection, {BaseTopic.id_field("users", uid): uid}
380 )
381 if old_pwd:
382 salt = user_data["_admin"]["salt"]
383 shadow_password = sha256(old_pwd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
384 if shadow_password != user_data["password"]:
385 raise AuthconnConflictException(
386 "Incorrect password",
387 http_code=HTTPStatus.CONFLICT
388 )
389 BaseTopic.format_on_edit(user_data, user_info)
390 # User Name
391 usnm = user_info.get("username")
392 if usnm:
393 user_data["username"] = usnm
394 # If password is given and is not already encripted
395 pswd = user_info.get("password")
396 if pswd and (
397 len(pswd) != 64 or not re.match("[a-fA-F0-9]*", pswd)
398 ): # TODO: Improve check?
399 salt = uuid4().hex
400 if "_admin" not in user_data:
401 user_data["_admin"] = {}
402 user_data["_admin"]["salt"] = salt
403 user_data["password"] = sha256(
404 pswd.encode("utf-8") + salt.encode("utf-8")
405 ).hexdigest()
406 if not user_data["username"] == "admin":
407 if self.config.get("pwd_expiry_check"):
408 present = time()
409 if self.config.get("days"):
410 expire = present + 86400 * self.config.get("days")
411 user_data["_admin"]["modified_time"] = present
412 user_data["_admin"]["expire_time"] = expire
413 # Project-Role Mappings
414 # TODO: Check that user_info NEVER includes "project_role_mappings"
415 if "project_role_mappings" not in user_data:
416 user_data["project_role_mappings"] = []
417 for prm in user_info.get("add_project_role_mappings", []):
418 user_data["project_role_mappings"].append(prm)
419 for prm in user_info.get("remove_project_role_mappings", []):
420 for pidf in ["project", "project_name"]:
421 for ridf in ["role", "role_name"]:
422 try:
423 user_data["project_role_mappings"].remove(
424 {"role": prm[ridf], "project": prm[pidf]}
425 )
426 except KeyError:
427 pass
428 except ValueError:
429 pass
430 idf = BaseTopic.id_field("users", uid)
431 self.db.set_one(self.users_collection, {idf: uid}, user_data)
432 if user_info.get("remove_project_role_mappings"):
433 idf = "user_id" if idf == "_id" else idf
434 self.db.del_list(self.tokens_collection, {idf: uid})
435
436 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 self.db.del_one(self.users_collection, {"_id": user_id})
444 self.db.del_list(self.tokens_collection, {"user_id": user_id})
445 return True
446
447 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 filt = filter_q or {}
457 if "name" in filt: # backward compatibility
458 filt["username"] = filt.pop("name")
459 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 filt["_id"] = filt.pop("username")
462 users = self.db.get_list(self.users_collection, filt)
463 project_id_name = {}
464 role_id_name = {}
465 for user in users:
466 prms = user.get("project_role_mappings")
467 projects = user.get("projects")
468 if prms:
469 projects = []
470 # add project_name and role_name. Generate projects for backward compatibility
471 for prm in prms:
472 project_id = prm["project"]
473 if project_id not in project_id_name:
474 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 project_id_name[project_id] = pr["name"] if pr else None
480 prm["project_name"] = project_id_name[project_id]
481 if prm["project_name"] not in projects:
482 projects.append(prm["project_name"])
483
484 role_id = prm["role"]
485 if role_id not in role_id_name:
486 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 role_id_name[role_id] = role["name"] if role else None
492 prm["role_name"] = role_id_name[role_id]
493 user["projects"] = projects # for backward compatibility
494 elif projects:
495 # user created with an old version. Create a project_role mapping with role project_admin
496 user["project_role_mappings"] = []
497 role = self.db.get_one(
498 self.roles_collection,
499 {BaseTopic.id_field("roles", "project_admin"): "project_admin"},
500 )
501 for p_id_name in projects:
502 pr = self.db.get_one(
503 self.projects_collection,
504 {BaseTopic.id_field("projects", p_id_name): p_id_name},
505 )
506 prm = {
507 "project": pr["_id"],
508 "project_name": pr["name"],
509 "role_name": "project_admin",
510 "role": role["_id"],
511 }
512 user["project_role_mappings"].append(prm)
513 else:
514 user["projects"] = []
515 user["project_role_mappings"] = []
516
517 return users
518
519 def get_project_list(self, filter_q={}):
520 """
521 Get role list.
522
523 :return: returns the list of projects.
524 """
525 return self.db.get_list(self.projects_collection, filter_q)
526
527 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 pid = self.db.create(self.projects_collection, project_info)
536 return pid
537
538 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 idf = BaseTopic.id_field("projects", project_id)
546 r = self.db.del_one(self.projects_collection, {idf: project_id})
547 idf = "project_id" if idf == "_id" else "project_name"
548 self.db.del_list(self.tokens_collection, {idf: project_id})
549 return r
550
551 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 self.db.set_one(
561 self.projects_collection,
562 {BaseTopic.id_field("projects", project_id): project_id},
563 project_info,
564 )