ca8ba69acb
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>
337 lines
12 KiB
Python
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",
|
|
)
|