Files
rfc-app/backend/migrations/012_otc.sql
T
Ben Stull 2590c244d6 Release 0.7.0: email/OTC sign-in (Gitea OAuth retained as fallback)
Replaces the Gitea OAuth gesture as the primary human-auth path
(roadmap item #5, SPEC §6.2). Users sign in by entering their email,
receiving a six-digit code via the existing SMTP layer, and entering
the code on a two-step /login surface. The Gitea OAuth callback
remains functional during migration — the new UI links to it as a
fallback for users with active OAuth sessions or older invite paths
— and is scheduled for removal in a future release once OTC adoption
is universal. Existing users are linked by email on first OTC sign-
in (gitea_id preserved); new users are provisioned with NULL
gitea_id and rely on email as the identity key. The migration
introduces backend/migrations/012_otc.sql (otc_codes table + users
schema rebuild for nullable gitea_id and a partial unique index on
email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify),
bcrypt as a new backend dependency for code hashing, and 11 new
tests in test_otc_vertical.py covering the happy path, expired and
consumed and wrong codes, the per-email rate limit, the allowlist
gate, the OAuth-era link path, fresh provisioning, and prior-code
invalidation on re-request. No new secrets are required — the
existing SECRET_KEY signs sessions and bcrypt's per-row salt covers
the code hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:55:29 -07:00

106 lines
5.1 KiB
SQL

-- §6.2 / v0.7.0: email + one-time-code sign-in.
--
-- Replaces the Gitea OAuth gesture as the primary human-auth path.
-- The Gitea bot user + token are still needed for server-side git
-- operations (repo reads, PR creation); only the operator-facing
-- sign-in surface moves. The /auth/callback OAuth route remains
-- functional during migration as a fallback, scheduled for removal
-- in a future release once every active user has signed in via OTC
-- at least once.
--
-- A row in `otc_codes` represents an outstanding 6-digit code that
-- was emailed to `email`. Codes are stored hashed (bcrypt) rather
-- than plaintext, so a database compromise does not expose the
-- in-flight code. TTL is enforced by `expires_at`. Each `verify`
-- success stamps `consumed_at` and refuses every later attempt
-- against the same row.
--
-- The §6.2 identity model under v0.7.0:
--
-- * `users.email` is the primary identity key for new sign-ins.
-- * `users.gitea_id` stays populated for users grandfathered in
-- via the OAuth-era flow; new users have `gitea_id = NULL`.
-- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it
-- was `INTEGER UNIQUE NOT NULL`) to permit the NULL.
-- * `users.email` becomes a (case-insensitive) unique key. An
-- existing OAuth user whose Gitea profile carried an email is
-- linked on first OTC sign-in; if no row matches, a fresh
-- contributor row is provisioned.
--
-- New env vars (v0.7.0):
-- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid.
-- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate
-- limit between successive `/auth/otc/request` calls.
CREATE TABLE otc_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL COLLATE NOCASE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
consumed_at TEXT
);
CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at);
-- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable
-- column with a partial unique index that ignores nulls. SQLite does
-- not support ALTER COLUMN, so we rebuild the table.
--
-- A few defensive notes:
-- * Every foreign key into `users(id)` continues to resolve — `id`
-- is the same INTEGER PRIMARY KEY in the rebuilt table.
-- * `email` is now declared NOCASE so a `WHERE email = ?` match
-- is case-insensitive without changing every read site. The
-- prior column accepted any text; existing rows pass through
-- unchanged.
-- * `gitea_login` likewise relaxes from NOT NULL to nullable, so
-- users provisioned by OTC alone don't carry a synthetic login.
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gitea_id INTEGER,
gitea_login TEXT,
email TEXT COLLATE NOCASE,
display_name TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
muted INTEGER NOT NULL DEFAULT 0,
email_personal_direct INTEGER NOT NULL DEFAULT 1,
email_watched_structural INTEGER NOT NULL DEFAULT 0,
email_admin_actionable INTEGER NOT NULL DEFAULT 1,
email_opt_out_all INTEGER NOT NULL DEFAULT 0,
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')),
notification_quiet_hours_start TEXT,
notification_quiet_hours_end TEXT,
notification_quiet_hours_timezone TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO users_new (
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
muted, email_personal_direct, email_watched_structural,
email_admin_actionable, email_opt_out_all, digest_cadence,
notification_quiet_hours_start, notification_quiet_hours_end,
notification_quiet_hours_timezone, created_at, last_seen_at
)
SELECT
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
muted, email_personal_direct, email_watched_structural,
email_admin_actionable, email_opt_out_all, digest_cadence,
notification_quiet_hours_start, notification_quiet_hours_end,
notification_quiet_hours_timezone, created_at, last_seen_at
FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
CREATE INDEX idx_users_role ON users (role);
-- Partial unique indexes so NULLs are permitted but populated values
-- collide. Gitea linkage stays unique per gitea_id; OTC-era identity
-- is keyed on email (case-insensitive via NOCASE on the column).
CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL;
CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL;
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != '';