Projet

Général

Profil

0001-ldap-optionally-collects-messages-from-ppolicy.patch

Loïc Dachary, 11 février 2021 14:11

Télécharger (16,3 ko)

Voir les différences:

Subject: [PATCH] ldap: optionally collects messages from ppolicy

Enable PasswordPolicyControl[0] in authenticate() and log the
information it returns, on success or error. In the context of a
request, this information is also set as a message[1] to be displayed
to the user.

All messages are translated into French.

[0] https://github.com/python-ldap/python-ldap/blob/python-ldap-3.3.1/Lib/ldap/controls/ppolicy.py
[1] https://docs.djangoproject.com/en/3.1/ref/contrib/messages/

Fixes: #50959

License: MIT
 src/authentic2/backends/ldap_backend.py       |  58 +++-
 .../locale/fr/LC_MESSAGES/django.po           |  50 ++++
 tests/test_ldap.py                            | 251 ++++++++++++++++++
 3 files changed, 354 insertions(+), 5 deletions(-)
src/authentic2/backends/ldap_backend.py
23 23
    from ldap.filter import filter_format
24 24
    from ldap.dn import escape_dn_chars
25 25
    from ldap.ldapobject import ReconnectLDAPObject as NativeLDAPObject
26
    from ldap.controls import SimplePagedResultsControl
26
    from ldap.controls import SimplePagedResultsControl, DecodeControlTuples
27
    from ldap.controls import ppolicy
28
    from pyasn1.codec.der import decoder
27 29
    PYTHON_LDAP3 = [int(x) for x in ldap.__version__.split('.')] >= [3]
28 30
    LDAPObject = NativeLDAPObject
29 31
except ImportError:
......
41 43
from django.core.cache import cache
42 44
from django.core.exceptions import ImproperlyConfigured
43 45
from django.conf import settings
46
from django.contrib import messages
44 47
from django.contrib.auth import get_user_model
45 48
from django.contrib.auth.models import Group
46 49
from django.utils.encoding import force_bytes, force_text
47 50
from django.utils import six
48 51
from django.utils.six.moves.urllib import parse as urlparse
52
from django.utils.translation import ugettext as _, ngettext
49 53

  
50 54
from authentic2.a2_rbac.models import Role
51 55

  
......
232 236
    raise NotImplementedError
233 237

  
234 238

  
239
def password_policy_control_messages(ctrl):
240
    messages = []
241

  
242
    if ctrl.error:
243
        error = ppolicy.PasswordPolicyError.namedValues[ctrl.error]
244
        error2message = {
245
            'passwordExpired': _('The password expired'),
246
            'accountLocked': _('The account is locked.'),
247
            'changeAfterReset': _('The password was reset and must be changed.'),
248
            'passwordModNotAllowed': _('It is not possible to modify the password.'),
249
            'mustSupplyOldPassword': _('The old password must be supplied.'),
250
            'insufficientPasswordQuality': _('The password does not meet the quality requirements.'),
251
            'passwordTooShort': _('The password is too short.'),
252
            'passwordTooYoung': _('It is too soon to change the password.'),
253
            'passwordInHistory': _('This password was recently used and cannot be used again.'),
254
        }
255
        messages.append(error2message.get(error, _('Unexpected error {error}')))
256
        return messages
257

  
258
    if ctrl.timeBeforeExpiration:
259
        messages.append(_(f'The password will expire in {ctrl.timeBeforeExpiration} seconds.'))
260
    if ctrl.graceAuthNsRemaining:
261
        messages.append(ngettext(
262
            f'This is the last time this password can be used.',
263
            f'This password can only be used {ctrl.graceAuthNsRemaining} times, including this one.',
264
            ctrl.graceAuthNsRemaining))
265
    return messages
266

  
235 267
class LDAPUser(User):
236 268
    SESSION_LDAP_DATA_KEY = 'ldap-data'
237 269
    _changed = False
......
537 569
        log.debug('got config %r', blocks)
538 570
        return blocks
539 571

  
572
    @staticmethod
573
    def process_controls(request, authz_id, ctrls):
574
        for c in ctrls:
575
            if c.controlType == ppolicy.PasswordPolicyControl.controlType:
576
                message = ' '.join(password_policy_control_messages(c))
577
                log.info('%s: %s', authz_id, message)
578
                if request is not None:
579
                    messages.add_message(request, messages.WARNING, message)
580
            else:
581
                log.info('%s: %s', authz_id, vars(c))
582

  
540 583
    def authenticate(self, request=None, username=None, password=None, realm=None, ou=None):
541 584
        if username is None or password is None:
542 585
            return None
......
566 609
                log.error(
567 610
                    "account name authentication filter doesn't contain '%s'")
568 611
                continue
569
            user = self.authenticate_block(block, uid, password)
612
            user = self.authenticate_block(request, block, uid, password)
570 613
            if user is not None:
571 614
                return user
572 615

  
573
    def authenticate_block(self, block, username, password):
616
    def authenticate_block(self, request, block, username, password):
574 617
        for conn in self.get_connections(block):
575 618
            ldap_uri = conn.get_option(ldap.OPT_URI)
576 619
            authz_ids = []
......
628 671
                        if failed:
629 672
                            continue
630 673
                        try:
631
                            conn.simple_bind_s(authz_id, password)
674
                            results = conn.simple_bind_s(authz_id, password, serverctrls=[
675
                                ppolicy.PasswordPolicyControl()
676
                            ])
677
                            self.process_controls(request, authz_id, results[3])
632 678
                            user_login_success(authz_id)
633 679
                            if not block['connect_with_user_credentials']:
634 680
                                try:
......
637 683
                                    log.exception(u'rebind failure after login bind')
638 684
                                    raise ldap.SERVER_DOWN
639 685
                            break
640
                        except ldap.INVALID_CREDENTIALS:
686
                        except ldap.INVALID_CREDENTIALS as e:
687
                            if len(e.args) > 0 and 'ctrls' in e.args[0]:
688
                                self.process_controls(request, authz_id, DecodeControlTuples(e.args[0]['ctrls']))
641 689
                            user_login_failure(authz_id)
642 690
                            pass
643 691
                    else:
src/authentic2/locale/fr/LC_MESSAGES/django.po
824 824
msgid "Username or email"
825 825
msgstr "Identifiant ou courriel"
826 826

  
827
#: src/authentic2/backends/ldap_backend.py:244
828
msgid "The password expired"
829
msgstr "Le mot de passe a expiré."
830

  
831
#: src/authentic2/backends/ldap_backend.py:245
832
msgid "The account is locked."
833
msgstr "Le compte est bloqué."
834

  
835
#: src/authentic2/backends/ldap_backend.py:246
836
msgid "The password was reset and must be changed."
837
msgstr "Le mot de passe a été re-initialisé et doit être changé."
838

  
839
#: src/authentic2/backends/ldap_backend.py:247
840
msgid "It is not possible to modify the password."
841
msgstr "Il n'est pas possible de modifier le mot de passe."
842

  
843
#: src/authentic2/backends/ldap_backend.py:248
844
msgid "The old password must be supplied."
845
msgstr "L'ancien mot de passe doit être fourni."
846

  
847
#: src/authentic2/backends/ldap_backend.py:249
848
msgid "The password does not meet the quality requirements."
849
msgstr "Le mot de passe n'a pas le niveau de qualité requis."
850

  
851
#: src/authentic2/backends/ldap_backend.py:250
852
msgid "The password is too short."
853
msgstr "Le mot de passe est trop court."
854

  
855
#: src/authentic2/backends/ldap_backend.py:251
856
msgid "It is too soon to change the password."
857
msgstr "Il est trop tôt pour changer ce mot de passe."
858

  
859
#: src/authentic2/backends/ldap_backend.py:252
860
msgid "This password was recently used and cannot be used again."
861
msgstr "Ce mot de passe a été utilisé récemment et ne peut pas être utilisé de nouveau."
862

  
863
#: src/authentic2/backends/ldap_backend.py:258
864
#, python-brace-format
865
msgid "The password will expire in {ctrl.timeBeforeExpiration} seconds."
866
msgstr "Le mot de passe expirera dans {ctrl.timeBeforeExpiration} secondes."
867

  
868
#: src/authentic2/backends/ldap_backend.py:261
869
#, python-brace-format
870
msgid "This is the last time this password can be used."
871
msgid_plural ""
872
"This password can only be used {ctrl.graceAuthNsRemaining} times, including "
873
"this one."
874
msgstr[0] "C'est la dernière fois qu'il est possible d'utiliser ce mot de passe."
875
msgstr[1] "Ce mot de passe ne pourra être utilisé que {ctrl.graceAuthNsRemaining} fois, celle ci incluse."
876

  
827 877
#: src/authentic2/csv_import.py:157
828 878
msgid "Cannot detect encoding"
829 879
msgstr "Impossible de détecter l’encodage"
tests/test_ldap.py
20 20

  
21 21
import pytest
22 22
import mock
23
import time
23 24

  
24 25
import ldap
25 26
from ldap.dn import escape_dn_chars
......
77 78
    with create_slapd() as s:
78 79
        yield s
79 80

  
81
@pytest.fixture
82
def slapd_ppolicy():
83
    with create_slapd() as slapd:
84
        conn = slapd.get_connection_admin()
85
        assert conn.protocol_version == ldap.VERSION3
86
        conn.modify_s(
87
            'cn=module{0},cn=config',
88
            [
89
                (ldap.MOD_ADD, 'olcModuleLoad', [
90
                    force_bytes('ppolicy')
91
                ])
92
            ])
93
        slapd.add_ldif(open('/etc/ldap/schema/ppolicy.ldif').read())
94
        slapd.add_ldif('''
95
dn: olcOverlay={0}ppolicy,olcDatabase={2}mdb,cn=config
96
objectclass: olcOverlayConfig
97
objectclass: olcPPolicyConfig
98
olcoverlay: {0}ppolicy
99
olcppolicydefault: cn=default,ou=ppolicies,o=ôrga
100
olcppolicyforwardupdates: FALSE
101
olcppolicyhashcleartext: TRUE
102
olcppolicyuselockout: TRUE
103
''')
104

  
105
        slapd.add_ldif('''
106
dn: ou=ppolicies,o=ôrga
107
objectclass: organizationalUnit
108
ou: ppolicies
109
''')
110
        yield slapd
111

  
80 112

  
81 113
@pytest.fixture
82 114
def tls_slapd():
......
872 904
    assert user.pk == user2.pk
873 905

  
874 906

  
907
def test_login_ppolicy_pwdMaxFailure(slapd_ppolicy, settings, db, app):
908
    settings.LDAP_AUTH_SETTINGS = [{
909
        'url': [slapd_ppolicy.ldap_url],
910
        'basedn': u'o=ôrga',
911
        'use_tls': False,
912
    }]
913

  
914
    pwdMaxFailure = 2
915
    slapd_ppolicy.add_ldif(f'''
916
dn: cn=default,ou=ppolicies,o=ôrga
917
cn: default
918
objectclass: top
919
objectclass: device
920
objectclass: pwdPolicy
921
objectclass: pwdPolicyChecker
922
pwdAttribute: userPassword
923
pwdMinAge: 0
924
pwdMaxAge: 0
925
pwdInHistory: 0
926
pwdCheckQuality: 0
927
pwdMinLength: 0
928
pwdExpireWarning: 0
929
pwdGraceAuthnLimit: 0
930
pwdLockout: TRUE
931
pwdLockoutDuration: 0
932
pwdMaxFailure: {pwdMaxFailure}
933
pwdMaxRecordedFailure: 0
934
pwdFailureCountInterval: 0
935
pwdMustChange: FALSE
936
pwdAllowUserChange: FALSE
937
pwdSafeModify: FALSE
938
''')
939

  
940
    for _ in range(pwdMaxFailure):
941
        response = app.get('/login/')
942
        response.form.set('username', USERNAME)
943
        response.form.set('password', 'invalid')
944
        response = response.form.submit(name='login-password-submit')
945
        assert 'Incorrect Username or password' in str(response.pyquery('.errornotice'))
946
        assert 'account is locked' not in str(response.pyquery('.messages'))
947
    response = app.get('/login/')
948
    response.form.set('username', USERNAME)
949
    response.form.set('password', 'invalid')
950
    response = response.form.submit(name='login-password-submit')
951
    assert 'account is locked' in str(response.pyquery('.messages'))
952

  
953

  
954
def test_authenticate_ppolicy_pwdMaxFailure(slapd_ppolicy, settings, db, caplog):
955
    settings.LDAP_AUTH_SETTINGS = [{
956
        'url': [slapd_ppolicy.ldap_url],
957
        'basedn': u'o=ôrga',
958
        'use_tls': False,
959
    }]
960

  
961
    pwdMaxFailure = 2
962
    slapd_ppolicy.add_ldif(f'''
963
dn: cn=default,ou=ppolicies,o=ôrga
964
cn: default
965
objectclass: top
966
objectclass: device
967
objectclass: pwdPolicy
968
objectclass: pwdPolicyChecker
969
pwdAttribute: userPassword
970
pwdMinAge: 0
971
pwdMaxAge: 0
972
pwdInHistory: 0
973
pwdCheckQuality: 0
974
pwdMinLength: 0
975
pwdExpireWarning: 0
976
pwdGraceAuthnLimit: 0
977
pwdLockout: TRUE
978
pwdLockoutDuration: 0
979
pwdMaxFailure: {pwdMaxFailure}
980
pwdMaxRecordedFailure: 0
981
pwdFailureCountInterval: 0
982
pwdMustChange: FALSE
983
pwdAllowUserChange: FALSE
984
pwdSafeModify: FALSE
985
''')
986

  
987
    for _ in range(pwdMaxFailure):
988
        assert authenticate(username=USERNAME, password='incorrect') is None
989
        assert "failed to login" in caplog.text
990
    assert 'account is locked' not in caplog.text
991
    assert authenticate(username=USERNAME, password='incorrect') is None
992
    assert 'account is locked' in caplog.text
993

  
994

  
995
def test_authenticate_ppolicy_pwdGraceAuthnLimit(slapd_ppolicy, settings, db, caplog):
996
    settings.LDAP_AUTH_SETTINGS = [{
997
        'url': [slapd_ppolicy.ldap_url],
998
        'basedn': u'o=ôrga',
999
        'use_tls': False,
1000
    }]
1001

  
1002
    pwdMaxAge = 1
1003
    pwdGraceAuthnLimit = 2
1004
    slapd_ppolicy.add_ldif(f'''
1005
dn: cn=default,ou=ppolicies,o=ôrga
1006
cn: default
1007
objectclass: top
1008
objectclass: device
1009
objectclass: pwdPolicy
1010
objectclass: pwdPolicyChecker
1011
pwdAttribute: userPassword
1012
pwdMinAge: 0
1013
pwdMaxAge: {pwdMaxAge}
1014
pwdInHistory: 1
1015
pwdCheckQuality: 0
1016
pwdMinLength: 0
1017
pwdExpireWarning: 0
1018
pwdGraceAuthnLimit: {pwdGraceAuthnLimit}
1019
pwdLockout: TRUE
1020
pwdLockoutDuration: 0
1021
pwdMaxFailure: 0
1022
pwdMaxRecordedFailure: 0
1023
pwdFailureCountInterval: 0
1024
pwdMustChange: FALSE
1025
pwdAllowUserChange: TRUE
1026
pwdSafeModify: FALSE
1027
''')
1028

  
1029
    user = authenticate(username=USERNAME, password=UPASS)
1030
    assert user.check_password(UPASS)
1031
    password = u'ogutOmyetew4'
1032
    user.set_password(password)
1033

  
1034
    time.sleep(pwdMaxAge * 3)
1035

  
1036
    assert 'used 2 time' not in caplog.text
1037
    assert authenticate(username=USERNAME, password=password) is not None
1038
    assert 'used 2 times' in caplog.text
1039

  
1040
    assert 'last time' not in caplog.text
1041
    assert authenticate(username=USERNAME, password=password) is not None
1042
    assert 'last time' in caplog.text
1043

  
1044

  
1045
def test_authenticate_ppolicy_pwdExpireWarning(slapd_ppolicy, settings, db, caplog):
1046
    settings.LDAP_AUTH_SETTINGS = [{
1047
        'url': [slapd_ppolicy.ldap_url],
1048
        'basedn': u'o=ôrga',
1049
        'use_tls': False,
1050
    }]
1051

  
1052
    pwdMaxAge = 3600
1053
    slapd_ppolicy.add_ldif(f'''
1054
dn: cn=default,ou=ppolicies,o=ôrga
1055
cn: default
1056
objectclass: top
1057
objectclass: device
1058
objectclass: pwdPolicy
1059
objectclass: pwdPolicyChecker
1060
pwdAttribute: userPassword
1061
pwdMinAge: 0
1062
pwdMaxAge: {pwdMaxAge}
1063
pwdInHistory: 1
1064
pwdCheckQuality: 0
1065
pwdMinLength: 0
1066
pwdExpireWarning: {pwdMaxAge}
1067
pwdGraceAuthnLimit: 0
1068
pwdLockout: TRUE
1069
pwdLockoutDuration: 0
1070
pwdMaxFailure: 0
1071
pwdMaxRecordedFailure: 0
1072
pwdFailureCountInterval: 0
1073
pwdMustChange: FALSE
1074
pwdAllowUserChange: TRUE
1075
pwdSafeModify: FALSE
1076
''')
1077

  
1078
    user = authenticate(username=USERNAME, password=UPASS)
1079
    assert user.check_password(UPASS)
1080
    password = u'ogutOmyetew4'
1081
    user.set_password(password)
1082

  
1083
    time.sleep(2)
1084

  
1085
    assert 'password will expire' not in caplog.text
1086
    assert authenticate(username=USERNAME, password=password) is not None
1087
    assert 'password will expire' in caplog.text
1088

  
1089

  
1090
def test_authenticate_ppolicy_pwdAllowUserChange(slapd_ppolicy, settings, db, caplog):
1091
    settings.LDAP_AUTH_SETTINGS = [{
1092
        'url': [slapd_ppolicy.ldap_url],
1093
        'basedn': u'o=ôrga',
1094
        'use_tls': False,
1095
    }]
1096

  
1097
    slapd_ppolicy.add_ldif(f'''
1098
dn: cn=default,ou=ppolicies,o=ôrga
1099
cn: default
1100
objectclass: top
1101
objectclass: device
1102
objectclass: pwdPolicy
1103
pwdAttribute: userPassword
1104
pwdMinAge: 0
1105
pwdMaxAge: 0
1106
pwdInHistory: 0
1107
pwdCheckQuality: 0
1108
pwdMinLength: 0
1109
pwdExpireWarning: 0
1110
pwdGraceAuthnLimit: 0
1111
pwdLockout: TRUE
1112
pwdLockoutDuration: 0
1113
pwdMaxFailure: 0
1114
pwdMaxRecordedFailure: 0
1115
pwdFailureCountInterval: 0
1116
pwdMustChange: FALSE
1117
pwdAllowUserChange: FALSE
1118
pwdSafeModify: FALSE
1119
''')
1120

  
1121
    user = authenticate(username=USERNAME, password=UPASS)
1122
    with pytest.raises(ldap.STRONG_AUTH_REQUIRED):
1123
        user.set_password(u'ogutOmyetew4')
1124

  
1125

  
875 1126
def test_ou_selector(slapd, settings, app, ou1):
876 1127
    settings.LDAP_AUTH_SETTINGS = [{
877 1128
        'url': [slapd.ldap_url],
878
-