bug 832. Fixing non-authorized configuration for testing
[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 __date__ = "$06-jun-2019 11:16:08$"
29
30 from authconn import Authconn, AuthException # , AuthconnOperationException
31 from osm_common.dbbase import DbException
32 from base_topic import BaseTopic
33
34 import logging
35 import re
36 from time import time
37 from http import HTTPStatus
38 from uuid import uuid4
39 from hashlib import sha256
40 from copy import deepcopy
41 from random import choice as random_choice
42
43
44 class AuthconnInternal(Authconn):
45 def __init__(self, config, db, token_cache):
46 Authconn.__init__(self, config, db, token_cache)
47
48 self.logger = logging.getLogger("nbi.authenticator.internal")
49
50 self.db = db
51 self.token_cache = token_cache
52
53 # To be Confirmed
54 self.auth = None
55 self.sess = None
56
57 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()
79 token_info = self.token_cache.get(token)
80 if token_info and token_info["expires"] < now:
81 # 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)
83 token_info = None
84
85 # get from database if not in cache
86 if not token_info:
87 token_info = self.db.get_one("tokens", {"_id": token})
88 if token_info["expires"] < now:
89 raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED)
90
91 return token_info
92
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:
99 raise
100 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
124 def authenticate(self, user, password, project=None, token_info=None):
125 """
126 Authenticate a user using username/password or previous token_info plus project; its creates a new token
127
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
131 :param token_info: previous token_info to obtain authorization
132 :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
144 # 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]
159 else:
160 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)
164
165 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
166 for _ in range(0, 32))
167
168 # projects = user_content.get("projects", [])
169 prm_list = user_content.get("project_role_mappings", [])
170
171 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)
175
176 projects = [prm["project"] for prm in prm_list]
177
178 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)
184
185 # 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)
190
191 # 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}]
205
206 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 }
217
218 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={}):
223 """
224 Get role list.
225
226 :return: returns the list of roles.
227 """
228 return self.db.get_list("roles", filter_q)
229
230 def create_role(self, role_info):
231 """
232 Create a role.
233
234 :param role_info: full role info.
235 :return: returns the role id.
236 :raises AuthconnOperationException: if role creation failed.
237 """
238 # TODO: Check that role name does not exist ?
239 rid = str(uuid4())
240 role_info["_id"] = rid
241 rid = self.db.create("roles", role_info)
242 return rid
243
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 """
251 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})
329 return True
330
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)
343 project_id_name = {}
344 role_id_name = {}
345 for user in users:
346 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
351 for prm in prms:
352 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
384 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)