Release 0.10.0: user-set passcodes (OTC stays as fallback)

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>
This commit is contained in:
Ben Stull
2026-05-28 02:24:38 -07:00
parent ca8ba69acb
commit 55beba5c0a
13 changed files with 1967 additions and 191 deletions
+78
View File
@@ -24,6 +24,7 @@ from . import (
email_otc,
hygiene,
otc,
passcode as passcode_mod,
providers as providers_mod,
webhooks,
)
@@ -44,6 +45,15 @@ class OtcVerifyBody(BaseModel):
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()
@@ -204,4 +214,72 @@ def _oauth_router(config) -> APIRouter:
"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