"""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