Enable pylint, black and flake8 in tox.ini
[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 (
37 Authconn,
38 AuthException,
39 AuthconnConflictException,
40 ) # , AuthconnOperationException
41 from osm_common.dbbase import DbException
42 from osm_nbi.base_topic import BaseTopic
43 from osm_nbi.validation import is_valid_uuid
44 from time import time, sleep
45 from http import HTTPStatus
46 from uuid import uuid4
47 from hashlib import sha256
48 from copy import deepcopy
49 from random import choice as random_choice
50
51
52 class AuthconnInternal(Authconn):
53 token_time_window = 2 # seconds
54 token_delay = 1 # seconds to wait upon second request within time window
55
56 users_collection = "users"
57 roles_collection = "roles"
58 projects_collection = "projects"
59 tokens_collection = "tokens"
60
61 def __init__(self, config, db, role_permissions):
62 Authconn.__init__(self, config, db, role_permissions)
63 self.logger = logging.getLogger("nbi.authenticator.internal")
64
65 self.db = db
66 # self.msg = msg
67 # self.token_cache = token_cache
68
69 # To be Confirmed
70 self.sess = None
71
72 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 try:
89 if not token:
90 raise AuthException(
91 "Needed a token or Authorization HTTP header",
92 http_code=HTTPStatus.UNAUTHORIZED,
93 )
94
95 now = time()
96
97 # get from database if not in cache
98 # if not token_info:
99 token_info = self.db.get_one(self.tokens_collection, {"_id": token})
100 if token_info["expires"] < now:
101 raise AuthException(
102 "Expired Token or Authorization HTTP header",
103 http_code=HTTPStatus.UNAUTHORIZED,
104 )
105
106 return token_info
107
108 except DbException as e:
109 if e.http_code == HTTPStatus.NOT_FOUND:
110 raise AuthException(
111 "Invalid Token or Authorization HTTP header",
112 http_code=HTTPStatus.UNAUTHORIZED,
113 )
114 else:
115 raise
116 except AuthException:
117 raise
118 except Exception:
119 self.logger.exception(
120 "Error during token validation using internal backend"
121 )
122 raise AuthException(
123 "Error during token validation using internal backend",
124 http_code=HTTPStatus.UNAUTHORIZED,
125 )
126
127 def revoke_token(self, token):
128 """
129 Invalidate a token.
130
131 :param token: token to be revoked
132 """
133 try:
134 # self.token_cache.pop(token, None)
135 self.db.del_one(self.tokens_collection, {"_id": token})
136 return True
137 except DbException as e:
138 if e.http_code == HTTPStatus.NOT_FOUND:
139 raise AuthException(
140 "Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND
141 )
142 else:
143 # raise
144 exmsg = "Error during token revocation using internal backend"
145 self.logger.exception(exmsg)
146 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
147
148 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 user_rows = self.db.get_list(
155 self.users_collection, {BaseTopic.id_field("users", user): user}
156 )
157 user_content = None
158 if user_rows:
159 user_content = user_rows[0]
160 salt = user_content["_admin"]["salt"]
161 shadow_password = sha256(
162 password.encode("utf-8") + salt.encode("utf-8")
163 ).hexdigest()
164 if shadow_password != user_content["password"]:
165 user_content = None
166 return user_content
167
168 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 now = time()
187 user_content = None
188 user = credentials.get("username")
189 password = credentials.get("password")
190 project = credentials.get("project_id")
191
192 # Try using username/password
193 if user:
194 user_content = self.validate_user(user, password)
195 if not user_content:
196 raise AuthException(
197 "Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED
198 )
199 if not user_content.get("_admin", None):
200 raise AuthException(
201 "No default project for this user.",
202 http_code=HTTPStatus.UNAUTHORIZED,
203 )
204 elif token_info:
205 user_rows = self.db.get_list(
206 self.users_collection, {"username": token_info["username"]}
207 )
208 if user_rows:
209 user_content = user_rows[0]
210 else:
211 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
212 else:
213 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 if (
219 now - user_content["_admin"].get("last_token_time", 0)
220 < self.token_time_window
221 ):
222 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 self.db.set_one(
226 self.users_collection,
227 {"_id": user_content["_id"]},
228 {"_admin.last_token_time": now},
229 )
230
231 token_id = "".join(
232 random_choice(
233 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
234 )
235 for _ in range(0, 32)
236 )
237
238 # projects = user_content.get("projects", [])
239 prm_list = user_content.get("project_role_mappings", [])
240
241 if not project:
242 project = prm_list[0]["project"] if prm_list else None
243 if not project:
244 raise AuthException(
245 "can't find a default project for this user",
246 http_code=HTTPStatus.UNAUTHORIZED,
247 )
248
249 projects = [prm["project"] for prm in prm_list]
250
251 proj = self.db.get_one(
252 self.projects_collection, {BaseTopic.id_field("projects", project): project}
253 )
254 project_name = proj["name"]
255 project_id = proj["_id"]
256 if project_name not in projects and project_id not in projects:
257 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 if project_name == "admin":
264 token_admin = True
265 else:
266 token_admin = proj.get("admin", False)
267
268 # add token roles
269 roles = []
270 roles_list = []
271 for prm in prm_list:
272 if prm["project"] in [project_id, project_name]:
273 role = self.db.get_one(
274 self.roles_collection,
275 {BaseTopic.id_field("roles", prm["role"]): prm["role"]},
276 )
277 rid = role["_id"]
278 if rid not in roles:
279 rnm = role["name"]
280 roles.append(rid)
281 roles_list.append({"name": rnm, "id": rid})
282 if not roles_list:
283 rid = self.db.get_one(self.roles_collection, {"name": "project_admin"})[
284 "_id"
285 ]
286 roles_list = [{"name": "project_admin", "id": rid}]
287
288 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 self.db.create(self.tokens_collection, new_token)
302 return deepcopy(new_token)
303
304 def get_role_list(self, filter_q={}):
305 """
306 Get role list.
307
308 :return: returns the list of roles.
309 """
310 return self.db.get_list(self.roles_collection, filter_q)
311
312 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 rid = str(uuid4())
322 role_info["_id"] = rid
323 rid = self.db.create(self.roles_collection, role_info)
324 return rid
325
326 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 rc = self.db.del_one(self.roles_collection, {"_id": role_id})
334 self.db.del_list(self.tokens_collection, {"roles.id": role_id})
335 return rc
336
337 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 rid = role_info["_id"]
346 self.db.set_one(self.roles_collection, {"_id": rid}, role_info)
347 return {"_id": rid, "name": role_info["name"]}
348
349 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 BaseTopic.format_on_new(user_info, make_public=False)
357 salt = uuid4().hex
358 user_info["_admin"]["salt"] = salt
359 present = time()
360 if not user_info["username"] == "admin":
361 if self.config.get("pwd_expiry_check"):
362 user_info["_admin"]["modified_time"] = present
363 user_info["_admin"]["expire_time"] = present
364 if "password" in user_info:
365 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 if "projects" in user_info:
370 del user_info["projects"]
371 self.db.create(self.users_collection, user_info)
372 return {"username": user_info["username"], "_id": user_info["_id"]}
373
374 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 uid = user_info["_id"]
381 old_pwd = user_info.get("old_password")
382 user_data = self.db.get_one(
383 self.users_collection, {BaseTopic.id_field("users", uid): uid}
384 )
385 if old_pwd:
386 salt = user_data["_admin"]["salt"]
387 shadow_password = sha256(
388 old_pwd.encode("utf-8") + salt.encode("utf-8")
389 ).hexdigest()
390 if shadow_password != user_data["password"]:
391 raise AuthconnConflictException(
392 "Incorrect password", http_code=HTTPStatus.CONFLICT
393 )
394 BaseTopic.format_on_edit(user_data, user_info)
395 # User Name
396 usnm = user_info.get("username")
397 if usnm:
398 user_data["username"] = usnm
399 # If password is given and is not already encripted
400 pswd = user_info.get("password")
401 if pswd and (
402 len(pswd) != 64 or not re.match("[a-fA-F0-9]*", pswd)
403 ): # TODO: Improve check?
404 salt = uuid4().hex
405 if "_admin" not in user_data:
406 user_data["_admin"] = {}
407 user_data["_admin"]["salt"] = salt
408 user_data["password"] = sha256(
409 pswd.encode("utf-8") + salt.encode("utf-8")
410 ).hexdigest()
411 if not user_data["username"] == "admin":
412 if self.config.get("pwd_expiry_check"):
413 present = time()
414 if self.config.get("days"):
415 expire = present + 86400 * self.config.get("days")
416 user_data["_admin"]["modified_time"] = present
417 user_data["_admin"]["expire_time"] = expire
418 # Project-Role Mappings
419 # TODO: Check that user_info NEVER includes "project_role_mappings"
420 if "project_role_mappings" not in user_data:
421 user_data["project_role_mappings"] = []
422 for prm in user_info.get("add_project_role_mappings", []):
423 user_data["project_role_mappings"].append(prm)
424 for prm in user_info.get("remove_project_role_mappings", []):
425 for pidf in ["project", "project_name"]:
426 for ridf in ["role", "role_name"]:
427 try:
428 user_data["project_role_mappings"].remove(
429 {"role": prm[ridf], "project": prm[pidf]}
430 )
431 except KeyError:
432 pass
433 except ValueError:
434 pass
435 idf = BaseTopic.id_field("users", uid)
436 self.db.set_one(self.users_collection, {idf: uid}, user_data)
437 if user_info.get("remove_project_role_mappings"):
438 idf = "user_id" if idf == "_id" else idf
439 self.db.del_list(self.tokens_collection, {idf: uid})
440
441 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 self.db.del_one(self.users_collection, {"_id": user_id})
449 self.db.del_list(self.tokens_collection, {"user_id": user_id})
450 return True
451
452 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 filt = filter_q or {}
462 if "name" in filt: # backward compatibility
463 filt["username"] = filt.pop("name")
464 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 filt["_id"] = filt.pop("username")
467 users = self.db.get_list(self.users_collection, filt)
468 project_id_name = {}
469 role_id_name = {}
470 for user in users:
471 prms = user.get("project_role_mappings")
472 projects = user.get("projects")
473 if prms:
474 projects = []
475 # add project_name and role_name. Generate projects for backward compatibility
476 for prm in prms:
477 project_id = prm["project"]
478 if project_id not in project_id_name:
479 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 project_id_name[project_id] = pr["name"] if pr else None
485 prm["project_name"] = project_id_name[project_id]
486 if prm["project_name"] not in projects:
487 projects.append(prm["project_name"])
488
489 role_id = prm["role"]
490 if role_id not in role_id_name:
491 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 role_id_name[role_id] = role["name"] if role else None
497 prm["role_name"] = role_id_name[role_id]
498 user["projects"] = projects # for backward compatibility
499 elif projects:
500 # user created with an old version. Create a project_role mapping with role project_admin
501 user["project_role_mappings"] = []
502 role = self.db.get_one(
503 self.roles_collection,
504 {BaseTopic.id_field("roles", "project_admin"): "project_admin"},
505 )
506 for p_id_name in projects:
507 pr = self.db.get_one(
508 self.projects_collection,
509 {BaseTopic.id_field("projects", p_id_name): p_id_name},
510 )
511 prm = {
512 "project": pr["_id"],
513 "project_name": pr["name"],
514 "role_name": "project_admin",
515 "role": role["_id"],
516 }
517 user["project_role_mappings"].append(prm)
518 else:
519 user["projects"] = []
520 user["project_role_mappings"] = []
521
522 return users
523
524 def get_project_list(self, filter_q={}):
525 """
526 Get role list.
527
528 :return: returns the list of projects.
529 """
530 return self.db.get_list(self.projects_collection, filter_q)
531
532 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 pid = self.db.create(self.projects_collection, project_info)
541 return pid
542
543 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 idf = BaseTopic.id_field("projects", project_id)
551 r = self.db.del_one(self.projects_collection, {idf: project_id})
552 idf = "project_id" if idf == "_id" else "project_name"
553 self.db.del_list(self.tokens_collection, {idf: project_id})
554 return r
555
556 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 self.db.set_one(
566 self.projects_collection,
567 {BaseTopic.id_field("projects", project_id): project_id},
568 project_info,
569 )