Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Password resetting #74

Merged
merged 47 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
5fe0244
god help me
MaxIsJoe Jan 13, 2024
e7607bf
Update src/accounts/api/views.py
MaxIsJoe Jan 13, 2024
4b83cfa
fix save error
MaxIsJoe Jan 17, 2024
8d95ffb
remove account identifier from url
MaxIsJoe Jan 17, 2024
1234969
Update views.py
MaxIsJoe Jan 17, 2024
105cddf
fixes multiple arguments error
MaxIsJoe Jan 17, 2024
f20604b
random token
MaxIsJoe Jan 17, 2024
6264445
cacwka
MaxIsJoe Jan 17, 2024
c06af96
password resting works, woohoo!
MaxIsJoe Jan 17, 2024
833e69e
Update views.py
MaxIsJoe Jan 17, 2024
88614c8
token time lifespan
MaxIsJoe Jan 18, 2024
cab7dff
Update serializers.py
MaxIsJoe Jan 18, 2024
42b6a81
Update serializers.py
MaxIsJoe Jan 18, 2024
ad6fef7
Update models.py
MaxIsJoe Jan 18, 2024
edffbae
Update models.py
MaxIsJoe Jan 18, 2024
fd41b67
hour
MaxIsJoe Jan 18, 2024
395f686
view request changes
MaxIsJoe Jan 18, 2024
e9feb9f
name changes
MaxIsJoe Jan 18, 2024
498df54
remove some logs
MaxIsJoe Jan 18, 2024
4b60944
Update urls.py
MaxIsJoe Jan 18, 2024
eead438
Update urls.py
MaxIsJoe Jan 18, 2024
9c59243
Update urls.py
MaxIsJoe Jan 18, 2024
d8faa5e
precommit
MaxIsJoe Jan 18, 2024
69b8684
Update urls.py
MaxIsJoe Jan 18, 2024
34743a4
Update serializers.py
MaxIsJoe Jan 18, 2024
bde9f50
Update models.py
MaxIsJoe Jan 18, 2024
d32bef7
Update models.py
MaxIsJoe Jan 19, 2024
647c29a
e
MaxIsJoe Jan 19, 2024
ab501dc
email
MaxIsJoe Jan 21, 2024
7cb9285
Update views.py
MaxIsJoe Jan 21, 2024
6d03d36
Update views.py
MaxIsJoe Jan 21, 2024
341bf19
Update serializers.py
MaxIsJoe Jan 21, 2024
d83f73a
ee
MaxIsJoe Jan 21, 2024
95e1694
Update views.py
MaxIsJoe Jan 21, 2024
e0b3115
link correct
MaxIsJoe Jan 21, 2024
ada8c01
get email
MaxIsJoe Jan 21, 2024
27027ce
Update serializers.py
MaxIsJoe Jan 21, 2024
010ce67
password request
MaxIsJoe Jan 21, 2024
eed561a
Update views.py
MaxIsJoe Jan 21, 2024
c16d236
Update views.py
MaxIsJoe Jan 21, 2024
1cdbe5d
fixes an error with subscriptables
MaxIsJoe Jan 21, 2024
07fc838
Update src/accounts/api/serializers.py
MaxIsJoe Jan 22, 2024
1ccc17e
Merge remote-tracking branch 'upstream/develop' into forgetmepassword…
MaxIsJoe Jan 22, 2024
da3cef4
email working
MaxIsJoe Jan 22, 2024
9fcc842
Update serializers.py
MaxIsJoe Jan 22, 2024
7a04f96
Create 0001_squashed_0002_passwordresetrequest.py
MaxIsJoe Jan 23, 2024
f89154c
chore: make a single migration for the new model
corp-0 Jan 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/accounts/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import secrets

from django.conf import settings
from django.contrib.auth import authenticate
from django.utils import timezone
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 +84,36 @@ 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"]
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
MaxIsJoe marked this conversation as resolved.
Show resolved Hide resolved
corp-0 marked this conversation as resolved.
Show resolved Hide resolved

email = serializers.EmailField()

def validate(self, data):
email = data["email"]
account = Account.objects.filter(email=email).first()
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
if account is None:
raise serializers.ValidationError("Account with this email doesn't exist.")

# Create a new instance of PasswordResetRequestModel using the account's verification token
token = secrets.token_urlsafe(32)
new_model_data = {
"token": token,
"account": account,
"created_at": timezone.now() + timezone.timedelta(minutes=35),
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
}
return new_model_data
MaxIsJoe marked this conversation as resolved.
Show resolved Hide resolved

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"),
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
]
58 changes: 58 additions & 0 deletions src/accounts/api/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from uuid import uuid4

from central_command import settings
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.core.mail import send_mail
from knox.models import AuthToken
from knox.views import LoginView as KnoxLoginView
from rest_framework import status
Expand All @@ -13,8 +15,11 @@
from ..models import Account
from .serializers import (
LoginWithCredentialsSerializer,
PasswordResetRequestModel,
PublicAccountDataSerializer,
RegisterAccountSerializer,
ResetPasswordRequestSerializer,
ResetPasswordSerializer,
UpdateAccountSerializer,
VerifyAccountSerializer,
)
Expand Down Expand Up @@ -181,3 +186,56 @@ 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():
return Response({"error": "Invalid link or expired."}, status=status.HTTP_400_BAD_REQUEST)
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
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)
else:
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
return Response({"error": "Invalid link or expired."}, status=status.HTTP_400_BAD_REQUEST)
except PasswordResetRequestModel.DoesNotExist:
return Response({"error": "Invalid link or expired."}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
MaxIsJoe marked this conversation as resolved.
Show resolved Hide resolved

return Response({"detail": "Operation Done."}, status=status.HTTP_200_OK)


class RequestPasswordResetView(GenericAPIView):
permission_classes = (AllowAny,)
serializer_class = ResetPasswordRequestSerializer

def post(self, request):
serializer = self.serializer_class(data=request.data)
try:
serializer.is_valid(raise_exception=True)
except Exception:
# Don't tell the user about the error, just move on.
return Response(data={"detail": "Operation Done."}, status=status.HTTP_200_OK)
corp-0 marked this conversation as resolved.
Show resolved Hide resolved

serializer.save()
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
send_mail(
"Password Reset Request",
"A password reset request has been made for your account.\n"
+ "Please visit the following link to reset your password: "
+ settings.APP_URL
+ serializer.data["token"]
+ "\n\nIf you have not made this request, please ignore this email. The link will expire within 35 minutes.",
settings.EMAIL_FROM_ADDRESS,
[serializer.data["account"].email],
fail_silently=False,
)
return Response(data={"detail": "Operation Done."}, status=status.HTTP_200_OK)
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
23 changes: 23 additions & 0 deletions src/accounts/migrations/0002_passwordresetrequest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.22 on 2024-01-13 17:03

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='PasswordResetRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.UUIDField()),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
26 changes: 26 additions & 0 deletions src/accounts/migrations/0003_auto_20240113_1858.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.22 on 2024-01-13 18:58

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('accounts', '0002_passwordresetrequest'),
]

operations = [
migrations.CreateModel(
name='PasswordResetRequestModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.UUIDField()),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.DeleteModel(
name='PasswordResetRequest',
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.22 on 2024-01-18 20:03

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0003_auto_20240113_1858'),
]

operations = [
migrations.AlterField(
model_name='passwordresetrequestmodel',
name='token',
field=models.TextField(),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 3.2.22 on 2024-01-18 20:40

from django.db import migrations, models
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
('accounts', '0004_alter_passwordresetrequestmodel_token'),
]

operations = [
migrations.AddField(
model_name='passwordresetrequestmodel',
name='expiry_datetime',
field=models.DateTimeField(default=django.utils.timezone.now),
preserve_default=False,
),
]
24 changes: 24 additions & 0 deletions src/accounts/migrations/0006_auto_20240118_2053.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 3.2.22 on 2024-01-18 20:53

from django.db import migrations, models
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
('accounts', '0005_passwordresetrequestmodel_expiry_datetime'),
]

operations = [
migrations.RemoveField(
model_name='passwordresetrequestmodel',
name='expiry_datetime',
),
migrations.AddField(
model_name='passwordresetrequestmodel',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
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):
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
return timezone.now() <= timedelta(minutes=60)
1 change: 1 addition & 0 deletions src/central_command/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
EMAIL_ADDRESS = os.environ.get("EMAIL_HOST_USER")
EMAIL_FROM_ADDRESS = os.environ.get("EMAIL_HOST_USER")
EMAIL_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
EMAIL_PASSWORD_RESET = os.environ.get("WEBSITE_URL", "https://unitystation.org/reset-password/")
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
EMAIL_MAIL_SUBJECT = "Confirm your Unitystation account"
EMAIL_MAIL_HTML = "registration/confirmation_email.html"
EMAIL_PAGE_TEMPLATE = "confirm_template.html"
Expand Down