From 7ddb0732d05743a56ee3376446f76be8fa73d3ad Mon Sep 17 00:00:00 2001 From: K Sai Kiran Date: Fri, 30 Oct 2020 11:14:44 +0530 Subject: [PATCH 1/1] Feature 8532: Added new plugin authconn tacacs Added plugin authconn_tacacs.py Created new function validate_user() to contain the logic for username password validation. In authconn_tacacs, validate_user will be redefined to connect to TACACS. Created class variables which will be collections for internal mode. For TACACS mode, they will be roles_tacacs, projects_tacacs etc. Change-Id: Ib7fc8900860a492a79f6d0220bcdbb582edad017 Signed-off-by: K Sai Kiran --- Dockerfile.local | 1 + debian/python3-osm-nbi.postinst | 1 + osm_nbi/auth.py | 10 ++- osm_nbi/authconn_internal.py | 98 +++++++++++++++--------- osm_nbi/authconn_tacacs.py | 131 ++++++++++++++++++++++++++++++++ osm_nbi/engine.py | 4 + osm_nbi/nbi.cfg | 8 +- requirements.txt | 1 + 8 files changed, 213 insertions(+), 41 deletions(-) create mode 100644 osm_nbi/authconn_tacacs.py diff --git a/Dockerfile.local b/Dockerfile.local index 348feff..5ba1240 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -24,6 +24,7 @@ RUN apt-get update && apt-get install -y git python3 \ python3-pymongo python3-yaml python3-pip python3-keystoneclient \ && python3 -m pip install pip --upgrade \ && python3 -m pip install aiokafka dataclasses aiohttp cherrypy==18.1.2 keystoneauth1 requests jsonschema==3.2.0 \ + && python3 -m pip install tacacs_plus \ && mkdir -p /app/storage/kafka && mkdir -p /app/log # OSM_COMMON diff --git a/debian/python3-osm-nbi.postinst b/debian/python3-osm-nbi.postinst index 54dadb1..29baa0d 100755 --- a/debian/python3-osm-nbi.postinst +++ b/debian/python3-osm-nbi.postinst @@ -24,6 +24,7 @@ python3 -m pip install -U pip python3 -m pip install cherrypy==18.1.2 python3 -m pip install keystoneauth1 python3 -m pip install jsonschema==3.2.0 +python3 -m pip install tacacs_plus #Creation of log folder mkdir -p /var/log/osm diff --git a/osm_nbi/auth.py b/osm_nbi/auth.py index 7cbc404..6cbfe68 100644 --- a/osm_nbi/auth.py +++ b/osm_nbi/auth.py @@ -42,6 +42,7 @@ from os import path from osm_nbi.authconn import AuthException, AuthconnException, AuthExceptionUnauthorized from osm_nbi.authconn_keystone import AuthconnKeystone from osm_nbi.authconn_internal import AuthconnInternal +from osm_nbi.authconn_tacacs import AuthconnTacacs from osm_common import dbmemory, dbmongo, msglocal, msgkafka from osm_common.dbbase import DbException from osm_nbi.validation import is_valid_uuid @@ -119,7 +120,10 @@ class Authenticator: self.backend = AuthconnKeystone(self.config["authentication"], self.db, self.role_permissions) elif config["authentication"]["backend"] == "internal": self.backend = AuthconnInternal(self.config["authentication"], self.db, self.role_permissions) - self._internal_tokens_prune() + self._internal_tokens_prune("tokens") + elif config["authentication"]["backend"] == "tacacs": + self.backend = AuthconnTacacs(self.config["authentication"], self.db, self.role_permissions) + self._internal_tokens_prune("tokens_tacacs") else: raise AuthException("Unknown authentication backend: {}" .format(config["authentication"]["backend"])) @@ -591,10 +595,10 @@ class Authenticator: raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED) return token_value - def _internal_tokens_prune(self, now=None): + def _internal_tokens_prune(self, token_collection, 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.db.del_list(token_collection, {"expires.lt": now}) self.next_db_prune_time = self.periodin_db_pruning + now # self.tokens_cache.clear() # not required any more diff --git a/osm_nbi/authconn_internal.py b/osm_nbi/authconn_internal.py index b8cfe5b..b3de1cd 100644 --- a/osm_nbi/authconn_internal.py +++ b/osm_nbi/authconn_internal.py @@ -47,6 +47,11 @@ class AuthconnInternal(Authconn): token_time_window = 2 # seconds token_delay = 1 # seconds to wait upon second request within time window + users_collection = "users" + roles_collection = "roles" + projects_collection = "projects" + tokens_collection = "tokens" + def __init__(self, config, db, role_permissions): Authconn.__init__(self, config, db, role_permissions) self.logger = logging.getLogger("nbi.authenticator.internal") @@ -82,7 +87,7 @@ class AuthconnInternal(Authconn): # get from database if not in cache # if not token_info: - token_info = self.db.get_one("tokens", {"_id": token}) + token_info = self.db.get_one(self.tokens_collection, {"_id": token}) if token_info["expires"] < now: raise AuthException("Expired Token or Authorization HTTP header", http_code=HTTPStatus.UNAUTHORIZED) @@ -108,7 +113,7 @@ class AuthconnInternal(Authconn): """ try: # self.token_cache.pop(token, None) - self.db.del_one("tokens", {"_id": token}) + self.db.del_one(self.tokens_collection, {"_id": token}) return True except DbException as e: if e.http_code == HTTPStatus.NOT_FOUND: @@ -119,6 +124,22 @@ class AuthconnInternal(Authconn): self.logger.exception(exmsg) raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED) + def validate_user(self, user, password): + """ + Validate username and password via appropriate backend. + :param user: username of the user. + :param password: password to be validated. + """ + user_rows = self.db.get_list(self.users_collection, {BaseTopic.id_field("users", user): user}) + user_content = None + if user_rows: + user_content = user_rows[0] + salt = user_content["_admin"]["salt"] + shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest() + if shadow_password != user_content["password"]: + user_content = None + return user_content + def authenticate(self, credentials, token_info=None): """ Authenticate a user using username/password or previous token_info plus project; its creates a new token @@ -145,17 +166,13 @@ class AuthconnInternal(Authconn): # Try using username/password if user: - user_rows = self.db.get_list("users", {BaseTopic.id_field("users", user): user}) - if user_rows: - user_content = user_rows[0] - salt = user_content["_admin"]["salt"] - shadow_password = sha256(password.encode('utf-8') + salt.encode('utf-8')).hexdigest() - if shadow_password != user_content["password"]: - user_content = None + user_content = self.validate_user(user, password) if not user_content: raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED) + if not user_content.get("_admin", None): + raise AuthException("No default project for this user.", http_code=HTTPStatus.UNAUTHORIZED) elif token_info: - user_rows = self.db.get_list("users", {"username": token_info["username"]}) + user_rows = self.db.get_list(self.users_collection, {"username": token_info["username"]}) if user_rows: user_content = user_rows[0] else: @@ -163,13 +180,13 @@ class AuthconnInternal(Authconn): else: raise AuthException("Provide credentials: username/password or Authorization Bearer token", http_code=HTTPStatus.UNAUTHORIZED) - # Delay upon second request within time window if now - user_content["_admin"].get("last_token_time", 0) < self.token_time_window: sleep(self.token_delay) # user_content["_admin"]["last_token_time"] = now # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions - self.db.set_one("users", {"_id": user_content["_id"]}, {"_admin.last_token_time": now}) + self.db.set_one(self.users_collection, + {"_id": user_content["_id"]}, {"_admin.last_token_time": now}) token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(0, 32)) @@ -184,7 +201,8 @@ class AuthconnInternal(Authconn): projects = [prm["project"] for prm in prm_list] - proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project): project}) + proj = self.db.get_one(self.projects_collection, + {BaseTopic.id_field("projects", project): project}) project_name = proj["name"] project_id = proj["_id"] if project_name not in projects and project_id not in projects: @@ -202,14 +220,15 @@ class AuthconnInternal(Authconn): roles_list = [] for prm in prm_list: if prm["project"] in [project_id, project_name]: - role = self.db.get_one("roles", {BaseTopic.id_field("roles", prm["role"]): prm["role"]}) + role = self.db.get_one(self.roles_collection, + {BaseTopic.id_field("roles", prm["role"]): prm["role"]}) rid = role["_id"] if rid not in roles: rnm = role["name"] roles.append(rid) roles_list.append({"name": rnm, "id": rid}) if not roles_list: - rid = self.db.get_one("roles", {"name": "project_admin"})["_id"] + rid = self.db.get_one(self.roles_collection, {"name": "project_admin"})["_id"] roles_list = [{"name": "project_admin", "id": rid}] new_token = {"issued_at": now, @@ -224,7 +243,7 @@ class AuthconnInternal(Authconn): "roles": roles_list, } - self.db.create("tokens", new_token) + self.db.create(self.tokens_collection, new_token) return deepcopy(new_token) def get_role_list(self, filter_q={}): @@ -233,7 +252,7 @@ class AuthconnInternal(Authconn): :return: returns the list of roles. """ - return self.db.get_list("roles", filter_q) + return self.db.get_list(self.roles_collection, filter_q) def create_role(self, role_info): """ @@ -246,7 +265,7 @@ class AuthconnInternal(Authconn): # TODO: Check that role name does not exist ? rid = str(uuid4()) role_info["_id"] = rid - rid = self.db.create("roles", role_info) + rid = self.db.create(self.roles_collection, role_info) return rid def delete_role(self, role_id): @@ -256,8 +275,8 @@ class AuthconnInternal(Authconn): :param role_id: role identifier. :raises AuthconnOperationException: if role deletion failed. """ - rc = self.db.del_one("roles", {"_id": role_id}) - self.db.del_list("tokens", {"roles.id": role_id}) + rc = self.db.del_one(self.roles_collection, {"_id": role_id}) + self.db.del_list(self.tokens_collection, {"roles.id": role_id}) return rc def update_role(self, role_info): @@ -269,7 +288,7 @@ class AuthconnInternal(Authconn): :raises AuthconnOperationException: if user creation failed. """ rid = role_info["_id"] - self.db.set_one("roles", {"_id": rid}, role_info) + self.db.set_one(self.roles_collection, {"_id": rid}, role_info) return {"_id": rid, "name": role_info["name"]} def create_user(self, user_info): @@ -287,7 +306,7 @@ class AuthconnInternal(Authconn): # "projects" are not stored any more if "projects" in user_info: del user_info["projects"] - self.db.create("users", user_info) + self.db.create(self.users_collection, user_info) return {"username": user_info["username"], "_id": user_info["_id"]} def update_user(self, user_info): @@ -297,7 +316,7 @@ class AuthconnInternal(Authconn): :param user_info: user info modifications """ uid = user_info["_id"] - user_data = self.db.get_one("users", {BaseTopic.id_field("users", uid): uid}) + user_data = self.db.get_one(self.users_collection, {BaseTopic.id_field("users", uid): uid}) BaseTopic.format_on_edit(user_data, user_info) # User Name usnm = user_info.get("username") @@ -327,10 +346,10 @@ class AuthconnInternal(Authconn): except ValueError: pass idf = BaseTopic.id_field("users", uid) - self.db.set_one("users", {idf: uid}, user_data) + self.db.set_one(self.users_collection, {idf: uid}, user_data) if user_info.get("remove_project_role_mappings"): idf = "user_id" if idf == "_id" else idf - self.db.del_list("tokens", {idf: uid}) + self.db.del_list(self.tokens_collection, {idf: uid}) def delete_user(self, user_id): """ @@ -339,8 +358,8 @@ class AuthconnInternal(Authconn): :param user_id: user identifier. :raises AuthconnOperationException: if user deletion failed. """ - self.db.del_one("users", {"_id": user_id}) - self.db.del_list("tokens", {"user_id": user_id}) + self.db.del_one(self.users_collection, {"_id": user_id}) + self.db.del_list(self.tokens_collection, {"user_id": user_id}) return True def get_user_list(self, filter_q=None): @@ -358,7 +377,7 @@ class AuthconnInternal(Authconn): if filt.get("username") and is_valid_uuid(filt["username"]): # username cannot be a uuid. If this is the case, change from username to _id filt["_id"] = filt.pop("username") - users = self.db.get_list("users", filt) + users = self.db.get_list(self.users_collection, filt) project_id_name = {} role_id_name = {} for user in users: @@ -370,7 +389,8 @@ class AuthconnInternal(Authconn): for prm in prms: project_id = prm["project"] if project_id not in project_id_name: - pr = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, + pr = self.db.get_one(self.projects_collection, + {BaseTopic.id_field("projects", project_id): project_id}, fail_on_empty=False) project_id_name[project_id] = pr["name"] if pr else None prm["project_name"] = project_id_name[project_id] @@ -379,7 +399,8 @@ class AuthconnInternal(Authconn): role_id = prm["role"] if role_id not in role_id_name: - role = self.db.get_one("roles", {BaseTopic.id_field("roles", role_id): role_id}, + role = self.db.get_one(self.roles_collection, + {BaseTopic.id_field("roles", role_id): role_id}, fail_on_empty=False) role_id_name[role_id] = role["name"] if role else None prm["role_name"] = role_id_name[role_id] @@ -387,9 +408,11 @@ class AuthconnInternal(Authconn): elif projects: # user created with an old version. Create a project_role mapping with role project_admin user["project_role_mappings"] = [] - role = self.db.get_one("roles", {BaseTopic.id_field("roles", "project_admin"): "project_admin"}) + role = self.db.get_one(self.roles_collection, + {BaseTopic.id_field("roles", "project_admin"): "project_admin"}) for p_id_name in projects: - pr = self.db.get_one("projects", {BaseTopic.id_field("projects", p_id_name): p_id_name}) + pr = self.db.get_one(self.projects_collection, + {BaseTopic.id_field("projects", p_id_name): p_id_name}) prm = {"project": pr["_id"], "project_name": pr["name"], "role_name": "project_admin", @@ -408,7 +431,7 @@ class AuthconnInternal(Authconn): :return: returns the list of projects. """ - return self.db.get_list("projects", filter_q) + return self.db.get_list(self.projects_collection, filter_q) def create_project(self, project_info): """ @@ -418,7 +441,7 @@ class AuthconnInternal(Authconn): :return: the internal id of the created project :raises AuthconnOperationException: if project creation failed. """ - pid = self.db.create("projects", project_info) + pid = self.db.create(self.projects_collection, project_info) return pid def delete_project(self, project_id): @@ -429,9 +452,9 @@ class AuthconnInternal(Authconn): :raises AuthconnOperationException: if project deletion failed. """ idf = BaseTopic.id_field("projects", project_id) - r = self.db.del_one("projects", {idf: project_id}) + r = self.db.del_one(self.projects_collection, {idf: project_id}) idf = "project_id" if idf == "_id" else "project_name" - self.db.del_list("tokens", {idf: project_id}) + self.db.del_list(self.tokens_collection, {idf: project_id}) return r def update_project(self, project_id, project_info): @@ -443,4 +466,5 @@ class AuthconnInternal(Authconn): :return: None :raises AuthconnOperationException: if project update failed. """ - self.db.set_one("projects", {BaseTopic.id_field("projects", project_id): project_id}, project_info) + self.db.set_one(self.projects_collection, {BaseTopic.id_field("projects", project_id): project_id}, + project_info) diff --git a/osm_nbi/authconn_tacacs.py b/osm_nbi/authconn_tacacs.py new file mode 100644 index 0000000..27f38e9 --- /dev/null +++ b/osm_nbi/authconn_tacacs.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- + +# Copyright 2020 TATA ELXSI +# +# 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: saikiran.k@tataelxsi.co.in +## + + +""" +AuthconnTacacs implements implements the connector for TACACS. +Leverages AuthconnInternal for token lifecycle management and the RBAC model. + +When NBI bootstraps, it tries to create admin user with admin role associated to admin project. +Hence, the TACACS server should contain admin user. +""" + +__author__ = "K Sai Kiran " +__date__ = "$11-Nov-2020 11:04:00$" + + +from osm_nbi.authconn import Authconn, AuthException +from osm_nbi.authconn_internal import AuthconnInternal +from osm_nbi.base_topic import BaseTopic + +import logging +from time import time +from http import HTTPStatus + +# TACACS+ Library +from tacacs_plus.client import TACACSClient + + +class AuthconnTacacs(AuthconnInternal): + token_time_window = 2 + token_delay = 1 + + tacacs_def_port = 49 + tacacs_def_timeout = 10 + users_collection = "users_tacacs" + roles_collection = "roles_tacacs" + projects_collection = "projects_tacacs" + tokens_collection = "tokens_tacacs" + + def __init__(self, config, db, role_permissions): + """ + Constructor to initialize db and TACACS server attributes to members. + """ + Authconn.__init__(self, config, db, role_permissions) + self.logger = logging.getLogger("nbi.authenticator.tacacs") + self.db = db + self.tacacs_host = config["tacacs_host"] + self.tacacs_secret = config["tacacs_secret"] + self.tacacs_port = config["tacacs_port"] if config.get("tacacs_port") else self.tacacs_def_port + self.tacacs_timeout = config["tacacs_timeout"] if config.get("tacacs_timeout") else self.tacacs_def_timeout + self.tacacs_cli = TACACSClient(self.tacacs_host, self.tacacs_port, self.tacacs_secret, + self.tacacs_timeout) + + def validate_user(self, user, password): + """ + """ + now = time() + try: + tacacs_authen = self.tacacs_cli.authenticate(user, password) + except Exception as e: + raise AuthException("TACACS server error: {}".format(e), http_code=HTTPStatus.UNAUTHORIZED) + user_content = None + user_rows = self.db.get_list(self.users_collection, {BaseTopic.id_field("users", user): user}) + if not tacacs_authen.valid: + if user_rows: + # To remove TACACS stale user from system. + self.delete_user(user_rows[0][BaseTopic.id_field("users", user)]) + return user_content + if user_rows: + user_content = user_rows[0] + else: + new_user = {'username': user, + 'password': password, + '_admin': { + 'created': now, + 'modified': now + }, + 'project_role_mappings': [] + } + user_content = self.create_user(new_user) + return user_content + + def create_user(self, user_info): + """ + Validates user credentials in TACACS and add user. + + :param user_info: Full user information in dict. + :return: returns username and id if credentails are valid. Otherwise, raise exception + """ + BaseTopic.format_on_new(user_info, make_public=False) + try: + authen = self.tacacs_cli.authenticate(user_info["username"], user_info["password"]) + if authen.valid: + user_info.pop("password") + self.db.create(self.users_collection, user_info) + else: + raise AuthException("TACACS server error: Invalid credentials", http_code=HTTPStatus.FORBIDDEN) + except Exception as e: + raise AuthException("TACACS server error: {}".format(e), http_code=HTTPStatus.BAD_REQUEST) + return {"username": user_info["username"], "_id": user_info["_id"]} + + def update_user(self, user_info): + """ + Updates user information, in particular for add/remove of project and role mappings. + Does not allow change of username or password. + + :param user_info: Full user information in dict. + :return: returns None for successful add/remove of project and role map. + """ + if(user_info.get("username")): + raise AuthException("Can not update username of this user", http_code=HTTPStatus.FORBIDDEN) + if(user_info.get("password")): + raise AuthException("Can not update password of this user", http_code=HTTPStatus.FORBIDDEN) + super(AuthconnTacacs, self).update_user(user_info) diff --git a/osm_nbi/engine.py b/osm_nbi/engine.py index 133cb9d..a647784 100644 --- a/osm_nbi/engine.py +++ b/osm_nbi/engine.py @@ -23,6 +23,7 @@ from http import HTTPStatus from osm_nbi.authconn_keystone import AuthconnKeystone from osm_nbi.authconn_internal import AuthconnInternal +from osm_nbi.authconn_tacacs import AuthconnTacacs from osm_nbi.base_topic import EngineException, versiontuple from osm_nbi.admin_topics import VimAccountTopic, WimAccountTopic, SdnTopic from osm_nbi.admin_topics import K8sClusterTopic, K8sRepoTopic, OsmRepoTopic @@ -132,6 +133,9 @@ class Engine(object): if config["authentication"]["backend"] == "keystone": self.authconn = AuthconnKeystone(config["authentication"], self.db, self.authenticator.role_permissions) + elif config["authentication"]["backend"] == "tacacs": + self.authconn = AuthconnTacacs(config["authentication"], self.db, + self.authenticator.role_permissions) else: self.authconn = AuthconnInternal(config["authentication"], self.db, self.authenticator.role_permissions) diff --git a/osm_nbi/nbi.cfg b/osm_nbi/nbi.cfg index 3e8463a..60320ad 100644 --- a/osm_nbi/nbi.cfg +++ b/osm_nbi/nbi.cfg @@ -92,7 +92,7 @@ loglevel: "DEBUG" group_id: "nbi-server" [authentication] -backend: "keystone" # internal or keystone +backend: "keystone" # internal or keystone or tacacs # for keystone backend a comma separated list of user adn project _domain_name list can ba provided. # NBI will try authenticate with all of then if domain is not provided in the content of a POST token # user_domain_name: "default,ldap" @@ -110,5 +110,11 @@ backend: "keystone" # internal or keystone # user_not_authorized: "admin" # project_not_authorized: "admin" +# TACACS configuration +# tacacs_host: "" +# tacacs_secret: "" +# tacacs_port: 49 # Default value +# tacacs_timeout: 10 # Default value + [rbac] # roles_to_operations: "roles_to_operations.yml" # initial role generation when database diff --git a/requirements.txt b/requirements.txt index 6abb721..b51ddca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ requests git+https://osm.etsi.org/gerrit/osm/common.git#egg=osm-common git+https://osm.etsi.org/gerrit/osm/IM.git#egg=osm-im aiohttp>=2.3.10,<=3.6.2 +tacacs_plus -- 2.17.1