Skip to content

Commit 6a99dbc

Browse files
committed
Improve security by letting users set their own passwords
This commit addresses a security concern where user accounts were automatically created with a predictable password. Instead of setting a password based on a predictable pattern (e.g., website name combined with the current year), we now generate a secure, random token for each new user. This token is used in a password reset or account activation link sent via email, allowing users to set or reset their passwords securely. This change ensures that user accounts remain secure and that users are fully aware of and in control of their account creation and password management processes.
1 parent dcfea5e commit 6a99dbc

File tree

12 files changed

+600
-124
lines changed

12 files changed

+600
-124
lines changed

appointment/messages_.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@
88

99
from django.utils.translation import gettext as _
1010

11-
thank_you_no_payment = _("""We're excited to have you on board! Thank you for booking us.
12-
We hope you enjoy using our services and find them valuable.""")
11+
thank_you_no_payment = _("""We're excited to have you on board!""")
1312

14-
thank_you_payment_plus_down = _("""We're excited to have you on board! Thank you for booking us. The next step is
13+
thank_you_payment_plus_down = _("""We're excited to have you on board! The next step is
1514
to pay for the booking. You have the choice to pay the whole amount or a down deposit.
1615
If you choose the deposit, you will have to pay the rest of the amount on the day of the booking.""")
1716

1817
thank_you_payment = _("""We're excited to have you on board! Thank you for booking us. The next step is to pay for
1918
the booking.""")
2019

2120
appt_updated_successfully = _("Appointment updated successfully.")
21+
22+
passwd_set_successfully = _("We've successfully set your password. You can now log in to your account.")
23+
24+
passwd_error = _("The password reset link is invalid or has expired.")

appointment/models.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import datetime
1010
import random
1111
import string
12+
import uuid
1213

1314
from babel.numbers import get_currency_symbol
1415
from django.conf import settings
@@ -776,6 +777,84 @@ def check_code(self, code):
776777
return self.code == code
777778

778779

780+
class PasswordResetToken(models.Model):
781+
"""
782+
Represents a password reset token for users.
783+
784+
Author: Adams Pierre David
785+
Version: 3.x.x
786+
Since: 3.x.x
787+
"""
788+
789+
class TokenStatus(models.TextChoices):
790+
ACTIVE = 'active', 'Active'
791+
VERIFIED = 'verified', 'Verified'
792+
INVALIDATED = 'invalidated', 'Invalidated'
793+
794+
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='password_reset_tokens')
795+
token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
796+
expires_at = models.DateTimeField()
797+
status = models.CharField(max_length=11, choices=TokenStatus.choices, default=TokenStatus.ACTIVE)
798+
799+
# meta data
800+
created_at = models.DateTimeField(auto_now_add=True)
801+
updated_at = models.DateTimeField(auto_now=True)
802+
803+
def __str__(self):
804+
return f"Password reset token for {self.user} [{self.token} status: {self.status} expires at {self.expires_at}]"
805+
806+
@property
807+
def is_expired(self):
808+
"""Checks if the token has expired."""
809+
return timezone.now() >= self.expires_at
810+
811+
@property
812+
def is_verified(self):
813+
"""Checks if the token has been verified."""
814+
return self.status == self.TokenStatus.VERIFIED
815+
816+
@property
817+
def is_active(self):
818+
"""Checks if the token is still active."""
819+
return self.status == self.TokenStatus.ACTIVE
820+
821+
@property
822+
def is_invalidated(self):
823+
"""Checks if the token has been invalidated."""
824+
return self.status == self.TokenStatus.INVALIDATED
825+
826+
@classmethod
827+
def create_token(cls, user, expiration_minutes=60):
828+
"""
829+
Generates a new token for the user with a specified expiration time.
830+
Before creating a new token, invalidate all previous active tokens by marking them as invalidated.
831+
"""
832+
cls.objects.filter(user=user, expires_at__gte=timezone.now(), status=cls.TokenStatus.ACTIVE).update(
833+
status=cls.TokenStatus.INVALIDATED)
834+
expires_at = timezone.now() + timezone.timedelta(minutes=expiration_minutes)
835+
token = cls.objects.create(user=user, expires_at=expires_at, status=cls.TokenStatus.ACTIVE)
836+
return token
837+
838+
def mark_as_verified(self):
839+
"""
840+
Marks the token as verified.
841+
"""
842+
self.status = self.TokenStatus.VERIFIED
843+
self.save(update_fields=['status'])
844+
845+
@classmethod
846+
def verify_token(cls, user, token):
847+
"""
848+
Verifies if the provided token is valid and belongs to the given user.
849+
Additionally, checks if the token has not been marked as verified.
850+
"""
851+
try:
852+
return cls.objects.get(user=user, token=token, expires_at__gte=timezone.now(),
853+
status=cls.TokenStatus.ACTIVE)
854+
except cls.DoesNotExist:
855+
return None
856+
857+
779858
class DayOff(models.Model):
780859
staff_member = models.ForeignKey(StaffMember, on_delete=models.CASCADE)
781860
start_date = models.DateField()

appointment/tasks.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,19 @@ def send_email_reminder(to_email, first_name, reschedule_link, appointment_id):
2020
# Fetch the appointment using appointment_id
2121
logger.info(f"Sending reminder to {to_email} for appointment {appointment_id}")
2222
appointment = Appointment.objects.get(id=appointment_id)
23+
recipient_type = 'client'
2324
email_context = {
2425
'first_name': first_name,
2526
'appointment': appointment,
2627
'reschedule_link': reschedule_link,
28+
'recipient_type': recipient_type,
2729
}
2830
send_email(
2931
recipient_list=[to_email], subject=_("Reminder: Upcoming Appointment"),
3032
template_url='email_sender/reminder_email.html', context=email_context
3133
)
3234
# Notify the admin
35+
email_context['recipient_type'] = 'admin'
3336
notify_admin(
3437
subject=_("Admin Reminder: Upcoming Appointment"),
3538
template_url='email_sender/reminder_email.html', context=email_context
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import time
2+
3+
from django.utils import timezone
4+
5+
from appointment.models import PasswordResetToken
6+
from appointment.tests.base.base_test import BaseTest
7+
import datetime
8+
9+
10+
class PasswordResetTokenTests(BaseTest):
11+
def setUp(self):
12+
super().setUp()
13+
self.user = self.create_user_(username='test_user', email='[email protected]', password='test_pass123')
14+
self.expired_time = timezone.now() - datetime.timedelta(minutes=5)
15+
16+
def test_create_token(self):
17+
"""Test token creation for a user."""
18+
token = PasswordResetToken.create_token(user=self.user)
19+
self.assertIsNotNone(token)
20+
self.assertFalse(token.is_expired)
21+
self.assertFalse(token.is_verified)
22+
23+
def test_str_representation(self):
24+
"""Test the string representation of the token."""
25+
token = PasswordResetToken.create_token(self.user)
26+
expected_str = (f"Password reset token for {self.user} "
27+
f"[{token.token} status: {token.status} expires at {token.expires_at}]")
28+
self.assertEqual(str(token), expected_str)
29+
30+
def test_is_verified_property(self):
31+
"""Test the is_verified property to check if the token status is correctly identified as verified."""
32+
token = PasswordResetToken.create_token(self.user)
33+
self.assertFalse(token.is_verified, "Newly created token should not be verified.")
34+
token.mark_as_verified()
35+
self.assertTrue(token.is_verified, "Token should be marked as verified after calling mark_as_verified.")
36+
37+
def test_is_active_property(self):
38+
"""Test the is_active property to check if the token status is correctly identified as active."""
39+
token = PasswordResetToken.create_token(self.user)
40+
self.assertTrue(token.is_active, "Newly created token should be active.")
41+
token.mark_as_verified()
42+
token.refresh_from_db()
43+
self.assertFalse(token.is_active, "Token should not be active after being verified.")
44+
45+
# Invalidate the token and check is_active property
46+
token.status = PasswordResetToken.TokenStatus.INVALIDATED
47+
token.save()
48+
self.assertFalse(token.is_active, "Token should not be active after being invalidated.")
49+
50+
def test_is_invalidated_property(self):
51+
"""Test the is_invalidated property to check if the token status is correctly identified as invalidated."""
52+
token = PasswordResetToken.create_token(self.user)
53+
self.assertFalse(token.is_invalidated, "Newly created token should not be invalidated.")
54+
55+
# Invalidate the token and check is_invalidated property
56+
token.status = PasswordResetToken.TokenStatus.INVALIDATED
57+
token.save()
58+
self.assertTrue(token.is_invalidated, "Token should be marked as invalidated after status change.")
59+
60+
def test_token_expiration(self):
61+
"""Test that a token is considered expired after the expiration time."""
62+
token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-1) # Token already expired
63+
self.assertTrue(token.is_expired)
64+
65+
def test_verify_token_success(self):
66+
"""Test successful token verification."""
67+
token = PasswordResetToken.create_token(user=self.user)
68+
verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
69+
self.assertIsNotNone(verified_token)
70+
71+
def test_verify_token_failure_expired(self):
72+
"""Test token verification fails if the token has expired."""
73+
token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-1) # Token already expired
74+
verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
75+
self.assertIsNone(verified_token)
76+
77+
def test_verify_token_failure_wrong_user(self):
78+
"""Test token verification fails if the token does not belong to the given user."""
79+
another_user = self.create_user_(username='another_user', email='[email protected]',
80+
password='test_pass456')
81+
token = PasswordResetToken.create_token(user=self.user)
82+
verified_token = PasswordResetToken.verify_token(user=another_user, token=token.token)
83+
self.assertIsNone(verified_token)
84+
85+
def test_verify_token_failure_already_verified(self):
86+
"""Test token verification fails if the token has already been verified."""
87+
token = PasswordResetToken.create_token(user=self.user)
88+
token.mark_as_verified()
89+
verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
90+
self.assertIsNone(verified_token)
91+
92+
def test_mark_as_verified(self):
93+
"""Test marking a token as verified."""
94+
token = PasswordResetToken.create_token(user=self.user)
95+
self.assertFalse(token.is_verified)
96+
token.mark_as_verified()
97+
token.refresh_from_db() # Refresh the token object from the database
98+
self.assertTrue(token.is_verified)
99+
100+
def test_verify_token_invalid_token(self):
101+
"""Test token verification fails if the token does not exist."""
102+
PasswordResetToken.create_token(user=self.user)
103+
invalid_token_uuid = "12345678-1234-1234-1234-123456789012" # An invalid token UUID
104+
verified_token = PasswordResetToken.verify_token(user=self.user, token=invalid_token_uuid)
105+
self.assertIsNone(verified_token)
106+
107+
def test_token_expiration_boundary(self):
108+
"""Test token verification at the exact moment of expiration."""
109+
token = PasswordResetToken.create_token(user=self.user, expiration_minutes=0) # Token expires now
110+
# Assuming there might be a very slight delay before verification, we wait a second
111+
time.sleep(1)
112+
verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
113+
self.assertIsNone(verified_token)
114+
115+
def test_create_multiple_tokens_for_user(self):
116+
"""Test that multiple tokens can be created for a single user and only the latest is valid."""
117+
old_token = PasswordResetToken.create_token(user=self.user)
118+
new_token = PasswordResetToken.create_token(user=self.user)
119+
120+
old_verified = PasswordResetToken.verify_token(user=self.user, token=old_token.token)
121+
new_verified = PasswordResetToken.verify_token(user=self.user, token=new_token.token)
122+
123+
self.assertIsNone(old_verified, "Old token should not be valid after creating a new one")
124+
self.assertIsNotNone(new_verified, "New token should be valid")
125+
126+
def test_expired_token_does_not_verify(self):
127+
"""Test that an expired token does not verify even if correct."""
128+
token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-5) # Already expired
129+
# Fast-forward time to after expiration
130+
token.expires_at = timezone.now() - datetime.timedelta(minutes=5)
131+
token.save()
132+
133+
verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
134+
self.assertIsNone(verified_token, "Expired token should not verify")
135+
136+
def test_mark_as_verified_is_idempotent(self):
137+
"""Test that marking a token as verified multiple times has no adverse effect."""
138+
token = PasswordResetToken.create_token(user=self.user)
139+
token.mark_as_verified()
140+
first_verification_time = token.updated_at
141+
142+
time.sleep(1) # Ensure time has passed
143+
token.mark_as_verified()
144+
token.refresh_from_db()
145+
146+
self.assertTrue(token.is_verified)
147+
self.assertEqual(first_verification_time, token.updated_at,
148+
"Token verification time should not update on subsequent calls")
149+
150+
def test_deleting_user_cascades_to_tokens(self):
151+
"""Test that deleting a user deletes associated password reset tokens."""
152+
token = PasswordResetToken.create_token(user=self.user)
153+
self.user.delete()
154+
155+
with self.assertRaises(PasswordResetToken.DoesNotExist):
156+
PasswordResetToken.objects.get(pk=token.pk)
157+
158+
def test_token_verification_resets_after_expiration(self):
159+
"""Test that an expired token cannot be verified after its expiration, even if marked as verified."""
160+
token = PasswordResetToken.create_token(user=self.user, expiration_minutes=-1) # Already expired
161+
token.mark_as_verified()
162+
163+
verified_token = PasswordResetToken.verify_token(user=self.user, token=token.token)
164+
self.assertIsNone(verified_token, "Expired token should not verify, even if marked as verified")
165+
166+
def test_verify_token_invalidated(self):
167+
"""Test token verification fails if the token has been invalidated."""
168+
token = PasswordResetToken.create_token(self.user)
169+
# Invalidate the token by creating a new one
170+
PasswordResetToken.create_token(self.user)
171+
verified_token = PasswordResetToken.verify_token(self.user, token.token)
172+
self.assertIsNone(verified_token)
173+
174+
def test_expired_token_verification(self):
175+
"""Test that an expired token cannot be verified."""
176+
token = PasswordResetToken.objects.create(user=self.user, expires_at=self.expired_time,
177+
status=PasswordResetToken.TokenStatus.ACTIVE)
178+
self.assertTrue(token.is_expired)
179+
verified_token = PasswordResetToken.verify_token(self.user, token.token)
180+
self.assertIsNone(verified_token, "Expired token should not verify")
181+
182+
def test_token_verification_after_user_deletion(self):
183+
"""Test that a token cannot be verified after the associated user is deleted."""
184+
token = PasswordResetToken.create_token(self.user)
185+
self.user.delete()
186+
verified_token = PasswordResetToken.verify_token(self.user, token.token)
187+
self.assertIsNone(verified_token, "Token should not verify after user deletion")

appointment/tests/test_tasks.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ def test_send_email_reminder(self, mock_notify_admin, mock_send_email):
3131
recipient_list=[to_email],
3232
subject=_("Reminder: Upcoming Appointment"),
3333
template_url='email_sender/reminder_email.html',
34-
context={'first_name': first_name, 'appointment': appointment, 'reschedule_link': ""}
34+
context={'first_name': first_name, 'appointment': appointment, 'reschedule_link': "",
35+
'recipient_type': 'admin'}
3536
)
3637

3738
# Verify notify_admin was called with correct parameters
3839
mock_notify_admin.assert_called_once_with(
3940
subject=_("Admin Reminder: Upcoming Appointment"),
4041
template_url='email_sender/reminder_email.html',
41-
context={'first_name': first_name, 'appointment': appointment, 'reschedule_link': ""}
42+
context={'first_name': first_name, 'appointment': appointment, 'reschedule_link': "",
43+
'recipient_type': 'admin'}
4244
)

0 commit comments

Comments
 (0)