-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add authentication app with password reset functionality and up…
…date train line constants
- Loading branch information
1 parent
184deee
commit 72be356
Showing
31 changed files
with
1,248 additions
and
98 deletions.
There are no files selected for viewing
Empty file.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from django.apps import AppConfig | ||
|
||
|
||
class AuthenticationConfig(AppConfig): | ||
default_auto_field = "django.db.models.BigAutoField" | ||
name = "apps.authentication" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
# Generated by Django 4.2.18 on 2025-02-22 20:30 | ||
|
||
from django.conf import settings | ||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
import uuid | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
initial = True | ||
|
||
dependencies = [ | ||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
] | ||
|
||
operations = [ | ||
migrations.CreateModel( | ||
name="PasswordResetToken", | ||
fields=[ | ||
( | ||
"id", | ||
models.BigAutoField( | ||
auto_created=True, | ||
primary_key=True, | ||
serialize=False, | ||
verbose_name="ID", | ||
), | ||
), | ||
( | ||
"token", | ||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True), | ||
), | ||
("created_at", models.DateTimeField(auto_now_add=True)), | ||
("expires_at", models.DateTimeField()), | ||
("is_used", models.BooleanField(default=False)), | ||
( | ||
"user", | ||
models.ForeignKey( | ||
on_delete=django.db.models.deletion.CASCADE, | ||
to=settings.AUTH_USER_MODEL, | ||
), | ||
), | ||
], | ||
options={ | ||
"indexes": [ | ||
models.Index(fields=["token"], name="authenticat_token_3288c7_idx"), | ||
models.Index( | ||
fields=["user", "is_used"], | ||
name="authenticat_user_id_9751b1_idx", | ||
), | ||
], | ||
}, | ||
), | ||
] |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
# apps/authentication/models.py | ||
from django.db import models | ||
from django.contrib.auth import get_user_model | ||
import uuid | ||
from django.utils import timezone | ||
|
||
User = get_user_model() | ||
|
||
|
||
class PasswordResetToken(models.Model): | ||
user = models.ForeignKey(User, on_delete=models.CASCADE) | ||
token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) | ||
created_at = models.DateTimeField(auto_now_add=True) | ||
expires_at = models.DateTimeField() | ||
is_used = models.BooleanField(default=False) | ||
|
||
def __str__(self): | ||
return f"Password reset token for {self.user.email}" | ||
|
||
def is_valid(self): | ||
return not self.is_used and self.expires_at > timezone.now() | ||
|
||
class Meta: | ||
indexes = [ | ||
models.Index(fields=['token']), | ||
models.Index(fields=['user', 'is_used']), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
# authentication/serializers.py | ||
from rest_framework import serializers | ||
from django.contrib.auth import get_user_model | ||
from django.core.exceptions import ValidationError | ||
|
||
User = get_user_model() | ||
|
||
|
||
class RequestPasswordResetSerializer(serializers.Serializer): | ||
email = serializers.EmailField() | ||
|
||
def validate_email(self, value): | ||
if not User.objects.filter(email=value).exists(): | ||
raise ValidationError("No user found with this email address.") | ||
return value | ||
|
||
|
||
class ValidateTokenSerializer(serializers.Serializer): | ||
token = serializers.UUIDField() | ||
|
||
|
||
class ResetPasswordSerializer(serializers.Serializer): | ||
token = serializers.UUIDField() | ||
new_password = serializers.CharField(min_length=8, max_length=128) | ||
confirm_password = serializers.CharField(min_length=8, max_length=128) | ||
|
||
def validate(self, data): | ||
if data['new_password'] != data['confirm_password']: | ||
raise ValidationError("Passwords don't match.") | ||
return data |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
# authentication/services.py | ||
from django.core.mail import send_mail | ||
from django.conf import settings | ||
from django.utils import timezone | ||
from django.template.loader import render_to_string | ||
from datetime import timedelta | ||
import logging | ||
|
||
from .models import PasswordResetToken | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class PasswordResetService: | ||
TOKEN_EXPIRY_HOURS = 24 | ||
|
||
@staticmethod | ||
def create_reset_token(user): | ||
"""Create a new password reset token""" | ||
# Invalidate any existing tokens | ||
PasswordResetToken.objects.filter(user=user, is_used=False).update(is_used=True) | ||
|
||
# Create new token | ||
token = PasswordResetToken.objects.create( | ||
user=user, | ||
expires_at=timezone.now() + timedelta(hours=PasswordResetService.TOKEN_EXPIRY_HOURS) | ||
) | ||
return token | ||
|
||
@staticmethod | ||
def validate_token(token_str): | ||
"""Validate a password reset token""" | ||
try: | ||
token = PasswordResetToken.objects.get(token=token_str, is_used=False) | ||
return token.is_valid() | ||
except PasswordResetToken.DoesNotExist: | ||
return False | ||
|
||
@staticmethod | ||
def get_valid_token(token_str): | ||
"""Get a valid token object""" | ||
try: | ||
token = PasswordResetToken.objects.get(token=token_str, is_used=False) | ||
if token.is_valid(): | ||
return token | ||
return None | ||
except PasswordResetToken.DoesNotExist: | ||
return None | ||
|
||
@staticmethod | ||
def send_reset_email(user, token): | ||
"""Send password reset email""" | ||
try: | ||
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={token.token}" | ||
|
||
# Render email template | ||
context = { | ||
'user': user, | ||
'reset_url': reset_url, | ||
'expires_in': PasswordResetService.TOKEN_EXPIRY_HOURS | ||
} | ||
|
||
html_message = render_to_string('authentication/reset_password_email.html', context) | ||
plain_message = render_to_string('authentication/reset_password_email.txt', context) | ||
|
||
send_mail( | ||
subject='Reset Your Password', | ||
message=plain_message, | ||
from_email=settings.DEFAULT_FROM_EMAIL, | ||
recipient_list=[user.email], | ||
html_message=html_message | ||
) | ||
|
||
logger.info(f"Password reset email sent to {user.email}") | ||
return True | ||
|
||
except Exception as e: | ||
logger.error(f"Error sending password reset email: {str(e)}") | ||
return False | ||
|
||
@staticmethod | ||
def reset_password(token, new_password): | ||
"""Reset user's password""" | ||
token_obj = PasswordResetService.get_valid_token(token) | ||
if not token_obj: | ||
return False | ||
|
||
try: | ||
user = token_obj.user | ||
user.set_password(new_password) | ||
user.save() | ||
|
||
# Mark token as used | ||
token_obj.is_used = True | ||
token_obj.save() | ||
|
||
logger.info(f"Password reset successful for user {user.email}") | ||
return True | ||
|
||
except Exception as e: | ||
logger.error(f"Error resetting password: {str(e)}") | ||
return False |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
<!-- templates/authentication/reset_password_email.html --> | ||
<!DOCTYPE html> | ||
<html> | ||
<head> | ||
<style> | ||
.container { padding: 20px; } | ||
.button { | ||
background-color: #4CAF50; | ||
color: white; | ||
padding: 14px 20px; | ||
text-decoration: none; | ||
display: inline-block; | ||
} | ||
</style> | ||
</head> | ||
<body> | ||
<div class="container"> | ||
<h2>Reset Your Password</h2> | ||
<p>Hello {{ user.get_full_name|default:user.email }},</p> | ||
<p>We received a request to reset your password. Click the button below to reset it:</p> | ||
<p> | ||
<a href="{{ reset_url }}" class="button">Reset Password</a> | ||
</p> | ||
<p>This link will expire in {{ expires_in }} hours.</p> | ||
<p>If you didn't request this, please ignore this email.</p> | ||
</div> | ||
</body> | ||
</html> |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
# authentication/urls.py | ||
from django.urls import path | ||
from .views import RequestPasswordResetView, ValidateTokenView, ResetPasswordView | ||
|
||
urlpatterns = [ | ||
path('password/reset/request/', | ||
RequestPasswordResetView.as_view(), | ||
name='password-reset-request'), | ||
path('password/reset/validate/', | ||
ValidateTokenView.as_view(), | ||
name='password-reset-validate'), | ||
path('password/reset/confirm/', | ||
ResetPasswordView.as_view(), | ||
name='password-reset-confirm'), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
# apps/authentication/views.py | ||
from rest_framework import status | ||
from rest_framework.views import APIView | ||
from rest_framework.response import Response | ||
from django.contrib.auth import get_user_model | ||
from drf_spectacular.utils import extend_schema | ||
import logging | ||
|
||
from .serializers import ( | ||
RequestPasswordResetSerializer, | ||
ValidateTokenSerializer, | ||
ResetPasswordSerializer | ||
) | ||
from .services import PasswordResetService | ||
|
||
logger = logging.getLogger(__name__) | ||
User = get_user_model() | ||
|
||
|
||
class RequestPasswordResetView(APIView): | ||
serializer_class = RequestPasswordResetSerializer | ||
|
||
@extend_schema( | ||
summary="Request password reset", | ||
description="Send a password reset email to the user", | ||
responses={200: None} | ||
) | ||
def post(self, request): | ||
serializer = self.serializer_class(data=request.data) | ||
if serializer.is_valid(): | ||
try: | ||
user = User.objects.get(email=serializer.validated_data['email']) | ||
token = PasswordResetService.create_reset_token(user) | ||
|
||
if PasswordResetService.send_reset_email(user, token): | ||
return Response( | ||
{"message": "Password reset email sent successfully"}, | ||
status=status.HTTP_200_OK | ||
) | ||
else: | ||
return Response( | ||
{"error": "Failed to send reset email"}, | ||
status=status.HTTP_500_INTERNAL_SERVER_ERROR | ||
) | ||
|
||
except Exception as e: | ||
logger.error(f"Password reset request error: {str(e)}") | ||
return Response( | ||
{"error": "An error occurred processing your request"}, | ||
status=status.HTTP_500_INTERNAL_SERVER_ERROR | ||
) | ||
|
||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | ||
|
||
|
||
class ValidateTokenView(APIView): | ||
serializer_class = ValidateTokenSerializer | ||
|
||
@extend_schema( | ||
summary="Validate reset token", | ||
description="Check if a password reset token is valid", | ||
responses={200: None} | ||
) | ||
def post(self, request): | ||
serializer = self.serializer_class(data=request.data) | ||
if serializer.is_valid(): | ||
token = serializer.validated_data['token'] | ||
is_valid = PasswordResetService.validate_token(token) | ||
|
||
return Response({"is_valid": is_valid}, status=status.HTTP_200_OK) | ||
|
||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | ||
|
||
|
||
class ResetPasswordView(APIView): | ||
serializer_class = ResetPasswordSerializer | ||
|
||
@extend_schema( | ||
summary="Reset password", | ||
description="Reset user's password using a valid token", | ||
responses={200: None} | ||
) | ||
def post(self, request): | ||
serializer = self.serializer_class(data=request.data) | ||
if serializer.is_valid(): | ||
token = serializer.validated_data['token'] | ||
new_password = serializer.validated_data['new_password'] | ||
|
||
if PasswordResetService.reset_password(token, new_password): | ||
return Response( | ||
{"message": "Password reset successful"}, | ||
status=status.HTTP_200_OK | ||
) | ||
else: | ||
return Response( | ||
{"error": "Invalid or expired token"}, | ||
status=status.HTTP_400_BAD_REQUEST | ||
) | ||
|
||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) |
Oops, something went wrong.