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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user