From 3c9ca18e249e14ef9387405748814837285d3bd6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Lo=C3=AFc=20Dachary?= <ldachary@easter-eggs.com>
Date: Thu, 18 Feb 2021 18:12:22 +0100
Subject: [PATCH] ldap: redirect /password/change if it is about to expire
 (#51268)

Fixes: #51268

License: MIT
---
 src/authentic2/authenticators.py        |  4 ++
 src/authentic2/backends/ldap_backend.py |  2 +
 tests/test_ldap.py                      | 73 ++++++++++++++++++++++---
 3 files changed, 71 insertions(+), 8 deletions(-)

diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py
index f5958dfb2..0fc677594 100644
--- a/src/authentic2/authenticators.py
+++ b/src/authentic2/authenticators.py
@@ -132,6 +132,10 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
                 if 'ou' in form.fields:
                     utils.prepend_remember_cookie(request, response, 'preferred-ous', form.cleaned_data['ou'].pk)
 
+                if hasattr(request, 'needs_password_change'):
+                    del request.needs_password_change
+                    return utils.redirect(request, 'password_change', params={'next': response.url}, resolve=True)
+
                 return response
             else:
                 username = form.cleaned_data.get('username', '').strip()
diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index 28f212e31..edbe89764 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -585,6 +585,8 @@ class LDAPBackend(object):
                 message = ' '.join(password_policy_control_messages(c))
                 if request is not None:
                     messages.add_message(request, messages.WARNING, message)
+                    if c.graceAuthNsRemaining or c.timeBeforeExpiration:
+                        request.needs_password_change = True
             else:
                 message = str(vars(c))
             log.info('ldap: bind error with authz_id "%s" -> "%s"', authz_id, message)
diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index c02898a30..adb2a4775 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -742,14 +742,7 @@ def test_no_connect_with_user_credentials(slapd_strict_acl, db, settings, app):
     assert force_bytes('Étienne Michu') in response.body
 
 
-def test_reset_password_ldap_user(slapd, settings, app, db):
-    settings.LDAP_AUTH_SETTINGS = [{
-        'url': [slapd.ldap_url],
-        'binddn': force_text(slapd.root_bind_dn),
-        'bindpw': force_text(slapd.root_bind_password),
-        'basedn': u'o=ôrga',
-        'use_tls': False,
-    }]
+def reset_password_ldap_user(settings, app):
     assert User.objects.count() == 0
     # first login
     response = app.get('/login/')
@@ -781,6 +774,24 @@ def test_reset_password_ldap_user(slapd, settings, app, db):
     response.form['new_password1'] = new_password
     response.form['new_password2'] = new_password
     response = response.form.submit(status=302).maybe_follow()
+    # logout
+    response = response.click('Logout')
+    if response.status_code == 200:  # Django 1.7, same_origin is bugged; testserver != localhost:80
+        response = response.form.submit().maybe_follow()
+    else:
+        response = response.maybe_follow()
+    return new_password
+
+
+def test_reset_password_ldap_user(slapd, settings, app, db):
+    settings.LDAP_AUTH_SETTINGS = [{
+        'url': [slapd.ldap_url],
+        'binddn': force_text(slapd.root_bind_dn),
+        'bindpw': force_text(slapd.root_bind_password),
+        'basedn': u'o=ôrga',
+        'use_tls': False,
+    }]
+    new_password = reset_password_ldap_user(settings, app)
     # verify password has changed
     slapd.get_connection().bind_s(DN, new_password)
     with pytest.raises(ldap.INVALID_CREDENTIALS):
@@ -1130,6 +1141,52 @@ pwdSafeModify: FALSE
     assert 'password will expire' in caplog.text
 
 
+def test_login_ppolicy_pwdExpireWarning(slapd_ppolicy, settings, app, db, caplog):
+    settings.LDAP_AUTH_SETTINGS = [{
+        'url': [slapd_ppolicy.ldap_url],
+        'binddn': force_text(slapd_ppolicy.root_bind_dn),
+        'bindpw': force_text(slapd_ppolicy.root_bind_password),
+        'basedn': u'o=ôrga',
+        'use_tls': False,
+    }]
+
+    pwdMaxAge = 3600
+    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: {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))
+
+    password = reset_password_ldap_user(settings, app)
+
+    time.sleep(2)
+
+    response = app.get('/login/')
+    response.form['username'] = USERNAME
+    response.form['password'] = password
+    response = response.form.submit('login-password-submit')
+    assert '/password/change/' in response['Location']
+
+
 def test_authenticate_ppolicy_pwdAllowUserChange(slapd_ppolicy, settings, db, caplog):
     settings.LDAP_AUTH_SETTINGS = [{
         'url': [slapd_ppolicy.ldap_url],
-- 
2.25.1

