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',