blob: 672892d8ab698c58e9fda9d8526497407de97844 [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
delacruzramoad682a52019-12-10 16:26:34 +010037from time import time, sleep
delacruzramoceb8baf2019-06-21 14:25:38 +020038from 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):
delacruzramoad682a52019-12-10 16:26:34 +010046 token_time_window = 2 # seconds
47 token_delay = 1 # seconds to wait upon second request within time window
delacruzramoceb8baf2019-06-21 14:25:38 +020048
delacruzramoad682a52019-12-10 16:26:34 +010049 def __init__(self, config, db):
50 Authconn.__init__(self, config, db)
delacruzramoceb8baf2019-06-21 14:25:38 +020051 self.logger = logging.getLogger("nbi.authenticator.internal")
52
delacruzramoceb8baf2019-06-21 14:25:38 +020053 self.db = db
delacruzramoad682a52019-12-10 16:26:34 +010054 # self.msg = msg
55 # self.token_cache = token_cache
delacruzramoceb8baf2019-06-21 14:25:38 +020056
57 # To be Confirmed
delacruzramoceb8baf2019-06-21 14:25:38 +020058 self.sess = None
59
delacruzramoceb8baf2019-06-21 14:25:38 +020060 def validate_token(self, token):
61 """
62 Check if the token is valid.
63
64 :param token: token to validate
65 :return: dictionary with information associated with the token:
66 "_id": token id
67 "project_id": project id
68 "project_name": project name
69 "user_id": user id
70 "username": user name
71 "roles": list with dict containing {name, id}
72 "expires": expiration date
73 If the token is not valid an exception is raised.
74 """
75
76 try:
77 if not token:
78 raise AuthException("Needed a token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
79
delacruzramoceb8baf2019-06-21 14:25:38 +020080 now = time()
delacruzramoceb8baf2019-06-21 14:25:38 +020081
82 # get from database if not in cache
delacruzramoad682a52019-12-10 16:26:34 +010083 # if not token_info:
84 token_info = self.db.get_one("tokens", {"_id": token})
85 if token_info["expires"] < now:
86 raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
delacruzramoceb8baf2019-06-21 14:25:38 +020087
tierno701018c2019-06-25 11:13:14 +000088 return token_info
delacruzramoceb8baf2019-06-21 14:25:38 +020089
90 except DbException as e:
91 if e.http_code == HTTPStatus.NOT_FOUND:
92 raise AuthException("Invalid Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
93 else:
94 raise
95 except AuthException:
tiernoe1eb3b22019-08-26 15:59:24 +000096 raise
delacruzramoceb8baf2019-06-21 14:25:38 +020097 except Exception:
98 self.logger.exception("Error during token validation using internal backend")
99 raise AuthException("Error during token validation using internal backend",
100 http_code=HTTPStatus.UNAUTHORIZED)
101
102 def revoke_token(self, token):
103 """
104 Invalidate a token.
105
106 :param token: token to be revoked
107 """
108 try:
delacruzramoad682a52019-12-10 16:26:34 +0100109 # self.token_cache.pop(token, None)
delacruzramoceb8baf2019-06-21 14:25:38 +0200110 self.db.del_one("tokens", {"_id": token})
111 return True
112 except DbException as e:
113 if e.http_code == HTTPStatus.NOT_FOUND:
114 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
115 else:
116 # raise
delacruzramoad682a52019-12-10 16:26:34 +0100117 exmsg = "Error during token revocation using internal backend"
118 self.logger.exception(exmsg)
119 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
delacruzramoceb8baf2019-06-21 14:25:38 +0200120
tierno701018c2019-06-25 11:13:14 +0000121 def authenticate(self, user, password, project=None, token_info=None):
delacruzramoceb8baf2019-06-21 14:25:38 +0200122 """
tierno701018c2019-06-25 11:13:14 +0000123 Authenticate a user using username/password or previous token_info plus project; its creates a new token
delacruzramoceb8baf2019-06-21 14:25:38 +0200124
125 :param user: user: name, id or None
126 :param password: password or None
127 :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 +0000128 :param token_info: previous token_info to obtain authorization
delacruzramoceb8baf2019-06-21 14:25:38 +0200129 :param remote: remote host information
130 :return: the scoped token info or raises an exception. The token is a dictionary with:
131 _id: token string id,
132 username: username,
133 project_id: scoped_token project_id,
134 project_name: scoped_token project_name,
135 expires: epoch time when it expires,
136 """
137
138 now = time()
139 user_content = None
140
delacruzramo01b15d32019-07-02 14:37:47 +0200141 # Try using username/password
142 if user:
143 user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user})
144 if user_rows:
145 user_content = user_rows[0]
146 salt = user_content["_admin"]["salt"]
147 shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest()
148 if shadow_password != user_content["password"]:
149 user_content = None
150 if not user_content:
151 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
152 elif token_info:
153 user_rows = self.db.get_list("users", {"username": token_info["username"]})
154 if user_rows:
155 user_content = user_rows[0]
delacruzramoceb8baf2019-06-21 14:25:38 +0200156 else:
delacruzramo01b15d32019-07-02 14:37:47 +0200157 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
158 else:
159 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
160 http_code=HTTPStatus.UNAUTHORIZED)
delacruzramoceb8baf2019-06-21 14:25:38 +0200161
delacruzramoad682a52019-12-10 16:26:34 +0100162 # Delay upon second request within time window
163 if now - user_content["_admin"].get("last_token_time", 0) < self.token_time_window:
164 sleep(self.token_delay)
165 # user_content["_admin"]["last_token_time"] = now
166 # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions
167 self.db.set_one("users", {"_id": user_content["_id"]}, {"_admin.last_token_time": now})
168
delacruzramo01b15d32019-07-02 14:37:47 +0200169 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
170 for _ in range(0, 32))
delacruzramoceb8baf2019-06-21 14:25:38 +0200171
delacruzramo01b15d32019-07-02 14:37:47 +0200172 # projects = user_content.get("projects", [])
173 prm_list = user_content.get("project_role_mappings", [])
delacruzramoceb8baf2019-06-21 14:25:38 +0200174
delacruzramo01b15d32019-07-02 14:37:47 +0200175 if not project:
176 project = prm_list[0]["project"] if prm_list else None
177 if not project:
178 raise AuthException("can't find a default project for this user", http_code=HTTPStatus.UNAUTHORIZED)
tierno701018c2019-06-25 11:13:14 +0000179
delacruzramo01b15d32019-07-02 14:37:47 +0200180 projects = [prm["project"] for prm in prm_list]
delacruzramoceb8baf2019-06-21 14:25:38 +0200181
delacruzramo01b15d32019-07-02 14:37:47 +0200182 proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
183 project_name = proj["name"]
184 project_id = proj["_id"]
185 if project_name not in projects and project_id not in projects:
186 raise AuthException("project {} not allowed for this user".format(project),
187 http_code=HTTPStatus.UNAUTHORIZED)
tierno701018c2019-06-25 11:13:14 +0000188
delacruzramo01b15d32019-07-02 14:37:47 +0200189 # TODO remove admin, this vill be used by roles RBAC
190 if project_name == "admin":
191 token_admin = True
192 else:
193 token_admin = proj.get("admin", False)
delacruzramoceb8baf2019-06-21 14:25:38 +0200194
delacruzramo01b15d32019-07-02 14:37:47 +0200195 # add token roles
196 roles = []
197 roles_list = []
198 for prm in prm_list:
199 if prm["project"] in [project_id, project_name]:
200 role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]})
201 rid = role["_id"]
202 if rid not in roles:
203 rnm = role["name"]
204 roles.append(rid)
205 roles_list.append({"name": rnm, "id": rid})
206 if not roles_list:
207 rid = self.db.get_one("roles", {"name": "project_admin"})["_id"]
208 roles_list = [{"name": "project_admin", "id": rid}]
delacruzramoceb8baf2019-06-21 14:25:38 +0200209
delacruzramo01b15d32019-07-02 14:37:47 +0200210 new_token = {"issued_at": now,
211 "expires": now + 3600,
212 "_id": token_id,
213 "id": token_id,
214 "project_id": proj["_id"],
215 "project_name": proj["name"],
216 "username": user_content["username"],
217 "user_id": user_content["_id"],
218 "admin": token_admin,
219 "roles": roles_list,
220 }
delacruzramoceb8baf2019-06-21 14:25:38 +0200221
delacruzramo01b15d32019-07-02 14:37:47 +0200222 self.db.create("tokens", new_token)
223 return deepcopy(new_token)
224
225 def get_role_list(self, filter_q={}):
delacruzramoceb8baf2019-06-21 14:25:38 +0200226 """
227 Get role list.
228
229 :return: returns the list of roles.
230 """
delacruzramo01b15d32019-07-02 14:37:47 +0200231 return self.db.get_list("roles", filter_q)
delacruzramoceb8baf2019-06-21 14:25:38 +0200232
delacruzramo01b15d32019-07-02 14:37:47 +0200233 def create_role(self, role_info):
delacruzramoceb8baf2019-06-21 14:25:38 +0200234 """
235 Create a role.
236
delacruzramo01b15d32019-07-02 14:37:47 +0200237 :param role_info: full role info.
238 :return: returns the role id.
delacruzramoceb8baf2019-06-21 14:25:38 +0200239 :raises AuthconnOperationException: if role creation failed.
240 """
delacruzramoceb8baf2019-06-21 14:25:38 +0200241 # TODO: Check that role name does not exist ?
delacruzramo01b15d32019-07-02 14:37:47 +0200242 rid = str(uuid4())
243 role_info["_id"] = rid
244 rid = self.db.create("roles", role_info)
245 return rid
delacruzramoceb8baf2019-06-21 14:25:38 +0200246
247 def delete_role(self, role_id):
248 """
249 Delete a role.
250
251 :param role_id: role identifier.
252 :raises AuthconnOperationException: if role deletion failed.
253 """
delacruzramoad682a52019-12-10 16:26:34 +0100254 rc = self.db.del_one("roles", {"_id": role_id})
255 self.db.del_list("tokens", {"roles.id": role_id})
256 return rc
delacruzramo01b15d32019-07-02 14:37:47 +0200257
258 def update_role(self, role_info):
259 """
260 Update a role.
261
262 :param role_info: full role info.
263 :return: returns the role name and id.
264 :raises AuthconnOperationException: if user creation failed.
265 """
266 rid = role_info["_id"]
delacruzramoad682a52019-12-10 16:26:34 +0100267 self.db.set_one("roles", {"_id": rid}, role_info)
delacruzramo01b15d32019-07-02 14:37:47 +0200268 return {"_id": rid, "name": role_info["name"]}
269
270 def create_user(self, user_info):
271 """
272 Create a user.
273
274 :param user_info: full user info.
275 :return: returns the username and id of the user.
276 """
277 BaseTopic.format_on_new(user_info, make_public=False)
278 salt = uuid4().hex
279 user_info["_admin"]["salt"] = salt
280 if "password" in user_info:
281 user_info["password"] = sha256(user_info["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest()
282 # "projects" are not stored any more
283 if "projects" in user_info:
284 del user_info["projects"]
285 self.db.create("users", user_info)
286 return {"username": user_info["username"], "_id": user_info["_id"]}
287
288 def update_user(self, user_info):
289 """
290 Change the user name and/or password.
291
292 :param user_info: user info modifications
293 """
294 uid = user_info["_id"]
295 user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid})
296 BaseTopic.format_on_edit(user_data, user_info)
297 # User Name
298 usnm = user_info.get("username")
299 if usnm:
300 user_data["username"] = usnm
301 # If password is given and is not already encripted
302 pswd = user_info.get("password")
303 if pswd and (len(pswd) != 64 or not re.match('[a-fA-F0-9]*', pswd)): # TODO: Improve check?
304 salt = uuid4().hex
305 if "_admin" not in user_data:
306 user_data["_admin"] = {}
307 user_data["_admin"]["salt"] = salt
308 user_data["password"] = sha256(pswd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
309 # Project-Role Mappings
310 # TODO: Check that user_info NEVER includes "project_role_mappings"
311 if "project_role_mappings" not in user_data:
312 user_data["project_role_mappings"] = []
313 for prm in user_info.get("add_project_role_mappings", []):
314 user_data["project_role_mappings"].append(prm)
315 for prm in user_info.get("remove_project_role_mappings", []):
316 for pidf in ["project", "project_name"]:
317 for ridf in ["role", "role_name"]:
318 try:
319 user_data["project_role_mappings"].remove({"role": prm[ridf], "project": prm[pidf]})
320 except KeyError:
321 pass
322 except ValueError:
323 pass
delacruzramo3d6881c2019-12-04 13:42:26 +0100324 idf = BaseTopic.id_field("users", uid)
325 self.db.set_one("users", {idf: uid}, user_data)
326 if user_info.get("remove_project_role_mappings"):
delacruzramoad682a52019-12-10 16:26:34 +0100327 idf = "user_id" if idf == "_id" else idf
328 self.db.del_list("tokens", {idf: uid})
delacruzramo01b15d32019-07-02 14:37:47 +0200329
330 def delete_user(self, user_id):
331 """
332 Delete user.
333
334 :param user_id: user identifier.
335 :raises AuthconnOperationException: if user deletion failed.
336 """
337 self.db.del_one("users", {"_id": user_id})
delacruzramo3d6881c2019-12-04 13:42:26 +0100338 self.db.del_list("tokens", {"user_id": user_id})
delacruzramoceb8baf2019-06-21 14:25:38 +0200339 return True
delacruzramo01b15d32019-07-02 14:37:47 +0200340
341 def get_user_list(self, filter_q=None):
342 """
343 Get user list.
344
345 :param filter_q: dictionary to filter user list by name (username is also admited) and/or _id
346 :return: returns a list of users.
347 """
348 filt = filter_q or {}
349 if "name" in filt:
350 filt["username"] = filt["name"]
351 del filt["name"]
352 users = self.db.get_list("users", filt)
tierno1546f2a2019-08-20 15:38:11 +0000353 project_id_name = {}
354 role_id_name = {}
delacruzramo01b15d32019-07-02 14:37:47 +0200355 for user in users:
tierno1546f2a2019-08-20 15:38:11 +0000356 prms = user.get("project_role_mappings")
357 projects = user.get("projects")
358 if prms:
359 projects = []
360 # add project_name and role_name. Generate projects for backward compatibility
delacruzramo01b15d32019-07-02 14:37:47 +0200361 for prm in prms:
tierno1546f2a2019-08-20 15:38:11 +0000362 project_id = prm["project"]
363 if project_id not in project_id_name:
364 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id},
365 fail_on_empty=False)
366 project_id_name[project_id] = pr["name"] if pr else None
367 prm["project_name"] = project_id_name[project_id]
368 if prm["project_name"] not in projects:
369 projects.append(prm["project_name"])
370
371 role_id = prm["role"]
372 if role_id not in role_id_name:
373 role = self.db.get_one("roles", {BaseTopic.id_field("roles", role_id): role_id},
374 fail_on_empty=False)
375 role_id_name[role_id] = role["name"] if role else None
376 prm["role_name"] = role_id_name[role_id]
377 user["projects"] = projects # for backward compatibility
378 elif projects:
379 # user created with an old version. Create a project_role mapping with role project_admin
380 user["project_role_mappings"] = []
381 role = self.db.get_one("roles", {BaseTopic.id_field("roles", "project_admin"): "project_admin"})
382 for p_id_name in projects:
383 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", p_id_name): p_id_name})
384 prm = {"project": pr["_id"],
385 "project_name": pr["name"],
386 "role_name": "project_admin",
387 "role": role["_id"]
388 }
389 user["project_role_mappings"].append(prm)
390 else:
391 user["projects"] = []
392 user["project_role_mappings"] = []
393
delacruzramo01b15d32019-07-02 14:37:47 +0200394 return users
395
396 def get_project_list(self, filter_q={}):
397 """
398 Get role list.
399
400 :return: returns the list of projects.
401 """
402 return self.db.get_list("projects", filter_q)
403
404 def create_project(self, project_info):
405 """
406 Create a project.
407
408 :param project: full project info.
409 :return: the internal id of the created project
410 :raises AuthconnOperationException: if project creation failed.
411 """
412 pid = self.db.create("projects", project_info)
413 return pid
414
415 def delete_project(self, project_id):
416 """
417 Delete a project.
418
419 :param project_id: project identifier.
420 :raises AuthconnOperationException: if project deletion failed.
421 """
delacruzramoad682a52019-12-10 16:26:34 +0100422 idf = BaseTopic.id_field("projects", project_id)
423 r = self.db.del_one("projects", {idf: project_id})
424 idf = "project_id" if idf == "_id" else "project_name"
425 self.db.del_list("tokens", {idf: project_id})
delacruzramo01b15d32019-07-02 14:37:47 +0200426 return r
427
428 def update_project(self, project_id, project_info):
429 """
430 Change the name of a project
431
432 :param project_id: project to be changed
433 :param project_info: full project info
434 :return: None
435 :raises AuthconnOperationException: if project update failed.
436 """
437 self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info)