blob: b8cfe5b8a706843f9b7e5c5052ed88e23d413b9a [file] [log] [blame]
delacruzramoceb8baf2019-06-21 14:25:38 +02001# -*- 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"""
23AuthconnInternal implements implements the connector for
24OSM Internal Authentication Backend and leverages the RBAC model
25"""
26
tierno23acf402019-08-28 13:36:34 +000027__author__ = "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, " \
28 "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
delacruzramoceb8baf2019-06-21 14:25:38 +020029__date__ = "$06-jun-2019 11:16:08$"
30
tierno5ec768a2020-03-31 09:46:44 +000031import logging
32import re
33
tierno23acf402019-08-28 13:36:34 +000034from osm_nbi.authconn import Authconn, AuthException # , AuthconnOperationException
delacruzramoceb8baf2019-06-21 14:25:38 +020035from osm_common.dbbase import DbException
tierno23acf402019-08-28 13:36:34 +000036from osm_nbi.base_topic import BaseTopic
tierno5ec768a2020-03-31 09:46:44 +000037from osm_nbi.validation import is_valid_uuid
delacruzramoad682a52019-12-10 16:26:34 +010038from time import time, sleep
delacruzramoceb8baf2019-06-21 14:25:38 +020039from http import HTTPStatus
40from uuid import uuid4
41from hashlib import sha256
42from copy import deepcopy
43from random import choice as random_choice
44
45
46class AuthconnInternal(Authconn):
delacruzramoad682a52019-12-10 16:26:34 +010047 token_time_window = 2 # seconds
48 token_delay = 1 # seconds to wait upon second request within time window
delacruzramoceb8baf2019-06-21 14:25:38 +020049
tierno9e87a7f2020-03-23 09:24:10 +000050 def __init__(self, config, db, role_permissions):
51 Authconn.__init__(self, config, db, role_permissions)
delacruzramoceb8baf2019-06-21 14:25:38 +020052 self.logger = logging.getLogger("nbi.authenticator.internal")
53
delacruzramoceb8baf2019-06-21 14:25:38 +020054 self.db = db
delacruzramoad682a52019-12-10 16:26:34 +010055 # self.msg = msg
56 # self.token_cache = token_cache
delacruzramoceb8baf2019-06-21 14:25:38 +020057
58 # To be Confirmed
delacruzramoceb8baf2019-06-21 14:25:38 +020059 self.sess = None
60
delacruzramoceb8baf2019-06-21 14:25:38 +020061 def validate_token(self, token):
62 """
63 Check if the token is valid.
64
65 :param token: token to validate
66 :return: dictionary with information associated with the token:
67 "_id": token id
68 "project_id": project id
69 "project_name": project name
70 "user_id": user id
71 "username": user name
72 "roles": list with dict containing {name, id}
73 "expires": expiration date
74 If the token is not valid an exception is raised.
75 """
76
77 try:
78 if not token:
79 raise AuthException("Needed a token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
80
delacruzramoceb8baf2019-06-21 14:25:38 +020081 now = time()
delacruzramoceb8baf2019-06-21 14:25:38 +020082
83 # get from database if not in cache
delacruzramoad682a52019-12-10 16:26:34 +010084 # if not token_info:
85 token_info = self.db.get_one("tokens", {"_id": token})
86 if token_info["expires"] < now:
87 raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
delacruzramoceb8baf2019-06-21 14:25:38 +020088
tierno701018c2019-06-25 11:13:14 +000089 return token_info
delacruzramoceb8baf2019-06-21 14:25:38 +020090
91 except DbException as e:
92 if e.http_code == HTTPStatus.NOT_FOUND:
93 raise AuthException("Invalid Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
94 else:
95 raise
96 except AuthException:
tiernoe1eb3b22019-08-26 15:59:24 +000097 raise
delacruzramoceb8baf2019-06-21 14:25:38 +020098 except Exception:
99 self.logger.exception("Error during token validation using internal backend")
100 raise AuthException("Error during token validation using internal backend",
101 http_code=HTTPStatus.UNAUTHORIZED)
102
103 def revoke_token(self, token):
104 """
105 Invalidate a token.
106
107 :param token: token to be revoked
108 """
109 try:
delacruzramoad682a52019-12-10 16:26:34 +0100110 # self.token_cache.pop(token, None)
delacruzramoceb8baf2019-06-21 14:25:38 +0200111 self.db.del_one("tokens", {"_id": token})
112 return True
113 except DbException as e:
114 if e.http_code == HTTPStatus.NOT_FOUND:
115 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
116 else:
117 # raise
delacruzramoad682a52019-12-10 16:26:34 +0100118 exmsg = "Error during token revocation using internal backend"
119 self.logger.exception(exmsg)
120 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
delacruzramoceb8baf2019-06-21 14:25:38 +0200121
tierno6486f742020-02-13 16:30:14 +0000122 def authenticate(self, credentials, token_info=None):
delacruzramoceb8baf2019-06-21 14:25:38 +0200123 """
tierno701018c2019-06-25 11:13:14 +0000124 Authenticate a user using username/password or previous token_info plus project; its creates a new token
delacruzramoceb8baf2019-06-21 14:25:38 +0200125
tierno6486f742020-02-13 16:30:14 +0000126 :param credentials: dictionary that contains:
127 username: name, id or None
128 password: password or None
129 project_id: name, id, or None. If None first found project will be used to get an scope token
130 other items are allowed and ignored
tierno701018c2019-06-25 11:13:14 +0000131 :param token_info: previous token_info to obtain authorization
delacruzramoceb8baf2019-06-21 14:25:38 +0200132 :return: the scoped token info or raises an exception. The token is a dictionary with:
133 _id: token string id,
134 username: username,
135 project_id: scoped_token project_id,
136 project_name: scoped_token project_name,
137 expires: epoch time when it expires,
138 """
139
140 now = time()
141 user_content = None
tierno6486f742020-02-13 16:30:14 +0000142 user = credentials.get("username")
143 password = credentials.get("password")
144 project = credentials.get("project_id")
delacruzramoceb8baf2019-06-21 14:25:38 +0200145
delacruzramo01b15d32019-07-02 14:37:47 +0200146 # Try using username/password
147 if user:
148 user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user})
149 if user_rows:
150 user_content = user_rows[0]
151 salt = user_content["_admin"]["salt"]
152 shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest()
153 if shadow_password != user_content["password"]:
154 user_content = None
155 if not user_content:
156 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
157 elif token_info:
158 user_rows = self.db.get_list("users", {"username": token_info["username"]})
159 if user_rows:
160 user_content = user_rows[0]
delacruzramoceb8baf2019-06-21 14:25:38 +0200161 else:
delacruzramo01b15d32019-07-02 14:37:47 +0200162 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
163 else:
164 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
165 http_code=HTTPStatus.UNAUTHORIZED)
delacruzramoceb8baf2019-06-21 14:25:38 +0200166
delacruzramoad682a52019-12-10 16:26:34 +0100167 # Delay upon second request within time window
168 if now - user_content["_admin"].get("last_token_time", 0) < self.token_time_window:
169 sleep(self.token_delay)
170 # user_content["_admin"]["last_token_time"] = now
171 # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions
172 self.db.set_one("users", {"_id": user_content["_id"]}, {"_admin.last_token_time": now})
173
delacruzramo01b15d32019-07-02 14:37:47 +0200174 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
175 for _ in range(0, 32))
delacruzramoceb8baf2019-06-21 14:25:38 +0200176
delacruzramo01b15d32019-07-02 14:37:47 +0200177 # projects = user_content.get("projects", [])
178 prm_list = user_content.get("project_role_mappings", [])
delacruzramoceb8baf2019-06-21 14:25:38 +0200179
delacruzramo01b15d32019-07-02 14:37:47 +0200180 if not project:
181 project = prm_list[0]["project"] if prm_list else None
182 if not project:
183 raise AuthException("can't find a default project for this user", http_code=HTTPStatus.UNAUTHORIZED)
tierno701018c2019-06-25 11:13:14 +0000184
delacruzramo01b15d32019-07-02 14:37:47 +0200185 projects = [prm["project"] for prm in prm_list]
delacruzramoceb8baf2019-06-21 14:25:38 +0200186
delacruzramo01b15d32019-07-02 14:37:47 +0200187 proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
188 project_name = proj["name"]
189 project_id = proj["_id"]
190 if project_name not in projects and project_id not in projects:
191 raise AuthException("project {} not allowed for this user".format(project),
192 http_code=HTTPStatus.UNAUTHORIZED)
tierno701018c2019-06-25 11:13:14 +0000193
delacruzramo01b15d32019-07-02 14:37:47 +0200194 # TODO remove admin, this vill be used by roles RBAC
195 if project_name == "admin":
196 token_admin = True
197 else:
198 token_admin = proj.get("admin", False)
delacruzramoceb8baf2019-06-21 14:25:38 +0200199
delacruzramo01b15d32019-07-02 14:37:47 +0200200 # add token roles
201 roles = []
202 roles_list = []
203 for prm in prm_list:
204 if prm["project"] in [project_id, project_name]:
205 role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]})
206 rid = role["_id"]
207 if rid not in roles:
208 rnm = role["name"]
209 roles.append(rid)
210 roles_list.append({"name": rnm, "id": rid})
211 if not roles_list:
212 rid = self.db.get_one("roles", {"name": "project_admin"})["_id"]
213 roles_list = [{"name": "project_admin", "id": rid}]
delacruzramoceb8baf2019-06-21 14:25:38 +0200214
delacruzramo01b15d32019-07-02 14:37:47 +0200215 new_token = {"issued_at": now,
216 "expires": now + 3600,
217 "_id": token_id,
218 "id": token_id,
219 "project_id": proj["_id"],
220 "project_name": proj["name"],
221 "username": user_content["username"],
222 "user_id": user_content["_id"],
223 "admin": token_admin,
224 "roles": roles_list,
225 }
delacruzramoceb8baf2019-06-21 14:25:38 +0200226
delacruzramo01b15d32019-07-02 14:37:47 +0200227 self.db.create("tokens", new_token)
228 return deepcopy(new_token)
229
230 def get_role_list(self, filter_q={}):
delacruzramoceb8baf2019-06-21 14:25:38 +0200231 """
232 Get role list.
233
234 :return: returns the list of roles.
235 """
delacruzramo01b15d32019-07-02 14:37:47 +0200236 return self.db.get_list("roles", filter_q)
delacruzramoceb8baf2019-06-21 14:25:38 +0200237
delacruzramo01b15d32019-07-02 14:37:47 +0200238 def create_role(self, role_info):
delacruzramoceb8baf2019-06-21 14:25:38 +0200239 """
240 Create a role.
241
delacruzramo01b15d32019-07-02 14:37:47 +0200242 :param role_info: full role info.
243 :return: returns the role id.
delacruzramoceb8baf2019-06-21 14:25:38 +0200244 :raises AuthconnOperationException: if role creation failed.
245 """
delacruzramoceb8baf2019-06-21 14:25:38 +0200246 # TODO: Check that role name does not exist ?
delacruzramo01b15d32019-07-02 14:37:47 +0200247 rid = str(uuid4())
248 role_info["_id"] = rid
249 rid = self.db.create("roles", role_info)
250 return rid
delacruzramoceb8baf2019-06-21 14:25:38 +0200251
252 def delete_role(self, role_id):
253 """
254 Delete a role.
255
256 :param role_id: role identifier.
257 :raises AuthconnOperationException: if role deletion failed.
258 """
delacruzramoad682a52019-12-10 16:26:34 +0100259 rc = self.db.del_one("roles", {"_id": role_id})
260 self.db.del_list("tokens", {"roles.id": role_id})
261 return rc
delacruzramo01b15d32019-07-02 14:37:47 +0200262
263 def update_role(self, role_info):
264 """
265 Update a role.
266
267 :param role_info: full role info.
268 :return: returns the role name and id.
269 :raises AuthconnOperationException: if user creation failed.
270 """
271 rid = role_info["_id"]
delacruzramoad682a52019-12-10 16:26:34 +0100272 self.db.set_one("roles", {"_id": rid}, role_info)
delacruzramo01b15d32019-07-02 14:37:47 +0200273 return {"_id": rid, "name": role_info["name"]}
274
275 def create_user(self, user_info):
276 """
277 Create a user.
278
279 :param user_info: full user info.
280 :return: returns the username and id of the user.
281 """
282 BaseTopic.format_on_new(user_info, make_public=False)
283 salt = uuid4().hex
284 user_info["_admin"]["salt"] = salt
285 if "password" in user_info:
286 user_info["password"] = sha256(user_info["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest()
287 # "projects" are not stored any more
288 if "projects" in user_info:
289 del user_info["projects"]
290 self.db.create("users", user_info)
291 return {"username": user_info["username"], "_id": user_info["_id"]}
292
293 def update_user(self, user_info):
294 """
295 Change the user name and/or password.
296
297 :param user_info: user info modifications
298 """
299 uid = user_info["_id"]
300 user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid})
301 BaseTopic.format_on_edit(user_data, user_info)
302 # User Name
303 usnm = user_info.get("username")
304 if usnm:
305 user_data["username"] = usnm
306 # If password is given and is not already encripted
307 pswd = user_info.get("password")
308 if pswd and (len(pswd) != 64 or not re.match('[a-fA-F0-9]*', pswd)): # TODO: Improve check?
309 salt = uuid4().hex
310 if "_admin" not in user_data:
311 user_data["_admin"] = {}
312 user_data["_admin"]["salt"] = salt
313 user_data["password"] = sha256(pswd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
314 # Project-Role Mappings
315 # TODO: Check that user_info NEVER includes "project_role_mappings"
316 if "project_role_mappings" not in user_data:
317 user_data["project_role_mappings"] = []
318 for prm in user_info.get("add_project_role_mappings", []):
319 user_data["project_role_mappings"].append(prm)
320 for prm in user_info.get("remove_project_role_mappings", []):
321 for pidf in ["project", "project_name"]:
322 for ridf in ["role", "role_name"]:
323 try:
324 user_data["project_role_mappings"].remove({"role": prm[ridf], "project": prm[pidf]})
325 except KeyError:
326 pass
327 except ValueError:
328 pass
delacruzramo3d6881c2019-12-04 13:42:26 +0100329 idf = BaseTopic.id_field("users", uid)
330 self.db.set_one("users", {idf: uid}, user_data)
331 if user_info.get("remove_project_role_mappings"):
delacruzramoad682a52019-12-10 16:26:34 +0100332 idf = "user_id" if idf == "_id" else idf
333 self.db.del_list("tokens", {idf: uid})
delacruzramo01b15d32019-07-02 14:37:47 +0200334
335 def delete_user(self, user_id):
336 """
337 Delete user.
338
339 :param user_id: user identifier.
340 :raises AuthconnOperationException: if user deletion failed.
341 """
342 self.db.del_one("users", {"_id": user_id})
delacruzramo3d6881c2019-12-04 13:42:26 +0100343 self.db.del_list("tokens", {"user_id": user_id})
delacruzramoceb8baf2019-06-21 14:25:38 +0200344 return True
delacruzramo01b15d32019-07-02 14:37:47 +0200345
346 def get_user_list(self, filter_q=None):
347 """
348 Get user list.
349
tierno5ec768a2020-03-31 09:46:44 +0000350 :param filter_q: dictionary to filter user list by:
351 name (username is also admitted). If a user id is equal to the filter name, it is also provided
352 other
delacruzramo01b15d32019-07-02 14:37:47 +0200353 :return: returns a list of users.
354 """
355 filt = filter_q or {}
tierno5ec768a2020-03-31 09:46:44 +0000356 if "name" in filt: # backward compatibility
357 filt["username"] = filt.pop("name")
358 if filt.get("username") and is_valid_uuid(filt["username"]):
359 # username cannot be a uuid. If this is the case, change from username to _id
360 filt["_id"] = filt.pop("username")
delacruzramo01b15d32019-07-02 14:37:47 +0200361 users = self.db.get_list("users", filt)
tierno1546f2a2019-08-20 15:38:11 +0000362 project_id_name = {}
363 role_id_name = {}
delacruzramo01b15d32019-07-02 14:37:47 +0200364 for user in users:
tierno1546f2a2019-08-20 15:38:11 +0000365 prms = user.get("project_role_mappings")
366 projects = user.get("projects")
367 if prms:
368 projects = []
369 # add project_name and role_name. Generate projects for backward compatibility
delacruzramo01b15d32019-07-02 14:37:47 +0200370 for prm in prms:
tierno1546f2a2019-08-20 15:38:11 +0000371 project_id = prm["project"]
372 if project_id not in project_id_name:
373 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id},
374 fail_on_empty=False)
375 project_id_name[project_id] = pr["name"] if pr else None
376 prm["project_name"] = project_id_name[project_id]
377 if prm["project_name"] not in projects:
378 projects.append(prm["project_name"])
379
380 role_id = prm["role"]
381 if role_id not in role_id_name:
382 role = self.db.get_one("roles", {BaseTopic.id_field("roles", role_id): role_id},
383 fail_on_empty=False)
384 role_id_name[role_id] = role["name"] if role else None
385 prm["role_name"] = role_id_name[role_id]
386 user["projects"] = projects # for backward compatibility
387 elif projects:
388 # user created with an old version. Create a project_role mapping with role project_admin
389 user["project_role_mappings"] = []
390 role = self.db.get_one("roles", {BaseTopic.id_field("roles", "project_admin"): "project_admin"})
391 for p_id_name in projects:
392 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", p_id_name): p_id_name})
393 prm = {"project": pr["_id"],
394 "project_name": pr["name"],
395 "role_name": "project_admin",
396 "role": role["_id"]
397 }
398 user["project_role_mappings"].append(prm)
399 else:
400 user["projects"] = []
401 user["project_role_mappings"] = []
402
delacruzramo01b15d32019-07-02 14:37:47 +0200403 return users
404
405 def get_project_list(self, filter_q={}):
406 """
407 Get role list.
408
409 :return: returns the list of projects.
410 """
411 return self.db.get_list("projects", filter_q)
412
413 def create_project(self, project_info):
414 """
415 Create a project.
416
417 :param project: full project info.
418 :return: the internal id of the created project
419 :raises AuthconnOperationException: if project creation failed.
420 """
421 pid = self.db.create("projects", project_info)
422 return pid
423
424 def delete_project(self, project_id):
425 """
426 Delete a project.
427
428 :param project_id: project identifier.
429 :raises AuthconnOperationException: if project deletion failed.
430 """
delacruzramoad682a52019-12-10 16:26:34 +0100431 idf = BaseTopic.id_field("projects", project_id)
432 r = self.db.del_one("projects", {idf: project_id})
433 idf = "project_id" if idf == "_id" else "project_name"
434 self.db.del_list("tokens", {idf: project_id})
delacruzramo01b15d32019-07-02 14:37:47 +0200435 return r
436
437 def update_project(self, project_id, project_info):
438 """
439 Change the name of a project
440
441 :param project_id: project to be changed
442 :param project_info: full project info
443 :return: None
444 :raises AuthconnOperationException: if project update failed.
445 """
446 self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info)