Skip to content

Commit

Permalink
Allow lookup of groups directly from user object
Browse files Browse the repository at this point in the history
Also add Microsoft Active directory specific lookup logic to resolve
nested groups.
  • Loading branch information
jfautley committed Jul 12, 2018
1 parent 718eb59 commit e4c4eca
Showing 1 changed file with 151 additions and 20 deletions.
171 changes: 151 additions & 20 deletions ldapauthenticator/ldapauthenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -62,6 +63,9 @@ def _server_port_default(self):
uid={username},ou=people,dc=wikimedia,dc=org,
uid={username},ou=Developers,dc=wikimedia,dc=org
]
Active Directory Example:
DOMAIN\{login}
"""
)

Expand Down Expand Up @@ -137,6 +141,20 @@ def _server_port_default(self):
"""
)

group_search_base = Unicode(
config=True,
default=None,
allow_none=True,
help="""
Base for looking up groups in the directory. Used if `activedirectory` is True.
For example:
```
c.LDAPAuthenticator.group_search_base = 'ou=groups,dc=wikimedia,dc=org'
```
"""
)

user_attribute = Unicode(
config=True,
default=None,
Expand All @@ -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 = True
c.LDAPAuthenticator.activedirectory = True
c.LDAPAuthenticator.get_groups_from_user = True
c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'cn'
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})',
Expand Down Expand Up @@ -204,7 +282,7 @@ def _server_port_default(self):
"""
)

def resolve_username(self, username_supplied_by_user):
def resolve_username(self, username_supplied_by_user, want_dn = False):
if self.lookup_dn:
server = ldap3.Server(
self.server_address,
Expand Down Expand Up @@ -241,7 +319,10 @@ def resolve_username(self, username_supplied_by_user):
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]
if want_dn:
return conn.response[0]['dn']
else:
return conn.response[0]['attributes'][self.lookup_dn_user_dn_attribute]
else:
return username_supplied_by_user

Expand Down Expand Up @@ -285,6 +366,42 @@ def getConnection(userdn, username, password):
)
return conn

def get_user_groups(username):
if self.activedirectory:
self.log.debug('Active Directory enabled')
user_dn = self.resolve_username(username, want_dn=True)
search_filter='(member:1.2.840.113556.1.4.1941:={dn})'.format(dn=self.escape_userdn_if_needed(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=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)
Expand Down Expand Up @@ -313,7 +430,7 @@ def getConnection(userdn, username, password):
bind_dn_template = [bind_dn_template]

for dn in bind_dn_template:
userdn = dn.format(username=resolved_username)
userdn = dn.format(username=resolved_username, login=username)
msg = 'Status of user bind {username} with {userdn} : {isBound}'
try:
conn = getConnection(userdn, username, password)
Expand All @@ -337,22 +454,36 @@ def getConnection(userdn, username, password):
if isBound:
if self.allowed_groups:
self.log.debug('username:%s Using dn %s', username, userdn)
for group in self.allowed_groups:
groupfilter = (
'(|'
'(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,
search_scope=ldap3.BASE,
search_filter=groupfilter,
attributes=groupattributes
):
return username

if self.get_groups_from_user:
user_groups = get_user_groups(username)
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 = (
'(|'
'(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,
search_scope=ldap3.BASE,
search_filter=groupfilter,
attributes=groupattributes
):
return username

# If we reach here, then none of the groups matched
self.log.warn('username:%s User not in any of the allowed groups', username)
return None
Expand Down

0 comments on commit e4c4eca

Please sign in to comment.