f8e797ab09
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>
106 lines
5.1 KiB
SQL
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 != '';
|