blob: 2d2ddabda1315546d107de0f7d7921a249a225a8 [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
27__author__ = "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>"
28__date__ = "$06-jun-2019 11:16:08$"
29
delacruzramo01b15d32019-07-02 14:37:47 +020030from authconn import Authconn, AuthException # , AuthconnOperationException
delacruzramoceb8baf2019-06-21 14:25:38 +020031from osm_common.dbbase import DbException
32from base_topic import BaseTopic
33
34import logging
delacruzramo01b15d32019-07-02 14:37:47 +020035import re
delacruzramoceb8baf2019-06-21 14:25:38 +020036from time import time
37from http import HTTPStatus
38from uuid import uuid4
39from hashlib import sha256
40from copy import deepcopy
41from random import choice as random_choice
42
43
44class AuthconnInternal(Authconn):
45 def __init__(self, config, db, token_cache):
delacruzramo01b15d32019-07-02 14:37:47 +020046 Authconn.__init__(self, config, db, token_cache)
delacruzramoceb8baf2019-06-21 14:25:38 +020047
48 self.logger = logging.getLogger("nbi.authenticator.internal")
49
delacruzramoceb8baf2019-06-21 14:25:38 +020050 self.db = db
51 self.token_cache = token_cache
52
53 # To be Confirmed
54 self.auth = None
55 self.sess = None
56
delacruzramoceb8baf2019-06-21 14:25:38 +020057 def validate_token(self, token):
58 """
59 Check if the token is valid.
60
61 :param token: token to validate
62 :return: dictionary with information associated with the token:
63 "_id": token id
64 "project_id": project id
65 "project_name": project name
66 "user_id": user id
67 "username": user name
68 "roles": list with dict containing {name, id}
69 "expires": expiration date
70 If the token is not valid an exception is raised.
71 """
72
73 try:
74 if not token:
75 raise AuthException("Needed a token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
76
77 # try to get from cache first
78 now = time()
tierno701018c2019-06-25 11:13:14 +000079 token_info = self.token_cache.get(token)
80 if token_info and token_info["expires"] < now:
delacruzramoceb8baf2019-06-21 14:25:38 +020081 # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del
82 self.token_cache.pop(token, None)
tierno701018c2019-06-25 11:13:14 +000083 token_info = None
delacruzramoceb8baf2019-06-21 14:25:38 +020084
85 # get from database if not in cache
tierno701018c2019-06-25 11:13:14 +000086 if not token_info:
87 token_info = self.db.get_one("tokens", {"_id": token})
88 if token_info["expires"] < now:
delacruzramoceb8baf2019-06-21 14:25:38 +020089 raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
90
tierno701018c2019-06-25 11:13:14 +000091 return token_info
delacruzramoceb8baf2019-06-21 14:25:38 +020092
93 except DbException as e:
94 if e.http_code == HTTPStatus.NOT_FOUND:
95 raise AuthException("Invalid Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
96 else:
97 raise
98 except AuthException:
tiernoe1eb3b22019-08-26 15:59:24 +000099 raise
delacruzramoceb8baf2019-06-21 14:25:38 +0200100 except Exception:
101 self.logger.exception("Error during token validation using internal backend")
102 raise AuthException("Error during token validation using internal backend",
103 http_code=HTTPStatus.UNAUTHORIZED)
104
105 def revoke_token(self, token):
106 """
107 Invalidate a token.
108
109 :param token: token to be revoked
110 """
111 try:
112 self.token_cache.pop(token, None)
113 self.db.del_one("tokens", {"_id": token})
114 return True
115 except DbException as e:
116 if e.http_code == HTTPStatus.NOT_FOUND:
117 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
118 else:
119 # raise
120 msg = "Error during token revocation using internal backend"
121 self.logger.exception(msg)
122 raise AuthException(msg, http_code=HTTPStatus.UNAUTHORIZED)
123
tierno701018c2019-06-25 11:13:14 +0000124 def authenticate(self, user, password, project=None, token_info=None):
delacruzramoceb8baf2019-06-21 14:25:38 +0200125 """
tierno701018c2019-06-25 11:13:14 +0000126 Authenticate a user using username/password or previous token_info plus project; its creates a new token
delacruzramoceb8baf2019-06-21 14:25:38 +0200127
128 :param user: user: name, id or None
129 :param password: password or None
130 :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 +0000131 :param token_info: previous token_info to obtain authorization
delacruzramoceb8baf2019-06-21 14:25:38 +0200132 :param remote: remote host information
133 :return: the scoped token info or raises an exception. The token is a dictionary with:
134 _id: token string id,
135 username: username,
136 project_id: scoped_token project_id,
137 project_name: scoped_token project_name,
138 expires: epoch time when it expires,
139 """
140
141 now = time()
142 user_content = None
143
delacruzramo01b15d32019-07-02 14:37:47 +0200144 # Try using username/password
145 if user:
146 user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user})
147 if user_rows:
148 user_content = user_rows[0]
149 salt = user_content["_admin"]["salt"]
150 shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest()
151 if shadow_password != user_content["password"]:
152 user_content = None
153 if not user_content:
154 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
155 elif token_info:
156 user_rows = self.db.get_list("users", {"username": token_info["username"]})
157 if user_rows:
158 user_content = user_rows[0]
delacruzramoceb8baf2019-06-21 14:25:38 +0200159 else:
delacruzramo01b15d32019-07-02 14:37:47 +0200160 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
161 else:
162 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
163 http_code=HTTPStatus.UNAUTHORIZED)
delacruzramoceb8baf2019-06-21 14:25:38 +0200164
delacruzramo01b15d32019-07-02 14:37:47 +0200165 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
166 for _ in range(0, 32))
delacruzramoceb8baf2019-06-21 14:25:38 +0200167
delacruzramo01b15d32019-07-02 14:37:47 +0200168 # projects = user_content.get("projects", [])
169 prm_list = user_content.get("project_role_mappings", [])
delacruzramoceb8baf2019-06-21 14:25:38 +0200170
delacruzramo01b15d32019-07-02 14:37:47 +0200171 if not project:
172 project = prm_list[0]["project"] if prm_list else None
173 if not project:
174 raise AuthException("can't find a default project for this user", http_code=HTTPStatus.UNAUTHORIZED)
tierno701018c2019-06-25 11:13:14 +0000175
delacruzramo01b15d32019-07-02 14:37:47 +0200176 projects = [prm["project"] for prm in prm_list]
delacruzramoceb8baf2019-06-21 14:25:38 +0200177
delacruzramo01b15d32019-07-02 14:37:47 +0200178 proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
179 project_name = proj["name"]
180 project_id = proj["_id"]
181 if project_name not in projects and project_id not in projects:
182 raise AuthException("project {} not allowed for this user".format(project),
183 http_code=HTTPStatus.UNAUTHORIZED)
tierno701018c2019-06-25 11:13:14 +0000184
delacruzramo01b15d32019-07-02 14:37:47 +0200185 # TODO remove admin, this vill be used by roles RBAC
186 if project_name == "admin":
187 token_admin = True
188 else:
189 token_admin = proj.get("admin", False)
delacruzramoceb8baf2019-06-21 14:25:38 +0200190
delacruzramo01b15d32019-07-02 14:37:47 +0200191 # add token roles
192 roles = []
193 roles_list = []
194 for prm in prm_list:
195 if prm["project"] in [project_id, project_name]:
196 role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]})
197 rid = role["_id"]
198 if rid not in roles:
199 rnm = role["name"]
200 roles.append(rid)
201 roles_list.append({"name": rnm, "id": rid})
202 if not roles_list:
203 rid = self.db.get_one("roles", {"name": "project_admin"})["_id"]
204 roles_list = [{"name": "project_admin", "id": rid}]
delacruzramoceb8baf2019-06-21 14:25:38 +0200205
delacruzramo01b15d32019-07-02 14:37:47 +0200206 new_token = {"issued_at": now,
207 "expires": now + 3600,
208 "_id": token_id,
209 "id": token_id,
210 "project_id": proj["_id"],
211 "project_name": proj["name"],
212 "username": user_content["username"],
213 "user_id": user_content["_id"],
214 "admin": token_admin,
215 "roles": roles_list,
216 }
delacruzramoceb8baf2019-06-21 14:25:38 +0200217
delacruzramo01b15d32019-07-02 14:37:47 +0200218 self.token_cache[token_id] = new_token
219 self.db.create("tokens", new_token)
220 return deepcopy(new_token)
221
222 def get_role_list(self, filter_q={}):
delacruzramoceb8baf2019-06-21 14:25:38 +0200223 """
224 Get role list.
225
226 :return: returns the list of roles.
227 """
delacruzramo01b15d32019-07-02 14:37:47 +0200228 return self.db.get_list("roles", filter_q)
delacruzramoceb8baf2019-06-21 14:25:38 +0200229
delacruzramo01b15d32019-07-02 14:37:47 +0200230 def create_role(self, role_info):
delacruzramoceb8baf2019-06-21 14:25:38 +0200231 """
232 Create a role.
233
delacruzramo01b15d32019-07-02 14:37:47 +0200234 :param role_info: full role info.
235 :return: returns the role id.
delacruzramoceb8baf2019-06-21 14:25:38 +0200236 :raises AuthconnOperationException: if role creation failed.
237 """
delacruzramoceb8baf2019-06-21 14:25:38 +0200238 # TODO: Check that role name does not exist ?
delacruzramo01b15d32019-07-02 14:37:47 +0200239 rid = str(uuid4())
240 role_info["_id"] = rid
241 rid = self.db.create("roles", role_info)
242 return rid
delacruzramoceb8baf2019-06-21 14:25:38 +0200243
244 def delete_role(self, role_id):
245 """
246 Delete a role.
247
248 :param role_id: role identifier.
249 :raises AuthconnOperationException: if role deletion failed.
250 """
delacruzramo01b15d32019-07-02 14:37:47 +0200251 return self.db.del_one("roles", {"_id": role_id})
252
253 def update_role(self, role_info):
254 """
255 Update a role.
256
257 :param role_info: full role info.
258 :return: returns the role name and id.
259 :raises AuthconnOperationException: if user creation failed.
260 """
261 rid = role_info["_id"]
262 self.db.set_one("roles", {"_id": rid}, role_info) # CONFIRM
263 return {"_id": rid, "name": role_info["name"]}
264
265 def create_user(self, user_info):
266 """
267 Create a user.
268
269 :param user_info: full user info.
270 :return: returns the username and id of the user.
271 """
272 BaseTopic.format_on_new(user_info, make_public=False)
273 salt = uuid4().hex
274 user_info["_admin"]["salt"] = salt
275 if "password" in user_info:
276 user_info["password"] = sha256(user_info["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest()
277 # "projects" are not stored any more
278 if "projects" in user_info:
279 del user_info["projects"]
280 self.db.create("users", user_info)
281 return {"username": user_info["username"], "_id": user_info["_id"]}
282
283 def update_user(self, user_info):
284 """
285 Change the user name and/or password.
286
287 :param user_info: user info modifications
288 """
289 uid = user_info["_id"]
290 user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid})
291 BaseTopic.format_on_edit(user_data, user_info)
292 # User Name
293 usnm = user_info.get("username")
294 if usnm:
295 user_data["username"] = usnm
296 # If password is given and is not already encripted
297 pswd = user_info.get("password")
298 if pswd and (len(pswd) != 64 or not re.match('[a-fA-F0-9]*', pswd)): # TODO: Improve check?
299 salt = uuid4().hex
300 if "_admin" not in user_data:
301 user_data["_admin"] = {}
302 user_data["_admin"]["salt"] = salt
303 user_data["password"] = sha256(pswd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
304 # Project-Role Mappings
305 # TODO: Check that user_info NEVER includes "project_role_mappings"
306 if "project_role_mappings" not in user_data:
307 user_data["project_role_mappings"] = []
308 for prm in user_info.get("add_project_role_mappings", []):
309 user_data["project_role_mappings"].append(prm)
310 for prm in user_info.get("remove_project_role_mappings", []):
311 for pidf in ["project", "project_name"]:
312 for ridf in ["role", "role_name"]:
313 try:
314 user_data["project_role_mappings"].remove({"role": prm[ridf], "project": prm[pidf]})
315 except KeyError:
316 pass
317 except ValueError:
318 pass
319 self.db.set_one("users", {BaseTopic.id_field("users", uid): uid}, user_data) # CONFIRM
320
321 def delete_user(self, user_id):
322 """
323 Delete user.
324
325 :param user_id: user identifier.
326 :raises AuthconnOperationException: if user deletion failed.
327 """
328 self.db.del_one("users", {"_id": user_id})
delacruzramoceb8baf2019-06-21 14:25:38 +0200329 return True
delacruzramo01b15d32019-07-02 14:37:47 +0200330
331 def get_user_list(self, filter_q=None):
332 """
333 Get user list.
334
335 :param filter_q: dictionary to filter user list by name (username is also admited) and/or _id
336 :return: returns a list of users.
337 """
338 filt = filter_q or {}
339 if "name" in filt:
340 filt["username"] = filt["name"]
341 del filt["name"]
342 users = self.db.get_list("users", filt)
tierno1546f2a2019-08-20 15:38:11 +0000343 project_id_name = {}
344 role_id_name = {}
delacruzramo01b15d32019-07-02 14:37:47 +0200345 for user in users:
tierno1546f2a2019-08-20 15:38:11 +0000346 prms = user.get("project_role_mappings")
347 projects = user.get("projects")
348 if prms:
349 projects = []
350 # add project_name and role_name. Generate projects for backward compatibility
delacruzramo01b15d32019-07-02 14:37:47 +0200351 for prm in prms:
tierno1546f2a2019-08-20 15:38:11 +0000352 project_id = prm["project"]
353 if project_id not in project_id_name:
354 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id},
355 fail_on_empty=False)
356 project_id_name[project_id] = pr["name"] if pr else None
357 prm["project_name"] = project_id_name[project_id]
358 if prm["project_name"] not in projects:
359 projects.append(prm["project_name"])
360
361 role_id = prm["role"]
362 if role_id not in role_id_name:
363 role = self.db.get_one("roles", {BaseTopic.id_field("roles", role_id): role_id},
364 fail_on_empty=False)
365 role_id_name[role_id] = role["name"] if role else None
366 prm["role_name"] = role_id_name[role_id]
367 user["projects"] = projects # for backward compatibility
368 elif projects:
369 # user created with an old version. Create a project_role mapping with role project_admin
370 user["project_role_mappings"] = []
371 role = self.db.get_one("roles", {BaseTopic.id_field("roles", "project_admin"): "project_admin"})
372 for p_id_name in projects:
373 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", p_id_name): p_id_name})
374 prm = {"project": pr["_id"],
375 "project_name": pr["name"],
376 "role_name": "project_admin",
377 "role": role["_id"]
378 }
379 user["project_role_mappings"].append(prm)
380 else:
381 user["projects"] = []
382 user["project_role_mappings"] = []
383
delacruzramo01b15d32019-07-02 14:37:47 +0200384 return users
385
386 def get_project_list(self, filter_q={}):
387 """
388 Get role list.
389
390 :return: returns the list of projects.
391 """
392 return self.db.get_list("projects", filter_q)
393
394 def create_project(self, project_info):
395 """
396 Create a project.
397
398 :param project: full project info.
399 :return: the internal id of the created project
400 :raises AuthconnOperationException: if project creation failed.
401 """
402 pid = self.db.create("projects", project_info)
403 return pid
404
405 def delete_project(self, project_id):
406 """
407 Delete a project.
408
409 :param project_id: project identifier.
410 :raises AuthconnOperationException: if project deletion failed.
411 """
412 filter_q = {BaseTopic.id_field("projects", project_id): project_id}
413 r = self.db.del_one("projects", filter_q)
414 return r
415
416 def update_project(self, project_id, project_info):
417 """
418 Change the name of a project
419
420 :param project_id: project to be changed
421 :param project_info: full project info
422 :return: None
423 :raises AuthconnOperationException: if project update failed.
424 """
425 self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info)