-- §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;