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