Skip to content

Commit

Permalink
use auth package
Browse files Browse the repository at this point in the history
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 [email protected] is
authenticated as cn=admin,dc=example,dc=org on LDAP server.
  • Loading branch information
tomkukral committed Feb 27, 2018
1 parent 193df58 commit 05cca18
Show file tree
Hide file tree
Showing 18 changed files with 300 additions and 6 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,5 @@ kqueen/config/local.py
# Remote kubeconfig
kubeconfig_remote

.cache
.pytest_cache
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ LABEL maintainer="[email protected]"
# 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 .
Expand Down
1 change: 1 addition & 0 deletions bandit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
skips: ['B101']
11 changes: 11 additions & 0 deletions docker-compose.auth.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions kqueen/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -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'
]
28 changes: 28 additions & 0 deletions kqueen/auth/base.py
Original file line number Diff line number Diff line change
@@ -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
43 changes: 39 additions & 4 deletions kqueen/auth.py → kqueen/auth/common.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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):
Expand Down
76 changes: 76 additions & 0 deletions kqueen/auth/ldap.py
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 26 additions & 0 deletions kqueen/auth/local.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions kqueen/auth/test_base.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added kqueen/auth/test_common.py
Empty file.
49 changes: 49 additions & 0 deletions kqueen/auth/test_ldap.py
Original file line number Diff line number Diff line change
@@ -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 = '[email protected]'
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', [
('[email protected]', 'cn=admin,dc=example,dc=org'),
('[email protected]', '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
3 changes: 3 additions & 0 deletions kqueen/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
9 changes: 9 additions & 0 deletions kqueen/config/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
},
}
1 change: 1 addition & 0 deletions kqueen/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion prod/nginx/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
'prometheus_client',
'python-etcd',
'python-jenkins',
'python-ldap==3.0.0b4',
'pyyaml',
'requests',
'google-api-python-client==1.6.4',
Expand Down

0 comments on commit 05cca18

Please sign in to comment.