From 2ef4583bea823074d4c01f205d7c5a643183fa61 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 10 Nov 2022 23:28:39 +0100
Subject: [PATCH 6/6] Add tests on LDAP password change/reset with ppolicy

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

diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index 5a896d0e..d4206c7e 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -1151,6 +1151,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(
@@ -1374,6 +1705,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',
         }
     ]
 
@@ -1418,7 +1750,9 @@ 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):
@@ -1623,6 +1957,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(
         '''
@@ -1633,21 +1968,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
         )
@@ -1679,6 +2001,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(
         '''
@@ -1689,21 +2012,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 53cedf36..04daf9f9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -92,6 +92,7 @@ deps =
   uwsgidecorators
   enum34<=1.1.6
   ldaptools>=0.24
+  python-ldap>=3.3.1
   numpy
   django-filter
   buster: djangorestframework>=3.9.2,<3.10
-- 
2.30.2

