Skip to content

Commit

Permalink
feat: Add authentication app with password reset functionality and up…
Browse files Browse the repository at this point in the history
…date train line constants
  • Loading branch information
AhmedNassar7 committed Feb 23, 2025
1 parent 184deee commit 72be356
Show file tree
Hide file tree
Showing 31 changed files with 1,248 additions and 98 deletions.
Empty file added apps/authentication/__init__.py
Empty file.
Empty file added apps/authentication/admin.py
Empty file.
6 changes: 6 additions & 0 deletions apps/authentication/apps.py
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"
55 changes: 55 additions & 0 deletions apps/authentication/migrations/0001_initial.py
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.
27 changes: 27 additions & 0 deletions apps/authentication/models.py
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']),
]
30 changes: 30 additions & 0 deletions apps/authentication/serializers.py
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
102 changes: 102 additions & 0 deletions apps/authentication/services.py
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 added apps/authentication/signals.py
Empty file.
28 changes: 28 additions & 0 deletions apps/authentication/templates/reset_password_email.html
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 added apps/authentication/tests.py
Empty file.
15 changes: 15 additions & 0 deletions apps/authentication/urls.py
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'),
]
100 changes: 100 additions & 0 deletions apps/authentication/views.py
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)
Loading

0 comments on commit 72be356

Please sign in to comment.