Files
rfc-app/backend/migrations/015_passcode.sql
Ben Stull 55beba5c0a 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>
2026-05-28 02:39:27 -07:00

53 lines
2.7 KiB
SQL

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