88b276d73c70d9d09cd52be7f5f45e0430462b23
[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, role_permissions):
50 Authconn.__init__(self, config, db, role_permissions)
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, credentials, 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 credentials: dictionary that contains:
126 username: name, id or None
127 password: password or None
128 project_id: name, id, or None. If None first found project will be used to get an scope token
129 other items are allowed and ignored
130 :param token_info: previous token_info to obtain authorization
131 :return: the scoped token info or raises an exception. The token is a dictionary with:
132 _id: token string id,
133 username: username,
134 project_id: scoped_token project_id,
135 project_name: scoped_token project_name,
136 expires: epoch time when it expires,
137 """
138
139 now = time()
140 user_content = None
141 user = credentials.get("username")
142 password = credentials.get("password")
143 project = credentials.get("project_id")
144
145 # 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]
160 else:
161 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)
165
166 # Delay upon second request within time window
167 if now - user_content["_admin"].get("last_token_time", 0) < self.token_time_window:
168 sleep(self.token_delay)
169 # user_content["_admin"]["last_token_time"] = now
170 # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions
171 self.db.set_one("users", {"_id": user_content["_id"]}, {"_admin.last_token_time": now})
172
173 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
174 for _ in range(0, 32))
175
176 # projects = user_content.get("projects", [])
177 prm_list = user_content.get("project_role_mappings", [])
178
179 if not project:
180 project = prm_list[0]["project"] if prm_list else None
181 if not project:
182 raise AuthException("can't find a default project for this user", http_code=HTTPStatus.UNAUTHORIZED)
183
184 projects = [prm["project"] for prm in prm_list]
185
186 proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project})
187 project_name = proj["name"]
188 project_id = proj["_id"]
189 if project_name not in projects and project_id not in projects:
190 raise AuthException("project {} not allowed for this user".format(project),
191 http_code=HTTPStatus.UNAUTHORIZED)
192
193 # TODO remove admin, this vill be used by roles RBAC
194 if project_name == "admin":
195 token_admin = True
196 else:
197 token_admin = proj.get("admin", False)
198
199 # add token roles
200 roles = []
201 roles_list = []
202 for prm in prm_list:
203 if prm["project"] in [project_id, project_name]:
204 role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]})
205 rid = role["_id"]
206 if rid not in roles:
207 rnm = role["name"]
208 roles.append(rid)
209 roles_list.append({"name": rnm, "id": rid})
210 if not roles_list:
211 rid = self.db.get_one("roles", {"name": "project_admin"})["_id"]
212 roles_list = [{"name": "project_admin", "id": rid}]
213
214 new_token = {"issued_at": now,
215 "expires": now + 3600,
216 "_id": token_id,
217 "id": token_id,
218 "project_id": proj["_id"],
219 "project_name": proj["name"],
220 "username": user_content["username"],
221 "user_id": user_content["_id"],
222 "admin": token_admin,
223 "roles": roles_list,
224 }
225
226 self.db.create("tokens", new_token)
227 return deepcopy(new_token)
228
229 def get_role_list(self, filter_q={}):
230 """
231 Get role list.
232
233 :return: returns the list of roles.
234 """
235 return self.db.get_list("roles", filter_q)
236
237 def create_role(self, role_info):
238 """
239 Create a role.
240
241 :param role_info: full role info.
242 :return: returns the role id.
243 :raises AuthconnOperationException: if role creation failed.
244 """
245 # TODO: Check that role name does not exist ?
246 rid = str(uuid4())
247 role_info["_id"] = rid
248 rid = self.db.create("roles", role_info)
249 return rid
250
251 def delete_role(self, role_id):
252 """
253 Delete a role.
254
255 :param role_id: role identifier.
256 :raises AuthconnOperationException: if role deletion failed.
257 """
258 rc = self.db.del_one("roles", {"_id": role_id})
259 self.db.del_list("tokens", {"roles.id": role_id})
260 return rc
261
262 def update_role(self, role_info):
263 """
264 Update a role.
265
266 :param role_info: full role info.
267 :return: returns the role name and id.
268 :raises AuthconnOperationException: if user creation failed.
269 """
270 rid = role_info["_id"]
271 self.db.set_one("roles", {"_id": rid}, role_info)
272 return {"_id": rid, "name": role_info["name"]}
273
274 def create_user(self, user_info):
275 """
276 Create a user.
277
278 :param user_info: full user info.
279 :return: returns the username and id of the user.
280 """
281 BaseTopic.format_on_new(user_info, make_public=False)
282 salt = uuid4().hex
283 user_info["_admin"]["salt"] = salt
284 if "password" in user_info:
285 user_info["password"] = sha256(user_info["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest()
286 # "projects" are not stored any more
287 if "projects" in user_info:
288 del user_info["projects"]
289 self.db.create("users", user_info)
290 return {"username": user_info["username"], "_id": user_info["_id"]}
291
292 def update_user(self, user_info):
293 """
294 Change the user name and/or password.
295
296 :param user_info: user info modifications
297 """
298 uid = user_info["_id"]
299 user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid})
300 BaseTopic.format_on_edit(user_data, user_info)
301 # User Name
302 usnm = user_info.get("username")
303 if usnm:
304 user_data["username"] = usnm
305 # If password is given and is not already encripted
306 pswd = user_info.get("password")
307 if pswd and (len(pswd) != 64 or not re.match('[a-fA-F0-9]*', pswd)): # TODO: Improve check?
308 salt = uuid4().hex
309 if "_admin" not in user_data:
310 user_data["_admin"] = {}
311 user_data["_admin"]["salt"] = salt
312 user_data["password"] = sha256(pswd.encode('utf-8') + salt.encode('utf-8')).hexdigest()
313 # Project-Role Mappings
314 # TODO: Check that user_info NEVER includes "project_role_mappings"
315 if "project_role_mappings" not in user_data:
316 user_data["project_role_mappings"] = []
317 for prm in user_info.get("add_project_role_mappings", []):
318 user_data["project_role_mappings"].append(prm)
319 for prm in user_info.get("remove_project_role_mappings", []):
320 for pidf in ["project", "project_name"]:
321 for ridf in ["role", "role_name"]:
322 try:
323 user_data["project_role_mappings"].remove({"role": prm[ridf], "project": prm[pidf]})
324 except KeyError:
325 pass
326 except ValueError:
327 pass
328 idf = BaseTopic.id_field("users", uid)
329 self.db.set_one("users", {idf: uid}, user_data)
330 if user_info.get("remove_project_role_mappings"):
331 idf = "user_id" if idf == "_id" else idf
332 self.db.del_list("tokens", {idf: uid})
333
334 def delete_user(self, user_id):
335 """
336 Delete user.
337
338 :param user_id: user identifier.
339 :raises AuthconnOperationException: if user deletion failed.
340 """
341 self.db.del_one("users", {"_id": user_id})
342 self.db.del_list("tokens", {"user_id": user_id})
343 return True
344
345 def get_user_list(self, filter_q=None):
346 """
347 Get user list.
348
349 :param filter_q: dictionary to filter user list by name (username is also admited) and/or _id
350 :return: returns a list of users.
351 """
352 filt = filter_q or {}
353 if "name" in filt:
354 filt["username"] = filt["name"]
355 del filt["name"]
356 users = self.db.get_list("users", filt)
357 project_id_name = {}
358 role_id_name = {}
359 for user in users:
360 prms = user.get("project_role_mappings")
361 projects = user.get("projects")
362 if prms:
363 projects = []
364 # add project_name and role_name. Generate projects for backward compatibility
365 for prm in prms:
366 project_id = prm["project"]
367 if project_id not in project_id_name:
368 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id},
369 fail_on_empty=False)
370 project_id_name[project_id] = pr["name"] if pr else None
371 prm["project_name"] = project_id_name[project_id]
372 if prm["project_name"] not in projects:
373 projects.append(prm["project_name"])
374
375 role_id = prm["role"]
376 if role_id not in role_id_name:
377 role = self.db.get_one("roles", {BaseTopic.id_field("roles", role_id): role_id},
378 fail_on_empty=False)
379 role_id_name[role_id] = role["name"] if role else None
380 prm["role_name"] = role_id_name[role_id]
381 user["projects"] = projects # for backward compatibility
382 elif projects:
383 # user created with an old version. Create a project_role mapping with role project_admin
384 user["project_role_mappings"] = []
385 role = self.db.get_one("roles", {BaseTopic.id_field("roles", "project_admin"): "project_admin"})
386 for p_id_name in projects:
387 pr = self.db.get_one("projects", {BaseTopic.id_field("projects", p_id_name): p_id_name})
388 prm = {"project": pr["_id"],
389 "project_name": pr["name"],
390 "role_name": "project_admin",
391 "role": role["_id"]
392 }
393 user["project_role_mappings"].append(prm)
394 else:
395 user["projects"] = []
396 user["project_role_mappings"] = []
397
398 return users
399
400 def get_project_list(self, filter_q={}):
401 """
402 Get role list.
403
404 :return: returns the list of projects.
405 """
406 return self.db.get_list("projects", filter_q)
407
408 def create_project(self, project_info):
409 """
410 Create a project.
411
412 :param project: full project info.
413 :return: the internal id of the created project
414 :raises AuthconnOperationException: if project creation failed.
415 """
416 pid = self.db.create("projects", project_info)
417 return pid
418
419 def delete_project(self, project_id):
420 """
421 Delete a project.
422
423 :param project_id: project identifier.
424 :raises AuthconnOperationException: if project deletion failed.
425 """
426 idf = BaseTopic.id_field("projects", project_id)
427 r = self.db.del_one("projects", {idf: project_id})
428 idf = "project_id" if idf == "_id" else "project_name"
429 self.db.del_list("tokens", {idf: project_id})
430 return r
431
432 def update_project(self, project_id, project_info):
433 """
434 Change the name of a project
435
436 :param project_id: project to be changed
437 :param project_info: full project info
438 :return: None
439 :raises AuthconnOperationException: if project update failed.
440 """
441 self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info)