Projet

Général

Profil

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

Serghei Mihai, 13 février 2019 11:29

Télécharger (15 ko)

Voir les différences:

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

 src/authentic2/backends/ldap_backend.py | 142 ++++++++++--------------
 src/authentic2_auth_saml/__init__.py    |   3 +-
 src/authentic2_auth_saml/backends.py    |  44 ++++++++
 tests/test_auth_saml.py                 |  47 ++++++++
 4 files changed, 154 insertions(+), 82 deletions(-)
src/authentic2/backends/ldap_backend.py
316 316
        'connect_with_user_credentials': True,
317 317
        # can reset password
318 318
        'can_reset_password': False,
319
        # entity id of the IDP based on the same LDAP
320
        'saml_entity_id': '',
321
        # template for SAML username
322
        'saml_username_template': '{uid[0]}'
319 323
    }
320 324
    _REQUIRED = ('url', 'basedn')
321 325
    _TO_ITERABLE = ('url', 'groupsu', 'groupstaff', 'groupactive')
......
344 348
        log.debug('got config %r', blocks)
345 349
        return blocks
346 350

  
347
    def authenticate(self, username=None, password=None, realm=None):
351
    def get_dn_from_username(self, conn, block, username):
352
        user_basedn = block.get('user_basedn') or block['basedn']
353
        if block['user_dn_template']:
354
            template = force_bytes(block['user_dn_template'])
355
            escaped_username = escape_dn_chars(username)
356
            username = template.format(username=escaped_username)
357

  
358
        # allow multiple occurences of the username in the filter
359
        user_filter = block['user_filter']
360
        n = len(user_filter.split('%s')) - 1
361

  
362
        try:
363
            query = filter_format(user_filter, (username,) * n)
364
        except TypeError, e:
365
            log.error('user_filter syntax error %r: %s', block['user_filter'], e)
366
            return
367
        log.debug('looking up dn for username %r using query %r', username,
368
                  query)
369
        try:
370
            return conn.search_s(user_basedn, ldap.SCOPE_SUBTREE, query)
371
        except ldap.NO_SUCH_OBJECT:
372
            log.error('user dn lookup failed: basedn %s not found', user_basedn)
373
            raise
374
        except ldap.LDAPError, e:
375
            log.error('user dn lookup failed: with query %r got error %s', username,
376
                      query)
377
            raise
378

  
379
    def try_bind(self, conn, block, dn, password):
380
        utf8_password = password and force_bytes(password)
381
        if not block['connect_with_user_credentials']:
382
            try:
383
                self.bind(block, conn)
384
                return True
385
            except Exception as e:
386
                log.exception(u'rebind failure after login bind')
387
                return False
388
        try:
389
            conn.simple_bind_s(dn, utf8_password)
390
            return True
391
        except ldap.INVALID_CREDENTIALS:
392
            return False
393
        except ldap.NO_SUCH_OBJECT:
394
            log.error('user bind failed: username not found %s', username)
395
            return False
396

  
397
    def authenticate(self, username=None, password=None, realm=None, without_password=False):
348 398
        if username is None or password is None:
349 399
            return None
350 400

  
......
367 417
            if user is not None:
368 418
                return user
369 419

  
370
    def authenticate_block(self, block, username, password):
420
    def authenticate_block(self, block, username, password=None):
371 421
        utf8_username = force_bytes(username)
372
        utf8_password = force_bytes(password)
373 422

  
374 423
        for conn in self.get_connections(block):
375
            authz_ids = []
376
            user_basedn = block.get('user_basedn') or block['basedn']
377

  
378 424
            try:
379
                if block['user_dn_template']:
380
                    template = force_bytes(block['user_dn_template'])
381
                    escaped_username = escape_dn_chars(utf8_username)
382
                    authz_ids.append(template.format(username=escaped_username))
383
                else:
384
                    try:
385
                        if block.get('bind_with_username'):
386
                            authz_ids.append(utf8_username)
387
                        elif block['user_filter']:
388
                            # allow multiple occurences of the username in the filter
389
                            user_filter = block['user_filter']
390
                            n = len(user_filter.split('%s')) - 1
391
                            try:
392
                                query = filter_format(user_filter, (utf8_username,) * n)
393
                            except TypeError, e:
394
                                log.error('user_filter syntax error %r: %s', block['user_filter'],
395
                                          e)
396
                                return
397
                            log.debug('looking up dn for username %r using query %r', username,
398
                                      query)
399
                            results = conn.search_s(user_basedn, ldap.SCOPE_SUBTREE, query)
400
                            # remove search references
401
                            results = [result for result in results if result[0] is not None]
402
                            log.debug('found dns %r', results)
403
                            if len(results) == 0:
404
                                log.debug('user lookup failed: no entry found, %s' % query)
405
                            elif not block['multimatch'] and len(results) > 1:
406
                                log.error('user lookup failed: too many (%d) entries found: %s',
407
                                          len(results), query)
408
                            else:
409
                                authz_ids.extend(result[0] for result in results)
410
                        else:
411
                            raise NotImplementedError
412
                    except ldap.NO_SUCH_OBJECT:
413
                        log.error('user lookup failed: basedn %s not found', user_basedn)
414
                        if block['replicas']:
415
                            break
416
                        continue
417
                    except ldap.LDAPError, e:
418
                        log.error('user lookup failed: with query %r got error %s: %s', username,
419
                                  query, e)
420
                        continue
421
                if not authz_ids:
422
                    continue
423

  
424
                try:
425
                    failed = False
426
                    for authz_id in authz_ids:
427
                        if failed:
428
                            continue
429
                        try:
430
                            conn.simple_bind_s(authz_id, utf8_password)
431
                            user_login_success(authz_id)
432
                            if not block['connect_with_user_credentials']:
433
                                try:
434
                                    self.bind(block, conn)
435
                                except Exception as e:
436
                                    log.exception(u'rebind failure after login bind')
437
                                    raise ldap.SERVER_DOWN
438
                            break
439
                        except ldap.INVALID_CREDENTIALS:
440
                            user_login_failure(authz_id)
441
                            pass
425
                for dn, attrs in self.get_dn_from_username(conn, block, username):
426
                    if password is None or self.try_bind(conn, block, dn, password):
427
                        user_login_success(dn)
428
                        return self._return_user(dn, password, conn, block)
442 429
                    else:
443
                        log.debug('user bind failed: invalid credentials')
444
                        if block['replicas']:
445
                            break
446
                        continue
447
                except ldap.NO_SUCH_OBJECT:
448
                    # should not happen as we just searched for this object !
449
                    log.error('user bind failed: authz_id not found %r', ', '.join(authz_ids))
450
                    if block['replicas']:
451
                        break
452
                return self._return_user(authz_id, password, conn, block)
430
                        user_login_failure(dn)
453 431
            except ldap.CONNECT_ERROR:
454 432
                log.error('connection to %r failed, did you forget to declare the TLS certificate '
455 433
                          'in /etc/ldap/ldap.conf ?', block['url'])
......
457 435
                log.error('connection to %r timed out', block['url'])
458 436
            except ldap.SERVER_DOWN:
459 437
                log.error('ldap authentication error: %r is down', block['url'])
438
            except ldap.LDAPError, e:
439
                log.error('ldap error: %s', e)
440
                pass
460 441
            finally:
461 442
                del conn
462 443
        return None
......
948 929

  
949 930
    @classmethod
950 931
    def get_users(cls):
951
        logger = logging.getLogger(__name__)
952 932
        for block in cls.get_config():
953 933
            conn = cls.get_connection(block)
954 934
            if conn is None:
......
1136 1116
                if isinstance(cls._DEFAULTS[d], bool) and not isinstance(block[d], bool):
1137 1117
                    raise ImproperlyConfigured(
1138 1118
                        'LDAP_AUTH_SETTINGS: attribute %r must be a boolean' % d)
1139
                if (isinstance(cls._DEFAULTS[d], (list, tuple)) and 
1119
                if (isinstance(cls._DEFAULTS[d], (list, tuple)) and
1140 1120
                        not isinstance(block[d], (list, tuple))):
1141 1121
                    raise ImproperlyConfigured(
1142 1122
                        '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) 2017 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 1
import pytest
2
import mock
2 3

  
3 4
from django.contrib.auth import get_user_model
5
from django.utils.timezone import datetime
6
from django.test.utils import override_settings
7

  
4 8
from authentic2.models import Attribute
5 9

  
6 10
pytestmark = pytest.mark.django_db
......
38 42
    del saml_attributes['mail']
39 43
    with pytest.raises(ValueError):
40 44
        adapter.finish_create_user(idp, saml_attributes, user)
45

  
46

  
47
@mock.patch('authentic2.backends.ldap_backend.LDAPBackend.get_dn_from_username')
48
@mock.patch('authentic2.backends.ldap_backend.LDAPBackend.authenticate_block')
49
def test_disabled_saml_ldap_backend(ldap_authenticate_block, ldap_user_dn):
50
    from authentic2_auth_saml.backends import SAMLLdapBackend
51
    backend = SAMLLdapBackend()
52
    saml_attributes = {}
53
    assert backend.authenticate(saml_attributes) == None
54
    with override_settings(LDAP_AUTH_SETTINGS=[]):
55
        assert backend.authenticate(saml_attributes) == None
56

  
57

  
58
    LDAP_SETTINGS = {
59
        "realm": "example.com",
60
        "url": "ldaps://ldap.example.com/",
61
        "basedn": "ou=people,o=example,o=com",
62
        "user_filter": "(|(mail=%s)(uid=%s))",
63
        "sync_ldap_users_filter": "",
64
        "username_template": "{uid[0]}",
65
        "attributes": [ "uid" ],
66
        "set_mandatory_groups": ["LDAP Entrouvert"],
67
        "timeout": 3,
68
        "use_tls": False,
69
        'saml_entity_id': 'https://some-idp.com/idp/saml2/metadata',
70
        'global_ldap_options': {},
71
    }
72

  
73
    saml_attributes = {'username': [u'foo'], 'first_name': [u'Foo'],
74
                'last_name': [u'Bar'], 'uid': [u'foobar'],
75
                'name_id_name_qualifier': u'https://some-idp.com/idp/saml2/metadata',
76
                'authn_instant': datetime(2019, 1, 30, 11, 12, 40),
77
                'name_id_format': u'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
78
                'name_id_content': u'9f160fa0eb6045e5a5257a34fb4651e0',
79
                'mail': [u'foo@example.com'],
80
                'issuer': 'https://some-idp.com/idp/saml2/metadata'
81
    }
82

  
83
    with override_settings(A2_AUTH_SAML_LDAP_ENABLE=True, LDAP_AUTH_SETTINGS=[LDAP_SETTINGS]):
84
        ldap_user_dn.return_value = []
85
        ldap_authenticate_block.return_value = None
86
        backend.authenticate(saml_attributes)
87
        assert ldap_authenticate_block.call_args[0][0] == LDAP_SETTINGS
41
-