Skip to content
Merged
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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@ make schema # regenerates openapi.json AND the TypeScript client
## Auth

The frontend never uses session cookies against `/api/`. It gets a Google ID
token, trades it at `/_allauth/app/v1/auth/provider/token` for our own JWT pair,
token (or an emailed code - below), trades it at `/_allauth/app/v1/auth/provider/token` for our own JWT pair,
and sends the access token as a bearer. Refresh tokens **rotate** — the new one
must replace the old. Access tokens live 15 minutes.

Session cookies still exist, but only for the Django admin and the
server-rendered `/accounts/` allauth views.

**Email sign-in is ours, not allauth's** (`apps/accounts/email_login.py`, next-auth provider
`email`). One mail carries a code and a link that share one row, so using either spends both. It
is sent in the request, never queued, because it carries the raw secrets. Requesting answers 204
for every address. The verify page acts on a button, never on load.

Google credentials come from environment variables, **not** a `SocialApp` row in
the admin — configuring both raises `MultipleObjectsReturned`.

Expand Down
43 changes: 37 additions & 6 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,39 @@ access, at the next request. Signing uses `JWT_SIGNING_KEY`, falling back to `SE
A browser redirect flow (`/_allauth/app/v1/auth/provider/redirect`) and the server-rendered
`/accounts/` views are also available; the latter is how you sign into the Django admin with Google.

Email/password and magic-link login are deliberately switched off for now
(`SOCIALACCOUNT_ONLY = True` in `config/settings/base.py`); allauth already ships both, so enabling
them later is a settings change plus frontend work.
### Signing in by email

Readers without Google sign in by email. One message carries both a six-digit code and a link, and
either one signs in. The first sign-in creates the account (and sends `welcome`). The endpoints are
ours (`apps/accounts/email_login.py`), not allauth's: allauth's login-by-code is bound to the
`X-Session-Token` of the browser that asked, so its link could not finish on a phone a sign-in
started on a laptop. allauth's own local login stays off (`SOCIALACCOUNT_ONLY = True`).

```
# 1. mail a code and a link - 204 whatever the address, so the form reveals no accounts
POST /api/v1/auth/email-login { "email": "...", "next": "/tools/acme" }

# 2a. the code, typed on /auth/signin 2b. the link, /auth/email/verify?token=...&next=...
POST /api/v1/auth/email-login/confirm { "email": "...", "code": "042917" } | { "token": "..." }
-> { "access_token", "refresh_token", "token_type", "expires_in", "user": {...} }
```

The pair is the same one Google's exchange returns, so refreshing it is step 3 above. On the
frontend it is a next-auth `Credentials` provider with id `email`: its `authorize` makes the confirm
call, and `resolveAuthToken` keeps the pair.

What bites:

- **The code and link share one row**, stored as SHA-256 only: using one spends the other, a new
request spends the previous mail's, and the row dies after 15 minutes or 5 wrong codes.
- **The mail is sent in the request, never queued**, like `claim_code`.
- **A repeat within 60 seconds sends nothing** and still answers 204. Both endpoints are also
throttled per IP (`EMAIL_LOGIN_REQUEST_RATE`, `EMAIL_LOGIN_CONFIRM_RATE`).
- **The verify page signs in on a button, never on load**, because mail scanners open links.
- **Signing in records a verified allauth `EmailAddress`.** That is what lets a later Google
sign-in with the same address land on the same account.
- **`next` is a path on this site or nothing.** The backend falls back to `/account`, and
`landingPath` (`src/lib/auth/landing.ts`) strips the origin that next-auth's `signIn()` adds.

## Adding an endpoint

Expand Down Expand Up @@ -291,7 +321,8 @@ production. Tests always use the in-memory backend.

| Email | Sent when | To |
| --- | --- | --- |
| `welcome` | allauth's `user_signed_up` - a first Google sign-in, not an account made in the admin | the new user |
| `welcome` | allauth's `user_signed_up` - a first Google or email sign-in, not an account made in the admin | the new user |
| `login_link` | someone asks to sign in by email (`POST /auth/email-login`) | that address |
| `submission_received` | a tool is submitted (`POST /catalog/submissions`) | `contact_email`, else the account |
| `submission_staff_notice` | the same, with a link to the row in the admin | every active `is_staff` account |
| `claim_code` | a vendor claims a listing | the claimant's work email |
Expand All @@ -315,8 +346,8 @@ re-queued with the admin's **Re-score** action, `services.rescore`. It runs only
submission that is scoring, failed or awaiting review, and settles it - emails included -
like any other.

`claim_code` and `claim_invite` are sent inside the request, never queued: a task's arguments are
kept in the database, and both carry a credential the server otherwise stores only as a hash. All
`claim_code`, `claim_invite` and `login_link` are sent inside the request, never queued: a task's arguments are
kept in the database, and each carries a credential the server otherwise stores only as a hash. All
the others go through the django-q2 queue after the transaction commits, so a
sign-in or a submission never waits on - or fails with - the mail provider, and a
rolled-back request mails nobody. On the host that means they only leave while
Expand Down
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ PUBLIC_MEDIA_URL=http://localhost:8007/media
# MAX_API_KEYS_PER_USER=10
# Per-IP rate at which `pdfredeval login` may open a sign-in.
# CLI_LOGIN_START_RATE=20/hour
# Per-IP rates for signing in by email: requesting a code and link, and using one.
# EMAIL_LOGIN_REQUEST_RATE=10/hour
# EMAIL_LOGIN_CONFIRM_RATE=30/hour

# --- Staff MCP server -----------------------------------------------------
# /mcp, with its OAuth authorization server at /oauth/. No required variables;
Expand Down
59 changes: 56 additions & 3 deletions backend/apps/accounts/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""API-side authentication: the bearer token scheme and the `/auth` router.

Obtaining tokens is allauth's job (`/_allauth/app/v1/...`, including
`auth/tokens/refresh`); this module only consumes them.
Obtaining tokens is mostly allauth's job (`/_allauth/app/v1/...`, including
`auth/tokens/refresh`). The exception is signing in by email (`email_login.py`),
which ends here in the same token pair.
"""

from django.conf import settings
Expand All @@ -13,7 +14,7 @@
from ninja_apikey.models import APIKey
from ninja_apikey.security import APIKeyAuth, generate_key

from . import cli_login, jwt, mcp_oauth
from . import cli_login, email_login, jwt, mcp_oauth
from .schemas import (
ApiKeyCreatedOut,
ApiKeyIn,
Expand All @@ -23,6 +24,9 @@
CliLoginStartOut,
CliLoginTokenIn,
CliLoginTokenOut,
EmailLoginConfirmIn,
EmailLoginIn,
EmailLoginTokenOut,
McpAuthorizationDecisionIn,
McpAuthorizationDecisionOut,
McpAuthorizationOut,
Expand Down Expand Up @@ -228,6 +232,55 @@ def deny_cli_login(request: HttpRequest, user_code: str):
raise _cli_error(exc) from exc


# --- Signing in by email ----------------------------------------------------------
# A code and a link in one message: see apps/accounts/email_login.py.


class EmailLoginRequestThrottle(AnonRateThrottle):
scope = "email_login_request"

def __init__(self):
super().__init__(settings.EMAIL_LOGIN_REQUEST_RATE)


class EmailLoginConfirmThrottle(AnonRateThrottle):
scope = "email_login_confirm"

def __init__(self):
super().__init__(settings.EMAIL_LOGIN_CONFIRM_RATE)


@router.post(
"/email-login",
response={204: None},
throttle=[EmailLoginRequestThrottle()],
summary="Email a sign-in code and link",
)
def request_email_login(request: HttpRequest, payload: EmailLoginIn):
try:
email_login.request_login(payload.email, payload.next)
except email_login.InvalidAddress as exc:
raise HttpError(422, str(exc)) from exc
return Status(204, None)


@router.post(
"/email-login/confirm",
response=EmailLoginTokenOut,
throttle=[EmailLoginConfirmThrottle()],
summary="Sign in with an emailed code or link",
)
def confirm_email_login(request: HttpRequest, payload: EmailLoginConfirmIn):
try:
if payload.token:
user = email_login.confirm_with_link(payload.token)
else:
user = email_login.confirm_with_code(payload.email, payload.code)
except email_login.EmailLoginError as exc:
raise HttpError(400, str(exc)) from exc
return {**jwt.issue_token_pair(user), "user": user}


# --- MCP connector consent ---------------------------------------------------------
# The authorize step of the /mcp OAuth flow: see apps/accounts/mcp_oauth.py. Staff
# only, because a code for anyone else mints a token the MCP server refuses anyway.
Expand Down
151 changes: 151 additions & 0 deletions backend/apps/accounts/email_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Signing in by email: one message with a six-digit code and a link.

Transport-free, like `cli_login.py`: the routes in `api.py` call these and map
`EmailLoginError` to HTTP. A successful sign-in ends in the same JWT pair Google's
does (`jwt.issue_token_pair`), so the frontend refreshes both the same way.

The mail is sent in the request, never queued: django-q keeps task arguments in the
database, which would store the raw code and link.
"""

import hashlib
import secrets
from datetime import timedelta
from urllib.parse import urlencode

from allauth.account.models import EmailAddress
from allauth.account.signals import user_signed_up
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import transaction
from django.utils import timezone

from apps.accounts.models import EmailLogin
from apps.core.email import send_templated_email

LIFETIME = timedelta(minutes=15)
# Wrong codes before the row is dead. Six digits against five guesses is 1 in 200,000.
MAX_ATTEMPTS = 5
# Per address, between mails: what stops the form being used to flood one inbox.
COOLDOWN = timedelta(seconds=60)
DEFAULT_NEXT = "/account"


class InvalidAddress(Exception):
"""The address is malformed - the one refusal requesting may give."""


class EmailLoginError(Exception):
"""Any failure to sign in. One message for all of them, so none leaks which."""

def __init__(self):
super().__init__("That code or link is wrong or has expired. Request a new one.")


def request_login(email, next_path=""):
"""Mail a code and a link to `email`. Says nothing about whether it has an account."""
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as exc:
raise InvalidAddress("Enter a valid email address.") from exc
recent = timezone.now() - COOLDOWN
if EmailLogin.objects.filter(email=email, created_at__gt=recent).exists():
return
if get_user_model().objects.filter(email__iexact=email, is_active=False).exists():
return
code = f"{secrets.randbelow(1_000_000):06d}"
link = secrets.token_urlsafe(32)
# One transaction with the send: a mail that never left must not start the cooldown.
with transaction.atomic():
# Only the newest mail works: an older one left in an inbox is one more to steal.
EmailLogin.objects.filter(email=email, used_at__isnull=True).update(used_at=timezone.now())
EmailLogin.objects.create(
email=email,
code_hash=_hash(code),
link_hash=_hash(link),
expires_at=timezone.now() + LIFETIME,
)
send_templated_email(
"login_link",
{
"code": code,
"link": _link_url(link, next_path),
"ttl_minutes": int(LIFETIME.total_seconds() // 60),
},
to=[email],
)


def confirm_with_code(email, code):
"""Sign in with the code. Returns the user or raises `EmailLoginError`."""
with transaction.atomic():
login = _open().filter(email=email.strip().lower(), attempts__lt=MAX_ATTEMPTS).first()
if login is not None and secrets.compare_digest(login.code_hash, _hash(code.strip())):
return _sign_in(login)
if login is not None:
# Committed, not raised inside the transaction: a rollback would undo the count.
login.attempts += 1
login.save(update_fields=["attempts"])
raise EmailLoginError()


def confirm_with_link(token):
"""Sign in with the link's token, on any device. Same return as `confirm_with_code`."""
with transaction.atomic():
login = _open().filter(link_hash=_hash(token.strip())).first()
if login is None:
raise EmailLoginError()
return _sign_in(login)


def _open():
"""Rows that can still sign someone in, locked for the rest of the transaction."""
return EmailLogin.objects.select_for_update().filter(
used_at__isnull=True, expires_at__gt=timezone.now()
)


def _sign_in(login):
user = get_user_model().objects.filter(email__iexact=login.email).first()
if user is not None and not user.is_active:
raise EmailLoginError()
login.used_at = timezone.now()
login.save(update_fields=["used_at"])
if user is None:
user = get_user_model().objects.create_user(login.email)
# The signal a Google sign-up sends, so the welcome mail goes out the same way.
user_signed_up.send(sender=type(user), request=None, user=user)
_mark_verified(user)
return user


def _mark_verified(user):
"""Record what the mail just proved. allauth connects a later Google sign-in with the
same address to this account only through a verified `EmailAddress`."""
address = EmailAddress.objects.filter(user=user, email__iexact=user.email).first()
if address is None:
address = EmailAddress(user=user, email=user.email)
address.verified = True
if not EmailAddress.objects.filter(user=user, primary=True).exclude(pk=address.pk).exists():
address.primary = True
address.save()


def _link_url(link, next_path):
query = urlencode({"token": link, "next": _safe_next(next_path)})
return f"{settings.FRONTEND_URL}/auth/email/verify?{query}"


def _safe_next(path):
"""A path on this site, or the account page: the link must not bounce anyone elsewhere."""
path = (path or "").strip()
if not path.startswith("/") or path.startswith(("//", "/\\")):
return DEFAULT_NEXT
return path


def _hash(secret):
return hashlib.sha256(secret.encode()).hexdigest()
30 changes: 30 additions & 0 deletions backend/apps/accounts/migrations/0003_email_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 6.1.1 on 2026-10-08 19:59

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0002_cli_login'),
]

operations = [
migrations.CreateModel(
name='EmailLogin',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(db_index=True, max_length=254)),
('code_hash', models.CharField(max_length=64)),
('link_hash', models.CharField(max_length=64, unique=True)),
('attempts', models.PositiveSmallIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
],
options={
'db_table': 'accounts_email_login',
'ordering': ['-created_at'],
},
),
]
24 changes: 24 additions & 0 deletions backend/apps/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,27 @@ def __str__(self):
@property
def expired(self):
return self.expires_at <= timezone.now()


class EmailLogin(models.Model):
"""One emailed sign-in: a six-digit code and a link, either of which signs in once.

Both secrets are stored only as SHA-256. They share the row, so using one spends the
other. Nothing ties the row to the browser that asked, which is what lets the link
finish on a phone a sign-in started on a laptop.
"""

email = models.EmailField(db_index=True)
code_hash = models.CharField(max_length=64)
link_hash = models.CharField(max_length=64, unique=True)
attempts = models.PositiveSmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)

class Meta:
db_table = "accounts_email_login"
ordering = ["-created_at"]

def __str__(self):
return f"{self.email} ({self.created_at:%Y-%m-%d %H:%M})"
Loading
Loading