blob: da5e543583d417b6f95b334477771c3fe8886c79 [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
tierno23acf402019-08-28 13:36:34 +000031from osm_nbi.authconn import Authconn, AuthException # , AuthconnOperationException
delacruzramoceb8baf2019-06-21 14:25:38 +020032from osm_common.dbbase import DbException
tierno23acf402019-08-28 13:36:34 +000033from osm_nbi.base_topic import BaseTopic
delacruzramoceb8baf2019-06-21 14:25:38 +020034
35import logging
delacruzramo01b15d32019-07-02 14:37:47 +020036import re
delacruzramoceb8baf2019-06-21 14:25:38 +020037from time import time
38from http import HTTPStatus
39from uuid import uuid4
40from hashlib import sha256
41from copy import deepcopy
42from random import choice as random_choice
43
44
45class AuthconnInternal(Authconn):
46 def __init__(self, config, db, token_cache):
delacruzramo01b15d32019-07-02 14:37:47 +020047 Authconn.__init__(self, config, db, token_cache)
delacruzramoceb8baf2019-06-21 14:25:38 +020048
49 self.logger = logging.getLogger("nbi.authenticator.internal")
50
delacruzramoceb8baf2019-06-21 14:25:38 +020051 self.db = db
52 self.token_cache = token_cache
53
54 # To be Confirmed
55 self.auth = None
56 self.sess = None
57
delacruzramoceb8baf2019-06-21 14:25:38 +020058 def validate_token(self, token):
59 """
60 Check if the token is valid.
61
62 :param token: token to validate
63 :return: dictionary with information associated with the token:
64 "_id": token id
65 "project_id": project id
66 "project_name": project name
67 "user_id": user id
68 "username": user name
69 "roles": list with dict containing {name, id}
70 "expires": expiration date
71 If the token is not valid an exception is raised.
72 """
73
74 try:
75 if not token:
76 raise AuthException("Needed a token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
77
78 # try to get from cache first
79 now = time()
tierno701018c2019-06-25 11:13:14 +000080 token_info = self.token_cache.get(token)
81 if token_info and token_info["expires"] < now:
delacruzramoceb8baf2019-06-21 14:25:38 +020082 # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del
83 self.token_cache.pop(token, None)
tierno701018c2019-06-25 11:13:14 +000084 token_info = None
delacruzramoceb8baf2019-06-21 14:25:38 +020085
86 # get from database if not in cache
tierno701018c2019-06-25 11:13:14 +000087 if not token_info:
88 token_info = self.db.get_one("tokens", {"_id": token})
89 if token_info["expires"] < now:
delacruzramoceb8baf2019-06-21 14:25:38 +020090 raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
91
tierno701018c2019-06-25 11:13:14 +000092 return token_info
delacruzramoceb8baf2019-06-21 14:25:38 +020093
94 except DbException as e:
95 if e.http_code == HTTPStatus.NOT_FOUND:
96 raise AuthException("Invalid Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
97 else:
98 raise
99 except AuthException:
tiernoe1eb3b22019-08-26 15:59:24 +0000100 raise
delacruzramoceb8baf2019-06-21 14:25:38 +0200101 except Exception:
102 self.logger.exception("Error during token validation using internal backend")
103 raise AuthException("Error during token validation using internal backend",
104 http_code=HTTPStatus.UNAUTHORIZED)
105
106 def revoke_token(self, token):
107 """
108 Invalidate a token.
109
110 :param token: token to be revoked
111 """
112 try:
113 self.token_cache.pop(token, None)
114 self.db.del_one("tokens", {"_id": token})
115 return True
116 except DbException as e:
117 if e.http_code == HTTPStatus.NOT_FOUND:
118 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
119 else:
120 # raise
121 msg = "Error during token revocation using internal backend"
122 self.logger.exception(msg)
123 raise AuthException(msg, http_code=HTTPStatus.UNAUTHORIZED)
124
tierno701018c2019-06-25 11:13:14 +0000125 def authenticate(self, user, password, project=None, token_info=None):
delacruzramoceb8baf2019-06-21 14:25:38 +0200126 """
tierno701018c2019-06-25 11:13:14 +0000127 Authenticate a user using username/password or previous token_info plus project; its creates a new token
delacruzramoceb8baf2019-06-21 14:25:38 +0200128
129 :param user: user: name, id or None
130 :param password: password or None
131 :param project: name, id, or None. If None first found project will be used to get an scope token
tierno701018c2019-06-25 11:13:14 +0000132 :param token_info: previous token_info to obtain authorization
delacruzramoceb8baf2019-06-21 14:25:38 +0200133 :param remote: remote host information
134 :return: the scoped token info or raises an exception. The token is a dictionary with:
135 _id: token string id,
136 username: username,
137 project_id: scoped_token project_id,
138 project_name: scoped_token project_name,
139 expires: epoch time when it expires,
140 """
141
142 now = time()
143 user_content = None
144
delacruzramo01b15d32019-07-02 14:37:47 +0200145 # Try using username/password
146 if user:
147 user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user})
148 if user_rows:
149 user_content = user_rows[0]
150 salt = user_content["_admin"]["salt"]
151 shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest()
152 if shadow_password != user_content["password"]:
153 user_content = None
154 if not user_content:
155 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
156 elif token_info:
157 user_rows = self.db.get_list("users", {"username": token_info["username"]})
158 if user_rows:
159 user_content = user_rows[0]
delacruzramoceb8baf2019-06-21 14:25:38 +0200160 else:
delacruzramo01b15d32019-07-02 14:37:47 +0200161 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
162 else:
163 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
164 http_code=HTTPStatus.UNAUTHORIZED)
delacruzramoceb8baf2019-06-21 14:25:38 +0200165
delacruzramo01b15d32019-07-02 14:37:47 +0200166 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
167 for _ in range(0, 32))
delacruzramoceb8baf2019-06-21 14:25:38 +0200168
delacruzramo01b15d32019-07-02 14:37:47 +0200169 # projects = user_content.get("projects", [])
170 prm_list = user_content.get("project_role_mappings", [])
delacruzramoceb8baf2019-06-21 14:25:38 +0200171
delacruzramo01b15d32019-07-02 14:37:47 +0200172 if not project:
173 project = prm_list[0]["project"] if prm_list else None
174 if not project:
175 raise AuthException("can't find a default project for this user", http_code=HTTPStatus.UNAUTHORIZED)
tierno701018c2019-06-25 11:13:14 +0000176
delacruzramo01b15d32019-07-02 14:37:47 +0200177 projects = [prm["project"] for prm in prm_list]
delacruzramoceb8baf2019-06-21 14:25:38 +0200178
delacruzramo01b15d32019-07-02 14:37:47 +0200179 proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
180 project_name = proj["name"]
181 project_id = proj["_id"]
182 if project_name not in projects and project_id not in projects:
183 raise AuthException("project {} not allowed for this user".format(project),
184 http_code=HTTPStatus.UNAUTHORIZED)
tierno701018c2019-06-25 11:13:14 +0000185
delacruzramo01b15d32019-07-02 14:37:47 +0200186 # TODO remove admin, this vill be used by roles RBAC
187 if project_name == "admin":
188 token_admin = True
189 else:
190 token_admin = proj.get("admin", False)
delacruzramoceb8baf2019-06-21 14:25:38 +0200191
delacruzramo01b15d32019-07-02 14:37:47 +0200192 # add token roles
193 roles = []
194 roles_list = []
195 for prm in prm_list:
196 if prm["project"] in [project_id, project_name]:
197 role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]})
198 rid = role["_id"]
199 if rid not in roles:
200 rnm = role["name"]
201 roles.append(rid)
202 roles_list.append({"name": rnm, "id": rid})
203 if not roles_list:
204 rid = self.db.get_one("roles", {"name": "project_admin"})["_id"]
205 roles_list = [{"name": "project_admin", "id": rid}]
delacruzramoceb8baf2019-06-21 14:25:38 +0200206
delacruzramo01b15d32019-07-02 14:37:47 +0200207 new_token = {"issued_at": now,
208 "expires": now + 3600,
209 "_id": token_id,
210 "id": token_id,
211 "project_id": proj["_id"],
212 "project_name": proj["name"],
213 "username": user_content["username"],
214 "user_id": user_content["_id"],
215 "admin": token_admin,
216 "roles": roles_list,
217 }
delacruzramoceb8baf2019-06-21 14:25:38 +0200218
delacruzramo01b15d32019-07-02 14:37:47 +0200219 self.token_cache[token_id] = new_token
220 self.db.create("tokens", new_token)
221 return deepcopy(new_token)
222
223 def get_role_list(self, filter_q={}):
delacruzramoceb8baf2019-06-21 14:25:38 +0200224 """
225 Get role list.
226
227 :return: returns the list of roles.
228 """
delacruzramo01b15d32019-07-02 14:37:47 +0200229 return self.db.get_list("roles", filter_q)
delacruzramoceb8baf2019-06-21 14:25:38 +0200230
delacruzramo01b15d32019-07-02 14:37:47 +0200231 def create_role(self, role_info):
delacruzramoceb8baf2019-06-21 14:25:38 +0200232 """
233 Create a role.
234
delacruzramo01b15d32019-07-02 14:37:47 +0200235 :param role_info: full role info.
236 :return: returns the role id.
delacruzramoceb8baf2019-06-21 14:25:38 +0200237 :raises AuthconnOperationException: if role creation failed.
238 """
delacruzramoceb8baf2019-06-21 14:25:38 +0200239 # TODO: Check that role name does not exist ?
delacruzramo01b15d32019-07-02 14:37:47 +0200240 rid = str(uuid4())
241 role_info["_id"] = rid
242 rid = self.db.create("roles", role_info)
243 return rid
delacruzramoceb8baf2019-06-21 14:25:38 +0200244
245 def delete_role(self, role_id):
246 """
247 Delete a role.
248
249 :param role_id: role identifier.
250 :raises AuthconnOperationException: if role deletion failed.
251 """
delacruzramo01b15d32019-07-02 14:37:47 +0200252 return self.db.del_one("roles", {"_id": role_id})
253
254 def update_role(self, role_info):
255 """
256 Update a role.
257
258 :param role_info: full role info.
259 :return: returns the role name and id.
260 :raises AuthconnOperationException: if user creation failed.
261 """
262 rid = role_info["_id"]
263 self.db.set_one("roles", {"_id": rid}, role_info) # CONFIRM
264 return {"_id": rid, "name": role_info["name"]}
265
266 def create_user(self, user_info):
267 """
268 Create a user.
269
270 :param user_info: full user info.
271 :return: returns the username and id of the user.
272 """
273 BaseTopic.format_on_new(user_info, make_public=False)
274 salt = uuid4().hex
275 user_info["_admin"]["salt"] = salt
276 if "password" in user_info:
277 user_info["password"] = sha256(user_info["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest()
278 # "projects" are not stored any more
279 if "projects" in user_info:
280 del user_info["projects"]
281 self.db.create("users", user_info)
282 return {"username": user_info["username"], "_id": user_info["_id"]}
283
284 def update_user(self, user_info):
285 """
286 Change the user name and/or password.
287
288 :param user_info: user info modifications
289 """
290 uid = user_info["_id"]
291 user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid})
292 BaseTopic.format_on_edit(user_data, user_info)
293 # User Name
294 usnm = user_info.get("username")
295 if usnm:
296 user_data["username"] = usnm
297 # If password is given and is not already encripted
298 pswd = user_info.get("password")
299 if pswd and (len(pswd) != 64 or not re.match('[a-fA-F0-9]*', pswd)): # TODO: Improve check?
300 salt = uuid4().hex
301 if "_admin" not in user_data:
302 user_data["_admin"] = {}
303 user_data["_admin"]["salt"] = salt
304 user_data["password"] = sha256(pswd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
305 # Project-Role Mappings
306 # TODO: Check that user_info NEVER includes "project_role_mappings"
307 if "project_role_mappings" not in user_data:
308 user_data["project_role_mappings"] = []
309 for prm in user_info.get("add_project_role_mappings", []):
310 user_data["project_role_mappings"].append(prm)
311 for prm in user_info.get("remove_project_role_mappings", []):
312 for pidf in ["project", "project_name"]:
313 for ridf in ["role", "role_name"]:
314 try:
315 user_data["project_role_mappings"].remove({"role": prm[ridf], "project": prm[pidf]})
316 except KeyError:
317 pass
318 except ValueError:
319 pass
320 self.db.set_one("users", {BaseTopic.id_field("users", uid): uid}, user_data) # CONFIRM
321
322 def delete_user(self, user_id):
323 """
324 Delete user.
325
326 :param user_id: user identifier.
327 :raises AuthconnOperationException: if user deletion failed.
328 """
329 self.db.del_one("users", {"_id": user_id})
delacruzramoceb8baf2019-06-21 14:25:38 +0200330 return True
delacruzramo01b15d32019-07-02 14:37:47 +0200331
332 def get_user_list(self, filter_q=None):
333 """
334 Get user list.
335
336 :param filter_q: dictionary to filter user list by name (username is also admited) and/or _id
337 :return: returns a list of users.
338 """
339 filt = filter_q or {}
340 if "name" in filt:
341 filt["username"] = filt["name"]
342 del filt["name"]
343 users = self.db.get_list("users", filt)
tierno1546f2a2019-08-20 15:38:11 +0000344 project_id_name = {}
345 role_id_name = {}
delacruzramo01b15d32019-07-02 14:37:47 +0200346 for user in users:
tierno1546f2a2019-08-20 15:38:11 +0000347 prms = user.get("project_role_mappings")
348 projects = user.get("projects")
349 if prms:
350 projects = []
351 # add project_name and role_name. Generate projects for backward compatibility
delacruzramo01b15d32019-07-02 14:37:47 +0200352 for prm in prms:
tierno1546f2a2019-08-20 15:38:11 +0000353 project_id = prm["project"]
354 if project_id not in project_id_name:
355 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id},
356 fail_on_empty=False)
357 project_id_name[project_id] = pr["name"] if pr else None
358 prm["project_name"] = project_id_name[project_id]
359 if prm["project_name"] not in projects:
360 projects.append(prm["project_name"])
361
362 role_id = prm["role"]
363 if role_id not in role_id_name:
364 role = self.db.get_one("roles", {BaseTopic.id_field("roles", role_id): role_id},
365 fail_on_empty=False)
366 role_id_name[role_id] = role["name"] if role else None
367 prm["role_name"] = role_id_name[role_id]
368 user["projects"] = projects # for backward compatibility
369 elif projects:
370 # user created with an old version. Create a project_role mapping with role project_admin
371 user["project_role_mappings"] = []
372 role = self.db.get_one("roles", {BaseTopic.id_field("roles", "project_admin"): "project_admin"})
373 for p_id_name in projects:
374 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", p_id_name): p_id_name})
375 prm = {"project": pr["_id"],
376 "project_name": pr["name"],
377 "role_name": "project_admin",
378 "role": role["_id"]
379 }
380 user["project_role_mappings"].append(prm)
381 else:
382 user["projects"] = []
383 user["project_role_mappings"] = []
384
delacruzramo01b15d32019-07-02 14:37:47 +0200385 return users
386
387 def get_project_list(self, filter_q={}):
388 """
389 Get role list.
390
391 :return: returns the list of projects.
392 """
393 return self.db.get_list("projects", filter_q)
394
395 def create_project(self, project_info):
396 """
397 Create a project.
398
399 :param project: full project info.
400 :return: the internal id of the created project
401 :raises AuthconnOperationException: if project creation failed.
402 """
403 pid = self.db.create("projects", project_info)
404 return pid
405
406 def delete_project(self, project_id):
407 """
408 Delete a project.
409
410 :param project_id: project identifier.
411 :raises AuthconnOperationException: if project deletion failed.
412 """
413 filter_q = {BaseTopic.id_field("projects", project_id): project_id}
414 r = self.db.del_one("projects", filter_q)
415 return r
416
417 def update_project(self, project_id, project_info):
418 """
419 Change the name of a project
420
421 :param project_id: project to be changed
422 :param project_info: full project info
423 :return: None
424 :raises AuthconnOperationException: if project update failed.
425 """
426 self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info)