Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simple signin and signup #246

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/signup.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions journal/accounts/forms.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 9 additions & 1 deletion journal/accounts/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
47 changes: 47 additions & 0 deletions journal/accounts/tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -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="[email protected]")
email = "[email protected]"
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="[email protected]")
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]
8 changes: 8 additions & 0 deletions journal/accounts/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
8 changes: 7 additions & 1 deletion journal/accounts/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
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

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):
Expand Down
7 changes: 7 additions & 0 deletions journal/core/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[email protected]"}
response = client.post(reverse("index"), data=data)

assert response.status_code == 302


class TestTerms:
def test_unauthenticated(self, client):
Expand Down
15 changes: 13 additions & 2 deletions journal/core/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)


Expand Down
10 changes: 9 additions & 1 deletion project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "/"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions project/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions templates/accounts/check_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "base.html" %}

{% block title %}Please check your email{% endblock %}

{% block content %}
<div class="max-w-xl mb-4 mx-auto">
<h1 class="text-xl my-4">Please check your email</h1>
<p class="mb-4">
We sent a login link to your email account.
Follow that link to log in.
</p>
</div>
{% endblock %}
14 changes: 14 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.