From ec3070e597f3b55289c99e587ea082c7f5566cbf Mon Sep 17 00:00:00 2001 From: Matt Layman Date: Fri, 10 Jan 2025 00:01:06 -0500 Subject: [PATCH] Start the signin form and background job. --- docs/signup.md | 27 ++++++++++++++++ journal/accounts/forms.py | 18 +++++++++++ journal/accounts/tasks.py | 10 +++++- journal/accounts/tests/test_forms.py | 47 ++++++++++++++++++++++++++++ journal/accounts/tests/test_views.py | 8 +++++ journal/accounts/views.py | 8 ++++- journal/core/tests/test_views.py | 7 +++++ journal/core/views.py | 15 +++++++-- project/settings.py | 10 +++++- project/urls.py | 4 +++ pyproject.toml | 1 + templates/accounts/check_email.html | 13 ++++++++ uv.lock | 14 +++++++++ 13 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 docs/signup.md create mode 100644 journal/accounts/forms.py create mode 100644 journal/accounts/tests/test_forms.py create mode 100644 templates/accounts/check_email.html diff --git a/docs/signup.md b/docs/signup.md new file mode 100644 index 0000000..2e49479 --- /dev/null +++ b/docs/signup.md @@ -0,0 +1,27 @@ +# Sign up and log in + +- Single stream sign up or log in +- No password +- Log in via magic links (expiration on the links) +- Extend the session to 365 days + +## Benefits + +- No passwords... no password reset flows +- Not multiple places to get into the site. + +## Drawbacks + +- A lot more emails +- Bots could be a problem + +## Implementation + +- https://github.com/aaugustin/django-sesame for the magic links +- custom signin page + - If a user doesn't exist, create one + - Magic a magic link for the user + - Email the link + - Redirect to a "check your email" page +- Remove allauth +- Account migration... invalidate existing passwords diff --git a/journal/accounts/forms.py b/journal/accounts/forms.py new file mode 100644 index 0000000..d5f11c6 --- /dev/null +++ b/journal/accounts/forms.py @@ -0,0 +1,18 @@ +import uuid + +from django import forms + +from .models import User +from .tasks import generate_magic_link + + +class SiginForm(forms.Form): + email = forms.EmailField() + + def save(self): + """Create a user if it doesn't exist, then trigger the magic link job.""" + email = self.cleaned_data["email"] + user, _ = User.objects.get_or_create( + email=email, defaults={"username": str(uuid.uuid4())} + ) + generate_magic_link(user.id) diff --git a/journal/accounts/tasks.py b/journal/accounts/tasks.py index eec3841..22f11e5 100644 --- a/journal/accounts/tasks.py +++ b/journal/accounts/tasks.py @@ -2,7 +2,7 @@ from django.utils import timezone from huey import crontab -from huey.contrib.djhuey import db_periodic_task +from huey.contrib.djhuey import db_periodic_task, db_task from . import constants from .models import Account @@ -20,3 +20,11 @@ def expire_trials(): ) count = expired_trials.update(status=Account.Status.TRIAL_EXPIRED) print(f"Expired {count} trial(s)") + + +@db_task() +def generate_magic_link(user_id): + """Generate magic link and send email.""" + # FIXME: fetch the user + # FIXME: generate the link + # FIXME: send the magic link email diff --git a/journal/accounts/tests/test_forms.py b/journal/accounts/tests/test_forms.py new file mode 100644 index 0000000..7a2cd92 --- /dev/null +++ b/journal/accounts/tests/test_forms.py @@ -0,0 +1,47 @@ +from journal.accounts.forms import SiginForm +from journal.accounts.models import User + + +class TestSigninForm: + def test_create_user(self): + """A non-existing email creates a new User record.""" + # Check the username uniqueness constraint. + User.objects.create(email="somethingelse@somewhere.com") + email = "newuser@somewhere.com" + data = {"email": email} + form = SiginForm(data=data) + is_valid = form.is_valid() + + form.save() + # Ensure only 1 account is created. + form.save() + + assert is_valid + assert User.objects.filter(email=email).count() == 1 + + def test_existing_user(self): + """When a user account exists for an email, use that user.""" + user = User.objects.create(email="test@testing.com") + data = {"email": user.email} + form = SiginForm(data=data) + is_valid = form.is_valid() + + form.save() + + assert is_valid + assert User.objects.filter(email=user.email).count() == 1 + + def test_triggers_signin_link_task(self): + """The magic link job fires.""" + + # FIXME: assert that the outbox has 1 email in it *to* the right email address. + + def test_invalid_email(self): + """An invalid email is rejected.""" + data = {"email": "not-an-email"} + form = SiginForm(data=data) + + is_valid = form.is_valid() + + assert not is_valid + assert "valid email" in form.errors["email"][0] diff --git a/journal/accounts/tests/test_views.py b/journal/accounts/tests/test_views.py index cea280b..776cad3 100644 --- a/journal/accounts/tests/test_views.py +++ b/journal/accounts/tests/test_views.py @@ -5,6 +5,14 @@ from journal.accounts.tests.factories import UserFactory +class TestCheckEmail: + def test_unauthenticated(self, client): + """An unauthenticated user gets a valid response.""" + response = client.get(reverse("check-email")) + + assert response.status_code == 200 + + class TestCreateCheckoutSession: def test_unauthenticated(self, client): """Only allow authenticated users.""" diff --git a/journal/accounts/views.py b/journal/accounts/views.py index 267021a..4f54e7f 100644 --- a/journal/accounts/views.py +++ b/journal/accounts/views.py @@ -1,7 +1,7 @@ import json from denied.authorizers import any_authorized -from denied.decorators import authorize +from denied.decorators import allow, authorize from django.http import JsonResponse from django.shortcuts import render from django.views.decorators.http import require_POST @@ -9,6 +9,12 @@ from journal.payments.gateway import PaymentsGateway +@allow +def check_email(request): + """This is the landing page after someone attempts to signin.""" + return render(request, "accounts/check_email.html", {}) + + @authorize(any_authorized) @require_POST def create_checkout_session(request): diff --git a/journal/core/tests/test_views.py b/journal/core/tests/test_views.py index f77f372..d3beb8a 100644 --- a/journal/core/tests/test_views.py +++ b/journal/core/tests/test_views.py @@ -34,6 +34,13 @@ def test_authenticated(self, client): assert response.status_code == 200 + def test_signin(self, client): + """A happy post redirect to the check email page.""" + data = {"email": "test@testing.com"} + response = client.post(reverse("index"), data=data) + + assert response.status_code == 302 + class TestTerms: def test_unauthenticated(self, client): diff --git a/journal/core/views.py b/journal/core/views.py index 4b2b6b6..08af6d3 100644 --- a/journal/core/views.py +++ b/journal/core/views.py @@ -1,8 +1,9 @@ from denied.decorators import allow from django.http import HttpRequest, HttpResponse -from django.shortcuts import render +from django.shortcuts import redirect, render, reverse from journal.accounts import constants +from journal.accounts.forms import SiginForm @allow @@ -22,10 +23,20 @@ def faq(request: HttpRequest) -> HttpResponse: @allow def index(request: HttpRequest) -> HttpResponse: """The entry point for the website.""" - context = {"trial_days": constants.TRIAL_DAYS} + context: dict = {"trial_days": constants.TRIAL_DAYS} template_name = "core/index_unauthenticated.html" + + form = SiginForm() + if request.method == "POST": + form = SiginForm(request.POST) + if form.is_valid(): + form.save() + return redirect(reverse("check-email")) + if request.user.is_authenticated: template_name = "core/index.html" + + context["form"] = form return render(request, template_name, context) diff --git a/project/settings.py b/project/settings.py index 1de06f3..d65e168 100644 --- a/project/settings.py +++ b/project/settings.py @@ -132,7 +132,7 @@ AUTH_USER_MODEL = "accounts.User" AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", - "allauth.account.auth_backends.AuthenticationBackend", + "sesame.backends.ModelBackend", ] LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" @@ -275,6 +275,14 @@ HASHID_FIELD_SALT = env("HASHID_FIELD_SALT") +# django-sesame + +SESAME_TOKEN_NAME = "token" # noqa S105 +SESAME_MAX_AGE = 60 * 60 # 1 hour +# If JourneyInbox allows email changes in the future, +# we may want to change this default. +# SESAME_INVALIDATE_ON_EMAIL_CHANGE = False + # django-waffle WAFFLE_CREATE_MISSING_FLAGS = True diff --git a/project/urls.py b/project/urls.py index 26ec4fe..65bff43 100644 --- a/project/urls.py +++ b/project/urls.py @@ -2,9 +2,11 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path +from sesame.views import LoginView from journal.accounts.views import ( account_settings, + check_email, create_billing_portal_session, create_checkout_session, success, @@ -25,6 +27,8 @@ # # Accounts # + path("check-email", check_email, name="check-email"), + path("login", LoginView.as_view(), name="sesame-login"), path( "accounts/create-checkout-session/", create_checkout_session, diff --git a/pyproject.toml b/pyproject.toml index 35fbbe3..4b1271e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "python-dateutil==2.9.0.post0", "sentry-sdk==2.9.0", "whitenoise[brotli]==6.7.0", + "django-sesame>=3.2.2", ] [dependency-groups] diff --git a/templates/accounts/check_email.html b/templates/accounts/check_email.html new file mode 100644 index 0000000..bfc939a --- /dev/null +++ b/templates/accounts/check_email.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Please check your email{% endblock %} + +{% block content %} +
+

Please check your email

+

+ We sent a login link to your email account. + Follow that link to log in. +

+
+{% endblock %} diff --git a/uv.lock b/uv.lock index e4dd4b0..190d3ec 100644 --- a/uv.lock +++ b/uv.lock @@ -274,6 +274,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/c1/6d92eaeae92000f72f2bdae6ce2c47f056f3aca25d5c919551149ea75d71/django_hashid_field-3.4.1-py3-none-any.whl", hash = "sha256:2d072f4caf37f02941a772003c1884f4ae9b31a142e5f23e9eec583b0e981bd9", size = 20313 }, ] +[[package]] +name = "django-sesame" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/88/584aa0c56b0788ef506ca178ba647fc4403b35f4660064dffd43014c3133/django_sesame-3.2.2.tar.gz", hash = "sha256:5d753a309166356b6a0d7fc047690943b9e80b4aa7952f1a6400fe6ce60d573c", size = 17615 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/e7/951f35106972668b61e79635c93933c51c2d58f49a9c8ebf0a8ff7262331/django_sesame-3.2.2-py3-none-any.whl", hash = "sha256:523ebd4d04e28c897c262f25b78b6fd8f37e11cdca6e277fdc8bf496bd686cf5", size = 20289 }, +] + [[package]] name = "django-simple-history" version = "3.7.0" @@ -434,6 +446,7 @@ dependencies = [ { name = "django-denied" }, { name = "django-extensions" }, { name = "django-hashid-field" }, + { name = "django-sesame" }, { name = "django-simple-history" }, { name = "django-waffle" }, { name = "environs" }, @@ -468,6 +481,7 @@ requires-dist = [ { name = "django-denied", specifier = "==1.3" }, { name = "django-extensions", specifier = "==3.2.3" }, { name = "django-hashid-field", specifier = "==3.4.1" }, + { name = "django-sesame", specifier = ">=3.2.2" }, { name = "django-simple-history", specifier = "==3.7.0" }, { name = "django-waffle", specifier = "==4.1.0" }, { name = "environs", specifier = ">=11.2.1" },