Projet

Général

Profil

complete.patch

Benjamin Renard, 05 juillet 2023 20:05

Télécharger (44,8 ko)

Voir les différences:

Subject: [PATCH 01/11] test_ldap: use USERNAME & PASS instead of hard-coded
 values (#66416)

Licence: MIT

 tests/test_ldap.py | 36 ++++++++++++++++++------------------
 1 file changed, 18 insertions(+), 18 deletions(-)

diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index 9cb07e78..7cb14437 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -49,11 +49,11 @@ User = get_user_model()
 pytestmark = pytest.mark.skipif(not has_slapd(), reason='slapd is not installed')
 
 USERNAME = 'etienne.michu'
-UID = 'etienne.michu'
+UID = USERNAME
 CN = 'Étienne Michu'
 DN = 'cn=%s,o=ôrga' % escape_dn_chars(CN)
-PASS = 'passé'
-UPASS = 'passé'
+PASS = 'Passé1234'
+UPASS = 'Passé1234'
 EMAIL = 'etienne.michu@example.net'
 CARLICENSE = '123445ABC'
 UUID = '8ff2f34a-4a36-103c-8d0a-e3a0333484d3'
@@ -822,7 +822,7 @@ def test_group_staff(slapd, settings, client, db):
         }
     ]
     response = client.post(
-        '/login/', {'login-password-submit': '1', 'username': 'etienne.michu', 'password': PASS}, follow=True
+        '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
     )
     assert Group.objects.count() == 0
     assert response.context['user'].username == '%s@ldap' % USERNAME
@@ -1418,19 +1418,19 @@ def test_set_password(slapd, settings, db, caplog):
             'use_tls': False,
         }
     ]
-    user = authenticate(username='etienne.michu', password='passé')
+    user = authenticate(username=USERNAME, password=PASS)
     assert user
-    assert user.check_password('passé')
+    assert user.check_password(PASS)
     user.set_password('àbon')
     assert user.check_password('àbon')
-    user2 = authenticate(username='etienne.michu', password='àbon')
+    user2 = authenticate(username=USERNAME, password='àbon')
     assert user.pk == user2.pk
 
     with mock.patch(
         'authentic2.backends.ldap_backend.LDAPBackend.modify_password', side_effect=ldap.UNWILLING_TO_PERFORM
     ):
         with pytest.raises(PasswordChangeError):
-            user.set_password('passé')
+            user.set_password(PASS)
             assert 'set_password failed (UNWILLING_TO_PERFORM)' in caplog.text
 
 
@@ -1956,10 +1956,10 @@ def test_sync_ldap_users(slapd, settings, app, db, caplog):
     assert caplog.records[1].message == 'Binding to server %s (anonymously)' % slapd.ldap_url
     assert caplog.records[2].message == (
         (
-            "Created user etienne.michu@ldap (uuid %s) from dn=cn=Étienne Michu,o=ôrga, uid=['etienne.michu'], "
+            "Created user etienne.michu@ldap (uuid %s) from dn=cn=Étienne Michu,o=ôrga, uid=['%s'], "
             "sn=['Michu'], givenname=['Étienne'], l=['Paris'], mail=['etienne.michu@example.net'], entryuuid=['%s']"
         )
-        % (User.objects.first().uuid, entryuuid)
+        % (User.objects.first().uuid, USERNAME, entryuuid)
     )
     assert caplog.records[-1].message == 'Search for (|(mail=*)(uid=*)) returned 6 users.'
 
@@ -1984,9 +1984,9 @@ def test_sync_ldap_users(slapd, settings, app, db, caplog):
     User.objects.update(first_name='John')
     management.call_command('sync-ldap-users', verbosity=3)
     assert caplog.records[2].message == (
-        "Updated user etienne.michu@ldap (uuid %s) from dn=cn=Étienne Michu,o=ôrga, uid=['etienne.michu'], "
+        "Updated user etienne.michu@ldap (uuid %s) from dn=cn=Étienne Michu,o=ôrga, uid=['%s'], "
         "sn=['Michu'], givenname=['Étienne'], l=['Paris'], mail=['etienne.michu@example.net'], entryuuid=['%s']"
-    ) % (User.objects.first().uuid, entryuuid)
+    ) % (User.objects.first().uuid, USERNAME, entryuuid)
 
 
 def test_get_users_select_realm(slapd, settings, db, caplog):
@@ -2045,7 +2045,7 @@ def test_get_attributes(slapd, settings, db, rf):
         'givenname': ['Étienne'],
         'mail': ['etienne.michu@example.net'],
         'sn': ['Michu'],
-        'uid': ['etienne.michu'],
+        'uid': [USERNAME],
         'carlicense': ['123445ABC'],
         'entryuuid': None,
     }
@@ -2056,7 +2056,7 @@ def test_get_attributes(slapd, settings, db, rf):
         'givenname': ['\xc9tienne'],
         'mail': ['etienne.michu@example.net'],
         'sn': ['Michu'],
-        'uid': ['etienne.michu'],
+        'uid': [USERNAME],
         'carlicense': ['123445ABC'],
         'entryuuid': None,
     }
@@ -2073,7 +2073,7 @@ def test_get_attributes(slapd, settings, db, rf):
         'givenname': ['\xc9tienne'],
         'mail': ['etienne.michu@example.net'],
         'sn': ['Micho'],
-        'uid': ['etienne.michu'],
+        'uid': [USERNAME],
         'carlicense': ['123445ABC'],
         'entryuuid': None,
     }
@@ -2106,7 +2106,7 @@ def test_get_extra_attributes(slapd, settings, client):
         }
     ]
     response = client.post(
-        '/login/', {'login-password-submit': '1', 'username': 'etienne.michu', 'password': PASS}, follow=True
+        '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
     )
     user = response.context['user']
     fetched_attrs = user.get_attributes(object(), {})
@@ -2277,7 +2277,7 @@ def test_technical_info_ldap(app, admin, superuser, slapd, settings, monkeypatch
         {
             'url': [slapd.ldap_url],
             'binddn': force_str('cn=%s,o=ôrga' % escape_dn_chars('Étienne Michu')),
-            'bindpw': 'passé',
+            'bindpw': PASS,
             'basedn': 'o=ôrga',
             'use_tls': False,
         }
@@ -2293,7 +2293,7 @@ def test_technical_info_ldap(app, admin, superuser, slapd, settings, monkeypatch
     assert 'Base ldapsearch command' in ldap_config_text
     assert 'ldapsearch -v -H ldapi://' in ldap_config_text
     assert '-D "cn=Étienne Michu,o=ôrga"' in ldap_config_text
-    assert '-w "passé"' in ldap_config_text
+    assert f'-w "{PASS}"' in ldap_config_text
     assert '-b "o=ôrga"' in ldap_config_text
     assert '"(|(mail=*)(uid=*))"' in ldap_config_text
 
-- 
2.30.2


From e43639d184a41965430c44f303124cdf7970a3a8 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 10 Nov 2022 22:21:57 +0100
Subject: [PATCH 02/11] password_policy_control_messages: fix handling
 passwordExpired (#66416)

Licence: MIT
---
 src/authentic2/backends/ldap_backend.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index 36052bb4..d40788fd 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -210,7 +210,7 @@ def map_text(d):
 def password_policy_control_messages(ctrl, attributes):
     messages = []
 
-    if ctrl.error:
+    if ctrl.error is not None:
         error = ppolicy.PasswordPolicyError.namedValues[ctrl.error]
         error2message = {
             'passwordExpired': _('The password expired after {pwdmaxage}').format(**attributes),
-- 
2.30.2


From 14718e2758d7af696b5e18f6e0465f62aeb8d591 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 10 Nov 2022 22:59:51 +0100
Subject: [PATCH 03/11] ldap: fix encoding password on modify_password (#66416)

Licence: MIT
---
 src/authentic2/backends/ldap_backend.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index d40788fd..36cb5551 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -1778,7 +1778,7 @@ class LDAPBackend:
                     modlist = [(ldap.MOD_REPLACE, key, [value])]
             else:
                 key = 'userPassword'
-                modlist = [(ldap.MOD_REPLACE, key, [new_password])]
+                modlist = [(ldap.MOD_REPLACE, key, [new_password.encode('utf8')])]
             conn.modify_s(dn, modlist)
         log.debug('modified password for dn %r', dn)
 
-- 
2.30.2


From eb7d96e5217e38d3afc9240ab5a4a43cfee991bc Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 10 Nov 2022 23:13:11 +0100
Subject: [PATCH 04/11] ldap: rename process_controls method to
 process_bind_controls (#66416)

Licence: MIT
---
 src/authentic2/backends/ldap_backend.py | 6 +++---
 tests/test_ldap.py                      | 6 +++---
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index 36cb5551..5ce84a08 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -585,7 +585,7 @@ class LDAPBackend:
         return blocks
 
     @classmethod
-    def process_controls(cls, request, block, conn, authz_id, ctrls):
+    def process_bind_controls(cls, request, block, conn, authz_id, ctrls):
         attributes = cls.get_ppolicy_attributes(block, conn, authz_id)
         for c in ctrls:
             if c.controlType == ppolicy.PasswordPolicyControl.controlType:
@@ -723,7 +723,7 @@ class LDAPBackend:
                             else:
                                 serverctrls = []
                             results = conn.simple_bind_s(authz_id, password, serverctrls=serverctrls)
-                            self.process_controls(request, block, conn, authz_id, results[3])
+                            self.process_bind_controls(request, block, conn, authz_id, results[3])
                             user_login_success(authz_id)
                             if not block['connect_with_user_credentials']:
                                 try:
@@ -734,7 +734,7 @@ class LDAPBackend:
                             break
                         except ldap.INVALID_CREDENTIALS as e:
                             if block.get('use_controls') and len(e.args) > 0 and 'ctrls' in e.args[0]:
-                                self.process_controls(
+                                self.process_bind_controls(
                                     request, block, conn, authz_id, DecodeControlTuples(e.args[0]['ctrls'])
                                 )
                             success, error = self.bind(block, conn)
diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index 7cb14437..24aa23d8 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -2257,13 +2257,13 @@ def test_user_journal_login_failure(slapd, settings, client, db, monkeypatch, ex
         '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
     )
 
-    def patched_process_controls(cls, request, block, conn, authz_id, ctrls):
+    def patched_process_bind_controls(cls, request, block, conn, authz_id, ctrls):
         raise exception[0]('oops')
 
     monkeypatch.setattr(
         ldap_backend.LDAPBackend,
-        'process_controls',
-        patched_process_controls,
+        'process_bind_controls',
+        patched_process_bind_controls,
     )
     client.post(
         '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
-- 
2.30.2


From 29a0f1517152da70a50b685a365b12630061c89d Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 10 Nov 2022 23:27:30 +0100
Subject: [PATCH 05/11] ldap: handle ppolicy control changing/reseting password
 (#66416)

Licence: MIT
---
 src/authentic2/backends/ldap_backend.py | 61 ++++++++++++++++++-------
 1 file changed, 44 insertions(+), 17 deletions(-)

diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index 5ce84a08..5acbcc1a 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -598,6 +598,21 @@ class LDAPBackend:
                 message = str(vars(c))
             log.info('ldap: bind error with authz_id "%s" -> "%s"', authz_id, message)
 
+    @classmethod
+    def process_modify_password_controls(cls, block, conn, authz_id, ctrls):
+        attributes = cls.get_ppolicy_attributes(block, conn, authz_id)
+        errors = []
+        for c in ctrls:
+            if c.controlType == ppolicy.PasswordPolicyControl.controlType:
+                message = ' '.join(password_policy_control_messages(c, attributes))
+            else:
+                message = str(vars(c))
+            log.info('ldap: fail to modify password of "%s" -> "%s"', authz_id, message)
+            errors.append(message)
+
+        if errors:
+            raise PasswordChangeError(' '.join(errors))
+
     @classmethod
     def check_group_to_role_mappings(cls, block):
         group_to_role_mapping = block.get('group_to_role_mapping')
@@ -1762,24 +1777,36 @@ class LDAPBackend:
     @classmethod
     def modify_password(cls, conn, block, dn, old_password, new_password):
         '''Change user password with adaptation for Active Directory'''
-        if old_password is not None and (block['use_password_modify'] and not block['active_directory']):
-            conn.passwd_s(dn, old_password, new_password)
-        else:
-            modlist = []
-            if block['active_directory']:
-                key = 'unicodePwd'
-                value = cls.ad_encoding(new_password)
-                if old_password:
-                    modlist = [
-                        (ldap.MOD_DELETE, key, [cls.ad_encoding(old_password)]),
-                        (ldap.MOD_ADD, key, [value]),
-                    ]
-                else:
-                    modlist = [(ldap.MOD_REPLACE, key, [value])]
+        serverctrls = []
+        if block.get('use_controls'):
+            serverctrls = [ppolicy.PasswordPolicyControl()]
+
+        try:
+            if old_password is not None and (block['use_password_modify'] and not block['active_directory']):
+                results = conn.passwd_s(dn, old_password, new_password, serverctrls=serverctrls)
             else:
-                key = 'userPassword'
-                modlist = [(ldap.MOD_REPLACE, key, [new_password.encode('utf8')])]
-            conn.modify_s(dn, modlist)
+                modlist = []
+                if block['active_directory']:
+                    attr = 'unicodePwd'
+                    value = cls.ad_encoding(new_password)
+                    if old_password:
+                        modlist = [
+                            (ldap.MOD_DELETE, attr, [cls.ad_encoding(old_password)]),
+                            (ldap.MOD_ADD, attr, [value]),
+                        ]
+                    else:
+                        modlist = [(ldap.MOD_REPLACE, attr, [value])]
+                else:
+                    key = 'userPassword'
+                    modlist = [(ldap.MOD_REPLACE, key, [new_password.encode('utf8')])]
+                results = conn.modify_ext_s(dn, modlist, serverctrls=serverctrls)
+            if block.get('use_controls') and len(results) >= 3:
+                cls.process_modify_password_controls(block, conn, dn, results[3])
+        except ldap.LDAPError as e:
+            if block.get('use_controls') and len(e.args) > 0 and 'ctrls' in e.args[0]:
+                cls.process_modify_password_controls(block, conn, dn, DecodeControlTuples(e.args[0]['ctrls']))
+            raise
+
         log.debug('modified password for dn %r', dn)
 
     @classmethod
-- 
2.30.2


From 9dd8d6e1fcd2cf4ddcf0cdc2ba8657796d42065e Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Fri, 12 May 2023 10:14:31 +0200
Subject: [PATCH 06/11] Add tests on LDAP password change/reset with ppolicy
 (#66416)

Licence: MIT
---
 tests/test_ldap.py | 363 +++++++++++++++++++++++++++++++++++++++++----
 tox.ini            |   1 +
 2 files changed, 337 insertions(+), 27 deletions(-)

diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index 24aa23d8..b84e2737 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -1218,6 +1218,337 @@ def test_user_change_password_denied(slapd, settings, app, db):
         assert 'LDAP directory refused the password change' in response.text
 
 
+def test_user_change_password(slapd, settings, app, db):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd.ldap_url],
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'user_can_change_password': True,
+        }
+    ]
+    assert User.objects.count() == 0
+    # first login
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = PASS
+    response = response.form.submit('login-password-submit').follow()
+
+    response = app.get('/accounts/password/change/')
+    response.form['old_password'] = PASS
+    response.form['new_password1'] = 'hopAbcde1'
+    response.form['new_password2'] = 'hopAbcde1'
+    response = response.form.submit().follow()
+    assert 'Password changed' in response.text
+
+
+def test_login_ppolicy_password_expired(slapd_ppolicy, settings, app, db, caplog):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd_ppolicy.ldap_url],
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'user_can_change_password': True,
+            'use_controls': True,
+        }
+    ]
+    # Add default ppolicy with pwdMaxAge defined
+    pwdMaxAge = 2
+    slapd_ppolicy.add_ldif(
+        '''
+dn: cn=default,ou=ppolicies,o=ôrga
+cn: default
+objectclass: top
+objectclass: device
+objectclass: pwdPolicy
+objectclass: pwdPolicyChecker
+pwdAttribute: userPassword
+pwdMaxAge: {pwdMaxAge}
+'''.format(
+            pwdMaxAge=pwdMaxAge
+        )
+    )
+
+    assert User.objects.count() == 0
+    # first login
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = PASS
+    response = response.form.submit('login-password-submit').follow()
+
+    password = 'hopAbcde1'
+    response = app.get('/accounts/password/change/')
+    response.form['old_password'] = PASS
+    response.form['new_password1'] = password
+    response.form['new_password2'] = password
+    response = response.form.submit().follow()
+    assert 'Password changed' in response.text
+
+    response = response.click('Logout')
+
+    time.sleep(pwdMaxAge * 2)
+
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = password
+    response = response.form.submit('login-password-submit').maybe_follow()
+
+    assert 'The password expired.' in response
+
+
+def test_user_change_password_in_history(slapd_ppolicy, settings, app, db):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd_ppolicy.ldap_url],
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'use_controls': True,
+            'user_can_change_password': True,
+        }
+    ]
+
+    # Add default ppolicy with pwdInHistory defined
+    slapd_ppolicy.add_ldif(
+        '''
+dn: cn=default,ou=ppolicies,o=ôrga
+cn: default
+objectclass: top
+objectclass: device
+objectclass: pwdPolicy
+objectclass: pwdPolicyChecker
+pwdAttribute: userPassword
+pwdMinAge: 0
+pwdMaxAge: 0
+pwdInHistory: 1
+'''
+    )
+
+    assert User.objects.count() == 0
+    # first login
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = PASS
+    response = response.form.submit('login-password-submit').follow()
+
+    # change password
+    NEW_PASS = 'hopAbcde1'
+    response = app.get('/accounts/password/change/')
+    response.form['old_password'] = PASS
+    response.form['new_password1'] = NEW_PASS
+    response.form['new_password2'] = NEW_PASS
+    response = response.form.submit().follow()
+    assert 'Password changed' in response.text
+
+    # change password again
+    response = app.get('/accounts/password/change/')
+    response.form['old_password'] = NEW_PASS
+    response.form['new_password1'] = PASS
+    response.form['new_password2'] = PASS
+    response = response.form.submit().maybe_follow()
+
+    assert 'This password has already been used and can no longer be used.' in response.text
+
+
+def test_user_change_password_too_short(slapd_ppolicy, settings, app, db):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd_ppolicy.ldap_url],
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'use_controls': True,
+            'user_can_change_password': True,
+            'ppolicy_dn': 'cn=default,ou=ppolicies,o=ôrga',
+        }
+    ]
+
+    # Add default ppolicy with pwdCheckQuality enabled and pwdMinLength defined
+    pwdMinLength = 15
+    slapd_ppolicy.add_ldif(
+        '''
+dn: cn=default,ou=ppolicies,o=ôrga
+cn: default
+objectclass: top
+objectclass: device
+objectclass: pwdPolicy
+objectclass: pwdPolicyChecker
+pwdAttribute: userPassword
+pwdCheckQuality: 1
+pwdMinLength: {pwdMinLength}
+'''.format(
+            pwdMinLength=pwdMinLength
+        )
+    )
+
+    assert User.objects.count() == 0
+    # first login
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = PASS
+    response = response.form.submit('login-password-submit').follow()
+
+    # change password
+    NEW_PASS = 'hopAbcde1'
+    response = app.get('/accounts/password/change/')
+    response.form['old_password'] = PASS
+    response.form['new_password1'] = NEW_PASS
+    response.form['new_password2'] = NEW_PASS
+    response = response.form.submit().maybe_follow()
+
+    assert f'The password is too short (minimun length: {pwdMinLength})' in response.text
+
+
+def test_user_change_password_too_soon(slapd_ppolicy, settings, app, db):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd_ppolicy.ldap_url],
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'use_controls': True,
+            'user_can_change_password': True,
+        }
+    ]
+
+    # Add default ppolicy with pwdMinAge defined
+    slapd_ppolicy.add_ldif(
+        '''
+dn: cn=default,ou=ppolicies,o=ôrga
+cn: default
+objectclass: top
+objectclass: device
+objectclass: pwdPolicy
+objectclass: pwdPolicyChecker
+pwdAttribute: userPassword
+pwdMinAge: 120
+'''
+    )
+
+    assert User.objects.count() == 0
+    # first login
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = PASS
+    response = response.form.submit('login-password-submit').follow()
+
+    # change password
+    NEW_PASS = 'hopAbcde1'
+    response = app.get('/accounts/password/change/')
+    response.form['old_password'] = PASS
+    response.form['new_password1'] = NEW_PASS
+    response.form['new_password2'] = NEW_PASS
+    response = response.form.submit().follow()
+    assert 'Password changed' in response.text
+
+    # change password again
+    response = app.get('/accounts/password/change/')
+    response.form['old_password'] = NEW_PASS
+    NEW_PASS += '1'
+    response.form['new_password1'] = NEW_PASS
+    response.form['new_password2'] = NEW_PASS
+    response = response.form.submit().maybe_follow()
+
+    assert 'It is too soon to change the password.' in response.text
+
+
+def test_reset_password_must_supply_old_password(slapd_ppolicy, settings, app, db, caplog):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd_ppolicy.ldap_url],
+            'binddn': force_str(slapd_ppolicy.root_bind_dn),
+            'bindpw': force_str(slapd_ppolicy.root_bind_password),
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'use_controls': True,
+            'can_reset_password': True,
+        }
+    ]
+
+    # Add default ppolicy with pwdSafeModify enabled
+    slapd_ppolicy.add_ldif(
+        '''
+dn: cn=default,ou=ppolicies,o=ôrga
+cn: default
+objectclass: top
+objectclass: device
+objectclass: pwdPolicy
+objectclass: pwdPolicyChecker
+pwdAttribute: userPassword
+pwdSafeModify: TRUE
+'''
+    )
+
+    assert User.objects.count() == 0
+    # first login
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = PASS
+    response = response.form.submit('login-password-submit').follow()
+    assert User.objects.count() == 1
+    assert 'Étienne Michu' in str(response)
+    user = User.objects.get()
+    assert user.email == EMAIL
+    # logout
+    response = response.click('Logout').maybe_follow()
+
+    # password reset
+    response = response.click('Reset it!')
+    response.form['email'] = EMAIL
+    assert len(mail.outbox) == 0
+    response = response.form.submit()
+    assert response['Location'].endswith('/instructions/')
+    assert len(mail.outbox) == 1
+    url = utils.get_link_from_mail(mail.outbox[0])
+    relative_url = url.split('testserver')[1]
+    response = app.get(relative_url, status=200)
+    response.form.set('new_password1', '1234==aA')
+    response.form.set('new_password2', '1234==aA')
+
+    response = response.form.submit()
+    assert 'The old password must be supplied.' in response
+
+
+def test_user_change_password_not_allowed(slapd_ppolicy, settings, app, db):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd_ppolicy.ldap_url],
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'use_controls': True,
+            'user_can_change_password': True,
+        }
+    ]
+
+    # Add default ppolicy with pwdAllowUserChange disabled
+    slapd_ppolicy.add_ldif(
+        '''
+dn: cn=default,ou=ppolicies,o=ôrga
+cn: default
+objectclass: top
+objectclass: device
+objectclass: pwdPolicy
+objectclass: pwdPolicyChecker
+pwdAttribute: userPassword
+pwdAllowUserChange: FALSE
+'''
+    )
+
+    assert User.objects.count() == 0
+    # first login
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = PASS
+    response = response.form.submit('login-password-submit').follow()
+
+    # change password
+    NEW_PASS = 'hopAbcde1'
+    response = app.get('/accounts/password/change/')
+    response.form['old_password'] = PASS
+    response.form['new_password1'] = NEW_PASS
+    response.form['new_password2'] = NEW_PASS
+    response = response.form.submit().maybe_follow()
+
+    assert 'It is not possible to modify the password.' in response.text
+
+
 def test_tls(db, tls_slapd, settings, client):
     conn = tls_slapd.get_connection_admin()
     conn.modify_s(
@@ -1441,6 +1772,7 @@ def test_login_ppolicy_pwdMaxFailure(slapd_ppolicy, settings, db, app):
             'basedn': 'o=ôrga',
             'use_tls': False,
             'use_controls': True,
+            'ppolicy_dn': 'cn=default,ou=ppolicies,o=ôrga',
         }
     ]
 
@@ -1485,7 +1817,8 @@ pwdSafeModify: FALSE
     response.form.set('username', USERNAME)
     response.form.set('password', 'invalid')
     response = response.form.submit(name='login-password-submit')
-    assert 'account is locked' in str(response.pyquery('.messages'))
+    assert 'account is locked since ' in str(response.pyquery('.messages'))
+    assert f'after {pwdMaxFailure} failures' in str(response.pyquery('.messages'))
 
 
 def ppolicy_authenticate_exactly_pwdMaxFailure(slapd_ppolicy, caplog):
@@ -1690,6 +2023,7 @@ def test_authenticate_ppolicy_pwdExpireWarning(slapd_ppolicy, settings, db, capl
         }
     ]
 
+    # Add default ppolicy with pwdMaxAge and pwdExpireWarning defined
     pwdMaxAge = 3600
     slapd_ppolicy.add_ldif(
         '''
@@ -1700,21 +2034,8 @@ objectclass: device
 objectclass: pwdPolicy
 objectclass: pwdPolicyChecker
 pwdAttribute: userPassword
-pwdMinAge: 0
 pwdMaxAge: {pwdMaxAge}
-pwdInHistory: 1
-pwdCheckQuality: 0
-pwdMinLength: 0
 pwdExpireWarning: {pwdMaxAge}
-pwdGraceAuthnLimit: 0
-pwdLockout: TRUE
-pwdLockoutDuration: 0
-pwdMaxFailure: 0
-pwdMaxRecordedFailure: 0
-pwdFailureCountInterval: 0
-pwdMustChange: FALSE
-pwdAllowUserChange: TRUE
-pwdSafeModify: FALSE
 '''.format(
             pwdMaxAge=pwdMaxAge
         )
@@ -1746,6 +2067,7 @@ def test_login_ppolicy_pwdExpireWarning(slapd_ppolicy, settings, app, db, caplog
         }
     ]
 
+    # Add default ppolicy with pwdMaxAge and pwdExpireWarning defined
     pwdMaxAge = 3600
     slapd_ppolicy.add_ldif(
         '''
@@ -1756,21 +2078,8 @@ objectclass: device
 objectclass: pwdPolicy
 objectclass: pwdPolicyChecker
 pwdAttribute: userPassword
-pwdMinAge: 0
 pwdMaxAge: {pwdMaxAge}
-pwdInHistory: 1
-pwdCheckQuality: 0
-pwdMinLength: 0
 pwdExpireWarning: {pwdMaxAge}
-pwdGraceAuthnLimit: 0
-pwdLockout: TRUE
-pwdLockoutDuration: 0
-pwdMaxFailure: 0
-pwdMaxRecordedFailure: 0
-pwdFailureCountInterval: 0
-pwdMustChange: FALSE
-pwdAllowUserChange: TRUE
-pwdSafeModify: FALSE
 '''.format(
             pwdMaxAge=pwdMaxAge
         )
diff --git a/tox.ini b/tox.ini
index b4920458..0218ebea 100644
--- a/tox.ini
+++ b/tox.ini
@@ -69,6 +69,7 @@ deps =
   uwsgidecorators
   enum34<=1.1.6
   ldaptools>=0.24
+  python-ldap>=3.3.1
   numpy
   django-filter
   stable-backports: djangorestframework>=3.12,<3.13
-- 
2.30.2


From eab2f9c5d4914aff9674d9797783424773c63c80 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Fri, 12 May 2023 10:20:10 +0200
Subject: [PATCH 07/11] ppolicy: handle reset redirect after a changeAfterReset
 error (#66416)

Licence: MIT
---
 src/authentic2/backends/ldap_backend.py |  9 +++-
 src/authentic2/views.py                 | 10 ++++
 tests/test_ldap.py                      | 68 +++++++++++++++++++++++++
 3 files changed, 86 insertions(+), 1 deletion(-)

diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index 5acbcc1a..2210b1af 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -592,7 +592,14 @@ class LDAPBackend:
                 message = ' '.join(password_policy_control_messages(c, attributes))
                 if request is not None:
                     messages.add_message(request, messages.WARNING, message)
-                    if c.graceAuthNsRemaining or c.timeBeforeExpiration:
+                    if (
+                        c.graceAuthNsRemaining
+                        or c.timeBeforeExpiration
+                        or (
+                            c.error is not None
+                            and ppolicy.PasswordPolicyError.namedValues[c.error] == 'changeAfterReset'
+                        )
+                    ):
                         request.needs_password_change = True
             else:
                 message = str(vars(c))
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 2f869f37..2ad62959 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -797,6 +797,16 @@ def login_password_login(request, authenticator, *args, **kwargs):
                     )
             elif username:
                 request.journal.record('user.login.failure', authenticator=authenticator, username=username)
+
+            if hasattr(request, 'needs_password_change'):
+                del request.needs_password_change
+                return utils_misc.redirect(
+                    request,
+                    'password_reset',
+                    resolve=True,
+                    params={'next': utils_misc.select_next_url(request, '')},
+                )
+
     context['form'] = form
     return render(request, 'authentic2/login_password_form.html', context)
 
diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index b84e2737..d7ae6357 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -1506,6 +1506,74 @@ pwdSafeModify: TRUE
     assert 'The old password must be supplied.' in response
 
 
+def test_login_ppolicy_must_change_password_after_locked(slapd_ppolicy, settings, db, app):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd_ppolicy.ldap_url],
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'use_controls': True,
+            'can_reset_password': True,
+            'ppolicy_dn': 'cn=default,ou=ppolicies,o=ôrga',
+        }
+    ]
+
+    # Add default ppolicy with pwdMaxFailure defined and pwdMustChange enabled
+    pwdMaxFailure = 2
+    slapd_ppolicy.add_ldif(
+        '''
+dn: cn=default,ou=ppolicies,o=ôrga
+cn: default
+objectclass: top
+objectclass: device
+objectclass: pwdPolicy
+objectclass: pwdPolicyChecker
+pwdAttribute: userPassword
+pwdLockout: TRUE
+pwdMaxFailure: {pwdMaxFailure}
+pwdMustChange: TRUE
+'''.format(
+            pwdMaxFailure=pwdMaxFailure
+        )
+    )
+
+    # Locked account after some login errors
+    for _ in range(pwdMaxFailure):
+        response = app.get('/login/')
+        response.form.set('username', USERNAME)
+        response.form.set('password', 'invalid')
+        response = response.form.submit(name='login-password-submit')
+        assert 'Incorrect Username or password' in str(response.pyquery('.errornotice'))
+        assert 'account is locked' not in str(response.pyquery('.messages'))
+    response = app.get('/login/')
+    response.form.set('username', USERNAME)
+    response.form.set('password', 'invalid')
+    response = response.form.submit(name='login-password-submit')
+
+    assert 'account is locked since ' in str(response.pyquery('.messages'))
+    assert f'after {pwdMaxFailure} failures' in str(response.pyquery('.messages'))
+
+    # Unlock account and force passwor reset
+    conn = slapd_ppolicy.get_connection_admin()
+    ldif = [
+        (ldap.MOD_DELETE, 'pwdAccountLockedTime', None),
+        (ldap.MOD_ADD, 'pwdReset', [b'TRUE']),
+    ]
+    conn.modify_s(DN, ldif)
+
+    # Login with the right password
+    next_url = '/'
+    response = app.get(f'/login/?next={next_url}')
+    response.form.set('username', USERNAME)
+    response.form.set('password', PASS)
+    response = response.form.submit(name='login-password-submit')
+
+    assert '/password/reset/' in response['Location']
+    assert f'next={next_url}' in response['Location']
+    response = response.follow()
+    assert 'The password was reset and must be changed.' in str(response.pyquery('.messages'))
+
+
 def test_user_change_password_not_allowed(slapd_ppolicy, settings, app, db):
     settings.LDAP_AUTH_SETTINGS = [
         {
-- 
2.30.2


From 714cb14835e78945da22ca03619cc5f4df31d2bc Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 22 Sep 2022 18:13:19 +0200
Subject: [PATCH 08/11] ldap: fix messages generation in
 password_policy_control_messages even if all attributes are not available

---
 src/authentic2/backends/ldap_backend.py | 26 +++++++++++++++----------
 1 file changed, 16 insertions(+), 10 deletions(-)

diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index 2210b1af..d66cde22 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -213,20 +213,26 @@ def password_policy_control_messages(ctrl, attributes):
     if ctrl.error is not None:
         error = ppolicy.PasswordPolicyError.namedValues[ctrl.error]
         error2message = {
-            'passwordExpired': _('The password expired after {pwdmaxage}').format(**attributes),
-            'accountLocked': _(
-                'The account is locked since {pwdaccountlockedtime[0]} after {pwdmaxfailure} failures.'
-            ).format(**attributes),
+            'passwordExpired': _('The password expired.'),
+            'accountLocked': _('The account is locked{since} after {failures_count}.').format(
+                since=(_(" since %s") % attributes['pwdaccountlockedtime'][0])
+                if attributes['pwdaccountlockedtime']
+                else "",
+                failures_count=(_("%s failures") % attributes['pwdmaxfailure'][0])
+                if attributes['pwdmaxfailure']
+                else _("multiple failures"),
+            ),
             'changeAfterReset': _('The password was reset and must be changed.'),
             'passwordModNotAllowed': _('It is not possible to modify the password.'),
             'mustSupplyOldPassword': _('The old password must be supplied.'),
             'insufficientPasswordQuality': _('The password does not meet the quality requirements.'),
-            'passwordTooShort': _('The password is too short {pwdminlength}.').format(**attributes),
-            'passwordTooYoung': _('It is too soon to change the password {pwdminage}.').format(**attributes),
-            'passwordInHistory': _(
-                'This password is among the last {pwdhistory} password that were used and cannot be used'
-                ' again.'
-            ).format(**attributes),
+            'passwordTooShort': _('The password is too short{minlength}.').format(
+                minlength=(" (minimun length: %s)" % attributes['pwdminlength'][0])
+                if attributes['pwdminlength']
+                else ""
+            ),
+            'passwordTooYoung': _('It is too soon to change the password.'),
+            'passwordInHistory': _('This password has already been used and can no longer be used.'),
         }
         messages.append(error2message.get(error, _('Unexpected error {error}').format(error=error)))
         return messages
-- 
2.30.2


From f3e957264969ade200967282588b63e5dd929b58 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Fri, 17 Mar 2023 10:33:40 +0100
Subject: [PATCH 09/11] ldap: improve formating password expiration date

License: MIT
---
 src/authentic2/backends/ldap_backend.py | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index d66cde22..b71826d2 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -16,6 +16,7 @@
 
 import base64
 import collections
+import datetime
 import hashlib
 import json
 import logging
@@ -38,6 +39,7 @@ from django.core.cache import cache
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 from django.db.transaction import atomic
+from django.utils.dateformat import format as dateformat
 from django.utils.encoding import force_bytes, force_str
 from django.utils.translation import gettext as _
 from django.utils.translation import ngettext
@@ -238,9 +240,11 @@ def password_policy_control_messages(ctrl, attributes):
         return messages
 
     if ctrl.timeBeforeExpiration:
-        expiration_date = time.asctime(time.localtime(time.time() + ctrl.timeBeforeExpiration))
+        expiration_date = datetime.datetime.fromtimestamp(time.time() + ctrl.timeBeforeExpiration)
         messages.append(
-            _('The password will expire at {expiration_date}.').format(expiration_date=expiration_date)
+            _('The password will expire at {expiration_date}.').format(
+                expiration_date=dateformat(expiration_date, 'l j F Y, P')
+            )
         )
     if ctrl.graceAuthNsRemaining:
         messages.append(
-- 
2.30.2


From 84ab2146b25a70418419c49a7fb87f4d9337d6e9 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 10 Nov 2022 11:03:12 +0100
Subject: [PATCH 10/11] PasswordChangeView: leave user on form page in case of
 PasswordChangeError (#69464)

License: MIT
---
 src/authentic2/views.py | 4 ++--
 tests/test_ldap.py      | 2 +-
 tests/test_views.py     | 1 -
 3 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 2ad62959..4b464e8a 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -1909,8 +1909,8 @@ class PasswordChangeView(HomeURLMixin, DjPasswordChangeView):
         try:
             response = super().form_valid(form)
         except utils_misc.PasswordChangeError as e:
-            messages.error(self.request, e.message)
-            return utils_misc.redirect(self.request, self.post_change_redirect)
+            form.add_error('new_password1', e.message)
+            return self.form_invalid(form)
         messages.info(self.request, _('Password changed'))
         self.request.journal.record('user.password.change', session=self.request.session)
         return response
diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index d7ae6357..2f181ed4 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -1214,7 +1214,7 @@ def test_user_change_password_denied(slapd, settings, app, db):
     with mock.patch(
         'authentic2.backends.ldap_backend.LDAPBackend.modify_password', side_effect=ldap.UNWILLING_TO_PERFORM
     ):
-        response = response.form.submit().follow()
+        response = response.form.submit()
         assert 'LDAP directory refused the password change' in response.text
 
 
diff --git a/tests/test_views.py b/tests/test_views.py
index 73b57440..fa5f76fa 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -115,7 +115,6 @@ def test_password_change_error(
     ):
         resp = resp.form.submit()
 
-    resp = resp.follow()
     assert 'Password changed' not in resp
     assert 'boum!' in resp
 
-- 
2.30.2


From 891b391ad02a2df7d478fd02a22018b245724627 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 10 Nov 2022 15:08:33 +0100
Subject: [PATCH 11/11] PasswordResetConfirmView: fix handling
 PasswordChangeError

License: MIT
---
 tests/test_ldap.py | 36 ++++++++++++++++++------------------
 1 file changed, 18 insertions(+), 18 deletions(-)
tests/test_ldap.py
49 49
pytestmark = pytest.mark.skipif(not has_slapd(), reason='slapd is not installed')
50 50

  
51 51
USERNAME = 'etienne.michu'
52
UID = 'etienne.michu'
52
UID = USERNAME
53 53
CN = 'Étienne Michu'
54 54
DN = 'cn=%s,o=ôrga' % escape_dn_chars(CN)
55
PASS = 'passé'
56
UPASS = 'passé'
55
PASS = 'Passé1234'
56
UPASS = 'Passé1234'
57 57
EMAIL = 'etienne.michu@example.net'
58 58
CARLICENSE = '123445ABC'
59 59
UUID = '8ff2f34a-4a36-103c-8d0a-e3a0333484d3'
......
822 822
        }
823 823
    ]
824 824
    response = client.post(
825
        '/login/', {'login-password-submit': '1', 'username': 'etienne.michu', 'password': PASS}, follow=True
825
        '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
826 826
    )
827 827
    assert Group.objects.count() == 0
828 828
    assert response.context['user'].username == '%s@ldap' % USERNAME
......
1418 1418
            'use_tls': False,
1419 1419
        }
1420 1420
    ]
1421
    user = authenticate(username='etienne.michu', password='passé')
1421
    user = authenticate(username=USERNAME, password=PASS)
1422 1422
    assert user
1423
    assert user.check_password('passé')
1423
    assert user.check_password(PASS)
1424 1424
    user.set_password('àbon')
1425 1425
    assert user.check_password('àbon')
1426
    user2 = authenticate(username='etienne.michu', password='àbon')
1426
    user2 = authenticate(username=USERNAME, password='àbon')
1427 1427
    assert user.pk == user2.pk
1428 1428

  
1429 1429
    with mock.patch(
1430 1430
        'authentic2.backends.ldap_backend.LDAPBackend.modify_password', side_effect=ldap.UNWILLING_TO_PERFORM
1431 1431
    ):
1432 1432
        with pytest.raises(PasswordChangeError):
1433
            user.set_password('passé')
1433
            user.set_password(PASS)
1434 1434
            assert 'set_password failed (UNWILLING_TO_PERFORM)' in caplog.text
1435 1435

  
1436 1436

  
......
1956 1956
    assert caplog.records[1].message == 'Binding to server %s (anonymously)' % slapd.ldap_url
1957 1957
    assert caplog.records[2].message == (
1958 1958
        (
1959
            "Created user etienne.michu@ldap (uuid %s) from dn=cn=Étienne Michu,o=ôrga, uid=['etienne.michu'], "
1959
            "Created user etienne.michu@ldap (uuid %s) from dn=cn=Étienne Michu,o=ôrga, uid=['%s'], "
1960 1960
            "sn=['Michu'], givenname=['Étienne'], l=['Paris'], mail=['etienne.michu@example.net'], entryuuid=['%s']"
1961 1961
        )
1962
        % (User.objects.first().uuid, entryuuid)
1962
        % (User.objects.first().uuid, USERNAME, entryuuid)
1963 1963
    )
1964 1964
    assert caplog.records[-1].message == 'Search for (|(mail=*)(uid=*)) returned 6 users.'
1965 1965

  
......
1984 1984
    User.objects.update(first_name='John')
1985 1985
    management.call_command('sync-ldap-users', verbosity=3)
1986 1986
    assert caplog.records[2].message == (
1987
        "Updated user etienne.michu@ldap (uuid %s) from dn=cn=Étienne Michu,o=ôrga, uid=['etienne.michu'], "
1987
        "Updated user etienne.michu@ldap (uuid %s) from dn=cn=Étienne Michu,o=ôrga, uid=['%s'], "
1988 1988
        "sn=['Michu'], givenname=['Étienne'], l=['Paris'], mail=['etienne.michu@example.net'], entryuuid=['%s']"
1989
    ) % (User.objects.first().uuid, entryuuid)
1989
    ) % (User.objects.first().uuid, USERNAME, entryuuid)
1990 1990

  
1991 1991

  
1992 1992
def test_get_users_select_realm(slapd, settings, db, caplog):
......
2045 2045
        'givenname': ['Étienne'],
2046 2046
        'mail': ['etienne.michu@example.net'],
2047 2047
        'sn': ['Michu'],
2048
        'uid': ['etienne.michu'],
2048
        'uid': [USERNAME],
2049 2049
        'carlicense': ['123445ABC'],
2050 2050
        'entryuuid': None,
2051 2051
    }
......
2056 2056
        'givenname': ['\xc9tienne'],
2057 2057
        'mail': ['etienne.michu@example.net'],
2058 2058
        'sn': ['Michu'],
2059
        'uid': ['etienne.michu'],
2059
        'uid': [USERNAME],
2060 2060
        'carlicense': ['123445ABC'],
2061 2061
        'entryuuid': None,
2062 2062
    }
......
2073 2073
        'givenname': ['\xc9tienne'],
2074 2074
        'mail': ['etienne.michu@example.net'],
2075 2075
        'sn': ['Micho'],
2076
        'uid': ['etienne.michu'],
2076
        'uid': [USERNAME],
2077 2077
        'carlicense': ['123445ABC'],
2078 2078
        'entryuuid': None,
2079 2079
    }
......
2106 2106
        }
2107 2107
    ]
2108 2108
    response = client.post(
2109
        '/login/', {'login-password-submit': '1', 'username': 'etienne.michu', 'password': PASS}, follow=True
2109
        '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
2110 2110
    )
2111 2111
    user = response.context['user']
2112 2112
    fetched_attrs = user.get_attributes(object(), {})
......
2277 2277
        {
2278 2278
            'url': [slapd.ldap_url],
2279 2279
            'binddn': force_str('cn=%s,o=ôrga' % escape_dn_chars('Étienne Michu')),
2280
            'bindpw': 'passé',
2280
            'bindpw': PASS,
2281 2281
            'basedn': 'o=ôrga',
2282 2282
            'use_tls': False,
2283 2283
        }
......
2293 2293
    assert 'Base ldapsearch command' in ldap_config_text
2294 2294
    assert 'ldapsearch -v -H ldapi://' in ldap_config_text
2295 2295
    assert '-D "cn=Étienne Michu,o=ôrga"' in ldap_config_text
2296
    assert '-w "passé"' in ldap_config_text
2296
    assert f'-w "{PASS}"' in ldap_config_text
2297 2297
    assert '-b "o=ôrga"' in ldap_config_text
2298 2298
    assert '"(|(mail=*)(uid=*))"' in ldap_config_text
2299 2299

  
2300
- 
src/authentic2/backends/ldap_backend.py
210 210
def password_policy_control_messages(ctrl, attributes):
211 211
    messages = []
212 212

  
213
    if ctrl.error:
213
    if ctrl.error is not None:
214 214
        error = ppolicy.PasswordPolicyError.namedValues[ctrl.error]
215 215
        error2message = {
216 216
            'passwordExpired': _('The password expired after {pwdmaxage}').format(**attributes),
217
- 
src/authentic2/backends/ldap_backend.py
1778 1778
                    modlist = [(ldap.MOD_REPLACE, key, [value])]
1779 1779
            else:
1780 1780
                key = 'userPassword'
1781
                modlist = [(ldap.MOD_REPLACE, key, [new_password])]
1781
                modlist = [(ldap.MOD_REPLACE, key, [new_password.encode('utf8')])]
1782 1782
            conn.modify_s(dn, modlist)
1783 1783
        log.debug('modified password for dn %r', dn)
1784 1784

  
1785
- 
src/authentic2/backends/ldap_backend.py
585 585
        return blocks
586 586

  
587 587
    @classmethod
588
    def process_controls(cls, request, block, conn, authz_id, ctrls):
588
    def process_bind_controls(cls, request, block, conn, authz_id, ctrls):
589 589
        attributes = cls.get_ppolicy_attributes(block, conn, authz_id)
590 590
        for c in ctrls:
591 591
            if c.controlType == ppolicy.PasswordPolicyControl.controlType:
......
723 723
                            else:
724 724
                                serverctrls = []
725 725
                            results = conn.simple_bind_s(authz_id, password, serverctrls=serverctrls)
726
                            self.process_controls(request, block, conn, authz_id, results[3])
726
                            self.process_bind_controls(request, block, conn, authz_id, results[3])
727 727
                            user_login_success(authz_id)
728 728
                            if not block['connect_with_user_credentials']:
729 729
                                try:
......
734 734
                            break
735 735
                        except ldap.INVALID_CREDENTIALS as e:
736 736
                            if block.get('use_controls') and len(e.args) > 0 and 'ctrls' in e.args[0]:
737
                                self.process_controls(
737
                                self.process_bind_controls(
738 738
                                    request, block, conn, authz_id, DecodeControlTuples(e.args[0]['ctrls'])
739 739
                                )
740 740
                            success, error = self.bind(block, conn)
tests/test_ldap.py
2257 2257
        '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
2258 2258
    )
2259 2259

  
2260
    def patched_process_controls(cls, request, block, conn, authz_id, ctrls):
2260
    def patched_process_bind_controls(cls, request, block, conn, authz_id, ctrls):
2261 2261
        raise exception[0]('oops')
2262 2262

  
2263 2263
    monkeypatch.setattr(
2264 2264
        ldap_backend.LDAPBackend,
2265
        'process_controls',
2266
        patched_process_controls,
2265
        'process_bind_controls',
2266
        patched_process_bind_controls,
2267 2267
    )
2268 2268
    client.post(
2269 2269
        '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
2270
- 
src/authentic2/backends/ldap_backend.py
598 598
                message = str(vars(c))
599 599
            log.info('ldap: bind error with authz_id "%s" -> "%s"', authz_id, message)
600 600

  
601
    @classmethod
602
    def process_modify_password_controls(cls, block, conn, authz_id, ctrls):
603
        attributes = cls.get_ppolicy_attributes(block, conn, authz_id)
604
        errors = []
605
        for c in ctrls:
606
            if c.controlType == ppolicy.PasswordPolicyControl.controlType:
607
                message = ' '.join(password_policy_control_messages(c, attributes))
608
            else:
609
                message = str(vars(c))
610
            log.info('ldap: fail to modify password of "%s" -> "%s"', authz_id, message)
611
            errors.append(message)
612

  
613
        if errors:
614
            raise PasswordChangeError(' '.join(errors))
615

  
601 616
    @classmethod
602 617
    def check_group_to_role_mappings(cls, block):
603 618
        group_to_role_mapping = block.get('group_to_role_mapping')
......
1762 1777
    @classmethod
1763 1778
    def modify_password(cls, conn, block, dn, old_password, new_password):
1764 1779
        '''Change user password with adaptation for Active Directory'''
1765
        if old_password is not None and (block['use_password_modify'] and not block['active_directory']):
1766
            conn.passwd_s(dn, old_password, new_password)
1767
        else:
1768
            modlist = []
1769
            if block['active_directory']:
1770
                key = 'unicodePwd'
1771
                value = cls.ad_encoding(new_password)
1772
                if old_password:
1773
                    modlist = [
1774
                        (ldap.MOD_DELETE, key, [cls.ad_encoding(old_password)]),
1775
                        (ldap.MOD_ADD, key, [value]),
1776
                    ]
1777
                else:
1778
                    modlist = [(ldap.MOD_REPLACE, key, [value])]
1780
        serverctrls = []
1781
        if block.get('use_controls'):
1782
            serverctrls = [ppolicy.PasswordPolicyControl()]
1783

  
1784
        try:
1785
            if old_password is not None and (block['use_password_modify'] and not block['active_directory']):
1786
                results = conn.passwd_s(dn, old_password, new_password, serverctrls=serverctrls)
1779 1787
            else:
1780
                key = 'userPassword'
1781
                modlist = [(ldap.MOD_REPLACE, key, [new_password.encode('utf8')])]
1782
            conn.modify_s(dn, modlist)
1788
                modlist = []
1789
                if block['active_directory']:
1790
                    attr = 'unicodePwd'
1791
                    value = cls.ad_encoding(new_password)
1792
                    if old_password:
1793
                        modlist = [
1794
                            (ldap.MOD_DELETE, attr, [cls.ad_encoding(old_password)]),
1795
                            (ldap.MOD_ADD, attr, [value]),
1796
                        ]
1797
                    else:
1798
                        modlist = [(ldap.MOD_REPLACE, attr, [value])]
1799
                else:
1800
                    key = 'userPassword'
1801
                    modlist = [(ldap.MOD_REPLACE, key, [new_password.encode('utf8')])]
1802
                results = conn.modify_ext_s(dn, modlist, serverctrls=serverctrls)
1803
            if block.get('use_controls') and len(results) >= 3:
1804
                cls.process_modify_password_controls(block, conn, dn, results[3])
1805
        except ldap.LDAPError as e:
1806
            if block.get('use_controls') and len(e.args) > 0 and 'ctrls' in e.args[0]:
1807
                cls.process_modify_password_controls(block, conn, dn, DecodeControlTuples(e.args[0]['ctrls']))
1808
            raise
1809

  
1783 1810
        log.debug('modified password for dn %r', dn)
1784 1811

  
1785 1812
    @classmethod
1786
- 
tests/test_ldap.py
1218 1218
        assert 'LDAP directory refused the password change' in response.text
1219 1219

  
1220 1220

  
1221
def test_user_change_password(slapd, settings, app, db):
1222
    settings.LDAP_AUTH_SETTINGS = [
1223
        {
1224
            'url': [slapd.ldap_url],
1225
            'basedn': 'o=ôrga',
1226
            'use_tls': False,
1227
            'user_can_change_password': True,
1228
        }
1229
    ]
1230
    assert User.objects.count() == 0
1231
    # first login
1232
    response = app.get('/login/')
1233
    response.form['username'] = USERNAME
1234
    response.form['password'] = PASS
1235
    response = response.form.submit('login-password-submit').follow()
1236

  
1237
    response = app.get('/accounts/password/change/')
1238
    response.form['old_password'] = PASS
1239
    response.form['new_password1'] = 'hopAbcde1'
1240
    response.form['new_password2'] = 'hopAbcde1'
1241
    response = response.form.submit().follow()
1242
    assert 'Password changed' in response.text
1243

  
1244

  
1245
def test_login_ppolicy_password_expired(slapd_ppolicy, settings, app, db, caplog):
1246
    settings.LDAP_AUTH_SETTINGS = [
1247
        {
1248
            'url': [slapd_ppolicy.ldap_url],
1249
            'basedn': 'o=ôrga',
1250
            'use_tls': False,
1251
            'user_can_change_password': True,
1252
            'use_controls': True,
1253
        }
1254
    ]
1255
    # Add default ppolicy with pwdMaxAge defined
1256
    pwdMaxAge = 2
1257
    slapd_ppolicy.add_ldif(
1258
        '''
1259
dn: cn=default,ou=ppolicies,o=ôrga
1260
cn: default
1261
objectclass: top
1262
objectclass: device
1263
objectclass: pwdPolicy
1264
objectclass: pwdPolicyChecker
1265
pwdAttribute: userPassword
1266
pwdMaxAge: {pwdMaxAge}
1267
'''.format(
1268
            pwdMaxAge=pwdMaxAge
1269
        )
1270
    )
1271

  
1272
    assert User.objects.count() == 0
1273
    # first login
1274
    response = app.get('/login/')
1275
    response.form['username'] = USERNAME
1276
    response.form['password'] = PASS
1277
    response = response.form.submit('login-password-submit').follow()
1278

  
1279
    password = 'hopAbcde1'
1280
    response = app.get('/accounts/password/change/')
1281
    response.form['old_password'] = PASS
1282
    response.form['new_password1'] = password
1283
    response.form['new_password2'] = password
1284
    response = response.form.submit().follow()
1285
    assert 'Password changed' in response.text
1286

  
1287
    response = response.click('Logout')
1288

  
1289
    time.sleep(pwdMaxAge * 2)
1290

  
1291
    response = app.get('/login/')
1292
    response.form['username'] = USERNAME
1293
    response.form['password'] = password
1294
    response = response.form.submit('login-password-submit').maybe_follow()
1295

  
1296
    assert 'The password expired.' in response
1297

  
1298

  
1299
def test_user_change_password_in_history(slapd_ppolicy, settings, app, db):
1300
    settings.LDAP_AUTH_SETTINGS = [
1301
        {
1302
            'url': [slapd_ppolicy.ldap_url],
1303
            'basedn': 'o=ôrga',
1304
            'use_tls': False,
1305
            'use_controls': True,
1306
            'user_can_change_password': True,
1307
        }
1308
    ]
1309

  
1310
    # Add default ppolicy with pwdInHistory defined
1311
    slapd_ppolicy.add_ldif(
1312
        '''
1313
dn: cn=default,ou=ppolicies,o=ôrga
1314
cn: default
1315
objectclass: top
1316
objectclass: device
1317
objectclass: pwdPolicy
1318
objectclass: pwdPolicyChecker
1319
pwdAttribute: userPassword
1320
pwdMinAge: 0
1321
pwdMaxAge: 0
1322
pwdInHistory: 1
1323
'''
1324
    )
1325

  
1326
    assert User.objects.count() == 0
1327
    # first login
1328
    response = app.get('/login/')
1329
    response.form['username'] = USERNAME
1330
    response.form['password'] = PASS
1331
    response = response.form.submit('login-password-submit').follow()
1332

  
1333
    # change password
1334
    NEW_PASS = 'hopAbcde1'
1335
    response = app.get('/accounts/password/change/')
1336
    response.form['old_password'] = PASS
1337
    response.form['new_password1'] = NEW_PASS
1338
    response.form['new_password2'] = NEW_PASS
1339
    response = response.form.submit().follow()
1340
    assert 'Password changed' in response.text
1341

  
1342
    # change password again
1343
    response = app.get('/accounts/password/change/')
1344
    response.form['old_password'] = NEW_PASS
1345
    response.form['new_password1'] = PASS
1346
    response.form['new_password2'] = PASS
1347
    response = response.form.submit().maybe_follow()
1348

  
1349
    assert 'This password has already been used and can no longer be used.' in response.text
1350

  
1351

  
1352
def test_user_change_password_too_short(slapd_ppolicy, settings, app, db):
1353
    settings.LDAP_AUTH_SETTINGS = [
1354
        {
1355
            'url': [slapd_ppolicy.ldap_url],
1356
            'basedn': 'o=ôrga',
1357
            'use_tls': False,
1358
            'use_controls': True,
1359
            'user_can_change_password': True,
1360
            'ppolicy_dn': 'cn=default,ou=ppolicies,o=ôrga',
1361
        }
1362
    ]
1363

  
1364
    # Add default ppolicy with pwdCheckQuality enabled and pwdMinLength defined
1365
    pwdMinLength = 15
1366
    slapd_ppolicy.add_ldif(
1367
        '''
1368
dn: cn=default,ou=ppolicies,o=ôrga
1369
cn: default
1370
objectclass: top
1371
objectclass: device
1372
objectclass: pwdPolicy
1373
objectclass: pwdPolicyChecker
1374
pwdAttribute: userPassword
1375
pwdCheckQuality: 1
1376
pwdMinLength: {pwdMinLength}
1377
'''.format(
1378
            pwdMinLength=pwdMinLength
1379
        )
1380
    )
1381

  
1382
    assert User.objects.count() == 0
1383
    # first login
1384
    response = app.get('/login/')
1385
    response.form['username'] = USERNAME
1386
    response.form['password'] = PASS
1387
    response = response.form.submit('login-password-submit').follow()
1388

  
1389
    # change password
1390
    NEW_PASS = 'hopAbcde1'
1391
    response = app.get('/accounts/password/change/')
1392
    response.form['old_password'] = PASS
1393
    response.form['new_password1'] = NEW_PASS
1394
    response.form['new_password2'] = NEW_PASS
1395
    response = response.form.submit().maybe_follow()
1396

  
1397
    assert f'The password is too short (minimun length: {pwdMinLength})' in response.text
1398

  
1399

  
1400
def test_user_change_password_too_soon(slapd_ppolicy, settings, app, db):
1401
    settings.LDAP_AUTH_SETTINGS = [
1402
        {
1403
            'url': [slapd_ppolicy.ldap_url],
1404
            'basedn': 'o=ôrga',
1405
            'use_tls': False,
1406
            'use_controls': True,
1407
            'user_can_change_password': True,
1408
        }
1409
    ]
1410

  
1411
    # Add default ppolicy with pwdMinAge defined
1412
    slapd_ppolicy.add_ldif(
1413
        '''
1414
dn: cn=default,ou=ppolicies,o=ôrga
1415
cn: default
1416
objectclass: top
1417
objectclass: device
1418
objectclass: pwdPolicy
1419
objectclass: pwdPolicyChecker
1420
pwdAttribute: userPassword
1421
pwdMinAge: 120
1422
'''
1423
    )
1424

  
1425
    assert User.objects.count() == 0
1426
    # first login
1427
    response = app.get('/login/')
1428
    response.form['username'] = USERNAME
1429
    response.form['password'] = PASS
1430
    response = response.form.submit('login-password-submit').follow()
1431

  
1432
    # change password
1433
    NEW_PASS = 'hopAbcde1'
1434
    response = app.get('/accounts/password/change/')
1435
    response.form['old_password'] = PASS
1436
    response.form['new_password1'] = NEW_PASS
1437
    response.form['new_password2'] = NEW_PASS
1438
    response = response.form.submit().follow()
1439
    assert 'Password changed' in response.text
1440

  
1441
    # change password again
1442
    response = app.get('/accounts/password/change/')
1443
    response.form['old_password'] = NEW_PASS
1444
    NEW_PASS += '1'
1445
    response.form['new_password1'] = NEW_PASS
1446
    response.form['new_password2'] = NEW_PASS
1447
    response = response.form.submit().maybe_follow()
1448

  
1449
    assert 'It is too soon to change the password.' in response.text
1450

  
1451

  
1452
def test_reset_password_must_supply_old_password(slapd_ppolicy, settings, app, db, caplog):
1453
    settings.LDAP_AUTH_SETTINGS = [
1454
        {
1455
            'url': [slapd_ppolicy.ldap_url],
1456
            'binddn': force_str(slapd_ppolicy.root_bind_dn),
1457
            'bindpw': force_str(slapd_ppolicy.root_bind_password),
1458
            'basedn': 'o=ôrga',
1459
            'use_tls': False,
1460
            'use_controls': True,
1461
            'can_reset_password': True,
1462
        }
1463
    ]
1464

  
1465
    # Add default ppolicy with pwdSafeModify enabled
1466
    slapd_ppolicy.add_ldif(
1467
        '''
1468
dn: cn=default,ou=ppolicies,o=ôrga
1469
cn: default
1470
objectclass: top
1471
objectclass: device
1472
objectclass: pwdPolicy
1473
objectclass: pwdPolicyChecker
1474
pwdAttribute: userPassword
1475
pwdSafeModify: TRUE
1476
'''
1477
    )
1478

  
1479
    assert User.objects.count() == 0
1480
    # first login
1481
    response = app.get('/login/')
1482
    response.form['username'] = USERNAME
1483
    response.form['password'] = PASS
1484
    response = response.form.submit('login-password-submit').follow()
1485
    assert User.objects.count() == 1
1486
    assert 'Étienne Michu' in str(response)
1487
    user = User.objects.get()
1488
    assert user.email == EMAIL
1489
    # logout
1490
    response = response.click('Logout').maybe_follow()
1491

  
1492
    # password reset
1493
    response = response.click('Reset it!')
1494
    response.form['email'] = EMAIL
1495
    assert len(mail.outbox) == 0
1496
    response = response.form.submit()
1497
    assert response['Location'].endswith('/instructions/')
1498
    assert len(mail.outbox) == 1
1499
    url = utils.get_link_from_mail(mail.outbox[0])
1500
    relative_url = url.split('testserver')[1]
1501
    response = app.get(relative_url, status=200)
1502
    response.form.set('new_password1', '1234==aA')
1503
    response.form.set('new_password2', '1234==aA')
1504

  
1505
    response = response.form.submit()
1506
    assert 'The old password must be supplied.' in response
1507

  
1508

  
1509
def test_user_change_password_not_allowed(slapd_ppolicy, settings, app, db):
1510
    settings.LDAP_AUTH_SETTINGS = [
1511
        {
1512
            'url': [slapd_ppolicy.ldap_url],
1513
            'basedn': 'o=ôrga',
1514
            'use_tls': False,
1515
            'use_controls': True,
1516
            'user_can_change_password': True,
1517
        }
1518
    ]
1519

  
1520
    # Add default ppolicy with pwdAllowUserChange disabled
1521
    slapd_ppolicy.add_ldif(
1522
        '''
1523
dn: cn=default,ou=ppolicies,o=ôrga
1524
cn: default
1525
objectclass: top
1526
objectclass: device
1527
objectclass: pwdPolicy
1528
objectclass: pwdPolicyChecker
1529
pwdAttribute: userPassword
1530
pwdAllowUserChange: FALSE
1531
'''
1532
    )
1533

  
1534
    assert User.objects.count() == 0
1535
    # first login
1536
    response = app.get('/login/')
1537
    response.form['username'] = USERNAME
1538
    response.form['password'] = PASS
1539
    response = response.form.submit('login-password-submit').follow()
1540

  
1541
    # change password
1542
    NEW_PASS = 'hopAbcde1'
1543
    response = app.get('/accounts/password/change/')
1544
    response.form['old_password'] = PASS
1545
    response.form['new_password1'] = NEW_PASS
1546
    response.form['new_password2'] = NEW_PASS
1547
    response = response.form.submit().maybe_follow()
1548

  
1549
    assert 'It is not possible to modify the password.' in response.text
1550

  
1551

  
1221 1552
def test_tls(db, tls_slapd, settings, client):
1222 1553
    conn = tls_slapd.get_connection_admin()
1223 1554
    conn.modify_s(
......
1441 1772
            'basedn': 'o=ôrga',
1442 1773
            'use_tls': False,
1443 1774
            'use_controls': True,
1775
            'ppolicy_dn': 'cn=default,ou=ppolicies,o=ôrga',
1444 1776
        }
1445 1777
    ]
1446 1778

  
......
1485 1817
    response.form.set('username', USERNAME)
1486 1818
    response.form.set('password', 'invalid')
1487 1819
    response = response.form.submit(name='login-password-submit')
1488
    assert 'account is locked' in str(response.pyquery('.messages'))
1820
    assert 'account is locked since ' in str(response.pyquery('.messages'))
1821
    assert f'after {pwdMaxFailure} failures' in str(response.pyquery('.messages'))
1489 1822

  
1490 1823

  
1491 1824
def ppolicy_authenticate_exactly_pwdMaxFailure(slapd_ppolicy, caplog):
......
1690 2023
        }
1691 2024
    ]
1692 2025

  
2026
    # Add default ppolicy with pwdMaxAge and pwdExpireWarning defined
1693 2027
    pwdMaxAge = 3600
1694 2028
    slapd_ppolicy.add_ldif(
1695 2029
        '''
......
1700 2034
objectclass: pwdPolicy
1701 2035
objectclass: pwdPolicyChecker
1702 2036
pwdAttribute: userPassword
1703
pwdMinAge: 0
1704 2037
pwdMaxAge: {pwdMaxAge}
1705
pwdInHistory: 1
1706
pwdCheckQuality: 0
1707
pwdMinLength: 0
1708 2038
pwdExpireWarning: {pwdMaxAge}
1709
pwdGraceAuthnLimit: 0
1710
pwdLockout: TRUE
1711
pwdLockoutDuration: 0
1712
pwdMaxFailure: 0
1713
pwdMaxRecordedFailure: 0
1714
pwdFailureCountInterval: 0
1715
pwdMustChange: FALSE
1716
pwdAllowUserChange: TRUE
1717
pwdSafeModify: FALSE
1718 2039
'''.format(
1719 2040
            pwdMaxAge=pwdMaxAge
1720 2041
        )
......
1746 2067
        }
1747 2068
    ]
1748 2069

  
2070
    # Add default ppolicy with pwdMaxAge and pwdExpireWarning defined
1749 2071
    pwdMaxAge = 3600
1750 2072
    slapd_ppolicy.add_ldif(
1751 2073
        '''
......
1756 2078
objectclass: pwdPolicy
1757 2079
objectclass: pwdPolicyChecker
1758 2080
pwdAttribute: userPassword
1759
pwdMinAge: 0
1760 2081
pwdMaxAge: {pwdMaxAge}
1761
pwdInHistory: 1
1762
pwdCheckQuality: 0
1763
pwdMinLength: 0
1764 2082
pwdExpireWarning: {pwdMaxAge}
1765
pwdGraceAuthnLimit: 0
1766
pwdLockout: TRUE
1767
pwdLockoutDuration: 0
1768
pwdMaxFailure: 0
1769
pwdMaxRecordedFailure: 0
1770
pwdFailureCountInterval: 0
1771
pwdMustChange: FALSE
1772
pwdAllowUserChange: TRUE
1773
pwdSafeModify: FALSE
1774 2083
'''.format(
1775 2084
            pwdMaxAge=pwdMaxAge
1776 2085
        )
tox.ini
69 69
  uwsgidecorators
70 70
  enum34<=1.1.6
71 71
  ldaptools>=0.24
72
  python-ldap>=3.3.1
72 73
  numpy
73 74
  django-filter
74 75
  stable-backports: djangorestframework>=3.12,<3.13
75
- 
src/authentic2/backends/ldap_backend.py
592 592
                message = ' '.join(password_policy_control_messages(c, attributes))
593 593
                if request is not None:
594 594
                    messages.add_message(request, messages.WARNING, message)
595
                    if c.graceAuthNsRemaining or c.timeBeforeExpiration:
595
                    if (
596
                        c.graceAuthNsRemaining
597
                        or c.timeBeforeExpiration
598
                        or (
599
                            c.error is not None
600
                            and ppolicy.PasswordPolicyError.namedValues[c.error] == 'changeAfterReset'
601
                        )
602
                    ):
596 603
                        request.needs_password_change = True
597 604
            else:
598 605
                message = str(vars(c))
src/authentic2/views.py
797 797
                    )
798 798
            elif username:
799 799
                request.journal.record('user.login.failure', authenticator=authenticator, username=username)
800

  
801
            if hasattr(request, 'needs_password_change'):
802
                del request.needs_password_change
803
                return utils_misc.redirect(
804
                    request,
805
                    'password_reset',
806
                    resolve=True,
807
                    params={'next': utils_misc.select_next_url(request, '')},
808
                )
809

  
800 810
    context['form'] = form
801 811
    return render(request, 'authentic2/login_password_form.html', context)
802 812

  
tests/test_ldap.py
1506 1506
    assert 'The old password must be supplied.' in response
1507 1507

  
1508 1508

  
1509
def test_login_ppolicy_must_change_password_after_locked(slapd_ppolicy, settings, db, app):
1510
    settings.LDAP_AUTH_SETTINGS = [
1511
        {
1512
            'url': [slapd_ppolicy.ldap_url],
1513
            'basedn': 'o=ôrga',
1514
            'use_tls': False,
1515
            'use_controls': True,
1516
            'can_reset_password': True,
1517
            'ppolicy_dn': 'cn=default,ou=ppolicies,o=ôrga',
1518
        }
1519
    ]
1520

  
1521
    # Add default ppolicy with pwdMaxFailure defined and pwdMustChange enabled
1522
    pwdMaxFailure = 2
1523
    slapd_ppolicy.add_ldif(
1524
        '''
1525
dn: cn=default,ou=ppolicies,o=ôrga
1526
cn: default
1527
objectclass: top
1528
objectclass: device
1529
objectclass: pwdPolicy
1530
objectclass: pwdPolicyChecker
1531
pwdAttribute: userPassword
1532
pwdLockout: TRUE
1533
pwdMaxFailure: {pwdMaxFailure}
1534
pwdMustChange: TRUE
1535
'''.format(
1536
            pwdMaxFailure=pwdMaxFailure
1537
        )
1538
    )
1539

  
1540
    # Locked account after some login errors
1541
    for _ in range(pwdMaxFailure):
1542
        response = app.get('/login/')
1543
        response.form.set('username', USERNAME)
1544
        response.form.set('password', 'invalid')
1545
        response = response.form.submit(name='login-password-submit')
1546
        assert 'Incorrect Username or password' in str(response.pyquery('.errornotice'))
1547
        assert 'account is locked' not in str(response.pyquery('.messages'))
1548
    response = app.get('/login/')
1549
    response.form.set('username', USERNAME)
1550
    response.form.set('password', 'invalid')
1551
    response = response.form.submit(name='login-password-submit')
1552

  
1553
    assert 'account is locked since ' in str(response.pyquery('.messages'))
1554
    assert f'after {pwdMaxFailure} failures' in str(response.pyquery('.messages'))
1555

  
1556
    # Unlock account and force passwor reset
1557
    conn = slapd_ppolicy.get_connection_admin()
1558
    ldif = [
1559
        (ldap.MOD_DELETE, 'pwdAccountLockedTime', None),
1560
        (ldap.MOD_ADD, 'pwdReset', [b'TRUE']),
1561
    ]
1562
    conn.modify_s(DN, ldif)
1563

  
1564
    # Login with the right password
1565
    next_url = '/'
1566
    response = app.get(f'/login/?next={next_url}')
1567
    response.form.set('username', USERNAME)
1568
    response.form.set('password', PASS)
1569
    response = response.form.submit(name='login-password-submit')
1570

  
1571
    assert '/password/reset/' in response['Location']
1572
    assert f'next={next_url}' in response['Location']
1573
    response = response.follow()
1574
    assert 'The password was reset and must be changed.' in str(response.pyquery('.messages'))
1575

  
1576

  
1509 1577
def test_user_change_password_not_allowed(slapd_ppolicy, settings, app, db):
1510 1578
    settings.LDAP_AUTH_SETTINGS = [
1511 1579
        {
1512
- 
src/authentic2/backends/ldap_backend.py
213 213
    if ctrl.error is not None:
214 214
        error = ppolicy.PasswordPolicyError.namedValues[ctrl.error]
215 215
        error2message = {
216
            'passwordExpired': _('The password expired after {pwdmaxage}').format(**attributes),
217
            'accountLocked': _(
218
                'The account is locked since {pwdaccountlockedtime[0]} after {pwdmaxfailure} failures.'
219
            ).format(**attributes),
216
            'passwordExpired': _('The password expired.'),
217
            'accountLocked': _('The account is locked{since} after {failures_count}.').format(
218
                since=(_(" since %s") % attributes['pwdaccountlockedtime'][0])
219
                if attributes['pwdaccountlockedtime']
220
                else "",
221
                failures_count=(_("%s failures") % attributes['pwdmaxfailure'][0])
222
                if attributes['pwdmaxfailure']
223
                else _("multiple failures"),
224
            ),
220 225
            'changeAfterReset': _('The password was reset and must be changed.'),
221 226
            'passwordModNotAllowed': _('It is not possible to modify the password.'),
222 227
            'mustSupplyOldPassword': _('The old password must be supplied.'),
223 228
            'insufficientPasswordQuality': _('The password does not meet the quality requirements.'),
224
            'passwordTooShort': _('The password is too short {pwdminlength}.').format(**attributes),
225
            'passwordTooYoung': _('It is too soon to change the password {pwdminage}.').format(**attributes),
226
            'passwordInHistory': _(
227
                'This password is among the last {pwdhistory} password that were used and cannot be used'
228
                ' again.'
229
            ).format(**attributes),
229
            'passwordTooShort': _('The password is too short{minlength}.').format(
230
                minlength=(" (minimun length: %s)" % attributes['pwdminlength'][0])
231
                if attributes['pwdminlength']
232
                else ""
233
            ),
234
            'passwordTooYoung': _('It is too soon to change the password.'),
235
            'passwordInHistory': _('This password has already been used and can no longer be used.'),
230 236
        }
231 237
        messages.append(error2message.get(error, _('Unexpected error {error}').format(error=error)))
232 238
        return messages
233
- 
src/authentic2/backends/ldap_backend.py
16 16

  
17 17
import base64
18 18
import collections
19
import datetime
19 20
import hashlib
20 21
import json
21 22
import logging
......
38 39
from django.core.exceptions import ImproperlyConfigured
39 40
from django.db.models import Q
40 41
from django.db.transaction import atomic
42
from django.utils.dateformat import format as dateformat
41 43
from django.utils.encoding import force_bytes, force_str
42 44
from django.utils.translation import gettext as _
43 45
from django.utils.translation import ngettext
......
238 240
        return messages
239 241

  
240 242
    if ctrl.timeBeforeExpiration:
241
        expiration_date = time.asctime(time.localtime(time.time() + ctrl.timeBeforeExpiration))
243
        expiration_date = datetime.datetime.fromtimestamp(time.time() + ctrl.timeBeforeExpiration)
242 244
        messages.append(
243
            _('The password will expire at {expiration_date}.').format(expiration_date=expiration_date)
245
            _('The password will expire at {expiration_date}.').format(
246
                expiration_date=dateformat(expiration_date, 'l j F Y, P')
247
            )
244 248
        )
245 249
    if ctrl.graceAuthNsRemaining:
246 250
        messages.append(
247
- 
src/authentic2/views.py
1909 1909
        try:
1910 1910
            response = super().form_valid(form)
1911 1911
        except utils_misc.PasswordChangeError as e:
1912
            messages.error(self.request, e.message)
1913
            return utils_misc.redirect(self.request, self.post_change_redirect)
1912
            form.add_error('new_password1', e.message)
1913
            return self.form_invalid(form)
1914 1914
        messages.info(self.request, _('Password changed'))
1915 1915
        self.request.journal.record('user.password.change', session=self.request.session)
1916 1916
        return response
tests/test_ldap.py
1214 1214
    with mock.patch(
1215 1215
        'authentic2.backends.ldap_backend.LDAPBackend.modify_password', side_effect=ldap.UNWILLING_TO_PERFORM
1216 1216
    ):
1217
        response = response.form.submit().follow()
1217
        response = response.form.submit()
1218 1218
        assert 'LDAP directory refused the password change' in response.text
1219 1219

  
1220 1220

  
tests/test_views.py
115 115
    ):
116 116
        resp = resp.form.submit()
117 117

  
118
    resp = resp.follow()
119 118
    assert 'Password changed' not in resp
120 119
    assert 'boum!' in resp
121 120

  
122
- 
src/authentic2/views.py
1119 1119
    def form_valid(self, form):
1120 1120
        # Changing password by mail validate the email
1121 1121
        form.user.set_email_verified(True, source='user')
1122
        form.save()
1122
        try:
1123
            form.save()
1124
        except utils_misc.PasswordChangeError as e:
1125
            form.add_error('new_password1', e.message)
1126
            return self.form_invalid(form)
1123 1127
        hooks.call_hooks('event', name='password-reset-confirm', user=form.user, token=self.token, form=form)
1124 1128
        logger.info('password reset for user %s with token %r', self.user, self.token.uuid)
1125 1129
        self.token.delete()
tests/test_ldap.py
1169 1169
    assert 'account is from ldap but it could not be retrieved' in caplog.text
1170 1170

  
1171 1171

  
1172
def test_reset_password_refused_by_ldap_server(slapd, settings, app, db, caplog):
1173
    settings.LDAP_AUTH_SETTINGS = [
1174
        {
1175
            'url': [slapd.ldap_url],
1176
            'binddn': force_str(slapd.root_bind_dn),
1177
            'bindpw': force_str(slapd.root_bind_password),
1178
            'basedn': 'o=ôrga',
1179
            'use_tls': False,
1180
            'attributes': ['uid', 'carLicense'],
1181
            'can_reset_password': True,
1182
        }
1183
    ]
1184

  
1185
    assert User.objects.count() == 0
1186
    # first login
1187
    response = app.get('/login/')
1188
    response.form['username'] = USERNAME
1189
    response.form['password'] = PASS
1190
    response = response.form.submit('login-password-submit').follow()
1191
    assert User.objects.count() == 1
1192
    assert 'Étienne Michu' in str(response)
1193
    user = User.objects.get()
1194
    assert user.email == EMAIL
1195
    # logout
1196
    response = response.click('Logout').maybe_follow()
1197

  
1198
    # password reset
1199
    response = response.click('Reset it!')
1200
    response.form['email'] = EMAIL
1201
    assert len(mail.outbox) == 0
1202
    response = response.form.submit()
1203
    assert response['Location'].endswith('/instructions/')
1204
    assert len(mail.outbox) == 1
1205
    url = utils.get_link_from_mail(mail.outbox[0])
1206
    relative_url = url.split('testserver')[1]
1207
    response = app.get(relative_url, status=200)
1208
    response.form.set('new_password1', '1234==aA')
1209
    response.form.set('new_password2', '1234==aA')
1210

  
1211
    # Make LDAP directory as read-only to trigger an error
1212
    conn = slapd.get_connection_admin()
1213
    ldif = [
1214
        (
1215
            ldap.MOD_REPLACE,
1216
            'olcReadOnly',
1217
            b'TRUE',
1218
        )
1219
    ]
1220
    conn.modify_s('olcDatabase={%s}mdb,cn=config' % (slapd.db_index - 1), ldif)
1221

  
1222
    response = response.form.submit()
1223
    assert 'LDAP directory refused the password change' in response
1224

  
1225

  
1172 1226
def test_user_cannot_change_password(slapd, settings, app, db):
1173 1227
    settings.LDAP_AUTH_SETTINGS = [
1174 1228
        {
......
1506 1560
    assert 'The old password must be supplied.' in response
1507 1561

  
1508 1562

  
1563
def test_reset_by_email_passwords_not_match(app, simple_user, mailoutbox, settings):
1564
    url = reverse('password_reset')
1565
    resp = app.get(url, status=200)
1566
    resp.form.set('email', simple_user.email)
1567
    assert len(mailoutbox) == 0
1568
    settings.DEFAULT_FROM_EMAIL = 'show only addr <noreply@example.net>'
1569
    resp = resp.form.submit()
1570
    utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email)
1571
    assert resp['Location'].endswith('/instructions/')
1572
    resp = resp.follow()
1573
    assert len(mailoutbox) == 1
1574
    url = utils.get_link_from_mail(mailoutbox[0])
1575
    relative_url = url.split('testserver')[1]
1576
    resp = app.get(relative_url, status=200)
1577
    resp.form.set('new_password1', '1234==aA')
1578
    resp.form.set('new_password2', '1234')
1579
    resp = resp.form.submit()
1580

  
1581
    assert 'Passwords do not match.' in resp
1582

  
1583

  
1509 1584
def test_login_ppolicy_must_change_password_after_locked(slapd_ppolicy, settings, db, app):
1510 1585
    settings.LDAP_AUTH_SETTINGS = [
1511 1586
        {
1512
-