83eafe72ee
The §9 pipeline's PPE+E2E stage was unreachable: e2e/metadata.spec.js was bound to Tier-1-only scaffolding (docker-seeded faceted collection, SQLite-injected owner, Mailpit OTC sink). This makes the same suite run against a deployed host. - backend: POST /auth/test/login — fail-closed, secret-gated, single- identity owner test-login (404 unless E2E_TEST_AUTH_SECRET + E2E_TEST_AUTH_EMAIL both set; constant-time compare; loud startup warn). 6 vertical tests; backend 665 green. Documented in backend/.env.example. - e2e/lib/auth.js: branch on E2E_TEST_AUTH_SECRET (deployed test-login vs local Mailpit OTC); OWNER_EMAIL from E2E_OWNER_EMAIL. Spec unchanged so the localhost Tier-1 path keeps working. - testing/seed-ppe.sh: seed a dedicated, prod-untouching PPE registry + content repo (faceted bdd collection) — real OHM content never touched. - docs/design/2026-06-07-deployed-env-e2e-harness.md; CHANGELOG; VERSION + frontend/package.json -> 0.52.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
699 lines
32 KiB
Python
699 lines
32 KiB
Python
"""FastAPI entrypoint.
|
|
|
|
Wires the §17 routers, the OAuth callbacks, the webhook receiver, and
|
|
the background reconciler. Per §4.2, single process, colocated SQLite —
|
|
no need for a separate worker.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import secrets
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
from pydantic import BaseModel, Field
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from . import (
|
|
api as api_routes,
|
|
auth,
|
|
cache,
|
|
db,
|
|
device_trust as device_trust_mod,
|
|
digest,
|
|
email_otc,
|
|
hygiene,
|
|
invites as invites_mod,
|
|
otc,
|
|
passcode as passcode_mod,
|
|
projects,
|
|
providers as providers_mod,
|
|
ratelimit,
|
|
registry as registry_mod,
|
|
turnstile,
|
|
webhooks,
|
|
)
|
|
from .bot import Bot
|
|
from .config import load_config
|
|
from .gitea import Gitea
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
log = logging.getLogger("rfc_app")
|
|
|
|
|
|
class OtcRequestBody(BaseModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
|
|
# frontend widget. Optional in the body so a deployment that has
|
|
# not yet wired the Turnstile site key (or a dev environment with
|
|
# the widget intentionally skipped) still routes through the same
|
|
# endpoint; the backend turnstile.verify_token call decides whether
|
|
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
|
|
# the secret.
|
|
turnstile_token: str | None = Field(default=None, max_length=4096)
|
|
|
|
|
|
class OtcVerifyBody(BaseModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
code: str = Field(min_length=1, max_length=16)
|
|
# v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx
|
|
# OTC step. When true and verify succeeds, the server issues a fresh
|
|
# device-trust row and sets the `rfc_device_trust` cookie on the
|
|
# response. Defaults to false so existing clients that don't send
|
|
# the flag continue to behave the way they did pre-v0.11.0.
|
|
trust_device: bool = False
|
|
|
|
|
|
class TestLoginBody(BaseModel):
|
|
# The single configured test identity to sign in as. Must equal
|
|
# E2E_TEST_AUTH_EMAIL (case-insensitive) or the request is refused —
|
|
# see `/auth/test/login`.
|
|
email: str = Field(min_length=3, max_length=320)
|
|
|
|
|
|
class PasscodeSetBody(BaseModel):
|
|
passcode: str = Field(min_length=1, max_length=64)
|
|
|
|
|
|
class PasscodeVerifyBody(BaseModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
passcode: str = Field(min_length=1, max_length=64)
|
|
# v0.11.0 — same trust-device opt-in as the OTC verify body.
|
|
trust_device: bool = False
|
|
|
|
|
|
class InviteClaimBody(BaseModel):
|
|
"""v0.17.0 / roadmap item #16 — claim an admin-issued invite token.
|
|
|
|
The frontend `/invites/claim?token=…` page reads the token from
|
|
the URL and POSTs it here. The body bound matches the
|
|
`secrets.token_urlsafe(32)` output shape (~43 URL-safe chars);
|
|
the upper bound stays generous in case `TOKEN_BYTES` is ever
|
|
raised. The token-shape is opaque to this layer — `invites.claim`
|
|
bcrypt-checks it against the active candidate set.
|
|
"""
|
|
token: str = Field(min_length=1, max_length=512)
|
|
# v0.11.0-style opt-in: the claim flow's "trust this device" gesture
|
|
# is bundled here so the invitee can land trusted on first sign-in
|
|
# without an extra roundtrip. Defaults to false so the gesture is
|
|
# explicit (the frontend modal renders a checkbox alongside the
|
|
# claim CTA).
|
|
trust_device: bool = False
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
config = load_config()
|
|
db.run_migrations(config)
|
|
db.init(config)
|
|
# v0.52.0: shout if the deployed-env E2E test-auth shortcut is live.
|
|
# It mints owner sessions for one configured identity (see
|
|
# `/auth/test/login`); it must only ever be on for a pre-prod (PPE)
|
|
# host. A loud startup line means an accidental prod enablement is
|
|
# visible in the logs rather than silent.
|
|
if os.environ.get("E2E_TEST_AUTH_SECRET", "").strip() and os.environ.get(
|
|
"E2E_TEST_AUTH_EMAIL", ""
|
|
).strip():
|
|
log.warning(
|
|
"E2E TEST-AUTH IS ENABLED: POST /auth/test/login will mint an owner "
|
|
"session for %s. This must NEVER be set on production.",
|
|
os.environ["E2E_TEST_AUTH_EMAIL"].strip(),
|
|
)
|
|
gitea = Gitea(config)
|
|
# §22 framework heal: reconcile a divergent default-project collection id
|
|
# (migration 029's ≥2-projects seed names it after the project, e.g. 'ohm',
|
|
# but the mirror expects 'default') BEFORE the mirror runs, so the mirror
|
|
# merges onto the canonical 'default' collection instead of duplicating it.
|
|
# Idempotent no-op on fresh / single-project / already-aligned deployments.
|
|
projects.reconcile_default_collection_id(config)
|
|
# §22.2: mirror the registry before anything reads projects/content_repo.
|
|
# First boot has no last-good rows, so a missing/invalid registry is fatal
|
|
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
|
# fresh thereafter and tolerates a later bad PR.
|
|
try:
|
|
await registry_mod.refresh_registry(config, gitea)
|
|
except Exception as e:
|
|
raise RuntimeError(
|
|
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
|
) from e
|
|
# §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the
|
|
# deployment's configured id (DEFAULT_PROJECT_ID) once the registry row
|
|
# exists, so the original corpus lands at a meaningful /p/<id>/ and
|
|
# 'default' is never a public URL. Idempotent no-op once done.
|
|
projects.restamp_default_project(config)
|
|
if projects.default_content_repo(config) is None:
|
|
raise RuntimeError(
|
|
f"registry does not describe the default project "
|
|
f"{projects.resolved_default_id(config)!r} (no content_repo). "
|
|
f"Add it to {config.registry_repo_full}/projects.yaml."
|
|
)
|
|
bot = Bot(gitea)
|
|
reconciler = cache.Reconciler(config, gitea)
|
|
digest_sched = digest.DigestScheduler()
|
|
hygiene_sched = hygiene.HygieneScheduler(config=config, bot=bot)
|
|
|
|
# §18 carryover: the multi-provider LLM abstraction. Provider
|
|
# construction can fail (missing key, wrong env value) — if it does,
|
|
# the rest of the app still serves; chat endpoints surface a clear
|
|
# 503 instead of crashing the process.
|
|
try:
|
|
providers = providers_mod.load_from_config(config)
|
|
except Exception:
|
|
log.exception("provider construction failed; chat will be disabled")
|
|
providers = {}
|
|
|
|
app.state.config = config
|
|
app.state.gitea = gitea
|
|
app.state.bot = bot
|
|
app.state.reconciler = reconciler
|
|
app.state.providers = providers
|
|
|
|
app.include_router(_oauth_router(config))
|
|
app.include_router(api_routes.make_router(config, gitea, bot, providers))
|
|
app.include_router(webhooks.make_router(config, gitea))
|
|
|
|
reconciler.start()
|
|
digest_sched.start()
|
|
hygiene_sched.start()
|
|
log.info("RFC app started — registry %s", config.registry_repo_full)
|
|
try:
|
|
yield
|
|
finally:
|
|
await hygiene_sched.stop()
|
|
await digest_sched.stop()
|
|
await reconciler.stop()
|
|
await gitea.close()
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
# The secret key is required at app construction (SessionMiddleware
|
|
# is added before lifespan runs), so we read just that one value
|
|
# eagerly via load_config(). Everything else waits for lifespan.
|
|
config = load_config()
|
|
app = FastAPI(lifespan=lifespan)
|
|
# v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
|
|
# auth credential and must carry `Secure` in production so it never
|
|
# travels cleartext. Default to Secure; a dev box serving over plain
|
|
# http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
|
|
# HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
|
|
session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
|
|
"0", "false", "no", "off",
|
|
)
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=config.secret_key,
|
|
session_cookie="rfc_session",
|
|
max_age=60 * 60 * 24 * 30,
|
|
https_only=session_secure,
|
|
)
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
def _set_device_trust_cookie(response: Response, cookie_value: str) -> None:
|
|
"""Attach the v0.11.0 device-trust cookie to the response.
|
|
|
|
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. As of
|
|
v0.25.0 (audit 0026 M1) the value is `IssueOutcome.cookie_value` —
|
|
"<row_id>.<raw_token>" — so `device_trust.lookup` can read one indexed
|
|
row instead of scanning; server-side storage remains the bcrypt hash
|
|
of the token half only. The cookie is "essential" per the v0.13.0
|
|
cookie-consent contract (it is part of authentication), so we set it
|
|
regardless of the user's analytics / other-cookies choice.
|
|
|
|
Secure=True means the cookie is only ever sent over HTTPS — the
|
|
device-trust cookie holds a 30-day credential and must never travel
|
|
cleartext. (The session cookie now also defaults to Secure; see M4 in
|
|
`create_app`.)
|
|
"""
|
|
response.set_cookie(
|
|
key=device_trust_mod.COOKIE_NAME,
|
|
value=cookie_value,
|
|
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
|
|
path="/",
|
|
secure=True,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
|
|
|
|
def _clear_device_trust_cookie(response: Response) -> None:
|
|
"""Delete the device-trust cookie on the response.
|
|
|
|
Used when the framework detects a presented cookie that is
|
|
expired, revoked, or otherwise stale — the next request from
|
|
this device will not carry a dead token.
|
|
"""
|
|
response.delete_cookie(
|
|
key=device_trust_mod.COOKIE_NAME,
|
|
path="/",
|
|
secure=True,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
|
|
|
|
def _oauth_router(config) -> APIRouter:
|
|
router = APIRouter()
|
|
|
|
@router.get("/auth/login")
|
|
async def login(request: Request):
|
|
state = auth.new_state()
|
|
request.session[auth.SESSION_STATE_KEY] = state
|
|
return RedirectResponse(auth.authorization_url(config, state))
|
|
|
|
@router.get("/auth/callback")
|
|
async def callback(request: Request, code: str = "", state: str = ""):
|
|
if not code:
|
|
raise HTTPException(400, "Missing code")
|
|
stored_state = request.session.get(auth.SESSION_STATE_KEY)
|
|
if not stored_state or not secrets.compare_digest(stored_state, state):
|
|
raise HTTPException(400, "Invalid state")
|
|
token_data = await auth.exchange_code(config, code)
|
|
access_token = token_data.get("access_token")
|
|
if not access_token:
|
|
raise HTTPException(400, "Token exchange failed")
|
|
profile = await auth.fetch_user_profile(config, access_token)
|
|
if not auth.is_allowed_sign_in(profile):
|
|
# Private-beta gate: clear any partial OAuth state and bounce to
|
|
# the public /beta-pending page. The session is left empty so the
|
|
# rejected viewer continues as anonymous read-only.
|
|
request.session.pop(auth.SESSION_STATE_KEY, None)
|
|
return RedirectResponse("/beta-pending")
|
|
user = auth.provision_user(config, profile)
|
|
auth.store_session(request, user)
|
|
return RedirectResponse("/")
|
|
|
|
@router.get("/auth/logout")
|
|
async def logout(request: Request):
|
|
request.session.clear()
|
|
return RedirectResponse("/")
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.7.0: email + one-time-code sign-in (§6.2).
|
|
#
|
|
# Replaces the OAuth gesture as the primary human-auth path. The
|
|
# /auth/callback handler above remains functional as a fallback;
|
|
# the new UI no longer surfaces it. A future release retires the
|
|
# OAuth path entirely once every active user has signed in at
|
|
# least once via OTC.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/auth/otc/request")
|
|
async def otc_request(body: OtcRequestBody, request: Request):
|
|
# v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
|
|
# before the Turnstile network call or any bcrypt/SMTP work.
|
|
if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
|
|
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
|
# v0.12.0 / roadmap item #10: gate the request on a successful
|
|
# Turnstile siteverify before the bcrypt hash + SMTP send. The
|
|
# check runs first so a failed challenge spends no rate budget
|
|
# and produces no envelope. When the operator has not wired the
|
|
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
|
|
# opens — see `backend/app/turnstile.py` for the full matrix.
|
|
client_ip = request.client.host if request.client else None
|
|
ts = await turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
|
|
if not ts.ok:
|
|
if ts.reason == "misconfigured":
|
|
# TURNSTILE_REQUIRED=true but the secret is unset. This
|
|
# is an operator/config problem, not a client problem;
|
|
# surface as 500 so the operator notices in their logs
|
|
# rather than blaming the user's browser.
|
|
raise HTTPException(500, "auth misconfigured")
|
|
# missing-token / failed / network → uniform 400 so the
|
|
# response does not enumerate which leg of the challenge
|
|
# broke. The reason is in the server logs.
|
|
raise HTTPException(400, "verification failed")
|
|
|
|
outcome = otc.request_code(body.email)
|
|
if outcome.reason == "cooldown":
|
|
# Loud failure per the rate-limit primitive — the abuse
|
|
# surface should be visible to clients hammering /request.
|
|
raise HTTPException(429, "Wait before requesting another code")
|
|
if outcome.sent and outcome.code is not None:
|
|
email_otc.send_otc_email(body.email.strip(), outcome.code)
|
|
# 202 regardless of allowlist/invalid — don't leak which
|
|
# emails are recognized.
|
|
return {"ok": True}
|
|
|
|
@router.post("/auth/otc/verify")
|
|
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
|
# v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
|
|
# plus the per-email lockout enforced inside otc.verify_code.
|
|
ip = ratelimit.client_key(request)
|
|
if not ratelimit.verify_limiter.allow(ip):
|
|
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
|
result = otc.verify_code(body.email, body.code)
|
|
if result.reason == "locked":
|
|
raise HTTPException(
|
|
423,
|
|
{
|
|
"detail": "Too many failed attempts; wait a few minutes or request a new code",
|
|
"locked_until": result.locked_until,
|
|
},
|
|
)
|
|
if not result.ok or result.user is None:
|
|
raise HTTPException(400, "Invalid or expired code")
|
|
# Legit sign-in: clear this IP's window so a user who fat-fingered
|
|
# a couple of codes isn't left throttled.
|
|
ratelimit.verify_limiter.reset(ip)
|
|
auth.store_session(request, result.user)
|
|
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
|
|
# decide whether to advance to the first/last/why capture step
|
|
# or jump straight to "/". `needs_profile=true` iff the user
|
|
# is `permission_state='pending'` AND the row has no profile
|
|
# fields yet — a fresh OTC user. Grandfathered users
|
|
# (`permission_state='granted'`) and pending users who already
|
|
# captured their fields both read as false.
|
|
row = db.conn().execute(
|
|
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
|
(result.user.user_id,),
|
|
).fetchone()
|
|
first_name = (row["first_name"] if row else None) or ""
|
|
last_name = (row["last_name"] if row else None) or ""
|
|
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
|
|
needs_profile = (
|
|
result.user.permission_state == "pending"
|
|
and not first_name
|
|
and not last_name
|
|
and not beta_request_reason
|
|
)
|
|
# v0.11.0 — opt-in device trust. The checkbox lives on the
|
|
# Login.jsx OTC step; when true, the server mints a fresh
|
|
# device-trust row and sets the long-lived cookie. The cookie
|
|
# is "essential" per the v0.13.0 consent contract (it is part
|
|
# of authentication, not analytics) so it lands regardless of
|
|
# the user's analytics / other-cookies choice. We capture the
|
|
# User-Agent at issuance so the /settings/devices surface can
|
|
# render a rough device label.
|
|
if body.trust_device:
|
|
ua = request.headers.get("user-agent", "")
|
|
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
|
_set_device_trust_cookie(response, outcome.cookie_value)
|
|
return {
|
|
"ok": True,
|
|
"user": {
|
|
"id": result.user.user_id,
|
|
"display_name": result.user.display_name,
|
|
"email": result.user.email,
|
|
"role": result.user.role,
|
|
"permission_state": result.user.permission_state,
|
|
},
|
|
"needs_profile": needs_profile,
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.52.0: deployed-environment E2E test-auth shortcut.
|
|
#
|
|
# Running the Playwright E2E suite against a *deployed* environment
|
|
# (PPE) is the §9 pre-prod gate. But the deployed env has neither of
|
|
# the two scaffolds the Tier-1 docker stack relies on for auth: a
|
|
# Mailpit sink to read the OTC code from, and direct SQLite access to
|
|
# inject a granted-owner row. This endpoint replaces both with a
|
|
# single gated gesture: it mints an authenticated OWNER session for
|
|
# one pre-configured throwaway identity.
|
|
#
|
|
# It is FAIL-CLOSED and must never function in production:
|
|
# * 404 unless BOTH `E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`
|
|
# are set — a prod deployment that sets neither cannot be coaxed
|
|
# into minting a session, and the route is invisible.
|
|
# * The caller must present the shared secret in `X-Test-Auth-Secret`
|
|
# (constant-time compare); a wrong/absent secret 404s (the route
|
|
# does not advertise itself to an unauthenticated caller).
|
|
# * Only the one configured email may be minted; any other address
|
|
# is refused (403). So an enabled PPE exposes exactly one
|
|
# throwaway owner identity, with the secret as the trust boundary.
|
|
#
|
|
# The hard secrets rule (§6.3) holds: the secret is a Secret Manager
|
|
# ref injected as env on the VM (never a literal in the repo), and the
|
|
# E2E runner presents it from SM at runtime (never echoed).
|
|
@router.post("/auth/test/login")
|
|
async def test_login(body: TestLoginBody, request: Request):
|
|
secret = os.environ.get("E2E_TEST_AUTH_SECRET", "").strip()
|
|
configured_email = os.environ.get("E2E_TEST_AUTH_EMAIL", "").strip()
|
|
# Feature off (the default, incl. production): route is invisible.
|
|
if not secret or not configured_email:
|
|
raise HTTPException(404, "Not Found")
|
|
presented = request.headers.get("x-test-auth-secret", "")
|
|
if not secrets.compare_digest(presented, secret):
|
|
# Don't reveal that the route exists to a caller without the
|
|
# secret — mirror the "off" shape exactly.
|
|
raise HTTPException(404, "Not Found")
|
|
if body.email.strip().lower() != configured_email.lower():
|
|
raise HTTPException(403, "email not permitted")
|
|
|
|
# Provision-or-link the row, then force it to a granted owner so
|
|
# the metadata write paths (SLICE-4/5) accept it — the deployed
|
|
# equivalent of the Tier-1 docker-compose backend-seed owner row.
|
|
user = otc.provision_or_link_user(body.email)
|
|
db.conn().execute(
|
|
"UPDATE users SET role = 'owner', permission_state = 'granted', "
|
|
"last_seen_at = datetime('now') WHERE id = ?",
|
|
(user.user_id,),
|
|
)
|
|
db.conn().commit()
|
|
user.role = "owner"
|
|
user.permission_state = "granted"
|
|
auth.store_session(request, user)
|
|
return {"ok": True}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
|
|
#
|
|
# After a successful OTC sign-in, a contributor may set a passcode
|
|
# and use email + passcode for subsequent sign-ins. OTC remains the
|
|
# forgot-passcode fallback — a verify failure beyond 5 consecutive
|
|
# attempts locks the passcode path for 15 minutes; the OTC path is
|
|
# unaffected by the lockout.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/auth/passcode/check")
|
|
async def passcode_check(request: Request, email: str = ""):
|
|
"""Does this email have a passcode set? Anonymous endpoint —
|
|
the Login.jsx flow calls this after the user types their email
|
|
to decide whether to render a passcode input or fall back to
|
|
OTC. We surface only the boolean; lockout state, the hash, and
|
|
the set-at stamp are not leaked here.
|
|
|
|
v0.25.0 (audit 0026 L3): per-IP rate limit so the has-passcode
|
|
boolean can't be bulk-harvested to enumerate accounts."""
|
|
if not ratelimit.check_limiter.allow(ratelimit.client_key(request)):
|
|
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
|
status = passcode_mod.passcode_status(email)
|
|
return {"has_passcode": status.has_passcode}
|
|
|
|
@router.post("/auth/passcode/set")
|
|
async def passcode_set(body: PasscodeSetBody, request: Request):
|
|
"""Set or replace the signed-in user's passcode. Requires an
|
|
active session (OTC- or passcode-authenticated)."""
|
|
user = auth.require_user(request)
|
|
try:
|
|
passcode_mod.set_passcode(user.user_id, body.passcode)
|
|
except passcode_mod.PasscodeValidationError as e:
|
|
raise HTTPException(422, str(e))
|
|
return {"ok": True}
|
|
|
|
@router.delete("/auth/passcode")
|
|
async def passcode_delete(request: Request):
|
|
"""Remove the signed-in user's passcode. The user is back to
|
|
OTC-only on next sign-in."""
|
|
user = auth.require_user(request)
|
|
passcode_mod.clear_passcode(user.user_id)
|
|
return {"ok": True}
|
|
|
|
@router.post("/auth/passcode/verify")
|
|
async def passcode_verify(body: PasscodeVerifyBody, request: Request, response: Response):
|
|
"""Sign in with email + passcode. Returns the standard session
|
|
payload on success; HTTP 423 with `locked_until` when the
|
|
account is in the lockout window; HTTP 400 for every other
|
|
failure (the wrong-vs-unknown distinction is intentionally
|
|
collapsed so a probing client cannot enumerate emails).
|
|
|
|
v0.11.0: the body's `trust_device` flag, if true, mints a
|
|
fresh device-trust row and sets the long-lived cookie. Same
|
|
opt-in contract as `/auth/otc/verify`."""
|
|
# v0.25.0 (audit 0026 H1): per-IP brake in front of the per-account
|
|
# passcode lockout, so fan-out across emails is throttled too.
|
|
ip = ratelimit.client_key(request)
|
|
if not ratelimit.verify_limiter.allow(ip):
|
|
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
|
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
|
if result.reason == "locked":
|
|
raise HTTPException(
|
|
423,
|
|
{
|
|
"detail": "Too many failed attempts; sign in with a one-time code instead",
|
|
"locked_until": result.locked_until,
|
|
},
|
|
)
|
|
if not result.ok or result.user is None:
|
|
raise HTTPException(400, "Invalid passcode")
|
|
ratelimit.verify_limiter.reset(ip)
|
|
auth.store_session(request, result.user)
|
|
if body.trust_device:
|
|
ua = request.headers.get("user-agent", "")
|
|
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
|
_set_device_trust_cookie(response, outcome.cookie_value)
|
|
return {
|
|
"ok": True,
|
|
"user": {
|
|
"id": result.user.user_id,
|
|
"display_name": result.user.display_name,
|
|
"email": result.user.email,
|
|
"role": result.user.role,
|
|
},
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
|
|
#
|
|
# The admin-create surface lives at POST /api/admin/users (see
|
|
# api_admin.py); this endpoint is the corresponding claim path the
|
|
# invitee hits when they click the link in their invite email.
|
|
# The frontend route `/invites/claim?token=…` reads the token from
|
|
# the URL and POSTs it here.
|
|
#
|
|
# The claim itself is the first-sign-in for the invitee: clicking
|
|
# the unique token in the email is proof of email control per the
|
|
# roadmap, so this endpoint skips the OTC step entirely on first
|
|
# sign-in. The session cookie lands; the response tells the
|
|
# frontend whether to route to passcode-set (if v0.10.0 passcode
|
|
# flow is in play and the user has not yet set a passcode) or to
|
|
# home.
|
|
#
|
|
# The endpoint is anonymous-reachable: the entire point is to
|
|
# establish the session, so we do not gate it on `require_user`.
|
|
# The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
|
|
# contract (the body's `trust_device` flag, when true, mints a
|
|
# fresh device-trust row on the same response so the invitee
|
|
# lands trusted on their first device).
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/invites/claim")
|
|
async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
|
|
result = invites_mod.claim(body.token)
|
|
if result.reason == "expired":
|
|
# The token's TTL window passed without a claim. HTTP 410
|
|
# (Gone) so the frontend can render a "this invite has
|
|
# expired — please contact the admin for a fresh one"
|
|
# message distinct from the generic invalid-token shape.
|
|
raise HTTPException(410, "This invite has expired")
|
|
if result.reason == "claimed":
|
|
# The token was already consumed. HTTP 410 for the same
|
|
# reason — the row is dead either way.
|
|
raise HTTPException(410, "This invite has already been claimed")
|
|
if not result.ok or result.user is None:
|
|
# 'unknown' / 'invalid' — the token does not match any
|
|
# active invite row. HTTP 400 so it reads distinct from
|
|
# the dead-token shape above.
|
|
raise HTTPException(400, "Invalid invite token")
|
|
|
|
# Establish the session. From here on the invitee is signed
|
|
# in as the pre-provisioned user row carrying their
|
|
# pre-assigned role.
|
|
auth.store_session(request, result.user)
|
|
|
|
# v0.11.0 — opt-in device trust on the claim response. Same
|
|
# contract as OTC/passcode verify: when the body's flag is
|
|
# true, the server mints a fresh device-trust row and sets
|
|
# the long-lived cookie, so the invitee skips the email step
|
|
# on subsequent visits to the same browser.
|
|
if body.trust_device:
|
|
ua = request.headers.get("user-agent", "")
|
|
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
|
_set_device_trust_cookie(response, outcome.cookie_value)
|
|
|
|
# Has the user already set a passcode? (Could only happen via
|
|
# an admin pre-population path that doesn't exist yet, but
|
|
# the response shape mirrors `/api/auth/me` so the frontend
|
|
# can read it without a second call.) If `needs_passcode` is
|
|
# true and v0.10.0 passcode flow is in play, the frontend
|
|
# routes to /settings/notifications#sign-in to set a passcode
|
|
# immediately; otherwise it routes to /.
|
|
row = db.conn().execute(
|
|
"SELECT passcode_hash FROM users WHERE id = ?",
|
|
(result.user.user_id,),
|
|
).fetchone()
|
|
has_passcode = bool(row and row["passcode_hash"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"user": {
|
|
"id": result.user.user_id,
|
|
"display_name": result.user.display_name,
|
|
"email": result.user.email,
|
|
"role": result.user.role,
|
|
"permission_state": result.user.permission_state,
|
|
},
|
|
# Roadmap §16: the claim flow skips OTC entirely; the
|
|
# natural next step is passcode-set (so the invitee can
|
|
# sign back in without needing an email roundtrip on their
|
|
# second visit). The frontend uses this hint to decide
|
|
# whether to route to the passcode-set screen or to home.
|
|
"needs_passcode": not has_passcode,
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
|
#
|
|
# The /auth/device-trust/start endpoint resolves a presented
|
|
# `rfc_device_trust` cookie. If it matches a non-expired,
|
|
# non-revoked row, the session is re-established and the client
|
|
# is told to skip OTC/passcode entry. A stale cookie (expired or
|
|
# revoked) is cleared on the response. A miss is structurally
|
|
# silent — the client falls back to the email step.
|
|
#
|
|
# The endpoint is anonymous-reachable: a returning visitor with
|
|
# the cookie hits this before the email step. We do not gate it
|
|
# on a session because the entire point is to establish one.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/auth/device-trust/start")
|
|
async def device_trust_start(request: Request):
|
|
"""Sign in via a presented device-trust cookie.
|
|
|
|
On a hit, re-establishes the session in the cookie store and
|
|
returns a user payload shaped like /auth/otc/verify (minus
|
|
`needs_profile`, which a returning device-trust user is
|
|
structurally past — they signed in at least once before).
|
|
On a miss, returns 401 + clears the stale cookie. An
|
|
'unknown' miss (cookie present but no row matches) also
|
|
clears, since the token is dead to the server either way.
|
|
|
|
Note on response construction: we return a `JSONResponse`
|
|
directly rather than raising `HTTPException` on the miss
|
|
path because FastAPI's exception handler builds a new
|
|
response from scratch and would drop any `set_cookie` /
|
|
`delete_cookie` calls. The hand-built `JSONResponse` lets
|
|
us attach the cookie-clear header alongside the 401.
|
|
"""
|
|
raw = request.cookies.get(device_trust_mod.COOKIE_NAME, "")
|
|
if not raw:
|
|
return JSONResponse({"detail": "No device trust"}, status_code=401)
|
|
outcome = device_trust_mod.lookup(raw)
|
|
if not outcome.ok or outcome.user is None:
|
|
# Clear the stale cookie so subsequent requests don't
|
|
# keep replaying a dead token. We surface 401 in all
|
|
# cases so a probing client can't tell "your row was
|
|
# revoked" from "this token never existed".
|
|
response = JSONResponse({"detail": "Device trust invalid"}, status_code=401)
|
|
_clear_device_trust_cookie(response)
|
|
return response
|
|
auth.store_session(request, outcome.user)
|
|
return {
|
|
"ok": True,
|
|
"user": {
|
|
"id": outcome.user.user_id,
|
|
"display_name": outcome.user.display_name,
|
|
"email": outcome.user.email,
|
|
"role": outcome.user.role,
|
|
"permission_state": outcome.user.permission_state,
|
|
},
|
|
}
|
|
|
|
return router
|