Skip to content

Commit

Permalink
Password resetting (#74)
Browse files Browse the repository at this point in the history
* god help me

* Update src/accounts/api/views.py

Co-authored-by: Gilles <[email protected]>

* fix save error

* remove account identifier from url

* Update views.py

* fixes multiple arguments error

* random token

* cacwka

* password resting works, woohoo!

* Update views.py

* token time lifespan

* Update serializers.py

* Update serializers.py

* Update models.py

* Update models.py

* hour

* view request changes

* name changes

* remove some logs

* Update urls.py

* Update urls.py

* Update urls.py

* precommit

* Update urls.py

* Update serializers.py

* Update models.py

* Update models.py

* e

* email

* Update views.py

* Update views.py

* Update serializers.py

* ee

* Update views.py

* link correct

* get email

* Update serializers.py

* password request

* Update views.py

* Update views.py

* fixes an error with subscriptables

* Update src/accounts/api/serializers.py

Co-authored-by: Gilles <[email protected]>

* email working

* Update serializers.py

* Create 0001_squashed_0002_passwordresetrequest.py

* chore: make a single migration for the new model

---------

Co-authored-by: Gilles <[email protected]>
  • Loading branch information
MaxIsJoe and corp-0 authored Jan 23, 2024
1 parent ff28fc2 commit 49e44c6
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 1 deletion.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion src/accounts/api/serializers.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions src/accounts/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
LoginWithTokenView,
PublicAccountDataView,
RegisterAccountView,
RequestPasswordResetView,
RequestVerificationTokenView,
ResetPasswordView,
UpdateAccountView,
VerifyAccountView,
)
Expand All @@ -32,4 +34,6 @@
name="request-verification-token",
),
path("verify-account", VerifyAccountView.as_view(), name="verify-account"),
path("reset-password/<str:reset_token>", ResetPasswordView.as_view(), name="reset-password-token"),
path("reset-password/", RequestPasswordResetView.as_view(), name="reset-password"),
]
56 changes: 56 additions & 0 deletions src/accounts/api/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,8 +16,11 @@
from ..models import Account
from .serializers import (
LoginWithCredentialsSerializer,
PasswordResetRequestModel,
PublicAccountDataSerializer,
RegisterAccountSerializer,
ResetPasswordRequestSerializer,
ResetPasswordSerializer,
UpdateAccountSerializer,
VerifyAccountSerializer,
)
Expand Down Expand Up @@ -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)
24 changes: 24 additions & 0 deletions src/accounts/migrations/0002_passwordresetrequestmodel.py
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
14 changes: 14 additions & 0 deletions src/accounts/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions src/central_command/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,5 @@

# Whitenoise statics compression and caching
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"

PASS_RESET_LINK = "https://unitystation.org/reset-password/"
46 changes: 46 additions & 0 deletions src/templates/password_reset.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<title>Unitystation - Password Reset</title>
<style>
body {
font-family: Arial, sans-serif;
color: #333333;
background-color: #f4f4f4;
padding: 20px;
}
.container {
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.1);
}
a.confirm-button {
background-color: #4A5568; /* bg-gray-700 equivalent */
color: white;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-block;
border-radius: 5px;
}
.footer {
font-size: small;
color: #666666;
}
</style>
</head>
<body>
<div class="container">
<h1>Password Reset Request</h1>
<p><a href="{{ link }}" class="confirm-button">Reset password here</a></p>
<p>If you did not initiate this request, please disregard this email, or contact us for support if you feel this is an error.</p>
<p>Need help? Join our community on <a href="https://discord.com/invite/tFcTpBp">Discord</a> for support and interaction.</p>
<p>Thank you for playing unitystation,</p>
<p>The Unitystation Team</p>
</div>
<div class="footer">
<p>This is an automated email, please do not reply. If you need to contact us, email us at <a href="mailto:[email protected]">[email protected]</a>.</p>
</div>
</body>
</html>

0 comments on commit 49e44c6

Please sign in to comment.