From 6adfdbabe0d44c17f32abc9d48a6e252e2a0792e Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 17 Nov 2022 19:16:34 -0700 Subject: Add signup and password reset --- users/admin.py | 16 +++++- users/migrations/0004_passwordreset.py | 60 ++++++++++++++++++++++ users/models/__init__.py | 1 + users/models/password_reset.py | 92 ++++++++++++++++++++++++++++++++++ users/views/admin.py | 5 ++ users/views/auth.py | 81 ++++++++++++++++++++++++++++++ 6 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 users/migrations/0004_passwordreset.py create mode 100644 users/models/password_reset.py (limited to 'users') diff --git a/users/admin.py b/users/admin.py index 7c3750d..de07e5c 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,6 +1,14 @@ from django.contrib import admin -from users.models import Domain, Follow, Identity, InboxMessage, User, UserEvent +from users.models import ( + Domain, + Follow, + Identity, + InboxMessage, + PasswordReset, + User, + UserEvent, +) @admin.register(Domain) @@ -42,6 +50,12 @@ class FollowAdmin(admin.ModelAdmin): raw_id_fields = ["source", "target"] +@admin.register(PasswordReset) +class PasswordResetAdmin(admin.ModelAdmin): + list_display = ["id", "user", "created"] + raw_id_fields = ["user"] + + @admin.register(InboxMessage) class InboxMessageAdmin(admin.ModelAdmin): list_display = ["id", "state", "state_attempted", "message_type", "message_actor"] diff --git a/users/migrations/0004_passwordreset.py b/users/migrations/0004_passwordreset.py new file mode 100644 index 0000000..d996ff4 --- /dev/null +++ b/users/migrations/0004_passwordreset.py @@ -0,0 +1,60 @@ +# Generated by Django 4.1.3 on 2022-11-18 01:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import stator.models +import users.models.password_reset + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0003_user_last_seen_alter_identity_domain"), + ] + + operations = [ + migrations.CreateModel( + name="PasswordReset", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=True)), + ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_attempted", models.DateTimeField(blank=True, null=True)), + ("state_locked_until", models.DateTimeField(blank=True, null=True)), + ( + "state", + stator.models.StateField( + choices=[("new", "new"), ("sent", "sent")], + default="new", + graph=users.models.password_reset.PasswordResetStates, + max_length=100, + ), + ), + ("token", models.CharField(max_length=500, unique=True)), + ("new_account", models.BooleanField()), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="password_resets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 28d62b0..e46860e 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -3,5 +3,6 @@ from .domain import Domain # noqa from .follow import Follow, FollowStates # noqa from .identity import Identity, IdentityStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa +from .password_reset import PasswordReset # noqa from .user import User # noqa from .user_event import UserEvent # noqa diff --git a/users/models/password_reset.py b/users/models/password_reset.py new file mode 100644 index 0000000..90062d3 --- /dev/null +++ b/users/models/password_reset.py @@ -0,0 +1,92 @@ +import random +import string + +from asgiref.sync import sync_to_async +from django.conf import settings +from django.core.mail import send_mail +from django.db import models +from django.template.loader import render_to_string + +from core.models import Config +from stator.models import State, StateField, StateGraph, StatorModel + + +class PasswordResetStates(StateGraph): + new = State(try_interval=3) + sent = State() + + new.transitions_to(sent) + + @classmethod + async def handle_new(cls, instance: "PasswordReset"): + """ + Sends the password reset email. + """ + reset = await instance.afetch_full() + if reset.new_account: + await sync_to_async(send_mail)( + subject=f"{Config.system.site_name}: Confirm new account", + message=render_to_string( + "emails/new_account.txt", + { + "reset": reset, + "config": Config.system, + "settings": settings, + }, + ), + from_email=settings.EMAIL_FROM, + recipient_list=[reset.user.email], + ) + else: + await sync_to_async(send_mail)( + subject=f"{Config.system.site_name}: Reset password", + message=render_to_string( + "emails/password_reset.txt", + { + "reset": reset, + "config": Config.system, + "settings": settings, + }, + ), + from_email=settings.EMAIL_FROM, + recipient_list=[reset.user.email], + ) + return cls.sent + + +class PasswordReset(StatorModel): + """ + A password reset for a user (this is also how we create accounts) + """ + + state = StateField(PasswordResetStates) + + user = models.ForeignKey( + "users.user", + on_delete=models.CASCADE, + related_name="password_resets", + ) + + token = models.CharField(max_length=500, unique=True) + new_account = models.BooleanField() + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + @classmethod + def create_for_user(cls, user): + return cls.objects.create( + user=user, + token="".join(random.choice(string.ascii_lowercase) for i in range(42)), + new_account=not user.password, + ) + + ### Async helpers ### + + async def afetch_full(self): + """ + Returns a version of the object with all relations pre-loaded + """ + return await PasswordReset.objects.select_related( + "user", + ).aget(pk=self.pk) diff --git a/users/views/admin.py b/users/views/admin.py index d7f23e8..93bf4ec 100644 --- a/users/views/admin.py +++ b/users/views/admin.py @@ -62,6 +62,10 @@ class BasicPage(AdminSettingsPage): "title": "Site Banner", "help_text": "Must be at least 650px wide. 3:1 ratio of width:height recommended.", }, + "identity_max_per_user": { + "title": "Maximum Identities Per User", + "help_text": "Non-admins will be blocked from creating more than this", + }, } layout = { @@ -73,6 +77,7 @@ class BasicPage(AdminSettingsPage): "highlight_color", ], "Posts": ["post_length"], + "Identities": ["identity_max_per_user"], } diff --git a/users/views/auth.py b/users/views/auth.py index 1acf920..7d4040b 100644 --- a/users/views/auth.py +++ b/users/views/auth.py @@ -1,4 +1,10 @@ +from django import forms +from django.contrib.auth.password_validation import validate_password from django.contrib.auth.views import LoginView, LogoutView +from django.shortcuts import get_object_or_404, render +from django.views.generic import FormView + +from users.models import PasswordReset, User class Login(LoginView): @@ -8,3 +14,78 @@ class Login(LoginView): class Logout(LogoutView): pass + + +class Signup(FormView): + + template_name = "auth/signup.html" + + class form_class(forms.Form): + + email = forms.EmailField( + help_text="We will send a link to this email to set your password and create your account", + ) + + def clean_email(self): + email = self.cleaned_data.get("email").lower() + if not email: + return + if User.objects.filter(email=email).exists(): + raise forms.ValidationError("This email already has an account") + return email + + def form_valid(self, form): + user = User.objects.create(email=form.cleaned_data["email"]) + PasswordReset.create_for_user(user) + return render( + self.request, + "auth/signup_success.html", + {"email": user.email}, + ) + + +class Reset(FormView): + + template_name = "auth/reset.html" + + class form_class(forms.Form): + + password = forms.CharField( + widget=forms.PasswordInput, + help_text="Must be at least 8 characters, and contain both letters and numbers.", + ) + + repeat_password = forms.CharField( + widget=forms.PasswordInput, + ) + + def clean_password(self): + password = self.cleaned_data["password"] + validate_password(password) + return password + + def clean_repeat_password(self): + if self.cleaned_data.get("password") != self.cleaned_data.get( + "repeat_password" + ): + raise forms.ValidationError("Passwords do not match") + return self.cleaned_data.get("repeat_password") + + def dispatch(self, request, token): + self.reset = get_object_or_404(PasswordReset, token=token) + return super().dispatch(request) + + def form_valid(self, form): + self.reset.user.set_password(form.cleaned_data["password"]) + self.reset.user.save() + self.reset.delete() + return render( + self.request, + "auth/reset_success.html", + {"email": self.reset.user.email}, + ) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context["reset"] = self.reset + return context -- cgit v1.2.3