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:
+9
-6
@@ -131,13 +131,12 @@ def make_router(
|
||||
user = auth.current_user(request)
|
||||
if user is None:
|
||||
return {"authenticated": False, "user": None}
|
||||
# v0.8.0: surface `permission_state` plus the capture-flow
|
||||
# readiness signal (`needs_profile`). The frontend gates
|
||||
# the /beta-pending page and the inline banner off these
|
||||
# fields, and decides whether to prompt for the first/last/why
|
||||
# capture on first OTC sign-in.
|
||||
# v0.8.0 + v0.10.0: single round-trip for everything the
|
||||
# frontend gates UI off of — beta-access state + passcode state.
|
||||
row = db.conn().execute(
|
||||
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
||||
"SELECT first_name, last_name, beta_request_reason, "
|
||||
"passcode_hash, passcode_set_at "
|
||||
"FROM users WHERE id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
first_name = (row["first_name"] if row else None) or ""
|
||||
@@ -153,6 +152,8 @@ def make_router(
|
||||
and not last_name
|
||||
and not beta_request_reason
|
||||
)
|
||||
has_passcode = bool(row and row["passcode_hash"])
|
||||
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
@@ -167,6 +168,8 @@ def make_router(
|
||||
"last_name": last_name,
|
||||
"beta_request_reason": beta_request_reason,
|
||||
"needs_profile": needs_profile,
|
||||
"has_passcode": has_passcode,
|
||||
"passcode_set_at": passcode_set_at,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""§6.2 / v0.10.0: user-set passcodes after OTC (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 fallback
|
||||
— a forgotten passcode is recovered by requesting a fresh OTC.
|
||||
|
||||
This module is the state machine behind the four `/auth/passcode/*`
|
||||
endpoints (`set`, `clear`, `verify`, `check`). The endpoints in
|
||||
`main.py` thin-wrap these helpers in the same shape the OTC module
|
||||
uses (see `otc.py`).
|
||||
|
||||
Shape:
|
||||
|
||||
* `set_passcode(user_id, passcode)` — bcrypt-hash the passcode and
|
||||
write it to `users.passcode_hash` + `users.passcode_set_at`.
|
||||
Validation (length, denylist) happens here, not at the endpoint,
|
||||
so the rule lives in one place. Replaces any prior passcode.
|
||||
* `clear_passcode(user_id)` — null out `passcode_hash` and
|
||||
`passcode_set_at`. The user is back to OTC-only.
|
||||
* `verify_passcode(email, passcode)` — locate the user by email,
|
||||
check lockout, compare via bcrypt, manage the failure counter,
|
||||
and return a populated `SessionUser` on success.
|
||||
* `passcode_status(email)` — does this email have a passcode set?
|
||||
Used by the `/auth/passcode/check` endpoint that the Login.jsx
|
||||
flow consults after the user types their email.
|
||||
|
||||
Lockout is a v1 shape: 5 consecutive failures sets
|
||||
`passcode_locked_until` to `now + 15 minutes`, after which a verify
|
||||
attempt that lands inside the window returns HTTP 423. The OTC path
|
||||
is unaffected by the lockout — a user can request and verify a fresh
|
||||
OTC to sign in while their passcode is locked out, and `verify_code`
|
||||
in `otc.py` does not consult these columns.
|
||||
|
||||
The lockout window and the failure threshold are hard-coded here.
|
||||
Tuning them via env vars (or moving to per-IP rate-limiting) is a
|
||||
§19.2 candidate; see SPEC §19.2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunables — intentionally hard-coded in v0.10.0 (see module docstring).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
PASSCODE_MIN_LENGTH = 4
|
||||
PASSCODE_MAX_LENGTH = 20
|
||||
|
||||
# A small denylist of patterns we never want a passcode to be. The
|
||||
# rule is "no obvious patterns"; the list is deliberately small —
|
||||
# every entry here is a verbatim string match. A heavier check
|
||||
# (sequential digits, single-character runs of length >= N, etc.)
|
||||
# is a §19.2 candidate.
|
||||
PASSCODE_DENYLIST: frozenset[str] = frozenset(
|
||||
{
|
||||
"0000",
|
||||
"1111",
|
||||
"2222",
|
||||
"3333",
|
||||
"4444",
|
||||
"5555",
|
||||
"6666",
|
||||
"7777",
|
||||
"8888",
|
||||
"9999",
|
||||
"1234",
|
||||
"12345",
|
||||
"123456",
|
||||
"1234567",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"0123",
|
||||
"01234",
|
||||
"012345",
|
||||
"0123456",
|
||||
"01234567",
|
||||
"012345678",
|
||||
"0123456789",
|
||||
"abcd",
|
||||
"abcde",
|
||||
"abcdef",
|
||||
"qwer",
|
||||
"qwerty",
|
||||
"asdf",
|
||||
"asdfg",
|
||||
"asdfgh",
|
||||
"aaaa",
|
||||
"bbbb",
|
||||
"cccc",
|
||||
"password",
|
||||
"letmein",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PasscodeValidationError(Exception):
|
||||
"""The proposed passcode failed validation. The endpoint surface
|
||||
maps this to HTTP 422 with the message intact."""
|
||||
|
||||
|
||||
def _validate(passcode: str) -> str:
|
||||
"""Return the normalized passcode (stripped) or raise.
|
||||
|
||||
Rules:
|
||||
* 4-20 characters after stripping leading/trailing whitespace.
|
||||
* Not on the small denylist of obvious patterns.
|
||||
|
||||
No character-class restriction beyond that — the spec says
|
||||
"numeric PIN or short alphanumeric"; we don't refuse other
|
||||
characters because the entropy isn't load-bearing (the per-account
|
||||
lockout is what carries the security weight, mirroring the OTC
|
||||
shape from v0.7.0).
|
||||
"""
|
||||
pc = (passcode or "").strip()
|
||||
if not pc:
|
||||
raise PasscodeValidationError("Passcode is required")
|
||||
if len(pc) < PASSCODE_MIN_LENGTH:
|
||||
raise PasscodeValidationError(
|
||||
f"Passcode must be at least {PASSCODE_MIN_LENGTH} characters"
|
||||
)
|
||||
if len(pc) > PASSCODE_MAX_LENGTH:
|
||||
raise PasscodeValidationError(
|
||||
f"Passcode must be at most {PASSCODE_MAX_LENGTH} characters"
|
||||
)
|
||||
if pc.lower() in PASSCODE_DENYLIST:
|
||||
raise PasscodeValidationError("Passcode is too common; pick something less obvious")
|
||||
return pc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hash(passcode: str) -> str:
|
||||
return bcrypt.hashpw(passcode.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def _check(passcode: str, passcode_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(passcode.encode("utf-8"), passcode_hash.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set / clear
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_passcode(user_id: int, passcode: str) -> None:
|
||||
"""Hash and store the passcode. Replaces any prior passcode on the
|
||||
same row; clears the failure counter and lockout (a user setting a
|
||||
fresh passcode is implicitly re-authenticating their account)."""
|
||||
pc = _validate(passcode)
|
||||
h = _hash(pc)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_hash = ?,
|
||||
passcode_set_at = datetime('now'),
|
||||
passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(h, user_id),
|
||||
)
|
||||
|
||||
|
||||
def clear_passcode(user_id: int) -> None:
|
||||
"""Remove the passcode. The user is back to OTC-only on next sign-in."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_hash = NULL,
|
||||
passcode_set_at = NULL,
|
||||
passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check (status surface for the Login.jsx flow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasscodeStatus:
|
||||
"""The shape `/auth/passcode/check` returns.
|
||||
|
||||
`has_passcode` is the only signal the frontend needs to decide
|
||||
whether to show a passcode input or an OTC request step. We do
|
||||
not leak the hash, the set-at timestamp, or the lockout state —
|
||||
a probing client that wants to know "is this account locked
|
||||
out" can attempt a verify and read the 423.
|
||||
"""
|
||||
has_passcode: bool
|
||||
|
||||
|
||||
def passcode_status(email: str) -> PasscodeStatus:
|
||||
email = (email or "").strip()
|
||||
if not email or "@" not in email:
|
||||
return PasscodeStatus(has_passcode=False)
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_hash FROM users WHERE email = ? COLLATE NOCASE",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return PasscodeStatus(has_passcode=False)
|
||||
return PasscodeStatus(has_passcode=bool(row["passcode_hash"]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyOutcome:
|
||||
"""Result of a `verify_passcode` call.
|
||||
|
||||
`reason` distinguishes the failure modes the endpoint surfaces as
|
||||
distinct HTTP shapes:
|
||||
* 'ok' — populated `user`, HTTP 200.
|
||||
* 'unknown' — no user with this email, HTTP 400 (generic).
|
||||
* 'no_passcode' — user exists but never set a passcode, HTTP 400
|
||||
(the frontend should fall back to OTC).
|
||||
* 'locked' — user is currently in the lockout window, HTTP
|
||||
423. `locked_until` carries the ISO-8601 stamp for the client.
|
||||
* 'wrong' — passcode didn't match. HTTP 400. If the failure
|
||||
crossed the lockout threshold the row is now locked; the
|
||||
endpoint surfaces this as a fresh `locked` response on the
|
||||
next attempt rather than collapsing the two states here.
|
||||
"""
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str
|
||||
locked_until: str | None = None
|
||||
|
||||
|
||||
def verify_passcode(email: str, passcode: str) -> VerifyOutcome:
|
||||
email = (email or "").strip()
|
||||
passcode = (passcode or "").strip()
|
||||
if not email or not passcode:
|
||||
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||
passcode_hash, passcode_failed_attempts, passcode_locked_until
|
||||
FROM users
|
||||
WHERE email = ? COLLATE NOCASE
|
||||
""",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||
if not row["passcode_hash"]:
|
||||
return VerifyOutcome(ok=False, user=None, reason="no_passcode")
|
||||
|
||||
# Lockout check: if `passcode_locked_until` is populated and in the
|
||||
# future, the verify is refused without touching the hash. Once the
|
||||
# window has elapsed we let the verify proceed; the failed-attempts
|
||||
# counter is also reset so the user gets a fresh 5-attempt budget.
|
||||
locked_until = row["passcode_locked_until"]
|
||||
if locked_until:
|
||||
still_locked = db.conn().execute(
|
||||
"SELECT datetime(?) > datetime('now') AS still_locked",
|
||||
(locked_until,),
|
||||
).fetchone()["still_locked"]
|
||||
if still_locked:
|
||||
return VerifyOutcome(
|
||||
ok=False,
|
||||
user=None,
|
||||
reason="locked",
|
||||
locked_until=locked_until,
|
||||
)
|
||||
# Lockout expired — clear the counter so the next failure starts
|
||||
# from zero, and continue with the verify.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
|
||||
if _check(passcode, row["passcode_hash"]):
|
||||
# Success: clear the counter (a single success wipes the
|
||||
# accumulated failures — the threshold tracks *consecutive*
|
||||
# failures).
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL,
|
||||
last_seen_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
return VerifyOutcome(
|
||||
ok=True,
|
||||
user=SessionUser(
|
||||
user_id=row["id"],
|
||||
gitea_id=row["gitea_id"] or 0,
|
||||
gitea_login=row["gitea_login"] or "",
|
||||
display_name=row["display_name"],
|
||||
email=row["email"] or email,
|
||||
avatar_url=row["avatar_url"] or "",
|
||||
role=row["role"],
|
||||
),
|
||||
reason="ok",
|
||||
)
|
||||
|
||||
# Failure: increment the counter. If this push crosses the
|
||||
# threshold, stamp the lockout. The next verify attempt against
|
||||
# the same row returns 423 with the `locked_until` stamp.
|
||||
next_count = (row["passcode_failed_attempts"] or 0) + 1
|
||||
if next_count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
||||
db.conn().execute(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = ?,
|
||||
passcode_locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(next_count, row["id"]),
|
||||
)
|
||||
new_locked_until = db.conn().execute(
|
||||
"SELECT passcode_locked_until FROM users WHERE id = ?",
|
||||
(row["id"],),
|
||||
).fetchone()["passcode_locked_until"]
|
||||
return VerifyOutcome(
|
||||
ok=False,
|
||||
user=None,
|
||||
reason="locked",
|
||||
locked_until=new_locked_until,
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE users SET passcode_failed_attempts = ? WHERE id = ?",
|
||||
(next_count, row["id"]),
|
||||
)
|
||||
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||
@@ -0,0 +1,52 @@
|
||||
-- §6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
|
||||
--
|
||||
-- After a successful OTC sign-in, a contributor may set a passcode
|
||||
-- (numeric PIN or short alphanumeric). Subsequent sign-ins on the same
|
||||
-- account can use email + passcode instead of email + OTC. OTC remains
|
||||
-- the structural fallback — a forgotten passcode is recovered by
|
||||
-- requesting a fresh OTC and signing in via that path. Per-account
|
||||
-- lockout after 5 consecutive verify failures redirects the user to
|
||||
-- the OTC path for 15 minutes; the OTC path itself is unaffected by
|
||||
-- the passcode lockout (a locked-out user can still receive a fresh
|
||||
-- code and sign in).
|
||||
--
|
||||
-- The columns are additive to the `users` table from `012_otc.sql`.
|
||||
-- v0.8.0's `permission_state` column (roadmap item #6) lands in the
|
||||
-- driver's integration order ahead of this migration; we do not touch
|
||||
-- that column here. v0.7.0's nullable-`gitea_id`/`gitea_login` shape
|
||||
-- is preserved verbatim.
|
||||
--
|
||||
-- Storage shape:
|
||||
--
|
||||
-- * `passcode_hash` (nullable) — bcrypt hash of the passcode.
|
||||
-- NULL means "no passcode set"; the user is OTC-only.
|
||||
-- * `passcode_set_at` (nullable) — timestamp of the most recent
|
||||
-- `passcode/set` call. Updated when a passcode is set or
|
||||
-- replaced; cleared when the passcode is removed.
|
||||
-- * `passcode_failed_attempts` — count of consecutive failed
|
||||
-- verify attempts since the last successful verify (or since
|
||||
-- the lockout cleared). Resets to 0 on success and on lockout
|
||||
-- expiry. Defaults to 0 so existing rows post-migration are
|
||||
-- not implicitly half-locked.
|
||||
-- * `passcode_locked_until` (nullable) — if populated and the
|
||||
-- timestamp is in the future, passcode verify is refused with
|
||||
-- HTTP 423. Cleared on successful verify after the window
|
||||
-- expires, or by the operator via direct DB intervention if
|
||||
-- ever needed (no admin endpoint surfaces this in v1).
|
||||
--
|
||||
-- v0.10.0 introduces no new env vars. The lockout window (5 attempts,
|
||||
-- 15 minutes) is hard-coded in `backend/app/passcode.py`; raising or
|
||||
-- lowering it is a future-§19.2 candidate. Passcode hashing reuses
|
||||
-- the bcrypt dependency added in v0.7.0 for OTC; no new secret is
|
||||
-- required (the existing `SECRET_KEY` continues to sign sessions).
|
||||
--
|
||||
-- Note on SQLite: ALTER TABLE ... ADD COLUMN is supported, so this
|
||||
-- migration does not need the rebuild dance that `012_otc.sql`
|
||||
-- required. The runner wraps each file in a single BEGIN/COMMIT
|
||||
-- block — see `backend/app/db.py` — so either every ADD COLUMN
|
||||
-- here lands or none do.
|
||||
|
||||
ALTER TABLE users ADD COLUMN passcode_hash TEXT;
|
||||
ALTER TABLE users ADD COLUMN passcode_set_at TEXT;
|
||||
ALTER TABLE users ADD COLUMN passcode_failed_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN passcode_locked_until TEXT;
|
||||
@@ -0,0 +1,532 @@
|
||||
"""End-to-end integration tests for the v0.10.0 user-set passcode
|
||||
vertical (§6.2, roadmap item #8).
|
||||
|
||||
After a successful OTC sign-in the user can set a passcode and use
|
||||
email + passcode for subsequent sign-ins. OTC remains the structural
|
||||
fallback — these tests prove:
|
||||
|
||||
* `/auth/passcode/set` requires an active session.
|
||||
* `/auth/passcode/check` returns `has_passcode` without leaking the
|
||||
hash, the set-at stamp, or the lockout state.
|
||||
* Happy path: OTC sign-in → set passcode → sign out → email +
|
||||
passcode signs in (no OTC roundtrip).
|
||||
* Wrong passcode increments the failure counter without locking.
|
||||
* Five consecutive failures lock the passcode path (HTTP 423) and
|
||||
persist `passcode_locked_until` on the user row.
|
||||
* The lockout expires after `passcode_locked_until`; a verify
|
||||
attempt past the window succeeds again and clears the counter.
|
||||
* The OTC path is unaffected by the passcode lockout — a user
|
||||
whose passcode is locked can still request and verify a fresh
|
||||
OTC to sign in.
|
||||
* Clearing the passcode wipes the hash; subsequent verify refuses
|
||||
with the no-passcode failure shape.
|
||||
* Setting a new passcode replaces the prior one (and resets the
|
||||
failure counter / lockout state).
|
||||
* `passcode_set_at` updates on every set call.
|
||||
* The validation denylist refuses obvious patterns (e.g. `0000`,
|
||||
`1234`).
|
||||
* Passcode length is enforced (4-20).
|
||||
|
||||
The fakes from `test_propose_vertical` give us a working app harness.
|
||||
The OTC envelope buffer from `test_otc_vertical` is reused for the
|
||||
OTC roundtrips this suite needs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — mirror the OTC suite's outbound-buffer helpers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _sign_in_via_otc(client, email: str) -> None:
|
||||
"""Run an OTC request+verify so the client carries an authenticated
|
||||
session. The cooldown is irrelevant on a fresh email; we don't
|
||||
need to drop it."""
|
||||
r = client.post("/auth/otc/request", json={"email": email})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes(email)[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": email, "code": code})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set passcode — auth-required, happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_passcode_requires_session(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_set_passcode_after_otc_landing_persists_hash(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com")
|
||||
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("alice@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["passcode_hash"] is not None
|
||||
# Not the plaintext.
|
||||
assert row["passcode_hash"] != "secret123"
|
||||
assert row["passcode_set_at"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check endpoint — leak-free shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_endpoint_returns_false_for_unknown_email(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/auth/passcode/check", params={"email": "nobody@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"has_passcode": False}
|
||||
|
||||
|
||||
def test_check_endpoint_returns_false_for_user_without_passcode(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "bob@example.com")
|
||||
|
||||
r = client.get("/auth/passcode/check", params={"email": "bob@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"has_passcode": False}
|
||||
|
||||
|
||||
def test_check_endpoint_returns_true_after_set(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "carol@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "letmein9"})
|
||||
|
||||
# Drop the session so the check is read in the anonymous shape.
|
||||
client.cookies.clear()
|
||||
r = client.get("/auth/passcode/check", params={"email": "carol@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"has_passcode": True}
|
||||
# The response carries ONLY the boolean — no hash, no stamp.
|
||||
assert set(r.json().keys()) == {"has_passcode"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify path — happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_passcode_signs_in_user(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "dave@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "dave@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == "dave@example.com"
|
||||
assert me["user"]["has_passcode"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify path — failure modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_passcode_wrong_increments_counter_without_locking(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "erin@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
# Three bad attempts — under the lockout threshold.
|
||||
for _ in range(3):
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "erin@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||
("erin@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_failed_attempts"] == 3
|
||||
assert row["passcode_locked_until"] is None
|
||||
|
||||
|
||||
def test_verify_passcode_locks_after_five_failures(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "frank@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
# Five bad attempts — the last crosses the threshold and the
|
||||
# response shape flips to 423.
|
||||
statuses = []
|
||||
for _ in range(5):
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "frank@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
statuses.append(r.status_code)
|
||||
# First four are 400, the fifth (threshold-crossing) is 423.
|
||||
assert statuses == [400, 400, 400, 400, 423]
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||
("frank@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_failed_attempts"] >= 5
|
||||
assert row["passcode_locked_until"] is not None
|
||||
|
||||
# Sixth attempt — still locked, still 423, even with the correct
|
||||
# passcode (lockout overrides the verify).
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "frank@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 423
|
||||
|
||||
|
||||
def test_verify_passcode_lockout_expires(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "gina@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "gina@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
|
||||
# Backdate the lockout to the past so the next attempt clears it.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_locked_until = datetime('now', '-1 minute')
|
||||
WHERE email = ?
|
||||
""",
|
||||
("gina@example.com",),
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "gina@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Lockout cleared, counter reset.
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||
("gina@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_failed_attempts"] == 0
|
||||
assert row["passcode_locked_until"] is None
|
||||
|
||||
|
||||
def test_otc_path_unaffected_by_passcode_lockout(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop the OTC cooldown so the second request lands without a 429.
|
||||
# The cooldown is re-read from env on every `request_code` call so
|
||||
# this takes effect mid-process.
|
||||
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "harvey@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
# Lock the passcode path.
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "harvey@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
|
||||
# The OTC path is unaffected by the passcode lockout: the user
|
||||
# can still request and verify a fresh code to sign in.
|
||||
r = client.post("/auth/otc/request", json={"email": "harvey@example.com"})
|
||||
assert r.status_code == 200
|
||||
code = _outbound_otc_codes("harvey@example.com")[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": "harvey@example.com", "code": code})
|
||||
assert r.status_code == 200
|
||||
|
||||
# The user is now signed in via OTC even though the passcode
|
||||
# path is locked. The /api/auth/me payload reflects this.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == "harvey@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clear + replace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_passcode_wipes_the_hash(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "ivy@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
|
||||
r = client.delete("/auth/passcode")
|
||||
assert r.status_code == 200
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ?",
|
||||
("ivy@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_hash"] is None
|
||||
assert row["passcode_set_at"] is None
|
||||
|
||||
# Verify against the cleared passcode refuses (no-passcode shape
|
||||
# collapses to a generic 400).
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "ivy@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_setting_new_passcode_replaces_old(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "jane@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
# Replace.
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "newsecret9"})
|
||||
assert r.status_code == 200
|
||||
|
||||
client.cookies.clear()
|
||||
|
||||
# Old passcode refuses.
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "jane@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# New passcode signs in.
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "jane@example.com", "passcode": "newsecret9"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_setting_new_passcode_resets_lockout(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "kate@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
|
||||
# Lock the passcode path with bad attempts (drop session first).
|
||||
client.cookies.clear()
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "kate@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_locked_until FROM users WHERE email = ?",
|
||||
("kate@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_locked_until"] is not None
|
||||
|
||||
# Sign back in via OTC and reset the passcode.
|
||||
r = client.post("/auth/otc/request", json={"email": "kate@example.com"})
|
||||
assert r.status_code == 200
|
||||
code = _outbound_otc_codes("kate@example.com")[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": "kate@example.com", "code": code})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "freshcode9"})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Lockout cleared on set.
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_locked_until, passcode_failed_attempts FROM users WHERE email = ?",
|
||||
("kate@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_locked_until"] is None
|
||||
assert row["passcode_failed_attempts"] == 0
|
||||
|
||||
|
||||
def test_passcode_set_at_updates_on_each_set(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
import time
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "luke@example.com")
|
||||
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
first_stamp = db.conn().execute(
|
||||
"SELECT passcode_set_at FROM users WHERE email = ?",
|
||||
("luke@example.com",),
|
||||
).fetchone()["passcode_set_at"]
|
||||
assert first_stamp is not None
|
||||
|
||||
# SQLite's datetime('now') has second precision; sleep so the
|
||||
# stamp visibly advances on the next set.
|
||||
time.sleep(1.1)
|
||||
|
||||
client.post("/auth/passcode/set", json={"passcode": "newcode99"})
|
||||
second_stamp = db.conn().execute(
|
||||
"SELECT passcode_set_at FROM users WHERE email = ?",
|
||||
("luke@example.com",),
|
||||
).fetchone()["passcode_set_at"]
|
||||
assert second_stamp is not None
|
||||
assert second_stamp >= first_stamp
|
||||
# Lexicographic compare on ISO-8601 datetime strings works for
|
||||
# the SQLite shape.
|
||||
assert second_stamp > first_stamp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_passcode_refuses_too_short(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "mia@example.com")
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "abc"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_set_passcode_refuses_denylist_pattern(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "nick@example.com")
|
||||
for bad in ["0000", "1234", "aaaa", "qwerty", "password"]:
|
||||
r = client.post("/auth/passcode/set", json={"passcode": bad})
|
||||
assert r.status_code == 422, f"expected 422 for {bad!r}, got {r.status_code}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth me payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auth_me_carries_has_passcode_flag(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "olga@example.com")
|
||||
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["has_passcode"] is False
|
||||
assert me["user"]["passcode_set_at"] is None
|
||||
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["has_passcode"] is True
|
||||
assert me["user"]["passcode_set_at"] is not None
|
||||
Reference in New Issue
Block a user