pdf-quiz-generator/backend/app/models/login_code.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

37 lines
1.7 KiB
Python

from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from app.database import Base
class LoginCode(Base):
"""One issued sign-in code.
Only the fingerprint of the code is kept. A row here is a session waiting
to happen — anybody who could read the table could sign in as its owner —
so it holds something to compare against rather than something to type in.
The failed guesses are counted in this row and not in Redis. The rate
limiter fails open when the cache is down, which is the right call for
anything it normally protects; it is the wrong call for the only thing
standing between a patient stranger and six characters.
"""
__tablename__ = "login_codes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
#: SHA-256 of the normalised code, hex. Not bcrypt: the guess budget is
#: what makes a short code safe, not the cost of testing one, and this is
#: compared on a path a person is waiting on. Not indexed and not unique
#: either — a code is only ever looked up against the one account it was
#: issued for, and six characters do collide.
code_hash = Column(String(64), nullable=False)
attempts = Column(Integer, nullable=False, default=0)
expires_at = Column(DateTime, nullable=False)
#: Set when the code is spent, when its guesses run out, and when a later
#: request retires it. All three mean the same thing to every reader here:
#: no longer usable.
consumed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)