pdf-quiz-generator/backend/app/services/login_codes.py
Daniel 25a9a8aca4 feat: sign in with a code sent by email
A password is a thing to remember and a thing to lose. Somebody who can read
their own mail can now sign in without one: ask, receive six characters, type
them into the page that is already open.

A code rather than a link, and the difference is not cosmetic. The token in a
link was 256 bits, unguessable however long it lived, so its length, its expiry
and its rate limit were three independent decisions. Six characters is 2^30,
and the three stop being independent — so they are argued together:

  * six characters of the invite alphabet, imported rather than copied, because
    there should be one answer to which characters a person may be asked to
    retype and that one already drops O/0 and I/1;
  * a code answers five guesses and is then retired, not slowed — whoever is
    typing has lost the mail or does not own it, and both are one click from a
    new one;
  * one code live per person, since several would mean one guess tested against
    all of them;
  * ten verify attempts per address per fifteen minutes, so nobody buys five
    fresh guesses at a time by asking again.

Tens of guesses an hour against a billion, and the victim gets a mail for every
code burned. Eight characters would buy a thousandfold against an attack the
guess budget has already ended, and cost every person two more characters.

The attempt count lives in the row, not the cache. The Redis limiter fails open
when Redis is down, which is right for what it usually guards and wrong for the
only thing standing between a patient stranger and six characters.

Verifying is scoped to the address. A short code looked up on its own would be
tried against every code live on the site at once — the short code's one real
weakness, closed by knowing whose code it should be before comparing.

Fifteen minutes, because a first mail between strangers is routinely greylisted
five to ten and a code that expires before it arrives is not a sign-in method.
Shortening it buys nothing: one code is live and it answers five guesses
however long it sits there.

Nothing distinguishes an address with an account from one without — same
message, same status, same duration, and both rate limits counted before the
account is looked up, so a 429 cannot become the tell. Redis keys are
fingerprints, and the table holds a fingerprint rather than the code.

SSO stays first where it is configured, and a password is still one click away
for anybody who has one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:29:28 +02:00

159 lines
6.8 KiB
Python

"""Signing in with a code sent to your own mailbox.
Six characters a person reads off an email and types into the page they asked
from. Everything here follows from the code being short: it can be guessed, so
what makes it safe is not its length on its own but the arithmetic between its
length, how many guesses it will ever answer, and how long it lives.
"""
import hashlib
import hmac
import re
import secrets
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.models.login_code import LoginCode
from app.models.user import User
#: The house alphabet, and the reason it is the house alphabet: no O/0 and no
#: I/1, because these are read off a screen and typed back. Borrowed rather
#: than copied — there should be one answer to "which characters may a person
#: be asked to retype", not one per feature.
from app.services.invites import ALPHABET
#: Six characters of a 32-letter alphabet: 2^30 codes, where the six digits
#: everyone is used to would be 2^20. The length is only half the argument
#: though, and the smaller half. A code answers at most MAX_ATTEMPTS guesses
#: before it is retired, only one is ever live per person, and the address may
#: only be tried so often — so an attacker gets tens of guesses an hour against
#: a billion, while sending the victim a mail for every code they burn through.
#: Eight characters would buy a thousandfold against an attack the guess budget
#: has already ended, and cost every user two more characters to type.
LENGTH = 6
#: Five. Enough that a mistyped character, a stale code from a first attempt
#: and one more fumble do not lock somebody out of their own account; few
#: enough that the whole budget is nothing against 2^30. Counted in the row
#: rather than the cache — see the model.
MAX_ATTEMPTS = 5
#: Fifteen minutes, unchanged from when this was a link, and for a reason that
#: survived the change: a first mail between a sender and a recipient who have
#: never corresponded is routinely greylisted five to ten minutes, and a code
#: that has expired before it arrives is not a sign-in method. Shortening it
#: would not slow an attacker down — only one code is ever live per person and
#: it answers five guesses however long it sits there — so the clock is a
#: deliverability question, and the one thing it does buy is a shorter window
#: for a mailbox somebody else is reading. Fifteen is where those meet.
CODE_TTL = timedelta(minutes=15)
#: Rows are dead weight once well past expiry and nothing reads them. Kept a
#: day rather than deleted on the spot, so "it did not work this morning" can
#: still be answered from the table.
KEEP_SPENT_FOR = timedelta(days=1)
def normalise(code: str) -> str:
"""What the person meant, from what they typed.
Case, spaces and dashes are not part of the code — the mail shows it in two
groups of three, and somebody will type the space. Anything that is not a
letter or a digit goes; the rest is upper-cased. There is no look-alike
folding to do, which is the point of that alphabet: O and 0 are not two
characters one might mistake for each other here, they are both simply not
characters a code can contain.
"""
return re.sub(r"[^A-Z0-9]", "", (code or "").upper())
def fingerprint(value: str) -> str:
return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()
def generate_code() -> str:
return "".join(secrets.choice(ALPHABET) for _ in range(LENGTH))
def for_display(code: str) -> str:
"""Grouped for reading aloud in one's head. Typed back in any shape."""
return f"{code[:3]} {code[3:]}" if len(code) == 6 else code
def issue(db: Session, user: User) -> str:
"""Retire this user's outstanding codes, mint a new one, return it.
Superseding rather than accumulating, and it matters more here than it did
for a link: with several codes live at once, a guess is tested against all
of them, and a six-character code gets easier to hit with every one left
lying around. Exactly one is live per person at any moment.
"""
now = datetime.utcnow()
db.query(LoginCode).filter(
LoginCode.user_id == user.id,
LoginCode.consumed_at.is_(None),
).update({"consumed_at": now}, synchronize_session=False)
db.query(LoginCode).filter(
LoginCode.user_id == user.id,
LoginCode.expires_at < now - KEEP_SPENT_FOR,
).delete(synchronize_session=False)
code = generate_code()
db.add(LoginCode(user_id=user.id, code_hash=fingerprint(code), expires_at=now + CODE_TTL))
db.commit()
return code
def verify(db: Session, email: str, code: str) -> User | None:
"""Spend a code and return whose it is, or None if it is no good.
Scoped to the address, and that is not merely convenient. A code looked up
on its own would be tested against every code live on the site at once, so
each guess would be as many guesses as there are people signing in — the
short code's one real weakness, closed by knowing whose code it is meant to
be before comparing.
One answer for every failure: wrong code, expired code, code whose guesses
have run out, address with no account. Telling them apart would say which
of those was true, and none of them leaves the person anything to do but
ask for a new code.
"""
user = db.query(User).filter(User.email == email.lower().strip()).first()
if user is None:
return None
row = db.query(LoginCode).filter(
LoginCode.user_id == user.id,
LoginCode.consumed_at.is_(None),
).order_by(LoginCode.id.desc()).first()
if row is None or row.expires_at <= datetime.utcnow():
return None
if not hmac.compare_digest(row.code_hash, fingerprint(normalise(code))):
# A wrong guess costs one of five, and the fifth ends the code rather
# than leaving it to be guessed at a slower rate: whoever is typing has
# either lost the mail or is not its owner, and both are one click from
# a new code. The count is a SQL expression so that two guesses racing
# each other cost two.
exhausted = row.attempts + 1 >= MAX_ATTEMPTS
spend = {"attempts": LoginCode.attempts + 1}
if exhausted:
spend["consumed_at"] = datetime.utcnow()
db.query(LoginCode).filter(
LoginCode.id == row.id,
LoginCode.consumed_at.is_(None),
).update(spend, synchronize_session=False)
db.commit()
return None
# The claim is the UPDATE, not the read above: two requests carrying the
# same code both pass the comparison, and only the one whose UPDATE matched
# a row still unspent gets to sign in.
claimed = db.query(LoginCode).filter(
LoginCode.id == row.id,
LoginCode.consumed_at.is_(None),
).update({"consumed_at": datetime.utcnow()}, synchronize_session=False)
db.commit()
if not claimed:
return None
return user