Skip to content

Commit

Permalink
Merge pull request #214 from Mirantis/ldap
Browse files Browse the repository at this point in the history
LDAP authentication
  • Loading branch information
Adam Tengler authored Feb 28, 2018
2 parents 193df58 + d867fda commit 50131f9
Show file tree
Hide file tree
Showing 19 changed files with 301 additions and 42 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
44 changes: 40 additions & 4 deletions kqueen/auth.py → kqueen/auth/common.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
"""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)

if callable(auth_class):
return auth_class(**auth_config)


def authenticate(username, password):
"""
Authenticate user.
Expand All @@ -22,14 +36,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"
Loading

0 comments on commit 50131f9

Please sign in to comment.