Ctrl K
ComunidadGuías…

app/twofactor.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""The second factor: TOTP codes and single-use recovery codes.

Separate from auth.py because none of this knows about sessions or requests —
it is only about proving possession of a phone.

The secret is stored as issued, the QR is rendered locally, one step of clock
drift is accepted, and recovery codes are hashed and marked used rather than
deleted. Each of those is a place this is commonly got wrong: decisions.md #79.
"""

from __future__ import annotations

import datetime as dt
import hashlib
import hmac
import io
import re
import secrets

from sqlalchemy import or_, select, update
from sqlalchemy.orm import Session as DbSession
from sqlalchemy.orm.attributes import set_committed_value

from .models import RecoveryCode, User

# Sixteen, matching GitHub — and why sixteen single-use codes rather than eight
# or one passphrase: decisions.md #30.
RECOVERY_CODE_COUNT = 16
# Unambiguous alphabet: no O/0, no I/1/L. These get written down by hand.
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
# Characters per code: 74 bits, grouped into three blocks of five so it can
# be transcribed. Why 15 and not 13, and why the throttle stays either way —
# decisions.md #42.
RECOVERY_CODE_CHARS = 15
_GROUP = 5


def _utcnow() -> dt.datetime:
    return dt.datetime.now(dt.timezone.utc)


def _pyotp():
    """Imported on use, like yfinance — see app/memory.py for the rationale.

    Also the seam the tests patch when they need a deterministic code.
    """
    import pyotp

    return pyotp


# --------------------------------------------------------------------------- #
# Secrets and codes
# --------------------------------------------------------------------------- #

def new_secret() -> str:
    """A fresh base32 TOTP secret."""
    return _pyotp().random_base32()


def provisioning_uri(secret: str, email: str, issuer: str) -> str:
    """The `otpauth://` URI an authenticator app scans.

    `issuer` is what shows up above the code in the app. It comes from the
    configured app name so two self-hosted installs don't collide in someone's
    authenticator list.
    """
    return _pyotp().TOTP(secret).provisioning_uri(name=email, issuer_name=issuer)


def matching_step(secret: str | None, code: str) -> int | None:
    """The time step this six-digit code belongs to, if it is valid now.

    One step of drift either side is accepted. Callers that sign someone in
    want `accept_code`, which records the answer; this on its own would accept
    the same code again for as long as it is in the window.

    Tolerates spaces (people paste "123 456") and rejects anything falsy
    outright rather than letting pyotp decide what an empty code means.
    """
    if not secret or not code:
        return None
    cleaned = code.replace(" ", "").strip()
    # ASCII digits: isdigit() also takes "²", and compare_digest raises on any
    # string that is not ASCII.
    if not re.fullmatch(r"[0-9]+", cleaned):
        return None
    totp = _pyotp().TOTP(secret)
    now = totp.timecode(_utcnow())
    for step in (now + 1, now, now - 1):
        if hmac.compare_digest(totp.generate_otp(step), cleaned):
            return step
    return None


def accept_code(db: DbSession, user: User, code: str) -> bool:
    """Whether this code proves the user's authenticator — once.

    A code stays valid for about 90 seconds (its own step and one either side),
    and without a record anybody who saw one used, over a shoulder or through a
    phishing relay, could use it again in that time. RFC 6238 §5.2: a verifier
    must not accept a second attempt with the same code. So each accepted step
    is recorded on the user, and only a later step is accepted after it.

    That rule lives in the UPDATE's condition and nowhere else, rather than in a
    read and then a write. Two requests carrying the same code at once — a relay
    racing the person it phished — would both read the old step and both pass.
    Only one UPDATE can move it forward.
    """
    step = matching_step(user.totp_secret, code)
    if step is None:
        return False
    moved = db.execute(
        update(User)
        .where(User.id == user.id,
               or_(User.totp_last_step.is_(None), User.totp_last_step < step))
        .values(totp_last_step=step),
        execution_options={"synchronize_session": False},
    )
    if moved.rowcount != 1:
        return False
    # The row is already written; this keeps the loaded object in step with it
    # without queueing a second UPDATE.
    set_committed_value(user, "totp_last_step", step)
    return True


def hash_code(raw: str) -> str:
    """Recovery codes are hashed like session tokens: the database never holds
    anything that could be replayed as a credential."""
    return hashlib.sha256(_normalise(raw).encode()).hexdigest()


def _normalise(raw: str) -> str:
    """Upper-cased, stripped of the dash and any whitespace, so a code typed
    back exactly as displayed still matches."""
    return "".join(raw.split()).replace("-", "").upper()


def generate_recovery_codes(db: DbSession, user: User) -> list[str]:
    """Issue a fresh set, replacing any that already exist.

    Returns the RAW codes — the only time they exist in readable form. The
    caller must show them once and never store them.
    """
    for existing in db.scalars(
        select(RecoveryCode).where(RecoveryCode.user_id == user.id)
    ).all():
        db.delete(existing)
    db.flush()

    raw_codes = []
    for _ in range(RECOVERY_CODE_COUNT):
        body = "".join(secrets.choice(_ALPHABET) for _ in range(RECOVERY_CODE_CHARS))
        # Grouped, because these get read aloud and typed from paper.
        raw = "-".join(body[i:i + _GROUP] for i in range(0, len(body), _GROUP))
        raw_codes.append(raw)
        db.add(RecoveryCode(user_id=user.id, code_hash=hash_code(raw)))
    db.flush()
    return raw_codes


def has_recovery_codes(db: DbSession, user: User) -> bool:
    return db.scalar(
        select(RecoveryCode.id).where(RecoveryCode.user_id == user.id).limit(1)
    ) is not None


def ensure_recovery_codes(db: DbSession, user: User) -> list[str]:
    """Issue a set only if this account has none, and say what was issued.

    Returns [] when codes already exist — which the caller must read as "do not
    show a page of codes", not as a failure. Regenerating instead would
    invalidate a list somebody printed at signup, and they would find out at
    the worst possible moment.
    """
    if has_recovery_codes(db, user):
        return []
    return generate_recovery_codes(db, user)


def consume_recovery_code(db: DbSession, user: User, raw: str) -> bool:
    """Spend a recovery code. False if it is unknown or already used.

    Marked used rather than deleted, so the count shown on the account page
    stays honest and a replay of a spent code is refused rather than looking
    like a code that never existed.
    """
    if not raw:
        return False
    match = db.scalar(
        select(RecoveryCode).where(
            RecoveryCode.user_id == user.id,
            RecoveryCode.code_hash == hash_code(raw),
            RecoveryCode.used_at.is_(None),
        )
    )
    if match is None:
        return False
    match.used_at = _utcnow()
    db.flush()
    return True


def remaining_recovery_codes(db: DbSession, user: User) -> int:
    return len(
        db.scalars(
            select(RecoveryCode).where(
                RecoveryCode.user_id == user.id, RecoveryCode.used_at.is_(None)
            )
        ).all()
    )


# --------------------------------------------------------------------------- #
# Enrolment
# --------------------------------------------------------------------------- #

def qr_svg(uri: str) -> str:
    """The provisioning URI as an inline SVG.

    Rendered in-process and embedded in the page: no external image service,
    because the obvious one (a chart URL) would mean posting the TOTP secret to
    a third party in a query string.
    """
    import qrcode
    import qrcode.image.svg

    image = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage)
    buffer = io.BytesIO()
    image.save(buffer)
    return buffer.getvalue().decode()


def is_enabled(user: User) -> bool:
    """Enrolled AND switched on. A secret alone is a half-finished enrolment —
    someone who scanned the QR and closed the tab must not be locked out."""
    return bool(user.totp_secret and user.totp_enabled_at)


def begin_enrolment(user: User) -> str:
    """Issue a secret without turning anything on, or resume one in progress.

    RESUMED rather than restarted, which is the whole point. A fresh secret on
    every render means a refresh — or anything else that re-fetches the page —
    silently invalidates the QR just scanned, and the code the phone shows stops
    matching for a reason nothing on screen explains. decisions.md #39.

    Storing it does not enable anything: `is_enabled` wants `totp_enabled_at`
    as well, so a secret on its own is a half-finished enrolment that signs
    nobody in.
    """
    if not (user.totp_secret and user.totp_enabled_at is None):
        user.totp_secret = new_secret()
        # Steps from the old secret say nothing about the new one, and keeping
        # one would refuse a fresh code that happens to share its step.
        user.totp_last_step = None
    return user.totp_secret


def enable(db: DbSession, user: User, secret: str) -> list[str]:
    """Turn it on. Caller has already verified a code from this secret — that
    check is what proves the app was really enrolled.

    Recovery codes are NOT reissued here. They belong to the account rather
    than to this factor, and are handed out when the account is created; making
    a fresh set now would quietly kill the list the user already saved.

    The exception is an account that has none — one created before recovery
    codes existed. A migration could not issue those, because a code has to be
    shown once and a migration has nobody to show it to.
    """
    user.totp_secret = secret
    user.totp_enabled_at = _utcnow()
    codes = ensure_recovery_codes(db, user)
    db.flush()
    return codes


def disable(db: DbSession, user: User) -> None:
    """Turn off the second factor. The recovery codes are left alone.

    Destroying them here would be right if they were a 2FA-only fallback — a
    later re-enrolment would otherwise inherit escape hatches the user thought
    were gone. They are the ACCOUNT's recovery, though: the way back in when the
    password is what went missing, so they outlive any single factor. Destroying
    them here would leave somebody who simply switched authenticators with no
    recovery at all, and nothing on screen to say so.
    """
    user.totp_secret = None
    user.totp_enabled_at = None
    user.totp_last_step = None
    db.flush()