Skip to content

Commit

Permalink
chore: test password similarity
Browse files Browse the repository at this point in the history
  • Loading branch information
damien.rabois committed Jan 31, 2024
1 parent 720d3db commit 82e9395
Show file tree
Hide file tree
Showing 2 changed files with 96 additions and 21 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project will be documented in this file.

## [0.0.5] - 2024-01-31

### Features

- Add Levenshtein distance to check password similarity

### Miscellaneous Tasks

- Test password similarity

## [0.0.4] - 2024-01-29

### Bug Fixes
Expand Down
107 changes: 86 additions & 21 deletions password_rotate/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def create_user(username="bob", password="password", date_joined=timezone.now())


class BaseTestCase(TestCase):
def setUp(self):
PasswordChange.objects.all().delete()
PasswordHistory.objects.all().delete()

def force_password_change(self, old, new):
return self.client.post(
reverse("force_password_change"),
Expand Down Expand Up @@ -95,10 +99,6 @@ def test_warn_with_no_time_to_go_using_date_joined(self):


class PasswordValidatorsTests(BaseTestCase):
def setUp(self):
PasswordChange.objects.all().delete()
PasswordHistory.objects.all().delete()

def test_password_never_used(self):
"""
Updating the password with a password never used should be allowed
Expand All @@ -109,7 +109,7 @@ def test_password_never_used(self):
self.client.login(**credentials)

# ACT
response = self.force_password_change("password", "password1")
response = self.force_password_change("password", "some new words")

# ASSERT
# There should be no error message
Expand All @@ -132,10 +132,10 @@ def test_password_already_used(self):

# ACT
# 1st password change, we use a new password
response = self.force_password_change("password", "password1")
response = self.force_password_change("password", "some new words")
pwcs = PasswordChange.objects.all()
# 2nd password change, we use the initial password
response = self.force_password_change("password1", "password")
response = self.force_password_change("some new words", "password")

# ASSERT
# The user should be warned with an error message
Expand All @@ -160,28 +160,35 @@ def test_history_count(self):
to the latest `PASSWORD_ROTATE_HISTORY_COUNT`
"""
# ARRANGE
credentials = {"username": "bob", "password": "password1"}
credentials = {"username": "bob", "password": "password"}
create_user(**credentials)
self.client.login(**credentials)

raw_passwords = [
"password",
"Hello world",
"Bonjour le monde",
"Goodbye world",
"Au revoir django",
]

# ACT
# We change the password PASSWORD_ROTATE_HISTORY_COUNT + 1 times
for i in range(1, settings.PASSWORD_ROTATE_HISTORY_COUNT + 1):
response = self.force_password_change(f"password{i}", f"password{i+1}")
for i in range(settings.PASSWORD_ROTATE_HISTORY_COUNT + 1):
response = self.force_password_change(raw_passwords[i], raw_passwords[i+1])

# ASSERT
self.assertNotContains(response, "You already used this password.", status_code=302)
self.assertEqual(PasswordChange.objects.count(), 1)
self.assertEqual(PasswordHistory.objects.count(), settings.PASSWORD_ROTATE_HISTORY_COUNT)

# Only the N latest passwords should be stored (N = PASSWORD_ROTATE_HISTORY_COUNT)
# password1 should be removed from PasswordHistory
raw_passwords = [f"password{i}" for i in (2, 3, 4)]
encrypted_passwords = (
PasswordHistory.objects.values_list("password", flat=True)
.order_by("created")
)
for raw, encrypted in zip(raw_passwords, encrypted_passwords):
# The first 2 passwords should be removed from PasswordHistory
for raw, encrypted in zip(raw_passwords[2:], encrypted_passwords):
hasher = identify_hasher(encrypted)
# NOTE A password is verified if encrypt(raw) == encrypted
self.assertTrue(hasher.verify(raw, encrypted))
Expand All @@ -194,31 +201,30 @@ def test_redirection_while_password_not_changed(self, messages):
When the password expired, while the user didn't change it, she should
be redirected to the "force_password_change" url pattern.
"""
# In our case, reverse("force_password_change") == "/password_rotate/"
# (cf tests/urls.py)

# ARRANGE
# The password expired
user = create_user(date_joined=timezone.now())
record = PasswordChange.objects.get(user=user)
record.last_changed = timezone.now() - timedelta(seconds=settings.PASSWORD_ROTATE_SECONDS + 1)
record.save()

fpc_url = reverse("force_password_change")

credentials = {"username": "bob", "password": "password"}
self.client.login(**credentials)

# ACT
responses = {}
responses["/some_page/"] = self.client.get("/some_page/")
responses["/admin/"] = self.client.get("/admin/")
responses["/password_rotate/"] = self.client.get("/password_rotate/")
responses[fpc_url] = self.client.get(fpc_url)

# ASSERT
self.assertEqual(responses["/some_page/"].status_code, 302)
self.assertEqual(responses["/some_page/"]["Location"], "/password_rotate/")
self.assertEqual(responses["/some_page/"]["Location"], fpc_url)
self.assertEqual(responses["/admin/"].status_code, 302)
self.assertEqual(responses["/admin/"]["Location"], "/password_rotate/")
self.assertEqual(responses["/password_rotate/"].status_code, 200)
self.assertEqual(responses["/admin/"]["Location"], fpc_url)
self.assertEqual(responses[fpc_url].status_code, 200)

@mock.patch("password_rotate.signals.messages", side_effect=do_nothing())
def test_redirection_stops_after_password_change(self, messages):
Expand All @@ -236,7 +242,66 @@ def test_redirection_stops_after_password_change(self, messages):
self.client.login(**credentials)

# ACT
self.force_password_change("password", "password1")
self.force_password_change("password", "some new words")

# ASSERT
self.assertEqual(self.client.get("/some_page/").status_code, 200)


class PasswordSimilarityRatioTest(BaseTestCase):
def test_password_with_high_similarity(self):
"""
When the new password is too similar too the old one, it should not be validated.
"""
# ARRANGE
user = create_user(date_joined=timezone.now())
credentials = {"username": "bob", "password": "password"}
self.client.login(**credentials)
pwcs = PasswordChange.objects.all()

# ACT
# fuzz.ratio("password", "password1") gives a similarity of 94%
# which is higher than the max similarity we set (30 %)
response = self.force_password_change("password", "password1")

# ASSERT
# An error message. We should stay on this page.
self.assertContains(
response, "The new password is too similar to the old one.", status_code=200
)

# The password didn't change
self.assertEqual(PasswordChange.objects.all().count(), 1)
self.assertEqual(PasswordChange.objects.all()[0], pwcs[0])

# There should be only 1 row
self.assertEqual(PasswordHistory.objects.filter(user=user).count(), 1)

def test_password_with_low_similarity(self):
"""
When the new password is too similar too the old one, it should not be validated.
"""
# ARRANGE
user = create_user(date_joined=timezone.now())
pwc = PasswordChange.objects.all()[0]
credentials = {"username": "bob", "password": "password"}
self.client.login(**credentials)

# ACT
# fuzz.ratio("password", "some new words")
# gives a similarity of 45% which is lower than the max similarity we set (50 %)
response = self.force_password_change("password", "some new words")

# ASSERT
# No error message and we should be redirected to password_change_done
self.assertNotContains(
response, "The new password is too similar to the old one.", status_code=302
)
self.assertEqual(response["Location"], reverse("password_change_done"))

# The password changed
self.assertEqual(PasswordChange.objects.all().count(), 1)
self.assertGreater(PasswordChange.objects.all()[0].last_changed, pwc.last_changed)

# There should be 2 rows
self.assertEqual(PasswordHistory.objects.filter(user=user).count(), 2)

0 comments on commit 82e9395

Please sign in to comment.