diff --git a/pyproject.toml b/pyproject.toml index 69bfae5..e8918c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ ignore = [ "S101", # it is annoying to annotate django Meta model properties as CalssVar for no reason "RUF012", + # treats a link with the word "password" in it as a hardcoded password + "S105", ] select = [ # pyflakes diff --git a/src/accounts/api/serializers.py b/src/accounts/api/serializers.py index 795fbf3..001cf39 100644 --- a/src/accounts/api/serializers.py +++ b/src/accounts/api/serializers.py @@ -1,9 +1,11 @@ +import secrets + from django.conf import settings from django.contrib.auth import authenticate from django_email_verification import sendConfirm from rest_framework import serializers -from ..models import Account +from ..models import Account, PasswordResetRequestModel class PublicAccountDataSerializer(serializers.ModelSerializer): @@ -81,3 +83,32 @@ def validate(self, data): "Verification token seems invalid or maybe outdated. Try requesting a new one." ) return account + + +class ResetPasswordSerializer(serializers.ModelSerializer): + class Meta: + model = Account + fields = ("password",) + extra_kwargs = {"password": {"write_only": True}} + + +class ResetPasswordRequestSerializer(serializers.ModelSerializer): + class Meta: + model = PasswordResetRequestModel + fields = ("email",) + + email = serializers.EmailField() + + def validate(self, data): + email = data["email"] + account = Account.objects.get(email=email) + if account is None: + raise serializers.ValidationError("Account with this email doesn't exist.") + + return { + "token": secrets.token_urlsafe(32), + "account": account, + } + + def create(self, validated_data): + return PasswordResetRequestModel.objects.create(**validated_data) diff --git a/src/accounts/api/urls.py b/src/accounts/api/urls.py index f8c0849..e51fab9 100644 --- a/src/accounts/api/urls.py +++ b/src/accounts/api/urls.py @@ -6,7 +6,9 @@ LoginWithTokenView, PublicAccountDataView, RegisterAccountView, + RequestPasswordResetView, RequestVerificationTokenView, + ResetPasswordView, UpdateAccountView, VerifyAccountView, ) @@ -32,4 +34,6 @@ name="request-verification-token", ), path("verify-account", VerifyAccountView.as_view(), name="verify-account"), + path("reset-password/", ResetPasswordView.as_view(), name="reset-password-token"), + path("reset-password/", RequestPasswordResetView.as_view(), name="reset-password"), ] diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 7506f45..db16450 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -1,6 +1,9 @@ from uuid import uuid4 +from central_command import settings from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.core.mail import EmailMessage +from django.template.loader import render_to_string from knox.models import AuthToken from knox.views import LoginView as KnoxLoginView from rest_framework import status @@ -13,8 +16,11 @@ from ..models import Account from .serializers import ( LoginWithCredentialsSerializer, + PasswordResetRequestModel, PublicAccountDataSerializer, RegisterAccountSerializer, + ResetPasswordRequestSerializer, + ResetPasswordSerializer, UpdateAccountSerializer, VerifyAccountSerializer, ) @@ -181,3 +187,53 @@ def post(self, request): public_data = PublicAccountDataSerializer(account).data return Response(public_data, status=status.HTTP_200_OK) + + +class ResetPasswordView(GenericAPIView): + permission_classes = (AllowAny,) + serializer_class = ResetPasswordSerializer + + def post(self, request, reset_token): + serializer = self.serializer_class(data=request.data) + try: + if serializer.is_valid(raise_exception=True): + reset_request = PasswordResetRequestModel.objects.get(token=reset_token) + if not reset_request.is_token_valid(): + raise PasswordResetRequestModel.DoesNotExist + account = reset_request.account + account.set_password(serializer.validated_data["password"]) + account.save() + reset_request.delete() + return Response(data={"detail": "Changed password succesfully"}, status=status.HTTP_200_OK) + except PasswordResetRequestModel.DoesNotExist: + return Response({"error": "Invalid link or expired."}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"detail": "Operation Done."}, status=status.HTTP_200_OK) + + +class RequestPasswordResetView(GenericAPIView): + permission_classes = (AllowAny,) + serializer_class = ResetPasswordRequestSerializer + + def send_email(self, recipient: str, context: dict) -> None: + email_subject = "Unitystation: Password Reset Request" + email_body = render_to_string("password_reset.html", context) + + email = EmailMessage(email_subject, email_body, settings.EMAIL_HOST_USER, [recipient]) + email.content_subtype = "html" + email.send() + + def post(self, request): + serializer = self.serializer_class(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except ValidationError: + # Don't tell the user about the error, just move on. + return Response(data={"detail": "Operation Done."}, status=status.HTTP_200_OK) + + serializer.save() + self.send_email( + recipient=serializer.validated_data["account"].email, + context={"link": f"{settings.PASS_RESET_LINK}{serializer.validated_data['token']}"}, + ) + return Response(data={"detail": "Operation Done."}, status=status.HTTP_200_OK) diff --git a/src/accounts/migrations/0002_passwordresetrequestmodel.py b/src/accounts/migrations/0002_passwordresetrequestmodel.py new file mode 100644 index 0000000..86e668e --- /dev/null +++ b/src/accounts/migrations/0002_passwordresetrequestmodel.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.23 on 2024-01-23 23:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PasswordResetRequestModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/accounts/models.py b/src/accounts/models.py index 3ec431b..7ebb701 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -1,7 +1,9 @@ +from datetime import timedelta from uuid import uuid4 from django.contrib.auth.models import AbstractUser from django.db import models +from django.utils import timezone from .validators import AccountNameValidator, UsernameValidator @@ -69,3 +71,15 @@ def save(self, *args, **kwargs): def __str__(self): return f"{self.unique_identifier} as {self.username}" + + +class PasswordResetRequestModel(models.Model): + token = models.TextField() + account = models.ForeignKey(Account, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.token} as {self.account} created at {self.created_at}" + + def is_token_valid(self): + return timezone.now() <= timedelta(minutes=60) diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 4d5a060..66dc9a4 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -176,3 +176,5 @@ # Whitenoise statics compression and caching STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +PASS_RESET_LINK = "https://unitystation.org/reset-password/" diff --git a/src/templates/password_reset.html b/src/templates/password_reset.html new file mode 100644 index 0000000..9a99bb8 --- /dev/null +++ b/src/templates/password_reset.html @@ -0,0 +1,46 @@ + + + + Unitystation - Password Reset + + + +
+

Password Reset Request

+

Reset password here

+

If you did not initiate this request, please disregard this email, or contact us for support if you feel this is an error.

+

Need help? Join our community on Discord for support and interaction.

+

Thank you for playing unitystation,

+

The Unitystation Team

+
+ + +