0001-backends-add-SAML-LDAP-authentication-backend-30125.patch
src/authentic2/backends/ldap_backend.py | ||
---|---|---|
318 | 318 |
'can_reset_password': False, |
319 | 319 |
# mapping from LDAP attributes to User attributes |
320 | 320 |
'user_attributes': [], |
321 |
# entity id of the IDP based on the same LDAP |
|
322 |
'saml_entity_id': '', |
|
323 |
# template for SAML username |
|
324 |
'saml_username_template': '{uid[0]}' |
|
321 | 325 |
} |
322 | 326 |
_REQUIRED = ('url', 'basedn') |
323 | 327 |
_TO_ITERABLE = ('url', 'groupsu', 'groupstaff', 'groupactive') |
... | ... | |
346 | 350 |
log.debug('got config %r', blocks) |
347 | 351 |
return blocks |
348 | 352 | |
349 |
def authenticate(self, username=None, password=None, realm=None): |
|
353 |
def authenticate(self, username=None, password=None, realm=None, without_password=False):
|
|
350 | 354 |
if username is None or password is None: |
351 | 355 |
return None |
352 | 356 | |
... | ... | |
369 | 373 |
if user is not None: |
370 | 374 |
return user |
371 | 375 | |
372 |
def authenticate_block(self, block, username, password): |
|
376 |
def get_dn_from_username(self, conn, block, username): |
|
377 |
user_basedn = block.get('user_basedn') or block['basedn'] |
|
378 |
if block['user_dn_template']: |
|
379 |
template = force_bytes(block['user_dn_template']) |
|
380 |
escaped_username = escape_dn_chars(username) |
|
381 |
username = template.format(username=escaped_username) |
|
382 | ||
383 |
# allow multiple occurences of the username in the filter |
|
384 |
user_filter = block['user_filter'] |
|
385 |
n = len(user_filter.split('%s')) - 1 |
|
386 | ||
387 |
try: |
|
388 |
query = filter_format(user_filter, (username,) * n) |
|
389 |
except TypeError, e: |
|
390 |
log.error('user_filter syntax error %r: %s', block['user_filter'], e) |
|
391 |
return [] |
|
392 |
log.debug('looking up dn for username %r using query %r', username, |
|
393 |
query) |
|
394 |
try: |
|
395 |
results = conn.search_s(user_basedn, ldap.SCOPE_SUBTREE, query) |
|
396 |
results = [result for result in results if result[0] is not None] |
|
397 |
log.debug('found dns %r', results) |
|
398 |
if len(results) == 0: |
|
399 |
log.debug('user lookup failed: no entry found, %s' % query) |
|
400 |
return [] |
|
401 |
elif not block['multimatch'] and len(results) > 1: |
|
402 |
log.error('user lookup failed: too many (%d) entries found: %s', |
|
403 |
len(results), query) |
|
404 |
return [] |
|
405 |
else: |
|
406 |
return [result[0] for result in results] |
|
407 |
except ldap.NO_SUCH_OBJECT: |
|
408 |
log.error('user dn lookup failed: basedn %s not found', user_basedn) |
|
409 |
raise |
|
410 |
except ldap.LDAPError, e: |
|
411 |
log.error('user dn lookup failed: with query %r got error %s', username, |
|
412 |
query) |
|
413 |
raise |
|
414 | ||
415 |
def authenticate_block(self, block, username, password=None): |
|
373 | 416 |
utf8_username = force_bytes(username) |
374 |
utf8_password = force_bytes(password) |
|
375 | 417 | |
376 | 418 |
for conn in self.get_connections(block): |
377 |
authz_ids = [] |
|
378 |
user_basedn = block.get('user_basedn') or block['basedn'] |
|
379 | ||
380 | 419 |
try: |
381 |
if block['user_dn_template']: |
|
382 |
template = force_bytes(block['user_dn_template']) |
|
383 |
escaped_username = escape_dn_chars(utf8_username) |
|
384 |
authz_ids.append(template.format(username=escaped_username)) |
|
385 |
else: |
|
386 |
try: |
|
387 |
if block.get('bind_with_username'): |
|
388 |
authz_ids.append(utf8_username) |
|
389 |
elif block['user_filter']: |
|
390 |
# allow multiple occurences of the username in the filter |
|
391 |
user_filter = block['user_filter'] |
|
392 |
n = len(user_filter.split('%s')) - 1 |
|
393 |
try: |
|
394 |
query = filter_format(user_filter, (utf8_username,) * n) |
|
395 |
except TypeError, e: |
|
396 |
log.error('user_filter syntax error %r: %s', block['user_filter'], |
|
397 |
e) |
|
398 |
return |
|
399 |
log.debug('looking up dn for username %r using query %r', username, |
|
400 |
query) |
|
401 |
results = conn.search_s(user_basedn, ldap.SCOPE_SUBTREE, query) |
|
402 |
# remove search references |
|
403 |
results = [result for result in results if result[0] is not None] |
|
404 |
log.debug('found dns %r', results) |
|
405 |
if len(results) == 0: |
|
406 |
log.debug('user lookup failed: no entry found, %s' % query) |
|
407 |
elif not block['multimatch'] and len(results) > 1: |
|
408 |
log.error('user lookup failed: too many (%d) entries found: %s', |
|
409 |
len(results), query) |
|
410 |
else: |
|
411 |
authz_ids.extend(result[0] for result in results) |
|
412 |
else: |
|
413 |
raise NotImplementedError |
|
414 |
except ldap.NO_SUCH_OBJECT: |
|
415 |
log.error('user lookup failed: basedn %s not found', user_basedn) |
|
416 |
if block['replicas']: |
|
417 |
break |
|
418 |
continue |
|
419 |
except ldap.LDAPError, e: |
|
420 |
log.error('user lookup failed: with query %r got error %s: %s', username, |
|
421 |
query, e) |
|
422 |
continue |
|
423 |
if not authz_ids: |
|
424 |
continue |
|
425 | ||
426 |
try: |
|
427 |
failed = False |
|
428 |
for authz_id in authz_ids: |
|
429 |
if failed: |
|
430 |
continue |
|
431 |
try: |
|
432 |
conn.simple_bind_s(authz_id, utf8_password) |
|
433 |
user_login_success(authz_id) |
|
434 |
if not block['connect_with_user_credentials']: |
|
435 |
try: |
|
436 |
self.bind(block, conn) |
|
437 |
except Exception as e: |
|
438 |
log.exception(u'rebind failure after login bind') |
|
439 |
raise ldap.SERVER_DOWN |
|
440 |
break |
|
441 |
except ldap.INVALID_CREDENTIALS: |
|
442 |
user_login_failure(authz_id) |
|
443 |
pass |
|
420 |
for dn in self.get_dn_from_username(conn, block, username): |
|
421 |
if password is None or self.try_bind(conn, block, dn, password): |
|
422 |
user_login_success(dn) |
|
423 |
return self._return_user(dn, password, conn, block) |
|
444 | 424 |
else: |
445 |
log.debug('user bind failed: invalid credentials') |
|
446 |
if block['replicas']: |
|
447 |
break |
|
448 |
continue |
|
449 |
except ldap.NO_SUCH_OBJECT: |
|
450 |
# should not happen as we just searched for this object ! |
|
451 |
log.error('user bind failed: authz_id not found %r', ', '.join(authz_ids)) |
|
452 |
if block['replicas']: |
|
453 |
break |
|
454 |
return self._return_user(authz_id, password, conn, block) |
|
425 |
user_login_failure(dn) |
|
455 | 426 |
except ldap.CONNECT_ERROR: |
456 | 427 |
log.error('connection to %r failed, did you forget to declare the TLS certificate ' |
457 | 428 |
'in /etc/ldap/ldap.conf ?', block['url']) |
... | ... | |
459 | 430 |
log.error('connection to %r timed out', block['url']) |
460 | 431 |
except ldap.SERVER_DOWN: |
461 | 432 |
log.error('ldap authentication error: %r is down', block['url']) |
433 |
except ldap.LDAPError, e: |
|
434 |
log.error('ldap error: %s', e) |
|
435 |
pass |
|
462 | 436 |
finally: |
463 | 437 |
del conn |
464 | 438 |
return None |
465 | 439 | |
440 |
def try_bind(self, conn, block, dn, password): |
|
441 |
utf8_password = password and force_bytes(password) |
|
442 | ||
443 |
try: |
|
444 |
conn.simple_bind_s(dn, utf8_password) |
|
445 |
if not block['connect_with_user_credentials']: |
|
446 |
try: |
|
447 |
self.bind(block, conn) |
|
448 |
except Exception as e: |
|
449 |
log.exception(u'rebind failure after login bind') |
|
450 |
return False |
|
451 |
return True |
|
452 |
except ldap.INVALID_CREDENTIALS: |
|
453 |
return False |
|
454 |
except ldap.NO_SUCH_OBJECT: |
|
455 |
log.error('user bind failed: username not found %s', username) |
|
456 |
return False |
|
457 | ||
466 | 458 |
def get_user(self, user_id, session=None): |
467 | 459 |
try: |
468 | 460 |
try: |
... | ... | |
973 | 965 | |
974 | 966 |
@classmethod |
975 | 967 |
def get_users(cls): |
976 |
logger = logging.getLogger(__name__) |
|
977 | 968 |
for block in cls.get_config(): |
978 | 969 |
conn = cls.get_connection(block) |
979 | 970 |
if conn is None: |
... | ... | |
1161 | 1152 |
if isinstance(cls._DEFAULTS[d], bool) and not isinstance(block[d], bool): |
1162 | 1153 |
raise ImproperlyConfigured( |
1163 | 1154 |
'LDAP_AUTH_SETTINGS: attribute %r must be a boolean' % d) |
1164 |
if (isinstance(cls._DEFAULTS[d], (list, tuple)) and
|
|
1155 |
if (isinstance(cls._DEFAULTS[d], (list, tuple)) and |
|
1165 | 1156 |
not isinstance(block[d], (list, tuple))): |
1166 | 1157 |
raise ImproperlyConfigured( |
1167 | 1158 |
'LDAP_AUTH_SETTINGS: attribute %r must be a list or a tuple' % d) |
src/authentic2_auth_saml/__init__.py | ||
---|---|---|
7 | 7 |
return ['mellon', __name__] |
8 | 8 | |
9 | 9 |
def get_authentication_backends(self): |
10 |
return ['authentic2_auth_saml.backends.SAMLBackend'] |
|
10 |
return ['authentic2_auth_saml.backends.SAMLLdapBackend', |
|
11 |
'authentic2_auth_saml.backends.SAMLBackend'] |
|
11 | 12 | |
12 | 13 |
def get_auth_frontends(self): |
13 | 14 |
return ['authentic2_auth_saml.auth_frontends.SAMLFrontend'] |
src/authentic2_auth_saml/backends.py | ||
---|---|---|
1 |
# authentic2_auth_saml - Authentic2 SAML Auth plugin |
|
2 |
# Copyright (C) 2010-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 | ||
18 |
from django.conf import settings |
|
19 | ||
1 | 20 |
from mellon.backends import SAMLBackend |
2 | 21 | |
3 | 22 |
from authentic2.middleware import StoreRequestMiddleware |
23 |
from authentic2.backends.ldap_backend import LDAPBackend |
|
4 | 24 | |
5 | 25 |
from . import app_settings |
6 | 26 | |
... | ... | |
22 | 42 | |
23 | 43 |
import lasso |
24 | 44 |
return lasso.SAML2_AUTHN_CONTEXT_PREVIOUS_SESSION |
45 | ||
46 | ||
47 |
class SAMLLdapBackend(SAMLBackend): |
|
48 | ||
49 |
def get_username_from_saml_attributes(self, block, saml_attributes): |
|
50 |
username_template = block.get('saml_username_template', '') |
|
51 |
return username_template.format(**saml_attributes) |
|
52 | ||
53 |
def authenticate(self, saml_attributes, request=None): |
|
54 | ||
55 |
if not getattr(settings, 'LDAP_AUTH_SETTINGS', []): |
|
56 |
return None |
|
57 | ||
58 |
no_ldap_for_entity_id = True |
|
59 |
for block in settings.LDAP_AUTH_SETTINGS: |
|
60 |
if block.get('saml_entity_id', '') == saml_attributes['issuer']: |
|
61 |
no_ldap_for_entity_id = False |
|
62 |
break |
|
63 |
if no_ldap_for_entity_id: |
|
64 |
return None |
|
65 | ||
66 |
username = self.get_username_from_saml_attributes(block, saml_attributes) |
|
67 |
backend = LDAPBackend() |
|
68 |
return backend.authenticate_block(block, username) |
tests/test_auth_saml.py | ||
---|---|---|
1 |
# |
|
2 |
# Copyright (C) 2010-2019 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 | ||
1 | 18 |
import pytest |
19 |
import mock |
|
2 | 20 | |
3 | 21 |
from django.contrib.auth import get_user_model |
22 |
from django.utils.timezone import datetime |
|
23 |
from django.test.utils import override_settings |
|
24 | ||
4 | 25 |
from authentic2.models import Attribute |
5 | 26 | |
6 | 27 |
pytestmark = pytest.mark.django_db |
... | ... | |
38 | 59 |
del saml_attributes['mail'] |
39 | 60 |
with pytest.raises(ValueError): |
40 | 61 |
adapter.finish_create_user(idp, saml_attributes, user) |
62 | ||
63 | ||
64 |
@mock.patch('authentic2.backends.ldap_backend.LDAPBackend.get_dn_from_username') |
|
65 |
@mock.patch('authentic2.backends.ldap_backend.LDAPBackend.authenticate_block') |
|
66 |
def test_disabled_saml_ldap_backend(ldap_authenticate_block, ldap_user_dn): |
|
67 |
from authentic2_auth_saml.backends import SAMLLdapBackend |
|
68 |
backend = SAMLLdapBackend() |
|
69 |
saml_attributes = {} |
|
70 |
assert backend.authenticate(saml_attributes) == None |
|
71 |
with override_settings(LDAP_AUTH_SETTINGS=[]): |
|
72 |
assert backend.authenticate(saml_attributes) == None |
|
73 | ||
74 | ||
75 |
LDAP_SETTINGS = { |
|
76 |
"realm": "example.com", |
|
77 |
"url": "ldaps://ldap.example.com/", |
|
78 |
"basedn": "ou=people,o=example,o=com", |
|
79 |
"user_filter": "(|(mail=%s)(uid=%s))", |
|
80 |
"sync_ldap_users_filter": "", |
|
81 |
"username_template": "{uid[0]}", |
|
82 |
"attributes": [ "uid" ], |
|
83 |
"set_mandatory_groups": ["LDAP Entrouvert"], |
|
84 |
"timeout": 3, |
|
85 |
"use_tls": False, |
|
86 |
'saml_entity_id': 'https://some-idp.com/idp/saml2/metadata', |
|
87 |
'global_ldap_options': {}, |
|
88 |
} |
|
89 | ||
90 |
saml_attributes = {'username': [u'foo'], 'first_name': [u'Foo'], |
|
91 |
'last_name': [u'Bar'], 'uid': [u'foobar'], |
|
92 |
'name_id_name_qualifier': u'https://some-idp.com/idp/saml2/metadata', |
|
93 |
'authn_instant': datetime(2019, 1, 30, 11, 12, 40), |
|
94 |
'name_id_format': u'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', |
|
95 |
'name_id_content': u'9f160fa0eb6045e5a5257a34fb4651e0', |
|
96 |
'mail': [u'foo@example.com'], |
|
97 |
'issuer': 'https://some-idp.com/idp/saml2/metadata' |
|
98 |
} |
|
99 | ||
100 |
with override_settings(A2_AUTH_SAML_LDAP_ENABLE=True, LDAP_AUTH_SETTINGS=[LDAP_SETTINGS]): |
|
101 |
ldap_user_dn.return_value = [] |
|
102 |
ldap_authenticate_block.return_value = None |
|
103 |
backend.authenticate(saml_attributes) |
|
104 |
assert ldap_authenticate_block.call_args[0][0] == LDAP_SETTINGS |
|
41 |
- |