Feature 8532: Added new plugin authconn tacacs 28/9928/7
authorK Sai Kiran <saikiran.k@tataelxsi.co.in>
Fri, 30 Oct 2020 05:44:44 +0000 (11:14 +0530)
committerksaikiranr <saikiran.k@tataelxsi.co.in>
Wed, 25 Nov 2020 08:43:51 +0000 (09:43 +0100)
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 <saikiran.k@tataelxsi.co.in>
Dockerfile.local
debian/python3-osm-nbi.postinst
osm_nbi/auth.py
osm_nbi/authconn_internal.py
osm_nbi/authconn_tacacs.py [new file with mode: 0644]
osm_nbi/engine.py
osm_nbi/nbi.cfg
requirements.txt

index 348feff..5ba1240 100644 (file)
@@ -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
index 54dadb1..29baa0d 100755 (executable)
@@ -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
index 7cbc404..6cbfe68 100644 (file)
@@ -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
 
index b8cfe5b..b3de1cd 100644 (file)
@@ -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 (file)
index 0000000..27f38e9
--- /dev/null
@@ -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 <saikiran.k@tataelxsi.co.in>"
+__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)
index 133cb9d..a647784 100644 (file)
@@ -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)
index 3e8463a..60320ad 100644 (file)
@@ -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
index 6abb721..b51ddca 100644 (file)
@@ -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