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