@@ -51,7 +51,8 @@ def _server_port_default(self):
51
51
help = """
52
52
Template from which to construct the full dn
53
53
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.
55
56
56
57
If your LDAP is set in such a way that the userdn can not
57
58
be formed from a template, but must be looked up with an attribute
@@ -63,9 +64,12 @@ def _server_port_default(self):
63
64
64
65
List Example:
65
66
[
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}
69
73
""" ,
70
74
)
71
75
@@ -142,6 +146,20 @@ def _server_port_default(self):
142
146
""" ,
143
147
)
144
148
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
+
145
163
user_attribute = Unicode (
146
164
config = True ,
147
165
default = None ,
@@ -156,6 +174,66 @@ def _server_port_default(self):
156
174
""" ,
157
175
)
158
176
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
+
159
237
lookup_dn_search_filter = Unicode (
160
238
config = True ,
161
239
default_value = "({login_attr}={login})" ,
@@ -190,7 +268,7 @@ def _server_port_default(self):
190
268
default_value = None ,
191
269
allow_none = True ,
192
270
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.
194
272
195
273
See `user_search_base` for info on how this attribute is used.
196
274
@@ -234,7 +312,8 @@ def resolve_username(self, username_supplied_by_user):
234
312
if self .escape_userdn :
235
313
search_dn = escape_filter_chars (search_dn )
236
314
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 ,
238
317
)
239
318
is_bound = conn .bind ()
240
319
if not is_bound :
@@ -331,6 +410,42 @@ def authenticate(self, handler, data):
331
410
username = data ["username" ]
332
411
password = data ["password" ]
333
412
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
+
334
449
# Protect against invalid usernames as well as LDAP injection attacks
335
450
if not re .match (self .valid_username_regex , username ):
336
451
self .log .warning (
@@ -340,6 +455,10 @@ def authenticate(self, handler, data):
340
455
)
341
456
return None
342
457
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
+
343
462
# No empty passwords!
344
463
if password is None or password .strip () == "" :
345
464
self .log .warning ("username:%s Login denied for blank password" , username )
@@ -372,7 +491,7 @@ def authenticate(self, handler, data):
372
491
if not dn :
373
492
self .log .warning ("Ignoring blank 'bind_dn_template' entry!" )
374
493
continue
375
- userdn = dn .format (username = username )
494
+ userdn = dn .format (username = username , login = login )
376
495
if self .escape_userdn :
377
496
userdn = escape_filter_chars (userdn )
378
497
msg = "Attempting to bind {username} with {userdn}"
@@ -430,24 +549,37 @@ def authenticate(self, handler, data):
430
549
if self .allowed_groups :
431
550
self .log .debug ("username:%s Using dn %s" , username , userdn )
432
551
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
451
583
if not found :
452
584
# If we reach here, then none of the groups matched
453
585
msg = "username:{username} User not in any of the allowed groups"
@@ -463,7 +595,6 @@ def authenticate(self, handler, data):
463
595
return {"name" : username , "auth_state" : user_info }
464
596
return username
465
597
466
-
467
598
if __name__ == "__main__" :
468
599
import getpass
469
600
0 commit comments