feat: implement password reset functionality with dedicated service layer

This commit is contained in:
Chneemann 2025-04-05 23:10:39 +02:00
parent 32d49f8d15
commit f42ff47390
5 changed files with 131 additions and 9 deletions

46
auth_app/services.py Normal file
View file

@ -0,0 +1,46 @@
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from django.contrib.auth.tokens import default_token_generator
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes, force_str
from django.contrib.auth import get_user_model
User = get_user_model()
def create_password_reset_link(request, user, token):
"""Creates the password reset link."""
uidb64 = urlsafe_base64_encode(force_bytes(user.pk))
return f"{request.META.get('HTTP_ORIGIN', '')}/pw-reset/{uidb64}/{token}/"
def send_password_reset_email(user, token, reset_link):
"""Sends the password reset e-mail."""
subject = 'Password Reset Request'
message = render_to_string('emails/password_reset_email.html', {
'first_name': user.first_name,
'last_name': user.last_name,
'token': token,
'reset_link': reset_link,
})
from_email = settings.DEFAULT_FROM_EMAIL
to_email = [user.email]
send_mail(subject, message, from_email, to_email, html_message=message)
def verify_password_reset_token(uidb64, token):
"""Verifies the password reset token."""
try:
uid = force_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
user = None
if user is not None and default_token_generator.check_token(user, token):
return user
return None
def set_user_password(user, password):
"""Sets the new password for the user."""
user.set_password(password)
user.save()

View file

@ -9,6 +9,9 @@ from django.utils import timezone
from .models import ExpiringToken, ExpiringTokenAuthentication from .models import ExpiringToken, ExpiringTokenAuthentication
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from user_app.serializers import UserSerializer from user_app.serializers import UserSerializer
from django.contrib.auth.tokens import default_token_generator
from .services import create_password_reset_link, send_password_reset_email, verify_password_reset_token, set_user_password
User = get_user_model() User = get_user_model()
@ -39,7 +42,7 @@ class LoginView(APIView):
if user: if user:
if not user.is_active: if not user.is_active:
return Response({'error': 'Account is inactive, please check your mails'}, status=status.HTTP_403_FORBIDDEN) return Response({'error': 'Account is inactive, please check your mails'}, status=status.HTTP_403_FORBIDDEN)
return self._create_token_response(user) return self.create_token_response(user)
return Response({'error': 'Unable to login with provided credentials.'}, status=status.HTTP_401_UNAUTHORIZED) return Response({'error': 'Unable to login with provided credentials.'}, status=status.HTTP_401_UNAUTHORIZED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -56,6 +59,7 @@ class LogoutView(APIView):
if token.is_expired(): if token.is_expired():
return Response({"error": "No active session or token already expired"}, status=status.HTTP_401_UNAUTHORIZED) return Response({"error": "No active session or token already expired"}, status=status.HTTP_401_UNAUTHORIZED)
token.delete() token.delete()
return Response({"message": "Successfully logged out"}, status=status.HTTP_200_OK) return Response({"message": "Successfully logged out"}, status=status.HTTP_200_OK)
@ -70,13 +74,48 @@ class RegisterView(APIView):
serialized = UserSerializer(data=request.data) serialized = UserSerializer(data=request.data)
if serialized.is_valid(): if serialized.is_valid():
user = serialized.save() user = serialized.save()
user.set_password(password)
user.is_active = True user.is_active = True
user.save() set_user_password(user, password)
return Response({'message': 'User created.'}, status=status.HTTP_201_CREATED) return Response({'message': 'User created.'}, status=status.HTTP_201_CREATED)
return Response(serialized.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serialized.errors, status=status.HTTP_400_BAD_REQUEST)
class PasswordResetView(APIView):
def post(self, request):
email = request.data.get('email')
if not email:
return Response({'error': 'Email is required.'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(email=email)
token = default_token_generator.make_token(user)
reset_link = create_password_reset_link(request, user, token)
send_password_reset_email(user, token, reset_link)
return Response({'message': 'Password reset email sent.'}, status=status.HTTP_200_OK)
except User.DoesNotExist:
return Response({'message': 'Password reset email sent.'}, status=status.HTTP_200_OK)
except Exception:
return Response({'error': 'Could not send password reset email.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class PasswordResetConfirmView(APIView):
def post(self, request):
password = request.data.get('password')
uidb64 = request.data.get('uid')
token = request.data.get('token')
if not password or not uidb64 or not token:
return Response({'error': 'Missing required parameters.'}, status=status.HTTP_400_BAD_REQUEST)
user = verify_password_reset_token(uidb64, token)
if user:
set_user_password(user, password)
return Response({'message': 'Password reset successful.'}, status=status.HTTP_200_OK)
else:
return Response({'error': 'Invalid reset link.'}, status=status.HTTP_406_NOT_ACCEPTABLE)
class AuthView(APIView): class AuthView(APIView):
authentication_classes = [authentication.TokenAuthentication] authentication_classes = [authentication.TokenAuthentication]
@ -86,6 +125,7 @@ class AuthView(APIView):
try: try:
token = ExpiringToken.objects.get(user=request.user) token = ExpiringToken.objects.get(user=request.user)
except ExpiringToken.DoesNotExist: except ExpiringToken.DoesNotExist:
return Response({"error": "Token does not exist, please log in again"}, status=status.HTTP_401_UNAUTHORIZED) return Response({"error": "Token does not exist, please log in again"}, status=status.HTTP_401_UNAUTHORIZED)

View file

@ -9,9 +9,10 @@ https://docs.djangoproject.com/en/5.1/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/ https://docs.djangoproject.com/en/5.1/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
from datetime import timedelta from datetime import timedelta
from environ import Env
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -72,7 +73,9 @@ ROOT_URLCONF = 'join.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [
BASE_DIR / 'templates',
],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@ -179,3 +182,19 @@ REST_FRAMEWORK = {
'djangorestframework_camel_case.parser.CamelCaseJSONParser', 'djangorestframework_camel_case.parser.CamelCaseJSONParser',
), ),
} }
# Environment variables
env = Env()
Env.read_env()
DEBUG = env.bool('DEBUG', default=False)
SECRET_KEY = env('SECRET_KEY')
# EMAIL
EMAIL_USE_TLS = True
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_PORT = 587
EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='')
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
DEFAULT_FROM_EMAIL = 'noreply@join.andre-kempf.com'

View file

@ -3,7 +3,7 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from task_app.views import TaskViewSet, SubTaskViewSet, AssignedTaskViewSet from task_app.views import TaskViewSet, SubTaskViewSet, AssignedTaskViewSet
from user_app.views import UserViewSet from user_app.views import UserViewSet
from auth_app.views import LoginView, LogoutView, AuthView, RegisterView from auth_app.views import LoginView, LogoutView, AuthView, RegisterView, PasswordResetView, PasswordResetConfirmView
router = DefaultRouter() router = DefaultRouter()
router.register(r'tasks', TaskViewSet) router.register(r'tasks', TaskViewSet)
@ -19,5 +19,6 @@ urlpatterns = [
path('auth/login/', LoginView.as_view(), name='login'), path('auth/login/', LoginView.as_view(), name='login'),
path('auth/logout/', LogoutView.as_view(), name='logout'), path('auth/logout/', LogoutView.as_view(), name='logout'),
path('auth/register/', RegisterView.as_view(), name='register'), path('auth/register/', RegisterView.as_view(), name='register'),
path('auth/reset/', PasswordResetView.as_view(), name='password_reset'),
path('auth/reset/confirm/', PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
] ]

View file

@ -0,0 +1,16 @@
<html>
<head>
<style>
body { font-family: Arial, sans-serif; }
</style>
<body>
<p>Dear {{ first_name }} {{ last_name }},</p>
<p>We recently received a request to reset your password.</p>
<p>If you made this request, please click on the following link to reset your password:</p>
<p><a href="{{ reset_link }}">Reset Password</a></p>
<p>Please note that for security reasons, this link is only valid for a limited time.</p>
<p>If you did not request a password reset, please ignore this email.</p>
<p>Best regards,</p>
<p>Your Join team!</p>
</body>
</html>