From f95f444c8042f258d44290e524f75f87509c1509 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Mon, 14 Nov 2022 15:26:13 +0100
Subject: [PATCH 7/7] ppolicy: handle reset password redirection after a
 changeAfterReset error on login

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 e83a6229..65721389 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -599,7 +599,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 69e58edc..976ab998 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -750,6 +750,16 @@ def login_password_login(request, authenticator, *args, **kwargs):
                     )
             elif username:
                 request.journal.record('user.login.failure', 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 d4206c7e..0f684d5e 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -1439,6 +1439,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

