Skip to content

Commit 1732ad1

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 65b234a commit 1732ad1

File tree

1 file changed

+157
-26
lines changed

1 file changed

+157
-26
lines changed

ldapauthenticator/ldapauthenticator.py

Lines changed: 157 additions & 26 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

@@ -142,6 +146,20 @@ def _server_port_default(self):
142146
""",
143147
)
144148

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

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

413+
def get_user_groups(username):
414+
if self.activedirectory:
415+
self.log.debug('Active Directory enabled')
416+
user_dn = self.resolve_username(username)
417+
search_filter='(member:1.2.840.113556.1.4.1941:={dn})'.format(dn=escape_filter_chars(user_dn))
418+
search_attribs=['cn'] # We don't actually care, we just want the DN
419+
search_base=self.group_search_base,
420+
self.log.debug('LDAP Group query: user_dn:[%s] filter:[%s]', user_dn, search_filter)
421+
else:
422+
search_filter=self.lookup_dn_search_filter.format(login_attr=self.user_attribute, login=username)
423+
search_attribs=[self.memberof_attribute]
424+
search_base=self.user_search_base,
425+
self.log.debug('LDAP Group query: username:[%s] filter:[%s]', username, search_filter)
426+
427+
conn.search(
428+
search_base=self.group_search_base,
429+
search_scope=ldap3.SUBTREE,
430+
search_filter=search_filter,
431+
attributes=search_attribs)
432+
433+
if self.activedirectory:
434+
user_groups = []
435+
436+
if len(conn.response) == 0:
437+
return None
438+
439+
for g in conn.response:
440+
user_groups.append(g['dn'])
441+
return user_groups
442+
else:
443+
if len(conn.response) == 0 or 'attributes' not in conn.response[0].keys():
444+
self.log.debug('User %s is not a member of any groups (via memberOf)', username)
445+
return None
446+
else:
447+
return conn.response[0]['attributes'][self.memberof_attribute]
448+
334449
# Protect against invalid usernames as well as LDAP injection attacks
335450
if not re.match(self.valid_username_regex, username):
336451
self.log.warning(
@@ -340,6 +455,10 @@ def authenticate(self, handler, data):
340455
)
341456
return None
342457

458+
# Allow us to reference the actual username the user typed (rather than
459+
# what we might resolve it to later)
460+
login = username
461+
343462
# No empty passwords!
344463
if password is None or password.strip() == "":
345464
self.log.warning("username:%s Login denied for blank password", username)
@@ -372,7 +491,7 @@ def authenticate(self, handler, data):
372491
if not dn:
373492
self.log.warning("Ignoring blank 'bind_dn_template' entry!")
374493
continue
375-
userdn = dn.format(username=username)
494+
userdn = dn.format(username=username, login=login)
376495
if self.escape_userdn:
377496
userdn = escape_filter_chars(userdn)
378497
msg = "Attempting to bind {username} with {userdn}"
@@ -430,24 +549,37 @@ def authenticate(self, handler, data):
430549
if self.allowed_groups:
431550
self.log.debug("username:%s Using dn %s", username, userdn)
432551
found = False
433-
for group in self.allowed_groups:
434-
group_filter = (
435-
"(|"
436-
"(member={userdn})"
437-
"(uniqueMember={userdn})"
438-
"(memberUid={uid})"
439-
")"
440-
)
441-
group_filter = group_filter.format(userdn=userdn, uid=username)
442-
group_attributes = ["member", "uniqueMember", "memberUid"]
443-
found = conn.search(
444-
group,
445-
search_scope=ldap3.BASE,
446-
search_filter=group_filter,
447-
attributes=group_attributes,
448-
)
449-
if found:
450-
break
552+
553+
if self.get_groups_from_user:
554+
user_groups = get_user_groups(login)
555+
if user_groups is None:
556+
self.log.debug('Username %s has no group membership', username)
557+
return None
558+
else:
559+
self.log.debug('Username %s is a member of %d groups', username, len(user_groups))
560+
for group in self.allowed_groups:
561+
if group in user_groups:
562+
self.log.info('User %s is a member of permitted group %s', username, group)
563+
return username
564+
else:
565+
for group in self.allowed_groups:
566+
group_filter = (
567+
"(|"
568+
"(member={userdn})"
569+
"(uniqueMember={userdn})"
570+
"(memberUid={uid})"
571+
")"
572+
)
573+
group_filter = group_filter.format(userdn=userdn, uid=username)
574+
group_attributes = ["member", "uniqueMember", "memberUid"]
575+
found = conn.search(
576+
group,
577+
search_scope=ldap3.BASE,
578+
search_filter=group_filter,
579+
attributes=group_attributes,
580+
)
581+
if found:
582+
break
451583
if not found:
452584
# If we reach here, then none of the groups matched
453585
msg = "username:{username} User not in any of the allowed groups"
@@ -463,7 +595,6 @@ def authenticate(self, handler, data):
463595
return {"name": username, "auth_state": user_info}
464596
return username
465597

466-
467598
if __name__ == "__main__":
468599
import getpass
469600

0 commit comments

Comments
 (0)