Projet

Général

Profil

0001-backends-add-SAML-LDAP-authentication-backend-30125.patch

Serghei Mihai, 04 mars 2019 00:02

Télécharger (16,4 ko)

Voir les différences:

Subject: [PATCH] backends: add SAML LDAP authentication backend (#30125)

 src/authentic2/backends/ldap_backend.py | 153 +++++++++++-------------
 src/authentic2_auth_saml/__init__.py    |   3 +-
 src/authentic2_auth_saml/backends.py    |  44 +++++++
 tests/test_auth_saml.py                 |  64 ++++++++++
 4 files changed, 182 insertions(+), 82 deletions(-)
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
-