Skip to content

Commit 199c042

Browse files
Feat: Improve password reset workflow (#42)
* Add security notification email for password changes When a user changes their password (while logged in), they now receive a security alert email with details of the change. Features: 1. Email Templates: - password_changed_notification.html - Styled HTML email - password_changed_notification.txt - Plain text version - Includes timestamp, IP address, and user agent - Clear instructions for compromised accounts 2. Email Sending: - Async via Celery with synchronous fallback - fail_silently=True to not block password change - Includes security audit details (IP, timestamp, device) 3. Security Benefits: - Users notified of all password changes - Helps detect unauthorized access - Provides audit trail for security incidents - Clear action steps if account compromised This is a notification (not confirmation) - the password is already changed. Users can detect and respond to unauthorized changes. Workflow: - Password Change: Immediate + notification email (logged in) - Password Reset: Email with token → change (logged out) * Add direct password reset link to security notification email Enhanced the password change notification email with a prominent "Reset Password Now" button/link for compromised accounts. Improvements: 1. HTML Email: - Added red "Reset Password Now" button (urgent styling) - Links directly to password reset page on frontend - Reduces friction for users to secure their account - Clearer action hierarchy (reset first, then review) 2. Text Email: - Added direct reset password URL - Clear call-to-action for text-only email clients 3. Context: - Added reset_password_url to email context - Constructed from FRONTEND_URL setting - Points to /reset-password page Security Benefits: - One-click access to password reset (fastest response) - Reduces time window for attackers to cause damage - Follows industry best practices (GitHub, Google, etc.) - Makes security response as easy as possible User Experience: - If compromised: Click button → immediately start reset - If authorized: Ignore email → account already secured * Add frontend password reset pages and routes - Create PasswordResetRequestPage for email submission - Create PasswordResetConfirmPage for token-based password reset - Add routes for /reset-password and /reset-password/confirm - Add API functions requestPasswordReset and confirmPasswordReset - Add "Forgot password?" link to login form - Add CSS styling for all password reset pages This completes the password reset feature end-to-end, fixing the 404 error when users click the "Reset Password Now" link in security notification emails. * Fix password reset email link to go to confirm page The reset link in the email was pointing to /reset-password instead of /reset-password/confirm?token=..., preventing users from actually resetting their password. Now the link correctly goes to the confirm page where users can enter their new password. * Add direct password reset link to security notification email When a password is changed, the security notification email now includes a direct link to the password reset confirmation page with a pre-generated token. This allows users whose accounts may be compromised to immediately reset their password with one click, instead of having to: 1. Click link to request page 2. Enter email 3. Wait for another email 4. Click that link 5. Finally reset password Now it's just: Click link → Reset password immediately. * Fix flickering when navigating to password reset pages The theme was being re-applied even when already correctly set, causing a brief black flash during page transitions. Now the code checks if the current theme matches the expected theme before updating, preventing unnecessary DOM manipulation and eliminating the flickering effect. * Use React Router Link instead of anchor tags in LoginForm Replaced <a href> tags with <Link to> components to enable client-side navigation. This prevents full page reloads that cause flickering when navigating from login to register or password reset pages. - "Register here" link now uses Link component - "Forgot password?" link now uses Link component This ensures smooth transitions consistent with navbar navigation. * Refactor password reset pages to match login/register structure Extracted form logic into separate components to maintain consistency with existing authentication pages: - Created PasswordResetRequestForm component in components/auth/ - Created PasswordResetConfirmForm component in components/auth/ - Moved CSS files to components/auth/ with renamed files - Simplified page files to just render form components This improves code organization, consistency, and maintainability by following the established pattern where pages handle routing and forms handle UI/logic. * Fix: Lint * Fix: Lint * Fix: Add missing migration scripts --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e40ec5 commit 199c042

22 files changed

+2051
-2
lines changed

backend/apps/authentication/admin.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib import admin
22

33
from .models import EmailConfirmation
4+
from .models import PasswordReset
45
from .models import User
56

67

@@ -27,3 +28,23 @@ class EmailConfirmationAdmin(admin.ModelAdmin):
2728
list_filter = ("confirmation_type", "confirmed_at")
2829
ordering = ("-created_at",)
2930
readonly_fields = ("token", "new_email_hash", "created_at", "expires_at", "confirmed_at")
31+
32+
33+
@admin.register(PasswordReset)
34+
class PasswordResetAdmin(admin.ModelAdmin):
35+
list_display = (
36+
"user",
37+
"created_at",
38+
"expires_at",
39+
"confirmed_at",
40+
"is_expired",
41+
"ip_address",
42+
)
43+
search_fields = ("user__email", "user__username", "ip_address")
44+
list_filter = ("confirmed_at", "created_at")
45+
ordering = ("-created_at",)
46+
readonly_fields = ("token", "created_at", "expires_at", "confirmed_at", "ip_address")
47+
48+
def get_queryset(self, request):
49+
"""Optimize queryset with select_related."""
50+
return super().get_queryset(request).select_related("user")
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Generated by Django 4.2.27 on 2025-12-17 19:48
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations
6+
from django.db import models
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("authentication", "0004_alter_emailconfirmation_expires_at_and_more"),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="accountlog",
17+
name="operation",
18+
field=models.CharField(
19+
choices=[
20+
("USERNAME_CHANGED", "Username Changed"),
21+
("EMAIL_CHANGED", "Email Changed"),
22+
("PASSWORD_CHANGED", "Password Changed"),
23+
("PASSWORD_RESET_REQUESTED", "Password Reset Requested"),
24+
("PASSWORD_RESET_CONFIRMED", "Password Reset Confirmed"),
25+
("ACCOUNT_DELETED", "Account Deleted"),
26+
("EMAIL_CHANGE_REQUESTED", "Email Change Requested"),
27+
("EMAIL_CHANGE_CONFIRMED", "Email Change Confirmed"),
28+
("EMAIL_CONFIRMED", "Email Confirmed"),
29+
("CONFIRMATION_EMAIL_RESENT", "Confirmation Email Resent"),
30+
],
31+
help_text="Type of account operation",
32+
max_length=50,
33+
),
34+
),
35+
migrations.CreateModel(
36+
name="PasswordReset",
37+
fields=[
38+
(
39+
"id",
40+
models.BigAutoField(
41+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
42+
),
43+
),
44+
(
45+
"token",
46+
models.CharField(
47+
help_text="HMAC-based reset token", max_length=128, unique=True
48+
),
49+
),
50+
(
51+
"created_at",
52+
models.DateTimeField(auto_now_add=True, help_text="Token creation timestamp"),
53+
),
54+
(
55+
"expires_at",
56+
models.DateTimeField(
57+
help_text="Token expiration timestamp (created_at + 24 hours)"
58+
),
59+
),
60+
(
61+
"confirmed_at",
62+
models.DateTimeField(
63+
blank=True, help_text="Timestamp when confirmed, NULL if pending", null=True
64+
),
65+
),
66+
(
67+
"ip_address",
68+
models.GenericIPAddressField(
69+
blank=True,
70+
help_text="IP address of the request (for security audit)",
71+
null=True,
72+
),
73+
),
74+
(
75+
"user",
76+
models.ForeignKey(
77+
help_text="User requesting password reset",
78+
on_delete=django.db.models.deletion.CASCADE,
79+
related_name="password_resets",
80+
to=settings.AUTH_USER_MODEL,
81+
),
82+
),
83+
],
84+
options={
85+
"verbose_name": "Password Reset",
86+
"verbose_name_plural": "Password Resets",
87+
"db_table": "password_resets",
88+
"ordering": ["-created_at"],
89+
"indexes": [
90+
models.Index(fields=["user"], name="idx_pwd_reset_user"),
91+
models.Index(fields=["token"], name="idx_pwd_reset_token"),
92+
],
93+
},
94+
),
95+
]

backend/apps/authentication/models.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,71 @@ def __str__(self):
408408
return f"{status} {self.user} registration"
409409

410410

411+
class PasswordReset(models.Model):
412+
"""
413+
Token storage for password reset flow.
414+
415+
Stores password reset requests until confirmed via token link.
416+
Tokens expire after 24 hours for security.
417+
"""
418+
419+
user = models.ForeignKey(
420+
"User",
421+
on_delete=models.CASCADE,
422+
related_name="password_resets",
423+
help_text="User requesting password reset",
424+
)
425+
426+
token = models.CharField(max_length=128, unique=True, help_text="HMAC-based reset token")
427+
428+
created_at = models.DateTimeField(auto_now_add=True, help_text="Token creation timestamp")
429+
430+
expires_at = models.DateTimeField(
431+
help_text="Token expiration timestamp (created_at + 24 hours)"
432+
)
433+
434+
confirmed_at = models.DateTimeField(
435+
blank=True, null=True, help_text="Timestamp when confirmed, NULL if pending"
436+
)
437+
438+
ip_address = models.GenericIPAddressField(
439+
blank=True,
440+
null=True,
441+
help_text="IP address of the request (for security audit)",
442+
)
443+
444+
class Meta:
445+
db_table = "password_resets"
446+
verbose_name = "Password Reset"
447+
verbose_name_plural = "Password Resets"
448+
ordering = ["-created_at"]
449+
indexes = [
450+
models.Index(fields=["user"], name="idx_pwd_reset_user"),
451+
models.Index(fields=["token"], name="idx_pwd_reset_token"),
452+
]
453+
454+
@property
455+
def is_expired(self):
456+
"""Check if token has expired."""
457+
return timezone.now() > self.expires_at
458+
459+
@property
460+
def is_confirmed(self):
461+
"""Check if password reset has been confirmed."""
462+
return self.confirmed_at is not None
463+
464+
def save(self, *args, **kwargs):
465+
"""Set expires_at if not already set (24 hours from creation)."""
466+
if not self.expires_at:
467+
self.expires_at = timezone.now() + timedelta(hours=24)
468+
469+
super().save(*args, **kwargs)
470+
471+
def __str__(self):
472+
status = "✓" if self.confirmed_at else ("⏰" if not self.is_expired else "❌")
473+
return f"{status} Password reset for {self.user}"
474+
475+
411476
class AccountLog(models.Model):
412477
"""
413478
Audit trail for sensitive account operations.
@@ -420,6 +485,8 @@ class AccountLog(models.Model):
420485
("USERNAME_CHANGED", "Username Changed"),
421486
("EMAIL_CHANGED", "Email Changed"),
422487
("PASSWORD_CHANGED", "Password Changed"),
488+
("PASSWORD_RESET_REQUESTED", "Password Reset Requested"),
489+
("PASSWORD_RESET_CONFIRMED", "Password Reset Confirmed"),
423490
("ACCOUNT_DELETED", "Account Deleted"),
424491
("EMAIL_CHANGE_REQUESTED", "Email Change Requested"),
425492
("EMAIL_CHANGE_CONFIRMED", "Email Change Confirmed"),

backend/apps/authentication/serializers.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,61 @@ class UsernameValidationSerializer(serializers.Serializer):
358358
"""
359359

360360
username = serializers.CharField(required=False, allow_blank=True)
361+
362+
363+
class PasswordResetRequestSerializer(serializers.Serializer):
364+
"""
365+
Password reset request serializer.
366+
367+
Accepts email address to send password reset link.
368+
"""
369+
370+
email = serializers.EmailField(required=True)
371+
372+
def validate_email(self, value):
373+
"""
374+
Validate email exists in system.
375+
"""
376+
from .models import User
377+
378+
# Normalize email
379+
email_normalized = value.lower().strip()
380+
email_hash = User.hash_email(email_normalized)
381+
382+
try:
383+
User.objects.get(email_hash=email_hash)
384+
except User.DoesNotExist:
385+
# Don't reveal whether email exists (security best practice)
386+
# Return success message regardless
387+
pass
388+
389+
return email_normalized
390+
391+
392+
class PasswordResetConfirmSerializer(serializers.Serializer):
393+
"""
394+
Password reset confirmation serializer.
395+
396+
Validates token and new password for password reset.
397+
"""
398+
399+
token = serializers.CharField(max_length=128, required=True)
400+
new_password = serializers.CharField(
401+
write_only=True,
402+
required=True,
403+
validators=[validate_password],
404+
style={"input_type": "password"},
405+
min_length=8,
406+
help_text="New password (minimum 8 characters, must contain uppercase, lowercase, and numbers)",
407+
)
408+
409+
def validate_new_password(self, value):
410+
"""
411+
Validate new password strength.
412+
"""
413+
# Django's validate_password already runs
414+
# Just ensure it meets minimum requirements
415+
if len(value) < 8:
416+
raise serializers.ValidationError("Password must be at least 8 characters long.")
417+
418+
return value

0 commit comments

Comments
 (0)