@@ -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
@@ -141,6 +145,20 @@ def _server_port_default(self):
141
145
""" ,
142
146
)
143
147
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
+
144
162
user_attribute = Unicode (
145
163
config = True ,
146
164
default = None ,
@@ -155,6 +173,66 @@ def _server_port_default(self):
155
173
""" ,
156
174
)
157
175
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
+
158
236
lookup_dn_search_filter = Unicode (
159
237
config = True ,
160
238
default_value = "({login_attr}={login})" ,
@@ -189,7 +267,7 @@ def _server_port_default(self):
189
267
default_value = None ,
190
268
allow_none = True ,
191
269
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.
193
271
194
272
See `user_search_base` for info on how this attribute is used.
195
273
@@ -229,7 +307,8 @@ def resolve_username(self, username_supplied_by_user):
229
307
if self .escape_userdn :
230
308
search_dn = escape_filter_chars (search_dn )
231
309
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 ,
233
312
)
234
313
is_bound = conn .bind ()
235
314
if not is_bound :
@@ -292,6 +371,42 @@ def authenticate(self, handler, data):
292
371
username = data ["username" ]
293
372
password = data ["password" ]
294
373
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
+
295
410
# Protect against invalid usernames as well as LDAP injection attacks
296
411
if not re .match (self .valid_username_regex , username ):
297
412
self .log .warning (
@@ -301,13 +416,17 @@ def authenticate(self, handler, data):
301
416
)
302
417
return None
303
418
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
+
304
423
# No empty passwords!
305
424
if password is None or password .strip () == "" :
306
425
self .log .warning ("username:%s Login denied for blank password" , username )
307
426
return None
308
427
309
428
if self .lookup_dn :
310
- username = self .resolve_username (username )
429
+ username = self .resolve_username (login )
311
430
if not username :
312
431
return None
313
432
if isinstance (username , list ):
@@ -328,7 +447,7 @@ def authenticate(self, handler, data):
328
447
if not dn :
329
448
self .log .warning ("Ignoring blank 'bind_dn_template' entry!" )
330
449
continue
331
- userdn = dn .format (username = username )
450
+ userdn = dn .format (username = username , login = login )
332
451
if self .escape_userdn :
333
452
userdn = escape_filter_chars (userdn )
334
453
msg = "Attempting to bind {username} with {userdn}"
@@ -386,24 +505,37 @@ def authenticate(self, handler, data):
386
505
if self .allowed_groups :
387
506
self .log .debug ("username:%s Using dn %s" , username , userdn )
388
507
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
407
539
if not found :
408
540
# If we reach here, then none of the groups matched
409
541
msg = "username:{username} User not in any of the allowed groups"
@@ -415,7 +547,6 @@ def authenticate(self, handler, data):
415
547
else :
416
548
return data ["username" ]
417
549
418
-
419
550
if __name__ == "__main__" :
420
551
import getpass
421
552
0 commit comments