55beba5c0a
After a successful OTC sign-in, a contributor can set a passcode (4-20 characters, bcrypt-hashed) and use email + passcode for subsequent sign-ins. OTC remains the structural fallback: five consecutive failed verifies lock the passcode path for 15 minutes (HTTP 423), and a forgotten passcode is recovered by requesting a fresh code. Migration 015_passcode.sql adds four nullable columns to the users table; existing rows pass through as OTC-only and can opt into a passcode from a new Sign-in tab in /settings/notifications. The /login surface is extended to a five-step flow (email → either passcode or OTC code → optional post-OTC passcode offer → optional set-passcode). SPEC corrections per §19.3 rule 2: §6 names the three auth paths, §14.1 documents the stepped login flow, §17 lists the four new /auth/passcode/* endpoints, §19.2 surfaces four new candidates and refreshes the cross-refs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
11 KiB
Python
286 lines
11 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 secrets
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel, Field
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from . import (
|
|
api as api_routes,
|
|
auth,
|
|
cache,
|
|
db,
|
|
digest,
|
|
email_otc,
|
|
hygiene,
|
|
otc,
|
|
passcode as passcode_mod,
|
|
providers as providers_mod,
|
|
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)
|
|
|
|
|
|
class OtcVerifyBody(BaseModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
code: str = Field(min_length=1, max_length=16)
|
|
|
|
|
|
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)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
config = load_config()
|
|
db.run_migrations(config)
|
|
db.init(config)
|
|
gitea = Gitea(config)
|
|
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 — meta repo %s/%s", config.gitea_org, config.meta_repo)
|
|
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)
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=config.secret_key,
|
|
session_cookie="rfc_session",
|
|
max_age=60 * 60 * 24 * 30,
|
|
https_only=False,
|
|
)
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
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):
|
|
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):
|
|
result = otc.verify_code(body.email, body.code)
|
|
if not result.ok or result.user is None:
|
|
raise HTTPException(400, "Invalid or expired code")
|
|
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
|
|
)
|
|
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.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(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."""
|
|
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):
|
|
"""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)."""
|
|
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")
|
|
auth.store_session(request, result.user)
|
|
return {
|
|
"ok": True,
|
|
"user": {
|
|
"id": result.user.user_id,
|
|
"display_name": result.user.display_name,
|
|
"email": result.user.email,
|
|
"role": result.user.role,
|
|
},
|
|
}
|
|
|
|
return router
|