feat: implement password reset functionality with dedicated service layer
This commit is contained in:
parent
32d49f8d15
commit
f42ff47390
5 changed files with 131 additions and 9 deletions
46
auth_app/services.py
Normal file
46
auth_app/services.py
Normal 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()
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
]
|
]
|
||||||
16
templates/emails/password_reset_email.html
Executable file
16
templates/emails/password_reset_email.html
Executable 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>
|
||||||
Loading…
Reference in a new issue