diff --git a/auth_app/services.py b/auth_app/services.py new file mode 100644 index 0000000..81a3ec6 --- /dev/null +++ b/auth_app/services.py @@ -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() \ No newline at end of file diff --git a/auth_app/views.py b/auth_app/views.py index aa84dbd..b50ce03 100644 --- a/auth_app/views.py +++ b/auth_app/views.py @@ -9,6 +9,9 @@ from django.utils import timezone from .models import ExpiringToken, ExpiringTokenAuthentication from django.contrib.auth import get_user_model 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() @@ -39,7 +42,7 @@ class LoginView(APIView): if user: if not user.is_active: 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(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -54,8 +57,9 @@ class LogoutView(APIView): if token is None or not isinstance(token, ExpiringToken): return Response({"detail": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - if token.is_expired(): + if token.is_expired(): return Response({"error": "No active session or token already expired"}, status=status.HTTP_401_UNAUTHORIZED) + token.delete() return Response({"message": "Successfully logged out"}, status=status.HTTP_200_OK) @@ -70,13 +74,48 @@ class RegisterView(APIView): serialized = UserSerializer(data=request.data) if serialized.is_valid(): user = serialized.save() - user.set_password(password) user.is_active = True - user.save() + set_user_password(user, password) return Response({'message': 'User created.'}, status=status.HTTP_201_CREATED) 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): authentication_classes = [authentication.TokenAuthentication] @@ -86,6 +125,7 @@ class AuthView(APIView): try: token = ExpiringToken.objects.get(user=request.user) + except ExpiringToken.DoesNotExist: return Response({"error": "Token does not exist, please log in again"}, status=status.HTTP_401_UNAUTHORIZED) diff --git a/join/settings.py b/join/settings.py index 5f6e0fe..f968825 100644 --- a/join/settings.py +++ b/join/settings.py @@ -9,9 +9,10 @@ https://docs.djangoproject.com/en/5.1/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.1/ref/settings/ """ - +import os from pathlib import Path from datetime import timedelta +from environ import Env # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -72,7 +73,9 @@ ROOT_URLCONF = 'join.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [ + BASE_DIR / 'templates', + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -178,4 +181,20 @@ REST_FRAMEWORK = { 'DEFAULT_PARSER_CLASSES': ( 'djangorestframework_camel_case.parser.CamelCaseJSONParser', ), -} \ No newline at end of file +} + +# 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' diff --git a/join/urls.py b/join/urls.py index 9fd45ef..d4386ed 100644 --- a/join/urls.py +++ b/join/urls.py @@ -3,7 +3,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from task_app.views import TaskViewSet, SubTaskViewSet, AssignedTaskViewSet 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.register(r'tasks', TaskViewSet) @@ -19,5 +19,6 @@ urlpatterns = [ path('auth/login/', LoginView.as_view(), name='login'), path('auth/logout/', LogoutView.as_view(), name='logout'), 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'), ] \ No newline at end of file diff --git a/templates/emails/password_reset_email.html b/templates/emails/password_reset_email.html new file mode 100755 index 0000000..4d751f8 --- /dev/null +++ b/templates/emails/password_reset_email.html @@ -0,0 +1,16 @@ + +
+ + +Dear {{ first_name }} {{ last_name }},
+We recently received a request to reset your password.
+If you made this request, please click on the following link to reset your password:
+ +Please note that for security reasons, this link is only valid for a limited time.
+If you did not request a password reset, please ignore this email.
+Best regards,
+Your Join team!
+ +