diff --git a/ldapauthenticator/ldapauthenticator.py b/ldapauthenticator/ldapauthenticator.py index 1e9f380..5fc5434 100644 --- a/ldapauthenticator/ldapauthenticator.py +++ b/ldapauthenticator/ldapauthenticator.py @@ -47,7 +47,8 @@ def _server_port_default(self): help=""" Template from which to construct the full dn when authenticating to LDAP. {username} is replaced - with the actual username used to log in. + with the user's resolved username (i.e. their CN attribute). + {login} is replaced with the actual username used to login. If your LDAP is set in such a way that the userdn can not be formed from a template, but must be looked up with an attribute @@ -59,9 +60,12 @@ def _server_port_default(self): List Example: [ - uid={username},ou=people,dc=wikimedia,dc=org, - uid={username},ou=Developers,dc=wikimedia,dc=org - ] + uid={username},ou=people,dc=wikimedia,dc=org, + uid={username},ou=Developers,dc=wikimedia,dc=org + ] + + Active Directory Example: + DOMAIN\{login} """ ) @@ -137,6 +141,20 @@ def _server_port_default(self): """ ) + group_search_base = Unicode( + config=True, + default=user_search_base, + allow_none=True, + help=""" + Base for looking up groups in the directory. Defaults to the value of user_search_base if unset. + + For example: + ``` + c.LDAPAuthenticator.group_search_base = 'ou=groups,dc=wikimedia,dc=org' + ``` + """ + ) + user_attribute = Unicode( config=True, default=None, @@ -151,6 +169,66 @@ def _server_port_default(self): """ ) + memberof_attribute = Unicode( + config=True, + default_value='memberOf', + allow_none=False, + help=""" + Attribute attached to user objects containing the list of groups the user is a member of. + + Defaults to 'memberOf', you probably won't need to change this. + """ + ) + + get_groups_from_user = Bool( + False, + config=True, + help=""" + If set, this will confirm a user's group membership by querying the + user object in LDAP directly, and querying the attribute set in + `memberof_attribute` (defaults to `memberOf`). + + If unset (the default), then each authorised group set in + `allowed_group` is queried from LDAP and matched against the user's DN. + + This should be set when the LDAP server is Microsoft Active Directory, + and you probably also want to set the `activedirectory` configuration + setting to 'true' as well' + """ + ) + + activedirectory = Bool( + False, + config=True, + help=""" + If set, this treats the remote LDAP server as a Microsoft Active + Directory instance, and will optimise group membership queries where + `allow_groups` is used. This requires `get_groups_from_user` to be + enabled. + + This allows nested groups to be resolved when using Active Directory. + + Example Active Directory configuration: + ``` + c.LDAPAuthenticator.bind_dn_template = 'DOMAIN\{login}' + c.LDAPAuthenticator.lookup_dn = False + c.LDAPAuthenticator.activedirectory = True + c.LDAPAuthenticator.get_groups_from_user = True + c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'distinguishedName' + c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})' + c.LDAPAuthenticator.lookup_dn_search_user = 'readonly' + c.LDAPAuthenticator.lookup_dn_search_password = 'notarealpassword' + c.LDAPAuthenticator.user_attribute = 'sAMAccountName' + c.LDAPAuthenticator.user_search_base = 'OU=Users,DC=example,DC=org' + c.LDAPAuthenticator.group_search_base = 'OU=Groups,DC=example,DC=org' + + c.LDAPAuthenticator.admin_users = {'Administrator'} + c.LDAPAuthenticator.allowed_groups = [ + 'CN=JupyterHub_Users,OU=Groups,DC=example,DC=org'] + ``` + """ + ) + lookup_dn_search_filter = Unicode( config=True, default_value='({login_attr}={login})', @@ -185,7 +263,7 @@ def _server_port_default(self): default_value=None, allow_none=True, help=""" - Attribute containing user's name needed for building DN string, if `lookup_dn` is set to True. + Attribute containing user's name needed for building DN string, if `lookup_dn` is set to True. See `user_search_base` for info on how this attribute is used. @@ -204,53 +282,6 @@ def _server_port_default(self): """ ) - def resolve_username(self, username_supplied_by_user): - if self.lookup_dn: - server = ldap3.Server( - self.server_address, - port=self.server_port, - use_ssl=self.use_ssl - ) - - search_filter = self.lookup_dn_search_filter.format( - login_attr=self.user_attribute, - login=username_supplied_by_user - ) - self.log.debug( - "Looking up user with search_base={search_base}, search_filter='{search_filter}', attributes={attributes}".format( - search_base=self.user_search_base, - search_filter=search_filter, - attributes=self.user_attribute - ) - ) - - conn = ldap3.Connection(server, user=self.escape_userdn_if_needed(self.lookup_dn_search_user), password=self.lookup_dn_search_password) - is_bound = conn.bind() - if not is_bound: - self.log.warn("Can't connect to LDAP") - return None - - conn.search( - search_base=self.user_search_base, - search_scope=ldap3.SUBTREE, - search_filter=search_filter, - attributes=[self.lookup_dn_user_dn_attribute] - ) - - if len(conn.response) == 0 or 'attributes' not in conn.response[0].keys(): - self.log.warn('username:%s No such user entry found when looking up with attribute %s', username_supplied_by_user, - self.user_attribute) - return None - return conn.response[0]['attributes'][self.lookup_dn_user_dn_attribute] - else: - return username_supplied_by_user - - def escape_userdn_if_needed(self, userdn): - if self.escape_userdn: - return escape_filter_chars(userdn) - else: - return userdn - search_filter = Unicode( config=True, help="LDAP3 Search Filter whose results are allowed access" @@ -261,124 +292,256 @@ def escape_userdn_if_needed(self, userdn): help="List of attributes to be searched" ) + def resolve_username(self, username_supplied_by_user): + search_dn = self.lookup_dn_search_user + if self.escape_userdn: + search_dn = escape_filter_chars(search_dn) + conn = self.get_connection( + userdn=search_dn, + password=self.lookup_dn_search_password, + ) + is_bound = conn.bind() + if not is_bound: + msg = "Failed to connect to LDAP server with search user '{search_dn}'" + self.log.warn(msg.format(search_dn=search_dn)) + return None + + search_filter = self.lookup_dn_search_filter.format( + login_attr=self.user_attribute, + login=username_supplied_by_user, + ) + msg = '\n'.join([ + "Looking up user with:", + " search_base = '{search_base}'", + " search_filter = '{search_filter}'", + " attributes = '{attributes}'", + ]) + self.log.debug(msg.format( + search_base=self.user_search_base, + search_filter=search_filter, + attributes=self.user_attribute, + )) + conn.search( + search_base=self.user_search_base, + search_scope=ldap3.SUBTREE, + search_filter=search_filter, + attributes=[self.lookup_dn_user_dn_attribute], + ) + response = conn.response + if len(response) == 0 or 'attributes' not in response[0].keys(): + msg = ( + "No entry found for user '{username}' " + "when looking up attribute '{attribute}'" + ) + self.log.warn(msg.format( + username=username_supplied_by_user, + attribute=self.user_attribute, + )) + return None + return conn.response[0]['attributes'][self.lookup_dn_user_dn_attribute] + + def get_connection(self, userdn, password): + server = ldap3.Server( + self.server_address, + port=self.server_port, + use_ssl=self.use_ssl + ) + auto_bind = ( + self.use_ssl + and ldap3.AUTO_BIND_TLS_BEFORE_BIND + or ldap3.AUTO_BIND_NO_TLS + ) + conn = ldap3.Connection( + server, + user=userdn, + password=password, + auto_bind=auto_bind, + ) + return conn @gen.coroutine def authenticate(self, handler, data): username = data['username'] password = data['password'] - # Get LDAP Connection - def getConnection(userdn, username, password): - server = ldap3.Server( - self.server_address, - port=self.server_port, - use_ssl=self.use_ssl - ) - self.log.debug('Attempting to bind {username} with {userdn}'.format( - username=username, - userdn=userdn - )) - conn = ldap3.Connection( - server, - user=self.escape_userdn_if_needed(userdn), - password=password, - auto_bind=self.use_ssl and ldap3.AUTO_BIND_TLS_BEFORE_BIND or ldap3.AUTO_BIND_NO_TLS, - ) - return conn + + def get_user_groups(username): + if self.activedirectory: + self.log.debug('Active Directory enabled') + user_dn = self.resolve_username(username) + search_filter='(member:1.2.840.113556.1.4.1941:={dn})'.format(dn=escape_filter_chars(user_dn)) + search_attribs=['cn'] # We don't actually care, we just want the DN + search_base=self.group_search_base, + self.log.debug('LDAP Group query: user_dn:[%s] filter:[%s]', user_dn, search_filter) + else: + search_filter=self.lookup_dn_search_filter.format(login_attr=self.user_attribute, login=username) + search_attribs=[self.memberof_attribute] + search_base=self.user_search_base, + self.log.debug('LDAP Group query: username:[%s] filter:[%s]', username, search_filter) + + conn.search( + search_base=self.group_search_base, + search_scope=ldap3.SUBTREE, + search_filter=search_filter, + attributes=search_attribs) + + if self.activedirectory: + user_groups = [] + + if len(conn.response) == 0: + return None + + for g in conn.response: + user_groups.append(g['dn']) + return user_groups + else: + if len(conn.response) == 0 or 'attributes' not in conn.response[0].keys(): + self.log.debug('User %s is not a member of any groups (via memberOf)', username) + return None + else: + return conn.response[0]['attributes'][self.memberof_attribute] # Protect against invalid usernames as well as LDAP injection attacks if not re.match(self.valid_username_regex, username): - self.log.warn('username:%s Illegal characters in username, must match regex %s', username, self.valid_username_regex) + self.log.warn( + 'username:%s Illegal characters in username, must match regex %s', + username, self.valid_username_regex + ) return None + # Allow us to reference the actual username the user typed (rather than + # what we might resolve it to later) + login = username + # No empty passwords! if password is None or password.strip() == '': self.log.warn('username:%s Login denied for blank password', username) return None - isBound = False - self.log.debug("TYPE= '%s'",isinstance(self.bind_dn_template, list)) - - resolved_username = self.resolve_username(username) - if resolved_username is None: - return None + if self.lookup_dn: + username = self.resolve_username(login) + if not username: + return None if self.lookup_dn: if str(self.lookup_dn_user_dn_attribute).upper() == 'CN': # Only escape commas if the lookup attribute is CN - resolved_username = re.subn(r"([^\\]),", r"\1\,", resolved_username)[0] + username = re.subn(r"([^\\]),", r"\1\,", username)[0] bind_dn_template = self.bind_dn_template if isinstance(bind_dn_template, str): # bind_dn_template should be of type List[str] bind_dn_template = [bind_dn_template] + is_bound = False for dn in bind_dn_template: - userdn = dn.format(username=resolved_username) - msg = 'Status of user bind {username} with {userdn} : {isBound}' + if not dn: + self.log.warn("Ignoring blank 'bind_dn_template' entry!") + continue + userdn = dn.format(username=username, login=login) + if self.escape_userdn: + userdn = escape_filter_chars(userdn) + msg = 'Attempting to bind {username} with {userdn}' + self.log.debug(msg.format(username=username, userdn=userdn)) + msg = "Status of user bind {username} with {userdn} : {is_bound}" try: - conn = getConnection(userdn, username, password) + conn = self.get_connection(userdn, password) except ldap3.core.exceptions.LDAPBindError as exc: - isBound = False + is_bound = False msg += '\n{exc_type}: {exc_msg}'.format( exc_type=exc.__class__.__name__, exc_msg=exc.args[0] if exc.args else '' ) else: - isBound = conn.bind() + is_bound = conn.bind() msg = msg.format( username=username, userdn=userdn, - isBound=isBound + is_bound=is_bound ) self.log.debug(msg) - if isBound: + if is_bound: break - if isBound: - if self.allowed_groups: - self.log.debug('username:%s Using dn %s', username, userdn) + if not is_bound: + msg = "Invalid password for user '{username}'" + self.log.warn(msg.format(username=username)) + return None + + if self.search_filter: + search_filter = self.search_filter.format( + userattr=self.user_attribute, + username=username, + ) + conn.search( + search_base=self.user_search_base, + search_scope=ldap3.SUBTREE, + search_filter=search_filter, + attributes=self.attributes + ) + n_users = len(conn.response) + if n_users == 0: + msg = "User with '{userattr}={username}' not found in directory" + self.log.warn(msg.format( + userattr=self.user_attribute, + username=username) + ) + return None + if n_users > 1: + msg = ( + "Duplicate users found! " + "{n_users} users found with '{userattr}={username}'" + ) + self.log.warn(msg.format( + userattr=self.user_attribute, + username=username, + n_users=n_users) + ) + return None + + if self.allowed_groups: + self.log.debug('username:%s Using dn %s', username, userdn) + found = False + if self.get_groups_from_user: + user_groups = get_user_groups(login) + if user_groups is None: + self.log.debug('Username %s has no group membership', username) + return None + else: + self.log.debug('Username %s is a member of %d groups', username, len(user_groups)) + for group in self.allowed_groups: + if group in user_groups: + self.log.info('User %s is a member of permitted group %s', username, group) + return username + else: for group in self.allowed_groups: - groupfilter = ( + group_filter = ( '(|' '(member={userdn})' '(uniqueMember={userdn})' '(memberUid={uid})' ')' - ).format(userdn=escape_filter_chars(userdn), uid=escape_filter_chars(username)) - groupattributes = ['member', 'uniqueMember', 'memberUid'] - if conn.search( + ) + group_filter = group_filter.format( + userdn=userdn, + uid=username + ) + group_attributes = ['member', 'uniqueMember', 'memberUid'] + found = conn.search( group, search_scope=ldap3.BASE, - search_filter=groupfilter, - attributes=groupattributes - ): - return username + search_filter=group_filter, + attributes=group_attributes + ) + if found: + break + + if not found: # If we reach here, then none of the groups matched - self.log.warn('username:%s User not in any of the allowed groups', username) + msg = 'username:{username} User not in any of the allowed groups' + self.log.warn(msg.format(username=username)) return None - elif self.search_filter: - conn.search( - search_base=self.user_search_base, - search_scope=ldap3.SUBTREE, - search_filter=self.search_filter.format(userattr=self.user_attribute,username=username), - attributes=self.attributes - ) - if len(conn.response) == 0: - self.log.warn('User with {userattr}={username} not found in directory'.format( - userattr=self.user_attribute, username=username)) - return None - elif len(conn.response) > 1: - self.log.warn('User with {userattr}={username} found more than {len}-fold in directory'.format( - userattr=self.user_attribute, username=username, len=len(conn.response))) - return None - return username - else: - return username - else: - self.log.warn('Invalid password for user {username}'.format( - username=username, - )) - return None + + return username if __name__ == "__main__":