Files
rfc-app/backend/app/otc.py
T
Ben Stull ca8ba69acb Release 0.8.0: open beta-access request flow (first/last/why)
Replaces the v0.3.0 / v0.7.0 allowed_emails admission gate with an
admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 / §14.1 / §17).
Any valid email can sign in via OTC; a fresh user lands in
permission_state='pending' with a captured first/last/why profile,
and an admin grant flips them to 'granted' before write endpoints
accept them. Grandfathered users pass through the migration with
the column default 'granted' so existing contributors are unaffected.
The allowed_emails table stays in the schema as a fast-path bypass
pending v0.9.0's admin user-management page (item #7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:25:59 -07:00

337 lines
12 KiB
Python

"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
Replaces the Gitea OAuth gesture as the primary human-auth path. The
Gitea bot user + token are still needed for server-side git
operations (repo reads, PR creation); only the operator-facing
sign-in surface moves through this module.
The shape:
* `request_code(email)` generates a 6-digit decimal code,
hashes it (bcrypt), stores the hash + expiry in `otc_codes`,
and dispatches a plain-text email via `email_otc.send`. It
invalidates any prior unused codes for the same email so a
re-request keeps the surface to one outstanding code per
address. The TTL comes from `OTC_TTL_MINUTES` (default 10).
A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default
60) refuses back-to-back requests inside the window.
* `verify_code(email, code)` walks the most recent unconsumed
non-expired row for the email, checks the bcrypt hash, marks
the row consumed, and returns the linked or freshly-provisioned
user row.
* `provision_or_link_user(email)` is the migration path: if a
`users` row already carries `email` (case-insensitive), it is
reused — `gitea_id` is left alone so a grandfathered OAuth-era
user keeps the linker intact. Otherwise a fresh contributor
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
and `permission_state = 'pending'` (v0.8.0 — see below).
The endpoints in `main.py` thin-wrap this module.
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
the request surface. The request handler used to silently drop OTC
requests for emails not on the allowlist; now any valid email
receives a code. The admission gate moves to `permission_state` on
the freshly-provisioned `users` row: a fresh user lands in 'pending'
and waits for an admin grant before write endpoints accept them.
Read surfaces stay open (the same blast radius v0.6.0 / item #4
already audited for anonymous viewers).
The `allowed_emails` table itself stays in the schema as a
fast-path bypass — the admin UI from v0.3.0 continues to manage it,
and a future release (v0.9.0's admin user-management page) collapses
the two admission surfaces into one. The OTC request path no
longer consults the table.
"""
from __future__ import annotations
import logging
import os
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — env-driven with defaults so v0.7.0 needs no new secrets.
# ---------------------------------------------------------------------------
def _ttl_minutes() -> int:
raw = os.environ.get("OTC_TTL_MINUTES", "").strip()
if not raw:
return 10
try:
return max(1, int(raw))
except ValueError:
return 10
def _cooldown_seconds() -> int:
raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip()
if not raw:
return 60
try:
return max(0, int(raw))
except ValueError:
return 60
# ---------------------------------------------------------------------------
# Code generation + hashing
# ---------------------------------------------------------------------------
def _new_code() -> str:
"""Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the
code resists guessing even at the small (10^6) keyspace. The TTL
+ rate-limit are what carry the security weight — the entropy of a
six-digit code by itself is intentionally human-readable."""
return f"{secrets.randbelow(1_000_000):06d}"
def _hash_code(code: str) -> str:
"""bcrypt over the code bytes. The hash is stored at rest; the code
itself only travels in the outbound email and the inbound verify
body."""
return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check_code(code: str, code_hash: str) -> bool:
try:
return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Request path
#
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
# valid email receives a code; the admission gate moved to
# `permission_state` on the freshly-provisioned `users` row (see
# `provision_or_link_user`). The `allowed_emails` table stays in the
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
# user-management page will collapse the two surfaces.
# ---------------------------------------------------------------------------
@dataclass
class RequestOutcome:
"""The outcome of a `request_code` call.
`code` is None whenever no code was generated — the cooldown
window blocked the request or the email was syntactically
invalid. The caller (the API endpoint) does not surface the
invalid-email shape to the user; it returns 202 either way.
The cooldown shape surfaces as a loud 429 per the v0.7.0
contract.
"""
sent: bool
code: str | None
reason: str # 'sent' | 'cooldown' | 'invalid'
def request_code(email: str) -> RequestOutcome:
email = (email or "").strip()
if not email or "@" not in email:
return RequestOutcome(sent=False, code=None, reason="invalid")
# Cooldown: refuse if a code was issued for this email in the last
# COOLDOWN_SECONDS. We surface it as a distinct outcome so the
# endpoint can return 429 — the spec calls this out as a "loud
# failure" so the abuse path is visible rather than swallowed.
cooldown = _cooldown_seconds()
if cooldown > 0:
row = db.conn().execute(
f"""
SELECT 1 FROM otc_codes
WHERE email = ?
AND datetime(created_at, '+{cooldown} seconds') > datetime('now')
LIMIT 1
""",
(email,),
).fetchone()
if row is not None:
return RequestOutcome(sent=False, code=None, reason="cooldown")
# Invalidate prior unused codes for this email. A re-request is
# always for the most recent code; older codes are dead.
db.conn().execute(
"""
UPDATE otc_codes
SET consumed_at = datetime('now')
WHERE email = ?
AND consumed_at IS NULL
""",
(email,),
)
code = _new_code()
code_hash = _hash_code(code)
ttl = _ttl_minutes()
db.conn().execute(
f"""
INSERT INTO otc_codes (email, code_hash, expires_at)
VALUES (?, ?, datetime('now', '+{ttl} minutes'))
""",
(email, code_hash),
)
return RequestOutcome(sent=True, code=code, reason="sent")
# ---------------------------------------------------------------------------
# Verify path
# ---------------------------------------------------------------------------
@dataclass
class VerifyOutcome:
"""Result of a `verify_code` call.
`user` is populated only on success. `reason` distinguishes the
failure modes the UI can render — 'expired', 'consumed', 'wrong',
'unknown' (no outstanding code at all). The endpoint maps the
failure modes to a single 400 with a generic message; the reason
is logged for the operator.
"""
ok: bool
user: SessionUser | None
reason: str
def verify_code(email: str, code: str) -> VerifyOutcome:
email = (email or "").strip()
code = (code or "").strip()
if not email or not code:
return VerifyOutcome(ok=False, user=None, reason="invalid")
rows = db.conn().execute(
"""
SELECT id, code_hash, expires_at, consumed_at
FROM otc_codes
WHERE email = ?
ORDER BY id DESC
LIMIT 5
""",
(email,),
).fetchall()
if not rows:
return VerifyOutcome(ok=False, user=None, reason="unknown")
# Walk the recent rows so a user who pasted an older code still
# gets a sensible error — without this, the most-recent-row check
# would mask "you entered yesterday's code" as "wrong code".
matched = None
for row in rows:
if _check_code(code, row["code_hash"]):
matched = row
break
if matched is None:
return VerifyOutcome(ok=False, user=None, reason="wrong")
if matched["consumed_at"] is not None:
return VerifyOutcome(ok=False, user=None, reason="consumed")
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return VerifyOutcome(ok=False, user=None, reason="expired")
# Stamp consumed before provisioning so a parallel verify of the
# same row can't double-sign-in.
db.conn().execute(
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
user = provision_or_link_user(email)
return VerifyOutcome(ok=True, user=user, reason="ok")
# ---------------------------------------------------------------------------
# Provisioning — the migration path from OAuth identity to email identity.
# ---------------------------------------------------------------------------
def provision_or_link_user(email: str) -> SessionUser:
"""Link the OTC sign-in to a `users` row.
Match order:
1. An existing row whose email equals (case-insensitive) the
requested email — the OAuth-era user is grandfathered in via
this path. `gitea_id` is preserved so a future OAuth round
trip still resolves the same row. `permission_state` is
read off the row as-is — grandfathered users come through
migration with 'granted' (the column default), so their
contributor capabilities are unaffected.
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
`gitea_login = NULL`, and `permission_state = 'pending'`
(v0.8.0). The display name defaults to the local part of
the email (everything before the `@`); a separate
`POST /auth/me/beta-request` call lands first name / last
name / "why I want access" on the same row.
The §6.1 owner-zero bootstrap still applies: if the email matches
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
is provisioned with role='owner'. v0.7.0 keeps that field as the
Gitea login (so existing deployments don't break); a future
release may add a parallel `OWNER_EMAIL` env if the OAuth route is
dropped entirely.
"""
email = email.strip()
existing = db.conn().execute(
"SELECT * FROM users WHERE email = ? COLLATE NOCASE",
(email,),
).fetchone()
if existing is not None:
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(existing["id"],),
)
return SessionUser(
user_id=existing["id"],
gitea_id=existing["gitea_id"] or 0,
gitea_login=existing["gitea_login"] or "",
display_name=existing["display_name"],
email=existing["email"] or email,
avatar_url=existing["avatar_url"] or "",
role=existing["role"],
permission_state=existing["permission_state"] or "granted",
)
display = email.split("@", 1)[0] or email
# v0.8.0: 'pending' is the explicit insert value; the migration
# default of 'granted' is what passes grandfathered users
# through. A fresh OTC user lands in 'pending' regardless of
# what the migration default says, so the gate engages reliably
# even if a future migration changes the default.
cur = db.conn().execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
""",
(email, display),
)
user_id = cur.lastrowid
return SessionUser(
user_id=user_id,
gitea_id=0,
gitea_login="",
display_name=display,
email=email,
avatar_url="",
role="contributor",
permission_state="pending",
)