Skip to content

Commit b90fab3

Browse files
committed
Allow lookup of groups directly from user object
Also add Microsoft Active directory specific lookup logic to resolve nested groups.
1 parent f8701d4 commit b90fab3

File tree

1 file changed

+158
-27
lines changed

1 file changed

+158
-27
lines changed

ldapauthenticator/ldapauthenticator.py

Lines changed: 158 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ def _server_port_default(self):
5151
help="""
5252
Template from which to construct the full dn
5353
when authenticating to LDAP. {username} is replaced
54-
with the actual username used to log in.
54+
with the user's resolved username (i.e. their CN attribute).
55+
{login} is replaced with the actual username used to login.
5556
5657
If your LDAP is set in such a way that the userdn can not
5758
be formed from a template, but must be looked up with an attribute
@@ -63,9 +64,12 @@ def _server_port_default(self):
6364
6465
List Example:
6566
[
66-
uid={username},ou=people,dc=wikimedia,dc=org,
67-
uid={username},ou=Developers,dc=wikimedia,dc=org
68-
]
67+
uid={username},ou=people,dc=wikimedia,dc=org,
68+
uid={username},ou=Developers,dc=wikimedia,dc=org
69+
]
70+
71+
Active Directory Example:
72+
DOMAIN\{login}
6973
""",
7074
)
7175

@@ -141,6 +145,20 @@ def _server_port_default(self):
141145
""",
142146
)
143147

148+
group_search_base = Unicode(
149+
config=True,
150+
default=user_search_base,
151+
allow_none=True,
152+
help="""
153+
Base for looking up groups in the directory. Defaults to the value of user_search_base if unset.
154+
155+
For example:
156+
```
157+
c.LDAPAuthenticator.group_search_base = 'ou=groups,dc=wikimedia,dc=org'
158+
```
159+
"""
160+
)
161+
144162
user_attribute = Unicode(
145163
config=True,
146164
default=None,
@@ -155,6 +173,66 @@ def _server_port_default(self):
155173
""",
156174
)
157175

176+
memberof_attribute = Unicode(
177+
config=True,
178+
default_value='memberOf',
179+
allow_none=False,
180+
help="""
181+
Attribute attached to user objects containing the list of groups the user is a member of.
182+
183+
Defaults to 'memberOf', you probably won't need to change this.
184+
"""
185+
)
186+
187+
get_groups_from_user = Bool(
188+
False,
189+
config=True,
190+
help="""
191+
If set, this will confirm a user's group membership by querying the
192+
user object in LDAP directly, and querying the attribute set in
193+
`memberof_attribute` (defaults to `memberOf`).
194+
195+
If unset (the default), then each authorised group set in
196+
`allowed_group` is queried from LDAP and matched against the user's DN.
197+
198+
This should be set when the LDAP server is Microsoft Active Directory,
199+
and you probably also want to set the `activedirectory` configuration
200+
setting to 'true' as well'
201+
"""
202+
)
203+
204+
activedirectory = Bool(
205+
False,
206+
config=True,
207+
help="""
208+
If set, this treats the remote LDAP server as a Microsoft Active
209+
Directory instance, and will optimise group membership queries where
210+
`allow_groups` is used. This requires `get_groups_from_user` to be
211+
enabled.
212+
213+
This allows nested groups to be resolved when using Active Directory.
214+
215+
Example Active Directory configuration:
216+
```
217+
c.LDAPAuthenticator.bind_dn_template = 'DOMAIN\{login}'
218+
c.LDAPAuthenticator.lookup_dn = False
219+
c.LDAPAuthenticator.activedirectory = True
220+
c.LDAPAuthenticator.get_groups_from_user = True
221+
c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'distinguishedName'
222+
c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})'
223+
c.LDAPAuthenticator.lookup_dn_search_user = 'readonly'
224+
c.LDAPAuthenticator.lookup_dn_search_password = 'notarealpassword'
225+
c.LDAPAuthenticator.user_attribute = 'sAMAccountName'
226+
c.LDAPAuthenticator.user_search_base = 'OU=Users,DC=example,DC=org'
227+
c.LDAPAuthenticator.group_search_base = 'OU=Groups,DC=example,DC=org'
228+
229+
c.LDAPAuthenticator.admin_users = {'Administrator'}
230+
c.LDAPAuthenticator.allowed_groups = [
231+
'CN=JupyterHub_Users,OU=Groups,DC=example,DC=org']
232+
```
233+
"""
234+
)
235+
158236
lookup_dn_search_filter = Unicode(
159237
config=True,
160238
default_value="({login_attr}={login})",
@@ -189,7 +267,7 @@ def _server_port_default(self):
189267
default_value=None,
190268
allow_none=True,
191269
help="""
192-
Attribute containing user's name needed for building DN string, if `lookup_dn` is set to True.
270+
Attribute containing user's name needed for building DN string, if `lookup_dn` is set to True.
193271
194272
See `user_search_base` for info on how this attribute is used.
195273
@@ -229,7 +307,8 @@ def resolve_username(self, username_supplied_by_user):
229307
if self.escape_userdn:
230308
search_dn = escape_filter_chars(search_dn)
231309
conn = self.get_connection(
232-
userdn=search_dn, password=self.lookup_dn_search_password
310+
userdn=search_dn,
311+
password=self.lookup_dn_search_password,
233312
)
234313
is_bound = conn.bind()
235314
if not is_bound:
@@ -292,6 +371,42 @@ def authenticate(self, handler, data):
292371
username = data["username"]
293372
password = data["password"]
294373

374+
def get_user_groups(username):
375+
if self.activedirectory:
376+
self.log.debug('Active Directory enabled')
377+
user_dn = self.resolve_username(username)
378+
search_filter='(member:1.2.840.113556.1.4.1941:={dn})'.format(dn=escape_filter_chars(user_dn))
379+
search_attribs=['cn'] # We don't actually care, we just want the DN
380+
search_base=self.group_search_base,
381+
self.log.debug('LDAP Group query: user_dn:[%s] filter:[%s]', user_dn, search_filter)
382+
else:
383+
search_filter=self.lookup_dn_search_filter.format(login_attr=self.user_attribute, login=username)
384+
search_attribs=[self.memberof_attribute]
385+
search_base=self.user_search_base,
386+
self.log.debug('LDAP Group query: username:[%s] filter:[%s]', username, search_filter)
387+
388+
conn.search(
389+
search_base=self.group_search_base,
390+
search_scope=ldap3.SUBTREE,
391+
search_filter=search_filter,
392+
attributes=search_attribs)
393+
394+
if self.activedirectory:
395+
user_groups = []
396+
397+
if len(conn.response) == 0:
398+
return None
399+
400+
for g in conn.response:
401+
user_groups.append(g['dn'])
402+
return user_groups
403+
else:
404+
if len(conn.response) == 0 or 'attributes' not in conn.response[0].keys():
405+
self.log.debug('User %s is not a member of any groups (via memberOf)', username)
406+
return None
407+
else:
408+
return conn.response[0]['attributes'][self.memberof_attribute]
409+
295410
# Protect against invalid usernames as well as LDAP injection attacks
296411
if not re.match(self.valid_username_regex, username):
297412
self.log.warning(
@@ -301,13 +416,17 @@ def authenticate(self, handler, data):
301416
)
302417
return None
303418

419+
# Allow us to reference the actual username the user typed (rather than
420+
# what we might resolve it to later)
421+
login = username
422+
304423
# No empty passwords!
305424
if password is None or password.strip() == "":
306425
self.log.warning("username:%s Login denied for blank password", username)
307426
return None
308427

309428
if self.lookup_dn:
310-
username = self.resolve_username(username)
429+
username = self.resolve_username(login)
311430
if not username:
312431
return None
313432
if isinstance(username, list):
@@ -328,7 +447,7 @@ def authenticate(self, handler, data):
328447
if not dn:
329448
self.log.warning("Ignoring blank 'bind_dn_template' entry!")
330449
continue
331-
userdn = dn.format(username=username)
450+
userdn = dn.format(username=username, login=login)
332451
if self.escape_userdn:
333452
userdn = escape_filter_chars(userdn)
334453
msg = "Attempting to bind {username} with {userdn}"
@@ -386,24 +505,37 @@ def authenticate(self, handler, data):
386505
if self.allowed_groups:
387506
self.log.debug("username:%s Using dn %s", username, userdn)
388507
found = False
389-
for group in self.allowed_groups:
390-
group_filter = (
391-
"(|"
392-
"(member={userdn})"
393-
"(uniqueMember={userdn})"
394-
"(memberUid={uid})"
395-
")"
396-
)
397-
group_filter = group_filter.format(userdn=userdn, uid=username)
398-
group_attributes = ["member", "uniqueMember", "memberUid"]
399-
found = conn.search(
400-
group,
401-
search_scope=ldap3.BASE,
402-
search_filter=group_filter,
403-
attributes=group_attributes,
404-
)
405-
if found:
406-
break
508+
509+
if self.get_groups_from_user:
510+
user_groups = get_user_groups(login)
511+
if user_groups is None:
512+
self.log.debug('Username %s has no group membership', username)
513+
return None
514+
else:
515+
self.log.debug('Username %s is a member of %d groups', username, len(user_groups))
516+
for group in self.allowed_groups:
517+
if group in user_groups:
518+
self.log.info('User %s is a member of permitted group %s', username, group)
519+
return username
520+
else:
521+
for group in self.allowed_groups:
522+
group_filter = (
523+
"(|"
524+
"(member={userdn})"
525+
"(uniqueMember={userdn})"
526+
"(memberUid={uid})"
527+
")"
528+
)
529+
group_filter = group_filter.format(userdn=userdn, uid=username)
530+
group_attributes = ["member", "uniqueMember", "memberUid"]
531+
found = conn.search(
532+
group,
533+
search_scope=ldap3.BASE,
534+
search_filter=group_filter,
535+
attributes=group_attributes,
536+
)
537+
if found:
538+
break
407539
if not found:
408540
# If we reach here, then none of the groups matched
409541
msg = "username:{username} User not in any of the allowed groups"
@@ -415,7 +547,6 @@ def authenticate(self, handler, data):
415547
else:
416548
return data["username"]
417549

418-
419550
if __name__ == "__main__":
420551
import getpass
421552

0 commit comments

Comments
 (0)