From 6adfdbabe0d44c17f32abc9d48a6e252e2a0792e Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 17 Nov 2022 19:16:34 -0700 Subject: [PATCH] Add signup and password reset --- .../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 %} +
+ {% csrf_token %} +
+ Reset Password +

You are resetting your password for {{ reset.user.email }}.

+

Please choose your new password below.

+ {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} +
+
+ +
+
+{% 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 %} +
+
+ Password Reset +

+ Your password for {{ email }} has been reset! +

+
+
+{% 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 %} +
+ {% csrf_token %} +
+ Create An Account + {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} +
+
+ +
+
+{% 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 %} +
+
+ Email Sent +

+ An email has been sent to {{ email }} - please follow + the link inside to finish creating your account. +

+
+
+{% 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