From 05cca181a8de8eff46293f89d6495413f5dad413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kukr=C3=A1l?= Date: Thu, 1 Feb 2018 13:41:54 +0100 Subject: [PATCH] use auth package Provide auth package with various engines to provide authentication. LDAPAuth engine is implemented as an example, more engines can be added later. LDAP authentication can be use configured by (in config file) ``` AUTH = { "ldap": { "engine": "LDAPAuth", "param": { "uri": "ldap://127.0.0.1:398", } }, } ``` This configuration will try to authenticate users with auth = 'ldap' using bind on server 127.0.0.1:398. Username to DN conversion will apply, so admin@example.org is authenticated as cn=admin,dc=example,dc=org on LDAP server. --- .gitignore | 2 + .travis.yml | 2 +- Dockerfile | 5 ++ bandit.yml | 1 + docker-compose.auth.yml | 11 +++++ kqueen/auth/__init__.py | 12 +++++ kqueen/auth/base.py | 28 +++++++++++ kqueen/{auth.py => auth/common.py} | 43 +++++++++++++++-- kqueen/auth/ldap.py | 76 ++++++++++++++++++++++++++++++ kqueen/auth/local.py | 26 ++++++++++ kqueen/auth/test_base.py | 35 ++++++++++++++ kqueen/auth/test_common.py | 0 kqueen/auth/test_ldap.py | 49 +++++++++++++++++++ kqueen/config/base.py | 3 ++ kqueen/config/test.py | 9 ++++ kqueen/models.py | 1 + prod/nginx/Dockerfile | 2 +- setup.py | 1 + 18 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 bandit.yml create mode 100644 docker-compose.auth.yml create mode 100644 kqueen/auth/__init__.py create mode 100644 kqueen/auth/base.py rename kqueen/{auth.py => auth/common.py} (78%) create mode 100644 kqueen/auth/ldap.py create mode 100644 kqueen/auth/local.py create mode 100644 kqueen/auth/test_base.py create mode 100644 kqueen/auth/test_common.py create mode 100644 kqueen/auth/test_ldap.py diff --git a/.gitignore b/.gitignore index a06ba453..fe585680 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,5 @@ kqueen/config/local.py # Remote kubeconfig kubeconfig_remote +.cache +.pytest_cache diff --git a/.travis.yml b/.travis.yml index 44fd2b29..472b1c7c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,7 +22,7 @@ jobs: - python3 -m flake8 - stage: test before_install: - - docker-compose -f docker-compose.yml -f docker-compose.kubernetes.yml up -d + - docker-compose -f docker-compose.yml -f docker-compose.kubernetes.yml -f docker-compose.auth.yml up -d install: - pip install -e ".[dev]" script: diff --git a/Dockerfile b/Dockerfile index 10eb5801..25ca170c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,11 @@ LABEL maintainer="tkukral@mirantis.com" # prepare directory WORKDIR /code +# install dependencies +RUN apt-get update && \ + apt-get install --no-install-recommends -y libsasl2-dev python-dev libldap2-dev libssl-dev && \ + rm -rf /var/lib/apt/lists/* + # copy app COPY . . RUN pip install . diff --git a/bandit.yml b/bandit.yml new file mode 100644 index 00000000..75d550c3 --- /dev/null +++ b/bandit.yml @@ -0,0 +1 @@ +skips: ['B101'] diff --git a/docker-compose.auth.yml b/docker-compose.auth.yml new file mode 100644 index 00000000..2aa39491 --- /dev/null +++ b/docker-compose.auth.yml @@ -0,0 +1,11 @@ +version: '2' +services: + ldap: + image: osixia/openldap + command: + - --loglevel + - debug + ports: + - 127.0.0.1:389:389 + environment: + - LDAP_ADMIN_PASSWORD=heslo123 diff --git a/kqueen/auth/__init__.py b/kqueen/auth/__init__.py new file mode 100644 index 00000000..4ba7b896 --- /dev/null +++ b/kqueen/auth/__init__.py @@ -0,0 +1,12 @@ +from .common import authenticate, identity, encrypt_password, is_authorized +from .ldap import LDAPAuth +from .local import LocalAuth + +__all__ = [ + 'authenticate', + 'identity', + 'encrypt_password', + 'is_authorized', + 'LDAPAuth', + 'LocalAuth' +] diff --git a/kqueen/auth/base.py b/kqueen/auth/base.py new file mode 100644 index 00000000..69572cc5 --- /dev/null +++ b/kqueen/auth/base.py @@ -0,0 +1,28 @@ +class BaseAuth: + RESERVED_KWARGS = ['verify'] + + def __init__(self, *args, **kwargs): + """Create auth object and establish connection + + Args: + **kwargs: Keyword arguments specific to Auth engine + """ + + for k, v in kwargs.items(): + if k not in self.RESERVED_KWARGS: + setattr(self, k, v) + + def verify(self, user, password): + """Vefifies username and password. + + Args: + user (User): user object to verify + password (str): Password to verify + + Returns: + tuple: (user, error) + user (User): User object if username and password matched, None otherwise + error (string): Error message explaing authentication error + """ + + raise NotImplementedError diff --git a/kqueen/auth.py b/kqueen/auth/common.py similarity index 78% rename from kqueen/auth.py rename to kqueen/auth/common.py index 581bf497..ee90cc15 100644 --- a/kqueen/auth.py +++ b/kqueen/auth/common.py @@ -1,15 +1,28 @@ """Authentication methods for API.""" from kqueen.config import current_config -from kqueen.models import Organization, User +from kqueen.models import Organization +from kqueen.models import User from uuid import uuid4 import bcrypt +import importlib import logging logger = logging.getLogger('kqueen_api') +def get_auth_instance(name): + config = current_config() + + auth_config = config.get("AUTH", {}).get(name, {}) + + module = importlib.import_module('kqueen.auth') + auth_class = getattr(module, name) + + return auth_class(**auth_config) + + def authenticate(username, password): """ Authenticate user. @@ -22,14 +35,36 @@ def authenticate(username, password): user: authenticated user """ + + # find user by username users = list(User.list(None, return_objects=True).values()) username_table = {u.username: u for u in users} user = username_table.get(username) + if user: - user_password = user.password.encode('utf-8') given_password = password.encode('utf-8') - if user.active and bcrypt.checkpw(given_password, user_password): - return user + + # fallback to local auth, this options is default if nothing is specified + if not user.auth: + user.auth = "LocalAuth" + + logger.debug("User {} will be authenticated using {}".format(username, user.auth)) + + auth_instance = get_auth_instance(user.auth) + try: + verified_user, verification_error = auth_instance.verify(user, given_password) + except Exception as e: + logger.exception("Verification method {} failed".format(user.auth)) + verified_user, verification_error = None, str(e) + + if isinstance(verified_user, User) and verified_user.active: + return verified_user + else: + logger.info("User {user} failed auth using {method} auth method with error {error}".format( + user=user, + method=user.auth, + error=verification_error, + )) def identity(payload): diff --git a/kqueen/auth/ldap.py b/kqueen/auth/ldap.py new file mode 100644 index 00000000..e440cf89 --- /dev/null +++ b/kqueen/auth/ldap.py @@ -0,0 +1,76 @@ +from .base import BaseAuth + +import ldap +import logging + +logger = logging.getLogger('kqueen_api') + + +class LDAPAuth(BaseAuth): + def __init__(self, *args, **kwargs): + """ + Implementation of :func:`~kqueen.auth.base.__init__` + """ + + super(LDAPAuth, self).__init__(*args, **kwargs) + + if not hasattr(self, 'uri'): + raise Exception('Parameter uri is required') + + self.connection = ldap.initialize(self.uri) + + @staticmethod + def _email_to_dn(email): + """This function reads email and converts it to LDAP dn + + Args: + email (str): e-mail address + + Returns: + dn (str): LDAP dn, like 'cn=admin,dc=example,dc=org + """ + + segments = [] + + if '@' in email: + cn, dcs = email.split('@') + else: + cn = email + dcs = '' + + if cn: + segments.append('cn={}'.format(cn)) + + if '.' in dcs: + for s in dcs.split('.'): + segments.append('dc={}'.format(s)) + + return ','.join(segments) + + def verify(self, user, password): + """Implementation of :func:`~kqueen.auth.base.__init__` + + This function tries to bind LDAP and returns result + """ + + dn = self._email_to_dn(user.username) + + try: + bind = self.connection.simple_bind_s(dn, password) + + if bind: + return user, None + except ldap.INVALID_CREDENTIALS: + logger.exception("Invalid LDAP credentials for {}".format(dn)) + + return None, "Invalid LDAP credentials" + + except ldap.LDAPError as e: + logger.exception(e) + + return None, "LDAP auth failed, check log for error" + + finally: + self.connection.unbind() + + return None, "All LDAP authentication methods failed" diff --git a/kqueen/auth/local.py b/kqueen/auth/local.py new file mode 100644 index 00000000..7f36cd99 --- /dev/null +++ b/kqueen/auth/local.py @@ -0,0 +1,26 @@ +from .base import BaseAuth +from kqueen.models import User + +import bcrypt +import logging + +logger = logging.getLogger('kqueen_api') + + +class LocalAuth(BaseAuth): + def verify(self, user, password): + """Implementation of :func:`~kqueen.auth.base.__init__` + + This function tries to find local user and verify password. + """ + + if isinstance(user, User): + user_password = user.password.encode('utf-8') + given_password = password + + if bcrypt.checkpw(given_password, user_password): + return user, None + + msg = "Local authentication failed" + logger.info(msg) + return None, msg diff --git a/kqueen/auth/test_base.py b/kqueen/auth/test_base.py new file mode 100644 index 00000000..03e94e36 --- /dev/null +++ b/kqueen/auth/test_base.py @@ -0,0 +1,35 @@ +from .base import BaseAuth + +import pytest + + +class TestBaseAuth: + def setup(self): + self.engine = BaseAuth() + + def test_verify_raises(self): + + with pytest.raises(NotImplementedError): + self.engine.verify('username', 'password') + + def test_pass_kwargs(self): + kwargs = {"test1": "abc", "test2": 123} + + self.engine = BaseAuth(**kwargs) + + for k, v in kwargs.items(): + assert getattr(self.engine, k) == v + + def test_kwargs_reserved(self): + reserved = BaseAuth.RESERVED_KWARGS + default_value = "abc" + + assert 'verify' in reserved + + kwargs = {k: default_value for k in reserved} + + self.engine = BaseAuth(**kwargs) + + for k in reserved: + if hasattr(self.engine, k): + assert getattr(self.engine, k) != default_value diff --git a/kqueen/auth/test_common.py b/kqueen/auth/test_common.py new file mode 100644 index 00000000..e69de29b diff --git a/kqueen/auth/test_ldap.py b/kqueen/auth/test_ldap.py new file mode 100644 index 00000000..dbb2fd16 --- /dev/null +++ b/kqueen/auth/test_ldap.py @@ -0,0 +1,49 @@ +from .ldap import LDAPAuth +from kqueen.models import User + +import pytest + + +class TestAuthMethod: + @pytest.fixture(autouse=True) + def setup(self, user): + self.user = user + self.user.username = 'admin@example.org' + self.user.password = '' + self.user.save() + + self.auth_class = LDAPAuth(uri='ldap://127.0.0.1:389') + + def test_raise_on_missing_uri(self): + with pytest.raises(Exception, msg='Parameter uri is required'): + LDAPAuth() + + def test_login_pass(self): + password = 'heslo123' + user, error = self.auth_class.verify(self.user, password) + + assert isinstance(user, User) + assert error is None + + def test_login_bad_pass(self): + password = 'abc' + user, error = self.auth_class.verify(self.user, password) + + assert not user + assert error == "Invalid LDAP credentials" + + def test_bad_server(self): + password = 'heslo123' + auth_class = LDAPAuth(uri="ldap://127.0.0.1:55555") + + user, error = auth_class.verify(self.user, password) + assert not user + assert error == "LDAP auth failed, check log for error" + + @pytest.mark.parametrize('email, dn', [ + ('admin@example.org', 'cn=admin,dc=example,dc=org'), + ('name.surname@mail.example.net', 'cn=name.surname,dc=mail,dc=example,dc=net'), + ('user', 'cn=user'), + ]) + def test_email_to_dn(self, email, dn): + assert self.auth_class._email_to_dn(email) == dn diff --git a/kqueen/config/base.py b/kqueen/config/base.py index de8312b3..3576b5ee 100644 --- a/kqueen/config/base.py +++ b/kqueen/config/base.py @@ -45,6 +45,9 @@ class BaseConfig: PROVISIONER_TIMEOUT = 3600 PROMETHEUS_WHITELIST = '127.0.0.0/8' + # Auth settings + AUTH = {} + @classmethod def get(cls, name, default=None): """Emulate get method from dict""" diff --git a/kqueen/config/test.py b/kqueen/config/test.py index 294a89c6..dd8d29a7 100644 --- a/kqueen/config/test.py +++ b/kqueen/config/test.py @@ -30,3 +30,12 @@ class Config(BaseConfig): # SSH public key SSH_KEY = 'ssh-rsa AAAAB3NzadfadfafQEAylDZDzgMuEsJQpwFHDW+QivCVhryxXd1/HWqq1TVhJmT9oNAYdhUBnf/9kVtgmP0EWpDJtGSEaSugCmx8KE76I64RhpOTlm7wO0FFUVnzhFtTPx38WHfMjMdk1HF8twZU4svi72Xbg1KyBimwvaxTTd4zxq8Mskp3uwtkqPcQJDSQaZYv+wtuB6m6vHBCOTZwAognDGEvvCg0dgTU4hch1zoHSaxedS1UFHjUAM598iuI3+hMos/5hjG/vuay4cPLBJX5x1YF6blbFALwrQw8ZmTPaimqDUA9WD6KSmS1qg4rOkk4cszIfJ5vyymMrG+G3qk5LeT4VrgIgWQTAHyXw==' + + AUTH = { + "ldap": { + "engine": "LDAPAuth", + "param": { + "uri": "ldap://127.0.0.1:398", + } + }, + } diff --git a/kqueen/models.py b/kqueen/models.py index b5c39cf2..efeab226 100644 --- a/kqueen/models.py +++ b/kqueen/models.py @@ -428,6 +428,7 @@ class User(Model, metaclass=ModelMeta): role = StringField(required=True) active = BoolField(required=True) metadata = JSONField(required=False) + auth = StringField() @property def namespace(self): diff --git a/prod/nginx/Dockerfile b/prod/nginx/Dockerfile index 7e6a0ef0..086a5629 100644 --- a/prod/nginx/Dockerfile +++ b/prod/nginx/Dockerfile @@ -13,4 +13,4 @@ RUN rm -v /etc/nginx/conf.d/* COPY vhost.conf $DIR_CONF # edit vhost.conf -RUN sed -i "s/vhostname/$VHOSTNAME/g" $DIR_CONF/vhost.conf +RUN sed -i "s/vhostname/$VHOSTNAME/g" "$DIR_CONF/vhost.conf" diff --git a/setup.py b/setup.py index 0a9ea99f..14853b8e 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ 'prometheus_client', 'python-etcd', 'python-jenkins', + 'python-ldap==3.0.0b4', 'pyyaml', 'requests', 'google-api-python-client==1.6.4',