672892d8ab698c58e9fda9d8526497407de97844
[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__ = "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, " \
28 "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
29 __date__ = "$06-jun-2019 11:16:08$"
30
31 from osm_nbi.authconn import Authconn, AuthException # , AuthconnOperationException
32 from osm_common.dbbase import DbException
33 from osm_nbi.base_topic import BaseTopic
34
35 import logging
36 import re
37 from time import time, sleep
38 from http import HTTPStatus
39 from uuid import uuid4
40 from hashlib import sha256
41 from copy import deepcopy
42 from random import choice as random_choice
43
44
45 class AuthconnInternal(Authconn):
46 token_time_window = 2 # seconds
47 token_delay = 1 # seconds to wait upon second request within time window
48
49 def __init__(self, config, db):
50 Authconn.__init__(self, config, db)
51 self.logger = logging.getLogger("nbi.authenticator.internal")
52
53 self.db = db
54 # self.msg = msg
55 # self.token_cache = token_cache
56
57 # To be Confirmed
58 self.sess = None
59
60 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
80 now = time()
81
82 # get from database if not in cache
83 # 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)
87
88 return token_info
89
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:
96 raise
97 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:
109 # self.token_cache.pop(token, None)
110 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
117 exmsg = "Error during token revocation using internal backend"
118 self.logger.exception(exmsg)
119 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
120
121 def authenticate(self, user, password, project=None, token_info=None):
122 """
123 Authenticate a user using username/password or previous token_info plus project; its creates a new token
124
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
128 :param token_info: previous token_info to obtain authorization
129 :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
141 # 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]
156 else:
157 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)
161
162 # 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
169 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
170 for _ in range(0, 32))
171
172 # projects = user_content.get("projects", [])
173 prm_list = user_content.get("project_role_mappings", [])
174
175 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)
179
180 projects = [prm["project"] for prm in prm_list]
181
182 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)
188
189 # 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)
194
195 # 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}]
209
210 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 }
221
222 self.db.create("tokens", new_token)
223 return deepcopy(new_token)
224
225 def get_role_list(self, filter_q={}):
226 """
227 Get role list.
228
229 :return: returns the list of roles.
230 """
231 return self.db.get_list("roles", filter_q)
232
233 def create_role(self, role_info):
234 """
235 Create a role.
236
237 :param role_info: full role info.
238 :return: returns the role id.
239 :raises AuthconnOperationException: if role creation failed.
240 """
241 # TODO: Check that role name does not exist ?
242 rid = str(uuid4())
243 role_info["_id"] = rid
244 rid = self.db.create("roles", role_info)
245 return rid
246
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 """
254 rc = self.db.del_one("roles", {"_id": role_id})
255 self.db.del_list("tokens", {"roles.id": role_id})
256 return rc
257
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"]
267 self.db.set_one("roles", {"_id": rid}, role_info)
268 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
324 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"):
327 idf = "user_id" if idf == "_id" else idf
328 self.db.del_list("tokens", {idf: uid})
329
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})
338 self.db.del_list("tokens", {"user_id": user_id})
339 return True
340
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)
353 project_id_name = {}
354 role_id_name = {}
355 for user in users:
356 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
361 for prm in prms:
362 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
394 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 """
422 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})
426 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)