diff --git a/Dockerfile b/Dockerfile index e9deef69..46efd50a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/administration.rst b/docs/administration.rst index 42682424..76efa7f5 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -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 `_ diff --git a/lemur/auth/ldap.py b/lemur/auth/ldap.py new file mode 100644 index 00000000..e808a199 --- /dev/null +++ b/lemur/auth/ldap.py @@ -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 +""" +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) diff --git a/lemur/auth/views.py b/lemur/auth/views.py index 3a861a73..9d231cff 100644 --- a/lemur/auth/views.py +++ b/lemur/auth/views.py @@ -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 diff --git a/lemur/static/app/angular/authentication/login/login.tpl.html b/lemur/static/app/angular/authentication/login/login.tpl.html index d47e2c3f..3cbd6b6f 100644 --- a/lemur/static/app/angular/authentication/login/login.tpl.html +++ b/lemur/static/app/angular/authentication/login/login.tpl.html @@ -8,7 +8,7 @@ -