summaryrefslogtreecommitdiffstats
path: root/users
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-17 19:16:34 -0700
committerAndrew Godwin2022-11-17 19:16:34 -0700
commit6adfdbabe0d44c17f32abc9d48a6e252e2a0792e (patch)
tree6644c5eeab7970a9f9b8d9540b7ebe28cc499331 /users
parent2a3690d1c148da5dd799052403ba7290e1fb7de0 (diff)
downloadtakahe-6adfdbabe0d44c17f32abc9d48a6e252e2a0792e.tar.gz
takahe-6adfdbabe0d44c17f32abc9d48a6e252e2a0792e.tar.bz2
takahe-6adfdbabe0d44c17f32abc9d48a6e252e2a0792e.zip
Add signup and password reset
Diffstat (limited to 'users')
-rw-r--r--users/admin.py16
-rw-r--r--users/migrations/0004_passwordreset.py60
-rw-r--r--users/models/__init__.py1
-rw-r--r--users/models/password_reset.py92
-rw-r--r--users/views/admin.py5
-rw-r--r--users/views/auth.py81
6 files changed, 254 insertions, 1 deletions
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