44eaa9438df3048cb976db0b830da3b899f9c282
[osm/NBI.git] / osm_nbi / auth.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Whitestack, LLC
4 # Copyright 2018 Telefonica S.A.
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 alfonso.tiernosepulveda@telefonica.com
20 ##
21
22
23 """
24 Authenticator is responsible for authenticating the users,
25 create the tokens unscoped and scoped, retrieve the role
26 list inside the projects that they are inserted
27 """
28 from os import path
29
30 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
31 __date__ = "$27-jul-2018 23:59:59$"
32
33 import cherrypy
34 import logging
35 import yaml
36 from base64 import standard_b64decode
37 from copy import deepcopy
38 from functools import reduce
39 from hashlib import sha256
40 from http import HTTPStatus
41 from random import choice as random_choice
42 from time import time
43 from uuid import uuid4
44
45 from authconn import AuthException
46 from authconn_keystone import AuthconnKeystone
47 from osm_common import dbmongo
48 from osm_common import dbmemory
49 from osm_common.dbbase import DbException
50
51
52 class Authenticator:
53 """
54 This class should hold all the mechanisms for User Authentication and
55 Authorization. Initially it should support Openstack Keystone as a
56 backend through a plugin model where more backends can be added and a
57 RBAC model to manage permissions on operations.
58 """
59
60 periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
61
62 def __init__(self):
63 """
64 Authenticator initializer. Setup the initial state of the object,
65 while it waits for the config dictionary and database initialization.
66 """
67 self.backend = None
68 self.config = None
69 self.db = None
70 self.tokens_cache = dict()
71 self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done
72 self.resources_to_operations_file = None
73 self.roles_to_operations_file = None
74 self.resources_to_operations_mapping = {}
75 self.operation_to_allowed_roles = {}
76 self.logger = logging.getLogger("nbi.authenticator")
77
78 def start(self, config):
79 """
80 Method to configure the Authenticator object. This method should be called
81 after object creation. It is responsible by initializing the selected backend,
82 as well as the initialization of the database connection.
83
84 :param config: dictionary containing the relevant parameters for this object.
85 """
86 self.config = config
87
88 try:
89 if not self.db:
90 if config["database"]["driver"] == "mongo":
91 self.db = dbmongo.DbMongo()
92 self.db.db_connect(config["database"])
93 elif config["database"]["driver"] == "memory":
94 self.db = dbmemory.DbMemory()
95 self.db.db_connect(config["database"])
96 else:
97 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
98 .format(config["database"]["driver"]))
99 if not self.backend:
100 if config["authentication"]["backend"] == "keystone":
101 self.backend = AuthconnKeystone(self.config["authentication"])
102 elif config["authentication"]["backend"] == "internal":
103 self._internal_tokens_prune()
104 else:
105 raise AuthException("Unknown authentication backend: {}"
106 .format(config["authentication"]["backend"]))
107 if not self.resources_to_operations_file:
108 if "resources_to_operations" in config["rbac"]:
109 self.resources_to_operations_file = config["rbac"]["resources_to_operations"]
110 else:
111 for config_file in (__file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml",
112 "./resources_to_operations.yml"):
113 if path.isfile(config_file):
114 self.resources_to_operations_file = config_file
115 break
116 if not self.resources_to_operations_file:
117 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
118 if not self.roles_to_operations_file:
119 if "roles_to_operations" in config["rbac"]:
120 self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
121 else:
122 for config_file in (__file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml",
123 "./roles_to_operations.yml"):
124 if path.isfile(config_file):
125 self.roles_to_operations_file = config_file
126 break
127 if not self.roles_to_operations_file:
128 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
129 except Exception as e:
130 raise AuthException(str(e))
131
132 def stop(self):
133 try:
134 if self.db:
135 self.db.db_disconnect()
136 except DbException as e:
137 raise AuthException(str(e), http_code=e.http_code)
138
139 def init_db(self, target_version='1.0'):
140 """
141 Check if the database has been initialized, with at least one user. If not, create the required tables
142 and insert the predefined mappings between roles and permissions.
143
144 :param target_version: schema version that should be present in the database.
145 :return: None if OK, exception if error or version is different.
146 """
147 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
148 # Operations encoding: "<METHOD> <URL>"
149 # Note: it is faster to rewrite the value than to check if it is already there or not
150 operations = []
151 with open(self.resources_to_operations_file, "r") as stream:
152 resources_to_operations_yaml = yaml.load(stream)
153
154 for resource, operation in resources_to_operations_yaml["resources_to_operations"].items():
155 operation_key = operation.replace(".", ":")
156 if operation_key not in operations:
157 operations.append(operation_key)
158 self.resources_to_operations_mapping[resource] = operation_key
159
160 records = self.db.get_list("roles_operations")
161
162 # Loading permissions to MongoDB. If there are permissions already in MongoDB, do nothing.
163 if len(records) == 0:
164 with open(self.roles_to_operations_file, "r") as stream:
165 roles_to_operations_yaml = yaml.load(stream)
166
167 roles = []
168 for role_with_operations in roles_to_operations_yaml["roles_to_operations"]:
169 # Verifying if role already exists. If it does, send warning to log and ignore it.
170 if role_with_operations["role"] not in roles:
171 roles.append(role_with_operations["role"])
172 else:
173 self.logger.warning("Duplicated role with name: {0}. Role definition is ignored."
174 .format(role_with_operations["role"]))
175 continue
176
177 operations = {}
178 root = None
179
180 if not role_with_operations["operations"]:
181 continue
182
183 for operation, is_allowed in role_with_operations["operations"].items():
184 if not isinstance(is_allowed, bool):
185 continue
186
187 if operation == ".":
188 root = is_allowed
189 continue
190
191 if len(operation) != 1 and operation[-1] == ".":
192 self.logger.warning("Invalid operation {0} terminated in '.'. "
193 "Operation will be discarded"
194 .format(operation))
195 continue
196
197 operation_key = operation.replace(".", ":")
198 if operation_key not in operations.keys():
199 operations[operation_key] = is_allowed
200 else:
201 self.logger.info("In role {0}, the operation {1} with the value {2} was discarded due to "
202 "repetition.".format(role_with_operations["role"], operation, is_allowed))
203
204 if not root:
205 root = False
206 self.logger.info("Root for role {0} not defined. Default value 'False' applied."
207 .format(role_with_operations["role"]))
208
209 now = time()
210 operation_to_roles_item = {
211 "_id": str(uuid4()),
212 "_admin": {
213 "created": now,
214 "modified": now,
215 },
216 "role": role_with_operations["role"],
217 "root": root
218 }
219
220 for operation, value in operations.items():
221 operation_to_roles_item[operation] = value
222
223 self.db.create("roles_operations", operation_to_roles_item)
224
225 permissions = {oper: [] for oper in operations}
226 records = self.db.get_list("roles_operations")
227
228 ignore_fields = ["_id", "_admin", "role", "root"]
229 roles = []
230 for record in records:
231
232 roles.append(record["role"])
233 record_permissions = {oper: record["root"] for oper in operations}
234 operations_joined = [(oper, value) for oper, value in record.items() if oper not in ignore_fields]
235 operations_joined.sort(key=lambda x: x[0].count(":"))
236
237 for oper in operations_joined:
238 match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()))
239
240 for m in match:
241 record_permissions[m] = oper[1]
242
243 allowed_operations = [k for k, v in record_permissions.items() if v is True]
244
245 for allowed_op in allowed_operations:
246 permissions[allowed_op].append(record["role"])
247
248 for oper, role_list in permissions.items():
249 self.operation_to_allowed_roles[oper] = role_list
250
251 if self.config["authentication"]["backend"] != "internal":
252 for role in roles:
253 if role == "anonymous":
254 continue
255 self.backend.create_role(role)
256
257 self.backend.assign_role_to_user("admin", "admin", "system_admin")
258
259 def authorize(self):
260 token = None
261 user_passwd64 = None
262 try:
263 # 1. Get token Authorization bearer
264 auth = cherrypy.request.headers.get("Authorization")
265 if auth:
266 auth_list = auth.split(" ")
267 if auth_list[0].lower() == "bearer":
268 token = auth_list[-1]
269 elif auth_list[0].lower() == "basic":
270 user_passwd64 = auth_list[-1]
271 if not token:
272 if cherrypy.session.get("Authorization"):
273 # 2. Try using session before request a new token. If not, basic authentication will generate
274 token = cherrypy.session.get("Authorization")
275 if token == "logout":
276 token = None # force Unauthorized response to insert user password again
277 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
278 # 3. Get new token from user password
279 user = None
280 passwd = None
281 try:
282 user_passwd = standard_b64decode(user_passwd64).decode()
283 user, _, passwd = user_passwd.partition(":")
284 except Exception:
285 pass
286 outdata = self.new_token(None, {"username": user, "password": passwd})
287 token = outdata["id"]
288 cherrypy.session['Authorization'] = token
289 if self.config["authentication"]["backend"] == "internal":
290 return self._internal_authorize(token)
291 else:
292 if not token:
293 raise AuthException("Needed a token or Authorization http header",
294 http_code=HTTPStatus.UNAUTHORIZED)
295 try:
296 self.backend.validate_token(token)
297 self.check_permissions(self.tokens_cache[token], cherrypy.request.path_info,
298 cherrypy.request.method)
299 # TODO: check if this can be avoided. Backend may provide enough information
300 return deepcopy(self.tokens_cache[token])
301 except AuthException:
302 self.del_token(token)
303 raise
304 except AuthException as e:
305 if cherrypy.session.get('Authorization'):
306 del cherrypy.session['Authorization']
307 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
308 raise AuthException(str(e))
309
310 def new_token(self, session, indata, remote):
311 if self.config["authentication"]["backend"] == "internal":
312 return self._internal_new_token(session, indata, remote)
313 else:
314 if indata.get("username"):
315 token, projects = self.backend.authenticate_with_user_password(
316 indata.get("username"), indata.get("password"))
317 elif session:
318 token, projects = self.backend.authenticate_with_token(
319 session.get("id"), indata.get("project_id"))
320 else:
321 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
322 http_code=HTTPStatus.UNAUTHORIZED)
323
324 if indata.get("project_id"):
325 project_id = indata.get("project_id")
326 if project_id not in projects:
327 raise AuthException("Project {} not allowed for this user".format(project_id),
328 http_code=HTTPStatus.UNAUTHORIZED)
329 else:
330 project_id = projects[0]
331
332 if not session:
333 token, projects = self.backend.authenticate_with_token(token, project_id)
334
335 if project_id == "admin":
336 session_admin = True
337 else:
338 session_admin = reduce(lambda x, y: x or (True if y == "admin" else False),
339 projects, False)
340
341 now = time()
342 new_session = {
343 "_id": token,
344 "id": token,
345 "issued_at": now,
346 "expires": now + 3600,
347 "project_id": project_id,
348 "username": indata.get("username") if not session else session.get("username"),
349 "remote_port": remote.port,
350 "admin": session_admin
351 }
352
353 if remote.name:
354 new_session["remote_host"] = remote.name
355 elif remote.ip:
356 new_session["remote_host"] = remote.ip
357
358 # TODO: check if this can be avoided. Backend may provide enough information
359 self.tokens_cache[token] = new_session
360
361 return deepcopy(new_session)
362
363 def get_token_list(self, session):
364 if self.config["authentication"]["backend"] == "internal":
365 return self._internal_get_token_list(session)
366 else:
367 # TODO: check if this can be avoided. Backend may provide enough information
368 return [deepcopy(token) for token in self.tokens_cache.values()
369 if token["username"] == session["username"]]
370
371 def get_token(self, session, token):
372 if self.config["authentication"]["backend"] == "internal":
373 return self._internal_get_token(session, token)
374 else:
375 # TODO: check if this can be avoided. Backend may provide enough information
376 token_value = self.tokens_cache.get(token)
377 if not token_value:
378 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
379 if token_value["username"] != session["username"] and not session["admin"]:
380 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
381 return token_value
382
383 def del_token(self, token):
384 if self.config["authentication"]["backend"] == "internal":
385 return self._internal_del_token(token)
386 else:
387 try:
388 self.backend.revoke_token(token)
389 del self.tokens_cache[token]
390 return "token '{}' deleted".format(token)
391 except KeyError:
392 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
393
394 def check_permissions(self, session, url, method):
395 self.logger.info("Session: {}".format(session))
396 self.logger.info("URL: {}".format(url))
397 self.logger.info("Method: {}".format(method))
398
399 key, parameters = self._normalize_url(url, method)
400
401 # TODO: Check if parameters might be useful for the decision
402
403 operation = self.resources_to_operations_mapping[key]
404 roles_required = self.operation_to_allowed_roles[operation]
405 roles_allowed = self.backend.get_role_list(session["id"])
406
407 if "anonymous" in roles_required:
408 return
409
410 for role in roles_allowed:
411 if role in roles_required:
412 return
413
414 raise AuthException("Access denied: lack of permissions.")
415
416 def _normalize_url(self, url, method):
417 # Removing query strings
418 normalized_url = url if '?' not in url else url[:url.find("?")]
419 normalized_url_splitted = normalized_url.split("/")
420 parameters = {}
421
422 filtered_keys = [key for key in self.resources_to_operations_mapping.keys()
423 if method in key.split()[0]]
424
425 for idx, path_part in enumerate(normalized_url_splitted):
426 tmp_keys = []
427 for tmp_key in filtered_keys:
428 splitted = tmp_key.split()[1].split("/")
429 if "<" in splitted[idx] and ">" in splitted[idx]:
430 if splitted[idx] == "<artifactPath>":
431 tmp_keys.append(tmp_key)
432 continue
433 elif idx == len(normalized_url_splitted) - 1 and \
434 len(normalized_url_splitted) != len(splitted):
435 continue
436 else:
437 tmp_keys.append(tmp_key)
438 elif splitted[idx] == path_part:
439 if idx == len(normalized_url_splitted) - 1 and \
440 len(normalized_url_splitted) != len(splitted):
441 continue
442 else:
443 tmp_keys.append(tmp_key)
444 filtered_keys = tmp_keys
445 if len(filtered_keys) == 1 and \
446 filtered_keys[0].split("/")[-1] == "<artifactPath>":
447 break
448
449 if len(filtered_keys) == 0:
450 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url))
451 elif len(filtered_keys) > 1:
452 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url))
453
454 filtered_key = filtered_keys[0]
455
456 for idx, path_part in enumerate(filtered_key.split()[1].split("/")):
457 if "<" in path_part and ">" in path_part:
458 if path_part == "<artifactPath>":
459 parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:])
460 else:
461 parameters[path_part[1:-1]] = normalized_url_splitted[idx]
462
463 return filtered_key, parameters
464
465 def _internal_authorize(self, token_id):
466 try:
467 if not token_id:
468 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
469 # try to get from cache first
470 now = time()
471 session = self.tokens_cache.get(token_id)
472 if session and session["expires"] < now:
473 del self.tokens_cache[token_id]
474 session = None
475 if session:
476 return session
477
478 # get from database if not in cache
479 session = self.db.get_one("tokens", {"_id": token_id})
480 if session["expires"] < now:
481 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
482 self.tokens_cache[token_id] = session
483 return session
484 except DbException as e:
485 if e.http_code == HTTPStatus.NOT_FOUND:
486 raise AuthException("Invalid Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
487 else:
488 raise
489
490 except AuthException:
491 if self.config["global"].get("test.user_not_authorized"):
492 return {"id": "fake-token-id-for-test",
493 "project_id": self.config["global"].get("test.project_not_authorized", "admin"),
494 "username": self.config["global"]["test.user_not_authorized"]}
495 else:
496 raise
497
498 def _internal_new_token(self, session, indata, remote):
499 now = time()
500 user_content = None
501
502 # Try using username/password
503 if indata.get("username"):
504 user_rows = self.db.get_list("users", {"username": indata.get("username")})
505 if user_rows:
506 user_content = user_rows[0]
507 salt = user_content["_admin"]["salt"]
508 shadow_password = sha256(indata.get("password", "").encode('utf-8') + salt.encode('utf-8')).hexdigest()
509 if shadow_password != user_content["password"]:
510 user_content = None
511 if not user_content:
512 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
513 elif session:
514 user_rows = self.db.get_list("users", {"username": session["username"]})
515 if user_rows:
516 user_content = user_rows[0]
517 else:
518 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
519 else:
520 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
521 http_code=HTTPStatus.UNAUTHORIZED)
522
523 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
524 for _ in range(0, 32))
525 if indata.get("project_id"):
526 project_id = indata.get("project_id")
527 if project_id not in user_content["projects"]:
528 raise AuthException("project {} not allowed for this user"
529 .format(project_id), http_code=HTTPStatus.UNAUTHORIZED)
530 else:
531 project_id = user_content["projects"][0]
532 if project_id == "admin":
533 session_admin = True
534 else:
535 project = self.db.get_one("projects", {"_id": project_id})
536 session_admin = project.get("admin", False)
537 new_session = {"issued_at": now, "expires": now + 3600,
538 "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"],
539 "remote_port": remote.port, "admin": session_admin}
540 if remote.name:
541 new_session["remote_host"] = remote.name
542 elif remote.ip:
543 new_session["remote_host"] = remote.ip
544
545 self.tokens_cache[token_id] = new_session
546 self.db.create("tokens", new_session)
547 # check if database must be prune
548 self._internal_tokens_prune(now)
549 return deepcopy(new_session)
550
551 def _internal_get_token_list(self, session):
552 now = time()
553 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
554 return token_list
555
556 def _internal_get_token(self, session, token_id):
557 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
558 if not token_value:
559 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
560 if token_value["username"] != session["username"] and not session["admin"]:
561 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
562 return token_value
563
564 def _internal_del_token(self, token_id):
565 try:
566 self.tokens_cache.pop(token_id, None)
567 self.db.del_one("tokens", {"_id": token_id})
568 return "token '{}' deleted".format(token_id)
569 except DbException as e:
570 if e.http_code == HTTPStatus.NOT_FOUND:
571 raise AuthException("Token '{}' not found".format(token_id), http_code=HTTPStatus.NOT_FOUND)
572 else:
573 raise
574
575 def _internal_tokens_prune(self, now=None):
576 now = now or time()
577 if not self.next_db_prune_time or self.next_db_prune_time >= now:
578 self.db.del_list("tokens", {"expires.lt": now})
579 self.next_db_prune_time = self.periodin_db_pruning + now
580 self.tokens_cache.clear() # force to reload tokens from database