basic ldap support (#842)
This commit is contained in:
parent
c0784b40e0
commit
79d12578c7
|
@ -3,7 +3,7 @@ RUN apt-get update
|
|||
RUN apt-get install -y make python-software-properties curl
|
||||
RUN curl -sL https://deb.nodesource.com/setup_7.x | bash -
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y nodejs
|
||||
RUN apt-get install -y nodejs libldap2-dev libsasl2-dev libldap2-dev libssl-dev
|
||||
RUN pip install -U setuptools
|
||||
RUN pip install coveralls bandit
|
||||
WORKDIR /app
|
||||
|
|
|
@ -251,7 +251,108 @@ Lemur supports sending certification expiration notifications through SES and SM
|
|||
|
||||
Authentication Options
|
||||
----------------------
|
||||
Lemur currently supports Basic Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily.
|
||||
Lemur currently supports Basic Authentication, LDAP Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily.
|
||||
|
||||
LDAP Specific Options
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Lemur supports the use of an LDAP server in conjunction with Basic Authentication. Lemur local users can still be defined and take precedence over LDAP users. If a local user does not exist, LDAP will be queried for authentication. Only simple ldap binding with or without TLS is supported.
|
||||
|
||||
LDAP support requires the pyldap python library, which also depends on the following openldap packages.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo apt-get update
|
||||
$ sudo apt-get install libldap2-dev libsasl2-dev libldap2-dev libssl-dev
|
||||
|
||||
|
||||
To configure the use of an LDAP server, the following settings must be defined.
|
||||
|
||||
.. data:: LDAP_AUTH
|
||||
:noindex:
|
||||
|
||||
This enables the use of LDAP
|
||||
|
||||
::
|
||||
|
||||
LDAP_AUTH = True
|
||||
|
||||
.. data:: LDAP_BIND_URI
|
||||
:noindex:
|
||||
|
||||
Specifies the LDAP server connection string
|
||||
|
||||
::
|
||||
|
||||
LDAP_BIND_URI = 'ldaps://hostname'
|
||||
|
||||
.. data:: LDAP_BIND_URI
|
||||
:noindex:
|
||||
|
||||
Specifies the LDAP server connection string
|
||||
|
||||
::
|
||||
|
||||
LDAP_BIND_URI = 'ldaps://hostname'
|
||||
|
||||
.. data:: LDAP_BASE_DN
|
||||
:noindex:
|
||||
|
||||
Specifies the LDAP distinguished name location to search for users
|
||||
|
||||
::
|
||||
|
||||
LDAP_BASE_DN = 'DC=Users,DC=Evilcorp,DC=com'
|
||||
|
||||
.. data:: LDAP_EMAIL_DOMAIN
|
||||
:noindex:
|
||||
|
||||
The email domain used by users in your directory. This is used to build the userPrincipalName to search with.
|
||||
|
||||
::
|
||||
|
||||
LDAP_EMAIL_DOMAIN = 'evilcorp.com'
|
||||
|
||||
The following LDAP options are not required, however TLS is always recommended.
|
||||
|
||||
.. data:: LDAP_USE_TLS
|
||||
:noindex:
|
||||
|
||||
Enables the use of TLS when connecting to the LDAP server. Ensure the LDAP_BIND_URI is using ldaps scheme.
|
||||
|
||||
::
|
||||
|
||||
LDAP_USE_TLS = True
|
||||
|
||||
.. data:: LDAP_CACERT_FILE
|
||||
:noindex:
|
||||
|
||||
Specify a Certificate Authority file containing PEM encoded trusted issuer certificates. This can be used if your LDAP server is using certificates issued by a private CA. (ie Microsoft)
|
||||
|
||||
::
|
||||
|
||||
LDAP_CACERT_FILE = '/path/to/cacert/file'
|
||||
|
||||
.. data:: LDAP_REQUIRED_GROUP
|
||||
:noindex:
|
||||
|
||||
Lemur has pretty open permissions. You can define an LDAP group to specify who can access Lemur. Only members of this group will be able to login.
|
||||
|
||||
::
|
||||
|
||||
LDAP_REQUIRED_GROUP = 'Lemur LDAP Group Name'
|
||||
|
||||
.. data:: LDAP_GROUPS_TO_ROLES
|
||||
:noindex:
|
||||
|
||||
You can also define a dictionary of ldap groups mapped to lemur roles. This allows you to use ldap groups to manage access to owner/creator roles in Lemur
|
||||
|
||||
::
|
||||
|
||||
LDAP_GROUPS_TO_ROLES = {'lemur_admins': 'admin', 'Lemur Team DL Group': 'team@example.com'}
|
||||
|
||||
|
||||
|
||||
If you are not using an authentication provider you do not need to configure any of these options.
|
||||
|
||||
For more information about how to use social logins, see: `Satellizer <https://github.com/sahat/satellizer>`_
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
.. module: lemur.auth.ldap
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Ian Stahnke <ian.stahnke@myob.com>
|
||||
"""
|
||||
import ldap
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from lemur.users import service as user_service
|
||||
from lemur.roles import service as role_service
|
||||
from lemur.common.utils import validate_conf, get_psuedo_random_string
|
||||
|
||||
|
||||
class LdapPrincipal():
|
||||
"""
|
||||
Provides methods for authenticating against an LDAP server.
|
||||
"""
|
||||
def __init__(self, args):
|
||||
self._ldap_validate_conf()
|
||||
# setup ldap config
|
||||
if not args['username']:
|
||||
raise Exception("missing ldap username")
|
||||
if not args['password']:
|
||||
self.error_message = "missing ldap password"
|
||||
raise Exception("missing ldap password")
|
||||
self.ldap_principal = args['username']
|
||||
self.ldap_email_domain = current_app.config.get("LDAP_EMAIL_DOMAIN", None)
|
||||
if '@' not in self.ldap_principal:
|
||||
self.ldap_principal = '%s@%s' % (self.ldap_principal, self.ldap_email_domain)
|
||||
self.ldap_username = args['username']
|
||||
if '@' in self.ldap_username:
|
||||
self.ldap_username = args['username'].split("@")[0]
|
||||
self.ldap_password = args['password']
|
||||
self.ldap_server = current_app.config.get('LDAP_BIND_URI', None)
|
||||
self.ldap_base_dn = current_app.config.get("LDAP_BASE_DN", None)
|
||||
self.ldap_use_tls = current_app.config.get("LDAP_USE_TLS", False)
|
||||
self.ldap_cacert_file = current_app.config.get("LDAP_CACERT_FILE", None)
|
||||
self.ldap_default_role = current_app.config.get("LEMUR_DEFAULT_ROLE", None)
|
||||
self.ldap_required_group = current_app.config.get("LDAP_REQUIRED_GROUP", None)
|
||||
self.ldap_groups_to_roles = current_app.config.get("LDAP_GROUPS_TO_ROLES", None)
|
||||
self.ldap_attrs = ['memberOf']
|
||||
self.ldap_client = None
|
||||
self.ldap_groups = None
|
||||
|
||||
def _update_user(self, roles):
|
||||
"""
|
||||
create or update a local user instance.
|
||||
"""
|
||||
# try to get user from local database
|
||||
user = user_service.get_by_email(self.ldap_principal)
|
||||
|
||||
# create them a local account
|
||||
if not user:
|
||||
user = user_service.create(
|
||||
self.ldap_username,
|
||||
get_psuedo_random_string(),
|
||||
self.ldap_principal,
|
||||
True,
|
||||
'', # thumbnailPhotoUrl
|
||||
list(roles)
|
||||
)
|
||||
else:
|
||||
# we add 'lemur' specific roles, so they do not get marked as removed
|
||||
for ur in user.roles:
|
||||
if ur.authority_id:
|
||||
roles.add(ur)
|
||||
|
||||
# update any changes to the user
|
||||
user_service.update(
|
||||
user.id,
|
||||
self.ldap_username,
|
||||
self.ldap_principal,
|
||||
user.active,
|
||||
user.profile_picture,
|
||||
list(roles)
|
||||
)
|
||||
return user
|
||||
|
||||
def _authorize(self):
|
||||
"""
|
||||
check groups and roles to confirm access.
|
||||
return a list of roles if ok.
|
||||
raise an exception on error.
|
||||
"""
|
||||
if not self.ldap_principal:
|
||||
return None
|
||||
|
||||
if self.ldap_required_group:
|
||||
# ensure the user has the required group in their group list
|
||||
if self.ldap_required_group not in self.ldap_groups:
|
||||
return None
|
||||
|
||||
roles = set()
|
||||
if self.ldap_default_role:
|
||||
role = role_service.get_by_name(self.ldap_default_role)
|
||||
if role:
|
||||
roles.add(role)
|
||||
|
||||
# update their 'roles'
|
||||
role = role_service.get_by_name(self.ldap_principal)
|
||||
if not role:
|
||||
description = "auto generated role based on owner: {0}".format(self.ldap_principal)
|
||||
role = role_service.create(self.ldap_principal, description=description)
|
||||
roles.add(role)
|
||||
if not self.ldap_groups_to_roles:
|
||||
return roles
|
||||
|
||||
for ldap_group_name, role_name in self.ldap_groups_to_roles.items():
|
||||
role = role_service.get_by_name(role_name)
|
||||
if role:
|
||||
if ldap_group_name in self.ldap_groups:
|
||||
current_app.logger.debug("assigning role {0} to ldap user {1}".format(self.ldap_principal, role))
|
||||
roles.add(role)
|
||||
return roles
|
||||
|
||||
def authenticate(self):
|
||||
"""
|
||||
orchestrate the ldap login.
|
||||
raise an exception on error.
|
||||
"""
|
||||
self._bind()
|
||||
roles = self._authorize()
|
||||
if not roles:
|
||||
raise Exception('ldap authorization failed')
|
||||
return self._update_user(roles)
|
||||
|
||||
def _bind(self):
|
||||
"""
|
||||
authenticate an ldap user.
|
||||
list groups for a user.
|
||||
raise an exception on error.
|
||||
"""
|
||||
if '@' not in self.ldap_principal:
|
||||
self.ldap_principal = '%s@%s' % (self.ldap_principal, self.ldap_email_domain)
|
||||
ldap_filter = 'userPrincipalName=%s' % self.ldap_principal
|
||||
|
||||
# query ldap for auth
|
||||
try:
|
||||
# build a client
|
||||
if not self.ldap_client:
|
||||
self.ldap_client = ldap.initialize(self.ldap_server)
|
||||
# perform a synchronous bind
|
||||
self.ldap_client.set_option(ldap.OPT_REFERRALS, 0)
|
||||
if self.ldap_use_tls:
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
self.ldap_client.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
|
||||
self.ldap_client.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND)
|
||||
self.ldap_client.set_option(ldap.OPT_X_TLS_DEMAND, True)
|
||||
self.ldap_client.set_option(ldap.OPT_DEBUG_LEVEL, 255)
|
||||
if self.ldap_cacert_file:
|
||||
self.ldap_client.set_option(ldap.OPT_X_TLS_CACERTFILE, self.ldap_cacert_file)
|
||||
self.ldap_client.simple_bind_s(self.ldap_principal, self.ldap_password)
|
||||
except ldap.INVALID_CREDENTIALS:
|
||||
self.ldap_client.unbind()
|
||||
raise Exception('The supplied ldap credentials are invalid')
|
||||
except ldap.SERVER_DOWN:
|
||||
raise Exception('ldap server unavailable')
|
||||
except ldap.LDAPError as e:
|
||||
raise Exception("ldap error: {0}".format(e))
|
||||
|
||||
lgroups = self.ldap_client.search_s(self.ldap_base_dn,
|
||||
ldap.SCOPE_SUBTREE, ldap_filter, self.ldap_attrs)[0][1]['memberOf']
|
||||
# lgroups is a list of utf-8 encoded strings
|
||||
# convert to a single string of groups to allow matching
|
||||
self.ldap_groups = b''.join(lgroups).decode('ascii')
|
||||
self.ldap_client.unbind()
|
||||
|
||||
def _ldap_validate_conf(self):
|
||||
"""
|
||||
Confirms required ldap config settings exist.
|
||||
"""
|
||||
required_vars = [
|
||||
'LDAP_BIND_URI',
|
||||
'LDAP_BASE_DN',
|
||||
'LDAP_EMAIL_DOMAIN',
|
||||
]
|
||||
validate_conf(current_app, required_vars)
|
|
@ -21,6 +21,7 @@ from lemur.common.utils import get_psuedo_random_string
|
|||
from lemur.users import service as user_service
|
||||
from lemur.roles import service as role_service
|
||||
from lemur.auth.service import create_token, fetch_token_header, get_rsa_public_key
|
||||
import lemur.auth.ldap as ldap
|
||||
|
||||
|
||||
mod = Blueprint('auth', __name__)
|
||||
|
@ -94,6 +95,7 @@ class Login(Resource):
|
|||
else:
|
||||
user = user_service.get_by_username(args['username'])
|
||||
|
||||
# default to local authentication
|
||||
if user and user.check_password(args['password']) and user.active:
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(),
|
||||
|
@ -102,6 +104,24 @@ class Login(Resource):
|
|||
metrics.send('successful_login', 'counter', 1)
|
||||
return dict(token=create_token(user))
|
||||
|
||||
# try ldap login
|
||||
if current_app.config.get("LDAP_AUTH"):
|
||||
try:
|
||||
ldap_principal = ldap.LdapPrincipal(args)
|
||||
user = ldap_principal.authenticate()
|
||||
if user and user.active:
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(),
|
||||
identity=Identity(user.id))
|
||||
metrics.send('successful_login', 'counter', 1)
|
||||
return dict(token=create_token(user))
|
||||
except Exception as e:
|
||||
current_app.logger.error("ldap error: {0}".format(e))
|
||||
ldap_message = 'ldap error: %s' % e
|
||||
metrics.send('invalid_login', 'counter', 1)
|
||||
return dict(message=ldap_message), 403
|
||||
|
||||
# if not valid user - no certificates for you
|
||||
metrics.send('invalid_login', 'counter', 1)
|
||||
return dict(message='The supplied credentials are invalid'), 403
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-or">
|
||||
<div class="login-or" ng-if="providers.length > 0">
|
||||
<hr class="hr-or">
|
||||
<span class="span-or">or</span>
|
||||
</div>
|
||||
|
|
|
@ -177,3 +177,10 @@ ACME_URL = 'https://acme-v01.api.letsencrypt.org'
|
|||
ACME_EMAIL = 'jim@example.com'
|
||||
ACME_TEL = '4088675309'
|
||||
ACME_DIRECTORY_URL = 'https://acme-v01.api.letsencrypt.org'
|
||||
|
||||
LDAP_AUTH = True
|
||||
LDAP_BIND_URI = 'ldap://localhost'
|
||||
LDAP_BASE_DN = 'dc=example,dc=com'
|
||||
LDAP_EMAIL_DOMAIN = 'example.com'
|
||||
LDAP_REQUIRED_GROUP = 'Lemur Access'
|
||||
LDAP_DEFAULT_ROLE = 'role1'
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import pytest
|
||||
from lemur.auth.ldap import * # noqa
|
||||
from mock import patch, MagicMock
|
||||
|
||||
|
||||
class LdapPrincipalTester(LdapPrincipal):
|
||||
|
||||
def __init__(self, args):
|
||||
super().__init__(args)
|
||||
self.ldap_server = 'ldap://localhost'
|
||||
|
||||
def bind_test(self):
|
||||
groups = [('user', {'memberOf': ['CN=Lemur Access,OU=Groups,DC=example,DC=com'.encode('utf-8'),
|
||||
'CN=Pen Pushers,OU=Groups,DC=example,DC=com'.encode('utf-8')]})]
|
||||
self.ldap_client = MagicMock()
|
||||
self.ldap_client.search_s.return_value = groups
|
||||
self._bind()
|
||||
|
||||
def authorize_test_groups_to_roles_admin(self):
|
||||
self.ldap_groups = ''.join(['CN=Pen Pushers,OU=Groups,DC=example,DC=com',
|
||||
'CN=Lemur Admins,OU=Groups,DC=example,DC=com',
|
||||
'CN=Lemur Read Only,OU=Groups,DC=example,DC=com'])
|
||||
self.ldap_required_group = None
|
||||
self.ldap_groups_to_roles = {'Lemur Admins': 'admin', 'Lemur Read Only': 'read-only'}
|
||||
return self._authorize()
|
||||
|
||||
def authorize_test_required_group(self, group):
|
||||
self.ldap_groups = ''.join(['CN=Lemur Access,OU=Groups,DC=example,DC=com',
|
||||
'CN=Pen Pushers,OU=Groups,DC=example,DC=com'])
|
||||
self.ldap_required_group = group
|
||||
return self._authorize()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def principal(session):
|
||||
args = {'username': 'user', 'password': 'p4ssw0rd'}
|
||||
yield LdapPrincipalTester(args)
|
||||
|
||||
|
||||
class TestLdapPrincipal:
|
||||
|
||||
@patch('ldap.initialize')
|
||||
def test_bind(self, app, principal):
|
||||
self.test_ldap_user = principal
|
||||
self.test_ldap_user.bind_test()
|
||||
group = 'Pen Pushers'
|
||||
assert group in self.test_ldap_user.ldap_groups
|
||||
assert self.test_ldap_user.ldap_principal == 'user@example.com'
|
||||
|
||||
def test_authorize_groups_to_roles_admin(self, app, principal):
|
||||
self.test_ldap_user = principal
|
||||
roles = self.test_ldap_user.authorize_test_groups_to_roles_admin()
|
||||
assert any(x.name == "admin" for x in roles)
|
||||
|
||||
def test_authorize_required_group_missing(self, app, principal):
|
||||
self.test_ldap_user = principal
|
||||
roles = self.test_ldap_user.authorize_test_required_group('Not Allowed')
|
||||
assert not roles
|
||||
|
||||
def test_authorize_required_group_access(self, session, principal):
|
||||
self.test_ldap_user = principal
|
||||
roles = self.test_ldap_user.authorize_test_required_group('Lemur Access')
|
||||
assert len(roles) >= 1
|
||||
assert any(x.name == "user@example.com" for x in roles)
|
4
setup.py
4
setup.py
|
@ -64,6 +64,7 @@ install_requires = [
|
|||
'pem==17.1.0',
|
||||
'raven[flask]==6.1.0',
|
||||
'jinja2==2.9.6',
|
||||
'pyldap==2.4.36', # required by ldap auth provider
|
||||
'paramiko==2.2.1' # required for lemur_linuxdst plugin
|
||||
]
|
||||
|
||||
|
@ -76,7 +77,8 @@ tests_require = [
|
|||
'fake-factory==0.7.2',
|
||||
'pytest-flask==0.10.0',
|
||||
'freezegun==0.3.9',
|
||||
'requests-mock==1.3.0'
|
||||
'requests-mock==1.3.0',
|
||||
'pytest-mock'
|
||||
]
|
||||
|
||||
docs_require = [
|
||||
|
|
Loading…
Reference in New Issue