X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FNBI.git;a=blobdiff_plain;f=osm_nbi%2Fauth.py;h=576ae4d376d6964c7ccb489ee6dd0c179e31439d;hp=4ee9ce23ab2ee6bec66683d1f887537fa361de7c;hb=ace34903be528e8e10e096d3d059b81df30ddaa9;hpb=819d34c8315de5c2010743f499f7882b8957dcbf;ds=sidebyside diff --git a/osm_nbi/auth.py b/osm_nbi/auth.py index 4ee9ce2..576ae4d 100644 --- a/osm_nbi/auth.py +++ b/osm_nbi/auth.py @@ -1,26 +1,52 @@ # -*- coding: utf-8 -*- +# Copyright 2018 Whitestack, LLC +# Copyright 2018 Telefonica S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: esousa@whitestack.com or alfonso.tiernosepulveda@telefonica.com +## + + """ Authenticator is responsible for authenticating the users, create the tokens unscoped and scoped, retrieve the role list inside the projects that they are inserted """ -__author__ = "Eduardo Sousa " +__author__ = "Eduardo Sousa ; Alfonso Tierno " __date__ = "$27-jul-2018 23:59:59$" +import cherrypy import logging +import yaml from base64 import standard_b64decode from copy import deepcopy -from functools import reduce +# from functools import reduce +from hashlib import sha256 from http import HTTPStatus +from random import choice as random_choice from time import time +from os import path +from base_topic import BaseTopic # To allow project names in project_id -import cherrypy - -from authconn import AuthException +from authconn import AuthException, AuthExceptionUnauthorized from authconn_keystone import AuthconnKeystone -from engine import EngineException +from osm_common import dbmongo +from osm_common import dbmemory +from osm_common.dbbase import DbException class Authenticator: @@ -29,26 +55,27 @@ class Authenticator: Authorization. Initially it should support Openstack Keystone as a backend through a plugin model where more backends can be added and a RBAC model to manage permissions on operations. + This class must be threading safe """ - def __init__(self, engine): + periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned + + def __init__(self): """ Authenticator initializer. Setup the initial state of the object, while it waits for the config dictionary and database initialization. - - Note: engine is only here until all the calls can to it can be replaced. - - :param engine: reference to engine object used. """ - super().__init__() - - self.engine = engine - self.backend = None self.config = None self.db = None - self.tokens = dict() + self.tokens_cache = dict() + self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done + self.resources_to_operations_file = None + self.roles_to_operations_file = None + self.resources_to_operations_mapping = {} + self.operation_to_allowed_roles = {} self.logger = logging.getLogger("nbi.authenticator") + self.operations = [] def start(self, config): """ @@ -61,29 +88,173 @@ class Authenticator: self.config = config try: + if not self.db: + if config["database"]["driver"] == "mongo": + self.db = dbmongo.DbMongo() + self.db.db_connect(config["database"]) + elif config["database"]["driver"] == "memory": + self.db = dbmemory.DbMemory() + self.db.db_connect(config["database"]) + else: + raise AuthException("Invalid configuration param '{}' at '[database]':'driver'" + .format(config["database"]["driver"])) if not self.backend: if config["authentication"]["backend"] == "keystone": self.backend = AuthconnKeystone(self.config["authentication"]) elif config["authentication"]["backend"] == "internal": - pass + self._internal_tokens_prune() else: - raise Exception("No authentication backend defined") - if not self.db: - pass - # TODO: Implement database initialization - # NOTE: Database needed to store the mappings + raise AuthException("Unknown authentication backend: {}" + .format(config["authentication"]["backend"])) + if not self.resources_to_operations_file: + if "resources_to_operations" in config["rbac"]: + self.resources_to_operations_file = config["rbac"]["resources_to_operations"] + else: + possible_paths = ( + __file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml", + "./resources_to_operations.yml" + ) + for config_file in possible_paths: + if path.isfile(config_file): + self.resources_to_operations_file = config_file + break + if not self.resources_to_operations_file: + raise AuthException("Invalid permission configuration: resources_to_operations file missing") + if not self.roles_to_operations_file: + if "roles_to_operations" in config["rbac"]: + self.roles_to_operations_file = config["rbac"]["roles_to_operations"] + else: + possible_paths = ( + __file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml", + "./roles_to_operations.yml" + ) + for config_file in possible_paths: + if path.isfile(config_file): + self.roles_to_operations_file = config_file + break + if not self.roles_to_operations_file: + raise AuthException("Invalid permission configuration: roles_to_operations file missing") except Exception as e: raise AuthException(str(e)) + def stop(self): + try: + if self.db: + self.db.db_disconnect() + except DbException as e: + raise AuthException(str(e), http_code=e.http_code) + def init_db(self, target_version='1.0'): """ - Check if the database has been initialized. If not, create the required tables + Check if the database has been initialized, with at least one user. If not, create the required tables and insert the predefined mappings between roles and permissions. :param target_version: schema version that should be present in the database. :return: None if OK, exception if error or version is different. """ - pass + # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB) + # Operations encoding: " " + # Note: it is faster to rewrite the value than to check if it is already there or not + if self.config["authentication"]["backend"] == "internal": + return + + with open(self.resources_to_operations_file, "r") as stream: + resources_to_operations_yaml = yaml.load(stream) + + for resource, operation in resources_to_operations_yaml["resources_to_operations"].items(): + if operation not in self.operations: + self.operations.append(operation) + self.resources_to_operations_mapping[resource] = operation + + records = self.db.get_list("roles_operations") + + # Loading permissions to MongoDB if there is not any permission. + if not records: + with open(self.roles_to_operations_file, "r") as stream: + roles_to_operations_yaml = yaml.load(stream) + + role_names = [] + for role_with_operations in roles_to_operations_yaml["roles"]: + # Verifying if role already exists. If it does, raise exception + if role_with_operations["name"] not in role_names: + role_names.append(role_with_operations["name"]) + else: + raise AuthException("Duplicated role name '{}' at file '{}''" + .format(role_with_operations["name"], self.roles_to_operations_file)) + + if not role_with_operations["permissions"]: + continue + + for permission, is_allowed in role_with_operations["permissions"].items(): + if not isinstance(is_allowed, bool): + raise AuthException("Invalid value for permission '{}' at role '{}'; at file '{}'" + .format(permission, role_with_operations["name"], + self.roles_to_operations_file)) + + # TODO chek permission is ok + if permission[-1] == ":": + raise AuthException("Invalid permission '{}' terminated in ':' for role '{}'; at file {}" + .format(permission, role_with_operations["name"], + self.roles_to_operations_file)) + + if "default" not in role_with_operations["permissions"]: + role_with_operations["permissions"]["default"] = False + if "admin" not in role_with_operations["permissions"]: + role_with_operations["permissions"]["admin"] = False + + now = time() + role_with_operations["_admin"] = { + "created": now, + "modified": now, + } + + if self.config["authentication"]["backend"] != "internal" and \ + role_with_operations["name"] != "anonymous": + + backend_roles = self.backend.get_role_list(filter_q={"name": role_with_operations["name"]}) + + if backend_roles: + backend_id = backend_roles[0]["_id"] + else: + backend_id = self.backend.create_role(role_with_operations["name"]) + role_with_operations["_id"] = backend_id + + self.db.create("roles_operations", role_with_operations) + + if self.config["authentication"]["backend"] != "internal": + self.backend.assign_role_to_user("admin", "admin", "system_admin") + + self.load_operation_to_allowed_roles() + + def load_operation_to_allowed_roles(self): + """ + Fills the internal self.operation_to_allowed_roles based on database role content and self.operations + It works in a shadow copy and replace at the end to allow other threads working with the old copy + :return: None + """ + + permissions = {oper: [] for oper in self.operations} + records = self.db.get_list("roles_operations") + + ignore_fields = ["_id", "_admin", "name", "default"] + for record in records: + record_permissions = {oper: record["permissions"].get("default", False) for oper in self.operations} + operations_joined = [(oper, value) for oper, value in record["permissions"].items() + if oper not in ignore_fields] + operations_joined.sort(key=lambda x: x[0].count(":")) + + for oper in operations_joined: + match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys())) + + for m in match: + record_permissions[m] = oper[1] + + allowed_operations = [k for k, v in record_permissions.items() if v is True] + + for allowed_op in allowed_operations: + permissions[allowed_op].append(record["name"]) + + self.operation_to_allowed_roles = permissions def authorize(self): token = None @@ -102,7 +273,7 @@ class Authenticator: # 2. Try using session before request a new token. If not, basic authentication will generate token = cherrypy.session.get("Authorization") if token == "logout": - token = None # force Unauthorized response to insert user pasword again + token = None # force Unauthorized response to insert user password again elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"): # 3. Get new token from user password user = None @@ -116,58 +287,75 @@ class Authenticator: token = outdata["id"] cherrypy.session['Authorization'] = token if self.config["authentication"]["backend"] == "internal": - return self.engine.authorize(token) + return self._internal_authorize(token) else: - try: - self.backend.validate_token(token) - return self.tokens[token] - except AuthException: - self.del_token(token) - raise - except EngineException as e: - if cherrypy.session.get('Authorization'): - del cherrypy.session['Authorization'] - cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e) - raise AuthException(str(e)) + if not token: + raise AuthException("Needed a token or Authorization http header", + http_code=HTTPStatus.UNAUTHORIZED) + token_info = self.backend.validate_token(token) + # TODO add to token info remote host, port + + self.check_permissions(token_info, cherrypy.request.path_info, + cherrypy.request.method) + return token_info + except AuthException as e: + if not isinstance(e, AuthExceptionUnauthorized): + if cherrypy.session.get('Authorization'): + del cherrypy.session['Authorization'] + cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e) + raise def new_token(self, session, indata, remote): if self.config["authentication"]["backend"] == "internal": - return self.engine.new_token(session, indata, remote) + return self._internal_new_token(session, indata, remote) else: - if indata.get("username"): - token, projects = self.backend.authenticate_with_user_password( - indata.get("username"), indata.get("password")) - elif session: - token, projects = self.backend.authenticate_with_token( - session.get("id"), indata.get("project_id")) - else: - raise AuthException("Provide credentials: username/password or Authorization Bearer token", - http_code=HTTPStatus.UNAUTHORIZED) - - if indata.get("project_id"): - project_id = indata.get("project_id") - if project_id not in projects: - raise AuthException("Project {} not allowed for this user".format(project_id), - http_code=HTTPStatus.UNAUTHORIZED) - else: - project_id = projects[0] + current_token = None + if session: + current_token = session.get("token") + token_info = self.backend.authenticate( + user=indata.get("username"), + password=indata.get("password"), + token=current_token, + project=indata.get("project_id") + ) - if project_id == "admin": - session_admin = True - else: - session_admin = reduce(lambda x, y: x or (True if y == "admin" else False), - projects, False) + # if indata.get("username"): + # token, projects = self.backend.authenticate_with_user_password( + # indata.get("username"), indata.get("password")) + # elif session: + # token, projects = self.backend.authenticate_with_token( + # session.get("id"), indata.get("project_id")) + # else: + # raise AuthException("Provide credentials: username/password or Authorization Bearer token", + # http_code=HTTPStatus.UNAUTHORIZED) + # + # if indata.get("project_id"): + # project_id = indata.get("project_id") + # if project_id not in projects: + # raise AuthException("Project {} not allowed for this user".format(project_id), + # http_code=HTTPStatus.UNAUTHORIZED) + # else: + # project_id = projects[0] + # + # if not session: + # token, projects = self.backend.authenticate_with_token(token, project_id) + # + # if project_id == "admin": + # session_admin = True + # else: + # session_admin = reduce(lambda x, y: x or (True if y == "admin" else False), + # projects, False) now = time() new_session = { - "_id": token, - "id": token, + "_id": token_info["_id"], + "id": token_info["_id"], "issued_at": now, - "expires": now+3600, - "project_id": project_id, - "username": indata.get("username") if not session else session.get("username"), + "expires": token_info.get("expires", now + 3600), + "project_id": token_info["project_id"], + "username": token_info.get("username") or session.get("username"), "remote_port": remote.port, - "admin": session_admin + "admin": True if token_info.get("project_name") == "admin" else False # TODO put admin in RBAC } if remote.name: @@ -175,35 +363,243 @@ class Authenticator: elif remote.ip: new_session["remote_host"] = remote.ip - self.tokens[token] = new_session + # TODO: check if this can be avoided. Backend may provide enough information + self.tokens_cache[token_info["_id"]] = new_session return deepcopy(new_session) def get_token_list(self, session): if self.config["authentication"]["backend"] == "internal": - return self.engine.get_token_list(session) + return self._internal_get_token_list(session) else: - return [deepcopy(token) for token in self.tokens.values() + # TODO: check if this can be avoided. Backend may provide enough information + return [deepcopy(token) for token in self.tokens_cache.values() if token["username"] == session["username"]] def get_token(self, session, token): if self.config["authentication"]["backend"] == "internal": - return self.engine.get_token(session, token) + return self._internal_get_token(session, token) else: - token_value = self.tokens.get(token) + # TODO: check if this can be avoided. Backend may provide enough information + token_value = self.tokens_cache.get(token) if not token_value: - raise EngineException("token not found", http_code=HTTPStatus.NOT_FOUND) + raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND) if token_value["username"] != session["username"] and not session["admin"]: - raise EngineException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED) + raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED) return token_value def del_token(self, token): if self.config["authentication"]["backend"] == "internal": - return self.engine.del_token(token) + return self._internal_del_token(token) else: try: self.backend.revoke_token(token) - del self.tokens[token] + del self.tokens_cache[token] return "token '{}' deleted".format(token) except KeyError: - raise EngineException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND) + raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND) + + def check_permissions(self, session, url, method): + self.logger.info("Session: {}".format(session)) + self.logger.info("URL: {}".format(url)) + self.logger.info("Method: {}".format(method)) + + key, parameters = self._normalize_url(url, method) + + # TODO: Check if parameters might be useful for the decision + + operation = self.resources_to_operations_mapping[key] + roles_required = self.operation_to_allowed_roles[operation] + roles_allowed = [role["name"] for role in session["roles"]] + + # fills session["admin"] if some roles allows it + session["admin"] = False + for role in roles_allowed: + if role in self.operation_to_allowed_roles["admin"]: + session["admin"] = True + break + + if "anonymous" in roles_required: + return + + for role in roles_allowed: + if role in roles_required: + return + + raise AuthExceptionUnauthorized("Access denied: lack of permissions.") + + def get_user_list(self): + return self.backend.get_user_list() + + def _normalize_url(self, url, method): + # Removing query strings + normalized_url = url if '?' not in url else url[:url.find("?")] + normalized_url_splitted = normalized_url.split("/") + parameters = {} + + filtered_keys = [key for key in self.resources_to_operations_mapping.keys() + if method in key.split()[0]] + + for idx, path_part in enumerate(normalized_url_splitted): + tmp_keys = [] + for tmp_key in filtered_keys: + splitted = tmp_key.split()[1].split("/") + if idx >= len(splitted): + continue + elif "<" in splitted[idx] and ">" in splitted[idx]: + if splitted[idx] == "": + tmp_keys.append(tmp_key) + continue + elif idx == len(normalized_url_splitted) - 1 and \ + len(normalized_url_splitted) != len(splitted): + continue + else: + tmp_keys.append(tmp_key) + elif splitted[idx] == path_part: + if idx == len(normalized_url_splitted) - 1 and \ + len(normalized_url_splitted) != len(splitted): + continue + else: + tmp_keys.append(tmp_key) + filtered_keys = tmp_keys + if len(filtered_keys) == 1 and \ + filtered_keys[0].split("/")[-1] == "": + break + + if len(filtered_keys) == 0: + raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url)) + elif len(filtered_keys) > 1: + raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url)) + + filtered_key = filtered_keys[0] + + for idx, path_part in enumerate(filtered_key.split()[1].split("/")): + if "<" in path_part and ">" in path_part: + if path_part == "": + parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:]) + else: + parameters[path_part[1:-1]] = normalized_url_splitted[idx] + + return filtered_key, parameters + + def _internal_authorize(self, token_id): + try: + if not token_id: + raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED) + # try to get from cache first + now = time() + session = self.tokens_cache.get(token_id) + if session and session["expires"] < now: + # delete token. MUST be done with care, as another thread maybe already delete it. Do not use del + self.tokens_cache.pop(token_id, None) + session = None + if session: + return session + + # get from database if not in cache + session = self.db.get_one("tokens", {"_id": token_id}) + if session["expires"] < now: + raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED) + self.tokens_cache[token_id] = session + return session + except DbException as e: + if e.http_code == HTTPStatus.NOT_FOUND: + raise AuthException("Invalid Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED) + else: + raise + + except AuthException: + if self.config["global"].get("test.user_not_authorized"): + return {"id": "fake-token-id-for-test", + "project_id": self.config["global"].get("test.project_not_authorized", "admin"), + "username": self.config["global"]["test.user_not_authorized"], "admin": True} + else: + raise + + def _internal_new_token(self, session, indata, remote): + now = time() + user_content = None + + # Try using username/password + if indata.get("username"): + user_rows = self.db.get_list("users", {"username": indata.get("username")}) + if user_rows: + user_content = user_rows[0] + salt = user_content["_admin"]["salt"] + shadow_password = sha256(indata.get("password", "").encode('utf-8') + salt.encode('utf-8')).hexdigest() + if shadow_password != user_content["password"]: + user_content = None + if not user_content: + raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED) + elif session: + user_rows = self.db.get_list("users", {"username": session["username"]}) + if user_rows: + user_content = user_rows[0] + else: + raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED) + else: + raise AuthException("Provide credentials: username/password or Authorization Bearer token", + http_code=HTTPStatus.UNAUTHORIZED) + + token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') + for _ in range(0, 32)) + project_id = indata.get("project_id") + if project_id: + if project_id != "admin": + # To allow project names in project_id + proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id}) + if proj["_id"] not in user_content["projects"] and proj["name"] not in user_content["projects"]: + raise AuthException("project {} not allowed for this user" + .format(project_id), http_code=HTTPStatus.UNAUTHORIZED) + else: + project_id = user_content["projects"][0] + if project_id == "admin": + session_admin = True + else: + # To allow project names in project_id + project = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id}) + session_admin = project.get("admin", False) + new_session = {"issued_at": now, "expires": now + 3600, + "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"], + "remote_port": remote.port, "admin": session_admin} + if remote.name: + new_session["remote_host"] = remote.name + elif remote.ip: + new_session["remote_host"] = remote.ip + + self.tokens_cache[token_id] = new_session + self.db.create("tokens", new_session) + # check if database must be prune + self._internal_tokens_prune(now) + return deepcopy(new_session) + + def _internal_get_token_list(self, session): + now = time() + token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now}) + return token_list + + def _internal_get_token(self, session, token_id): + token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False) + if not token_value: + raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND) + if token_value["username"] != session["username"] and not session["admin"]: + raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED) + return token_value + + def _internal_del_token(self, token_id): + try: + self.tokens_cache.pop(token_id, None) + self.db.del_one("tokens", {"_id": token_id}) + return "token '{}' deleted".format(token_id) + except DbException as e: + if e.http_code == HTTPStatus.NOT_FOUND: + raise AuthException("Token '{}' not found".format(token_id), http_code=HTTPStatus.NOT_FOUND) + else: + raise + + def _internal_tokens_prune(self, now=None): + now = now or time() + if not self.next_db_prune_time or self.next_db_prune_time >= now: + self.db.del_list("tokens", {"expires.lt": now}) + self.next_db_prune_time = self.periodin_db_pruning + now + self.tokens_cache.clear() # force to reload tokens from database