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
---
.../migrations/0009_alter_postattachment_file.py | 28 +++++++
core/apps.py | 3 +
core/context.py | 2 +-
core/migrations/0002_alter_config_image.py | 28 +++++++
core/models/config.py | 11 +--
static/css/style.css | 26 ++++--
takahe/settings/base.py | 5 ++
takahe/settings/development.py | 2 +
takahe/urls.py | 6 +-
templates/activities/_menu.html | 4 +-
templates/auth/reset.html | 20 +++++
templates/auth/reset_success.html | 14 ++++
templates/auth/signup.html | 18 +++++
templates/auth/signup_success.html | 15 ++++
templates/emails/new_account.txt | 8 ++
templates/emails/password_reset.txt | 8 ++
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 +++++++++++++++++++
22 files changed, 435 insertions(+), 18 deletions(-)
create mode 100644 activities/migrations/0009_alter_postattachment_file.py
create mode 100644 core/migrations/0002_alter_config_image.py
create mode 100644 templates/auth/reset.html
create mode 100644 templates/auth/reset_success.html
create mode 100644 templates/auth/signup.html
create mode 100644 templates/auth/signup_success.html
create mode 100644 templates/emails/new_account.txt
create mode 100644 templates/emails/password_reset.txt
create mode 100644 users/migrations/0004_passwordreset.py
create mode 100644 users/models/password_reset.py
diff --git a/activities/migrations/0009_alter_postattachment_file.py b/activities/migrations/0009_alter_postattachment_file.py
new file mode 100644
index 0000000..0a250c3
--- /dev/null
+++ b/activities/migrations/0009_alter_postattachment_file.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.1.3 on 2022-11-18 01:40
+
+import functools
+
+from django.db import migrations, models
+
+import core.uploads
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("activities", "0008_postattachment"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="postattachment",
+ name="file",
+ field=models.FileField(
+ blank=True,
+ null=True,
+ upload_to=functools.partial(
+ core.uploads.upload_namer, *("attachments",), **{}
+ ),
+ ),
+ ),
+ ]
diff --git a/core/apps.py b/core/apps.py
index 6098f6b..54693d5 100644
--- a/core/apps.py
+++ b/core/apps.py
@@ -9,4 +9,7 @@ class CoreConfig(AppConfig):
name = "core"
def ready(self) -> None:
+ from core.models import Config
+
+ Config.system = Config.load_system()
jsonld.set_document_loader(builtin_document_loader)
diff --git a/core/context.py b/core/context.py
index a4aabf5..1db3436 100644
--- a/core/context.py
+++ b/core/context.py
@@ -3,7 +3,7 @@ from core.models import Config
def config_context(request):
return {
- "config": Config.load_system(),
+ "config": Config.system,
"config_identity": (
Config.load_identity(request.identity) if request.identity else None
),
diff --git a/core/migrations/0002_alter_config_image.py b/core/migrations/0002_alter_config_image.py
new file mode 100644
index 0000000..86dcebb
--- /dev/null
+++ b/core/migrations/0002_alter_config_image.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.1.3 on 2022-11-18 01:40
+
+import functools
+
+from django.db import migrations, models
+
+import core.uploads
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="config",
+ name="image",
+ field=models.ImageField(
+ blank=True,
+ null=True,
+ upload_to=functools.partial(
+ core.uploads.upload_namer, *("config",), **{}
+ ),
+ ),
+ ),
+ ]
diff --git a/core/models/config.py b/core/models/config.py
index 021bf67..4ba8375 100644
--- a/core/models/config.py
+++ b/core/models/config.py
@@ -5,7 +5,6 @@ import pydantic
from django.core.files import File
from django.db import models
from django.templatetags.static import static
-from django.utils.functional import classproperty
from core.uploads import upload_namer
from takahe import __version__
@@ -54,11 +53,6 @@ class Config(models.Model):
("key", "user", "identity"),
]
- @classproperty
- def system(cls):
- cls.system = cls.load_system()
- return cls.system
-
system: ClassVar["Config.ConfigOptions"] # type: ignore
@classmethod
@@ -160,13 +154,16 @@ class Config(models.Model):
version: str = __version__
- site_name: str = "takahē"
+ site_name: str = "Takahē"
highlight_color: str = "#449c8c"
site_about: str = "
Welcome!
\n\nThis is a community running Takahē."
site_icon: UploadedImage = static("img/icon-128.png")
site_banner: UploadedImage = static("img/fjords-banner-600.jpg")
+ allow_signups: bool = False
+
post_length: int = 500
+ identity_max_per_user: int = 5
identity_max_age: int = 24 * 60 * 60
class UserOptions(pydantic.BaseModel):
diff --git a/static/css/style.css b/static/css/style.css
index d7b561e..43d4448 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -136,6 +136,7 @@ header .logo {
font-weight: bold;
background: var(--color-highlight);
border-radius: 5px 0 0 0;
+ text-transform: lowercase;
padding: 10px 11px 9px 10px;
height: 50px;
font-size: 130%;
@@ -198,6 +199,12 @@ header menu a.identity {
width: 250px;
}
+header menu a.identity i {
+ display: inline-block;
+ vertical-align: middle;
+ padding: 0 7px 2px 0;
+}
+
header menu a img {
display: inline-block;
vertical-align: middle;
@@ -287,7 +294,7 @@ nav a i {
/* Icon menus */
-.icon-menu>a {
+.icon-menu .option {
display: block;
margin: 0px 0 20px 0;
background: var(--color-bg-box);
@@ -299,19 +306,28 @@ nav a i {
border-radius: 3px;
}
-.icon-menu>a:hover {
+.icon-menu .option:hover {
border: 2px solid var(--color-highlight);
}
-.icon-menu>a img,
-.icon-menu>a i {
+.icon-menu .option.empty {
+ color: var(--color-text-dull);
+}
+
+.icon-menu .option.empty:hover {
+ border: 0;
+ border: 2px solid rgba(255, 255, 255, 0);
+}
+
+.icon-menu .option img,
+.icon-menu .option i {
vertical-align: middle;
margin: 0 10px 3px 0;
height: 50px;
width: auto;
}
-.icon-menu>a i {
+.icon-menu .option i {
display: inline-block;
text-align: center;
width: 50px;
diff --git a/takahe/settings/base.py b/takahe/settings/base.py
index dd89818..04a43dd 100644
--- a/takahe/settings/base.py
+++ b/takahe/settings/base.py
@@ -108,6 +108,11 @@ STATICFILES_DIRS = [
ALLOWED_HOSTS = ["*"]
+MAIN_DOMAIN = os.environ["TAKAHE_MAIN_DOMAIN"]
+if "/" in MAIN_DOMAIN:
+ print("TAKAHE_MAIN_DOMAIN should be just the domain name - no https:// or path")
+
+EMAIL_FROM = os.environ["TAKAHE_EMAIL_FROM"]
# Note that this MUST be a fully qualified URL in production
MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/")
diff --git a/takahe/settings/development.py b/takahe/settings/development.py
index 051b4fb..30f74a0 100644
--- a/takahe/settings/development.py
+++ b/takahe/settings/development.py
@@ -16,3 +16,5 @@ CSRF_TRUSTED_ORIGINS = [
"http://127.0.0.1:8000",
"https://127.0.0.1:8000",
]
+
+EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
diff --git a/takahe/urls.py b/takahe/urls.py
index 0b23d7d..5c0b182 100644
--- a/takahe/urls.py
+++ b/takahe/urls.py
@@ -82,8 +82,10 @@ urlpatterns = [
path("@/posts//boost/", posts.Boost.as_view()),
path("@/posts//unboost/", posts.Boost.as_view(undo=True)),
# Authentication
- path("auth/login/", auth.Login.as_view()),
- path("auth/logout/", auth.Logout.as_view()),
+ path("auth/login/", auth.Login.as_view(), name="login"),
+ path("auth/logout/", auth.Logout.as_view(), name="logout"),
+ path("auth/signup/", auth.Signup.as_view(), name="signup"),
+ path("auth/reset//", auth.Reset.as_view(), name="password_reset"),
# Identity selection
path("@/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view()),
diff --git a/templates/activities/_menu.html b/templates/activities/_menu.html
index a671712..6b40197 100644
--- a/templates/activities/_menu.html
+++ b/templates/activities/_menu.html
@@ -23,11 +23,11 @@
Settings
{% else %}
-
+
Local Posts
-
+
Create Account
{% endif %}
diff --git a/templates/auth/reset.html b/templates/auth/reset.html
new file mode 100644
index 0000000..42eced9
--- /dev/null
+++ b/templates/auth/reset.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+
+{% block title %}Reset Password{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/templates/auth/reset_success.html b/templates/auth/reset_success.html
new file mode 100644
index 0000000..001e5d7
--- /dev/null
+++ b/templates/auth/reset_success.html
@@ -0,0 +1,14 @@
+{% extends "base.html" %}
+
+{% block title %}Password Reset{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/templates/auth/signup.html b/templates/auth/signup.html
new file mode 100644
index 0000000..d519476
--- /dev/null
+++ b/templates/auth/signup.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+
+{% block title %}Create Account{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/templates/auth/signup_success.html b/templates/auth/signup_success.html
new file mode 100644
index 0000000..20fc7c2
--- /dev/null
+++ b/templates/auth/signup_success.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+
+{% block title %}Email Sent{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/templates/emails/new_account.txt b/templates/emails/new_account.txt
new file mode 100644
index 0000000..73c7fa4
--- /dev/null
+++ b/templates/emails/new_account.txt
@@ -0,0 +1,8 @@
+Your email address was used to create a new account at {{config.site_name}} (https://{{settings.MAIN_DOMAIN}}).
+
+To confirm your new account, go to this link:
+
+https://{{settings.MAIN_DOMAIN}}/auth/reset/{{reset.token}}/
+
+If this was not you, then please ignore this message - your email will not be
+used to make an account if this link is not visited.
diff --git a/templates/emails/password_reset.txt b/templates/emails/password_reset.txt
new file mode 100644
index 0000000..989960f
--- /dev/null
+++ b/templates/emails/password_reset.txt
@@ -0,0 +1,8 @@
+A password reset was requested for your account ({{reset.user.email}}) at {{Config.system.site_name}} (https://{{settings.MAIN_DOMAIN}}).
+
+To reset your password, go to this link:
+
+https://{{settings.MAIN_DOMAIN}}/auth/reset/{{reset.token}}/
+
+If this was not you, then please ignore this message - your password will not be
+reset if this link is not visited.
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