2590c244d6
Replaces the Gitea OAuth gesture as the primary human-auth path (roadmap item #5, SPEC §6.2). Users sign in by entering their email, receiving a six-digit code via the existing SMTP layer, and entering the code on a two-step /login surface. The Gitea OAuth callback remains functional during migration — the new UI links to it as a fallback for users with active OAuth sessions or older invite paths — and is scheduled for removal in a future release once OTC adoption is universal. Existing users are linked by email on first OTC sign- in (gitea_id preserved); new users are provisioned with NULL gitea_id and rely on email as the identity key. The migration introduces backend/migrations/012_otc.sql (otc_codes table + users schema rebuild for nullable gitea_id and a partial unique index on email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify), bcrypt as a new backend dependency for code hashing, and 11 new tests in test_otc_vertical.py covering the happy path, expired and consumed and wrong codes, the per-email rate limit, the allowlist gate, the OAuth-era link path, fresh provisioning, and prior-code invalidation on re-request. No new secrets are required — the existing SECRET_KEY signs sessions and bcrypt's per-row salt covers the code hashes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
6.3 KiB
Python
186 lines
6.3 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,
|
|
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)
|
|
|
|
|
|
@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)
|
|
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
|