From 30111abbd9eebcf922df4c5d3e9e9f245d56d0ea Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 10 Nov 2022 19:30:10 +0100
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
---
 src/authentic2/views.py |  6 +++-
 tests/test_ldap.py      | 75 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 80 insertions(+), 1 deletion(-)

diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 4b464e8a..6ff95d58 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -1119,7 +1119,11 @@ class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView):
     def form_valid(self, form):
         # Changing password by mail validate the email
         form.user.set_email_verified(True, source='user')
-        form.save()
+        try:
+            form.save()
+        except utils_misc.PasswordChangeError as e:
+            form.add_error('new_password1', e.message)
+            return self.form_invalid(form)
         hooks.call_hooks('event', name='password-reset-confirm', user=form.user, token=self.token, form=form)
         logger.info('password reset for user %s with token %r', self.user, self.token.uuid)
         self.token.delete()
diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index 2f181ed4..58104f27 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -1169,6 +1169,60 @@ def test_reset_password_ldap_failure(slapd, settings, app, db, caplog):
     assert 'account is from ldap but it could not be retrieved' in caplog.text
 
 
+def test_reset_password_refused_by_ldap_server(slapd, settings, app, db, caplog):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd.ldap_url],
+            'binddn': force_str(slapd.root_bind_dn),
+            'bindpw': force_str(slapd.root_bind_password),
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'attributes': ['uid', 'carLicense'],
+            'can_reset_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()
+    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')
+
+    # Make LDAP directory as read-only to trigger an error
+    conn = slapd.get_connection_admin()
+    ldif = [
+        (
+            ldap.MOD_REPLACE,
+            'olcReadOnly',
+            b'TRUE',
+        )
+    ]
+    conn.modify_s('olcDatabase={%s}mdb,cn=config' % (slapd.db_index - 1), ldif)
+
+    response = response.form.submit()
+    assert 'LDAP directory refused the password change' in response
+
+
 def test_user_cannot_change_password(slapd, settings, app, db):
     settings.LDAP_AUTH_SETTINGS = [
         {
@@ -1506,6 +1560,27 @@ pwdSafeModify: TRUE
     assert 'The old password must be supplied.' in response
 
 
+def test_reset_by_email_passwords_not_match(app, simple_user, mailoutbox, settings):
+    url = reverse('password_reset')
+    resp = app.get(url, status=200)
+    resp.form.set('email', simple_user.email)
+    assert len(mailoutbox) == 0
+    settings.DEFAULT_FROM_EMAIL = 'show only addr <noreply@example.net>'
+    resp = resp.form.submit()
+    utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email)
+    assert resp['Location'].endswith('/instructions/')
+    resp = resp.follow()
+    assert len(mailoutbox) == 1
+    url = utils.get_link_from_mail(mailoutbox[0])
+    relative_url = url.split('testserver')[1]
+    resp = app.get(relative_url, status=200)
+    resp.form.set('new_password1', '1234==aA')
+    resp.form.set('new_password2', '1234')
+    resp = resp.form.submit()
+
+    assert 'Passwords do not match.' in resp
+
+
 def test_login_ppolicy_must_change_password_after_locked(slapd_ppolicy, settings, db, app):
     settings.LDAP_AUTH_SETTINGS = [
         {
-- 
2.30.2

