Release 0.7.0: email/OTC sign-in (Gitea OAuth retained as fallback)

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>
This commit is contained in:
Ben Stull
2026-05-28 00:55:29 -07:00
parent c92730a737
commit 2590c244d6
18 changed files with 1483 additions and 18 deletions
+61 -1
View File
@@ -12,9 +12,21 @@ 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, hygiene, providers as providers_mod, webhooks
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
@@ -23,6 +35,15 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
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()
@@ -122,4 +143,43 @@ def _oauth_router(config) -> APIRouter:
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