/
ldap.py
479 lines (392 loc) · 17.8 KB
/
ldap.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
"""LDAP authentication backend."""
from __future__ import annotations
import logging
from typing import Optional, TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.models import User
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _
try:
import ldap
from ldap.filter import filter_format
except ImportError:
ldap = None
filter_format = None
from reviewboard.accounts.backends.base import BaseAuthBackend
from reviewboard.accounts.forms.auth import LDAPSettingsForm
if TYPE_CHECKING:
from django.http import HttpRequest
from ldap.ldapobject import LDAPObject
logger = logging.getLogger(__name__)
class LDAPBackend(BaseAuthBackend):
"""Authentication backend for LDAP servers.
This allows the use of LDAP servers for authenticating users in Review
Board, and for importing individual users on-demand. It allows for a lot of
customization in terms of how the LDAP server is queried, providing
compatibility with most open source and commercial LDAP servers.
The following Django settings are supported:
``LDAP_ANON_BIND_UID``:
The full DN (distinguished name) of a user account with
sufficient access to perform lookups of users and groups in the LDAP
server. This is treated as a general or "anonymous" user for servers
requiring authentication, and will not be otherwise imported into the
Review Board server (unless attempting to log in with the same name).
This can be unset if the LDAP server supports actual anonymous binds
without a DN.
``LDAP_ANON_BIND_PASSWD``:
The password used for the account specified in ``LDAP_ANON_BIND_UID``.
``LDAP_ANON_BIND_UID``:
The full distinguished name of a user account with sufficient access
to perform lookups of users and groups in the LDAP server. This can
be unset if the LDAP server supports anonymous binds.
``LDAP_BASE_DN``:
The base DN (distinguished name) used to perform LDAP searches.
``LDAP_EMAIL_ATTRIBUTE``:
The attribute designating the e-mail address of a user in the
directory. E-mail attributes are only used if this is set and if
``LDAP_EMAIL_DOMAIN`` is not set.
``LDAP_EMAIL_DOMAIN``:
The domain name to use for e-mail addresses. If set, users imported
from LDAP will have an e-mail address in the form of
:samp:`{username}@{LDAP_EMAIL_DOMAIN}`. This takes priority over
``LDAP_EMAIL_ATTRIBUTE``.
``LDAP_GIVEN_NAME_ATTRIBUTE``:
The attribute designating the given name (or first name) of a user
in the directory. This defaults to ``givenName`` if not provided.
``LDAP_SURNAME_ATTRIBUTE``:
The attribute designating the surname (or last name) of a user in the
directory. This defaults to ``sn`` if not provided.
``LDAP_TLS``:
Whether to use TLS to communicate with the LDAP server.
``LDAP_UID``:
The attribute indicating a user's unique ID in the directory. This
is used to compute a user lookup filter in the format of
:samp:`({LDAP_UID}={username})`.
``LDAP_UID_MASK``:
A mask defining a filter for looking up users. This must contain
``%s`` somewhere in the string, representing the username.
For example: ``(something_special=%s)``.
``LDAP_URI``:
The URI to the LDAP server to connect to for all communication.
"""
backend_id = 'ldap'
name = _('LDAP')
settings_form = LDAPSettingsForm
login_instructions = \
_('Use your standard LDAP username and password.')
def authenticate(
self,
request: Optional[HttpRequest] = None,
*,
username: Optional[str] = None,
password: Optional[str] = None,
**credentials,
) -> Optional[User]:
"""Authenticate a user.
This will attempt to authenticate the user against the LDAP server.
If the username and password are valid, a user will be returned, and
added to the database if it doesn't already exist.
Version Changed:
6.0:
* ``request`` is now optional.
* ``username`` and ``password`` are technically optional, to
aid in consistency for type hints, but will result in a ``None``
result.
Version Changed:
4.0:
The ``request`` argument is now mandatory as the first positional
argument, as per requirements in Django.
Args:
request (django.http.HttpRequest, optional):
The HTTP request from the caller. This may be ``None``.
username (str):
The username used to authenticate.
password (str):
The password used to authenticate.
**kwargs (dict, unused):
Additional keyword arguments supplied by the caller.
Returns:
django.contrib.auth.models.User:
The authenticated user, or ``None`` if the user could not be
authenticated for any reason.
"""
if not username or not password:
logger.error('Attempted to authenticate LDAP user without '
'supplying either a username or password parameter! '
'This may be a bug in Review Board. Please report '
'it.')
return None
username = username.strip()
if not password:
# Don't try to bind using an empty password; the server will
# return success, which doesn't mean we have authenticated.
# http://tools.ietf.org/html/rfc4513#section-5.1.2
# http://tools.ietf.org/html/rfc4513#section-6.3.1
logger.warning('Attempted to authenticate "%s" with an empty '
'password against LDAP.',
username,
extra={'request': request})
return None
ldapo = self._connect()
if ldapo is None:
return None
userdn = self._get_user_dn(ldapo, username)
if not userdn:
return None
assert ldap
try:
# Now that we have the user, attempt to bind to verify
# authentication.
logger.debug('Attempting to authenticate user DN "%s" '
'(username %s) in LDAP',
userdn, username,
extra={'request': request})
ldapo.bind_s(userdn, password)
return self.get_or_create_user(username=username,
ldapo=ldapo,
userdn=userdn,
request=request)
except ldap.INVALID_CREDENTIALS:
logger.warning('Error authenticating user "%s": The credentials '
'provided were invalid',
username,
extra={'request': request})
except ldap.LDAPError as e:
logger.warning('Error authenticating user "%s": %s',
username, e,
extra={'request': request})
except Exception as e:
logger.exception('Unexpected error authenticating user "%s": %s',
username, e,
extra={'request': request})
return None
def get_or_create_user(
self,
username: str,
request: Optional[HttpRequest] = None,
ldapo: Optional[LDAPObject] = None,
userdn: Optional[str] = None,
) -> Optional[User]:
"""Return a user account, importing from LDAP if necessary.
If the user already exists in the database, it will be returned
directly. Otherwise, this will attempt to look up the user in LDAP
and create a local user account representing that user.
Args:
username (str):
The username to look up.
request (django.http.HttpRequest, optional):
The optional HTTP request for this operation.
ldapo (ldap.ldapobject.LDAPObject, optional):
The existing LDAP connection, if the caller has one. If not
provided, a new connection will be created.
userdn (str, optional):
The DN for the user being looked up, if the caller knows it.
If not provided, the DN will be looked up.
Returns:
django.contrib.auth.models.User:
The resulting user, if it could be found either locally or in
LDAP. If the user does not exist, ``None`` is returned.
"""
username = self.INVALID_USERNAME_CHAR_REGEX.sub('', username).lower()
try:
return User.objects.get(username=username)
except User.DoesNotExist:
# The user wasn't in the database, so we'll look it up in
# LDAP below.
pass
if ldap is None:
logger.error('Attempted to look up user "%s" in LDAP, but the '
'python-ldap package is not installed! Please '
'`pip install ReviewBoard[ldap]`.',
username,
extra={'request': request})
return None
try:
if ldapo is None:
ldapo = self._connect(request=request)
if ldapo is None:
return None
if userdn is None:
userdn = self._get_user_dn(ldapo=ldapo,
username=username,
request=request)
if not userdn:
return None
# Perform a BASE search since we already know the DN of
# the user
search_result = ldapo.search_s(userdn, ldap.SCOPE_BASE)
user_info = search_result[0][1]
given_name_attr = getattr(settings, 'LDAP_GIVEN_NAME_ATTRIBUTE',
'givenName')
first_name = force_str(
user_info.get(given_name_attr, [username])[0])
surname_attr = getattr(settings, 'LDAP_SURNAME_ATTRIBUTE', 'sn')
last_name = force_str(user_info.get(surname_attr, [''])[0])
# If a single ldap attribute is used to hold the full name of
# a user, split it into two parts. Where to split was a coin
# toss and I went with a left split for the first name and
# dumped the remainder into the last name field. The system
# admin can handle the corner cases.
#
# If the full name has no white space to split on, then the
# entire full name is dumped into the first name and the
# last name becomes an empty string.
try:
if settings.LDAP_FULL_NAME_ATTRIBUTE:
full_name = force_str(
user_info[settings.LDAP_FULL_NAME_ATTRIBUTE][0])
try:
first_name, last_name = full_name.split(' ', 1)
except ValueError:
first_name = full_name
last_name = ''
except AttributeError:
pass
if settings.LDAP_EMAIL_DOMAIN:
email = '%s@%s' % (username, settings.LDAP_EMAIL_DOMAIN)
elif settings.LDAP_EMAIL_ATTRIBUTE:
try:
email = force_str(
user_info[settings.LDAP_EMAIL_ATTRIBUTE][0])
except KeyError:
logger.error('Could not find the e-mail address for '
'user "%s" using attribute "%s"',
username, settings.LDAP_EMAIL_ATTRIBUTE,
extra={'request': request})
email = ''
else:
logger.warning('E-mail address for user "%s" is not specified',
username,
extra={'request': request})
email = ''
user = User(username=username,
password='',
first_name=first_name,
last_name=last_name,
email=email)
user.set_unusable_password()
user.save()
return user
except ldap.NO_SUCH_OBJECT as e:
logger.warning('Unable to locate the user "%s" using user DN '
'"%s" on base ' 'DN "%s": %s',
username,
userdn,
settings.LDAP_BASE_DN,
e,
exc_info=True,
extra={'request': request})
except ldap.LDAPError as e:
logger.warning('Unexpected LDAP error when locating user "%s": %s',
username,
e,
exc_info=True,
extra={'request': request})
return None
def _connect(
self,
request: Optional[HttpRequest] = None,
) -> Optional[LDAPObject]:
"""Connect to LDAP.
This will attempt to connect and authenticate (if needed) to the
configured LDAP server.
Args:
request (django.http.HttpRequest, optional):
The optional HTTP request used for logging context.
Returns:
ldap.ldapobject.LDAPObject:
The resulting LDAP connection, if it could connect. If LDAP
support isn't available, or there was an error, this will return
``None``.
"""
if ldap is None:
return None
try:
ldapo = ldap.initialize(settings.LDAP_URI,
bytes_mode=False)
ldapo.set_option(ldap.OPT_REFERRALS, 0)
ldapo.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
if settings.LDAP_TLS:
ldapo.start_tls_s()
if settings.LDAP_ANON_BIND_UID:
# Log in as the service account before searching.
ldapo.simple_bind_s(settings.LDAP_ANON_BIND_UID,
settings.LDAP_ANON_BIND_PASSWD)
else:
# Bind anonymously to the server.
ldapo.simple_bind_s()
return ldapo
except ldap.INVALID_CREDENTIALS:
if settings.LDAP_ANON_BIND_UID:
logger.warning('Error authenticating with LDAP: The '
'credentials provided for "%s" were invalid.',
settings.LDAP_ANON_BIND_UID,
extra={'request': request})
else:
logger.warning('Error authenticating with LDAP: Anonymous '
'access to this server is not permitted.',
extra={'request': request})
except ldap.LDAPError as e:
logger.warning('Error authenticating with LDAP: %s',
e,
extra={'request': request})
except Exception as e:
logger.exception('Unexpected error occurred while authenticating '
'with LDAP: %s',
e,
extra={'request': request})
return None
def _get_user_dn(
self,
ldapo: LDAPObject,
username: str,
request: Optional[HttpRequest] = None,
) -> Optional[str]:
"""Return the DN for a given username.
This will perform a lookup in LDAP to try to find a DN for a given
username, which can be used in subsequent lookups and for
authentication.
Args:
ldapo (ldap.ldapobject.LDAPObject):
The LDAP connection.
username (str):
The username to look up in the directory.
request (django.http.HttpRequest, optional):
The optional HTTP request used for logging context.
Returns:
str:
The DN for the username, if found. If not found, this will return
``None``.
"""
assert filter_format is not None
assert ldap is not None
assert ldapo is not None
try:
# If the UID mask has been explicitly set, use it instead of
# computing a search filter.
if settings.LDAP_UID_MASK:
uid_filter = filter_format(settings.LDAP_UID_MASK, [username])
else:
uid_filter = filter_format('(%s=%s)',
[settings.LDAP_UID, username])
# Search for the user with the given base DN and uid. If the user
# is found, a fully qualified DN is returned.
search = ldapo.search_s(settings.LDAP_BASE_DN,
ldap.SCOPE_SUBTREE,
uid_filter)
if search:
return search[0][0]
logger.warning('LDAP error: The specified object does '
'not exist in the Directory: %s',
username,
extra={'request': request})
except ldap.LDAPError as e:
logger.warning('Error authenticating user "%s" in LDAP: %s',
username, e,
extra={'request': request})
except Exception as e:
logger.exception('Unexpected error authenticating user "%s" '
'in LDAP: %s',
username, e,
extra={'request': request})
return None