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>
This commit is contained in:
Ben Stull
2026-05-28 00:55:29 -07:00
parent c92730a737
commit 2590c244d6
18 changed files with 1483 additions and 18 deletions
+135
View File
@@ -23,6 +23,141 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.7.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**
This release lands email + one-time-code sign-in (roadmap item #5,
SPEC §6.2) as the primary human-auth gesture. Users sign in by
typing their email, receiving a six-digit code via email, and
entering it. The Gitea OAuth callback (`/auth/callback`) remains
functional during migration — the new UI no longer points at it
primarily, but a "Sign in with Gitea (fallback)" link survives on
the new login surface so users with active OAuth sessions or older
invite paths still have a way in. A future release retires the
OAuth path entirely once every active user has signed in at least
once via OTC.
The migration path for existing users: first OTC sign-in matches by
`users.email` (case-insensitive) to the OAuth-era row and reuses
that row's `id` and `gitea_id`. New users provisioned via OTC carry
`gitea_id = NULL` and `gitea_login = NULL`. The `gitea_id` linker
remains the canonical handle for grandfathered users; `email`
becomes the identity key for everything provisioned after v0.7.0.
The Gitea **bot** user + token are still required (server-side git
operations — repo reads, PR creation — still flow through it). Only
the operator-facing sign-in surface moves.
### Upgrade steps (from 0.6.0)
1. **MUST** restart the backend so migration `012_otc.sql` runs.
The migration rebuilds the `users` table (SQLite cannot ALTER
COLUMN); existing rows pass through unchanged, but the new
schema relaxes `gitea_id` / `gitea_login` to nullable (with
partial unique indexes that ignore NULL) and adds a partial
unique index on `email`. A new `otc_codes` table is created.
2. **MUST** confirm the SMTP overlay is set (`SMTP_HOST`,
`SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_STARTTLS`,
`EMAIL_FROM`, `EMAIL_FROM_NAME`). The OHM overlay already
carries these as of v0.5.0; deployments without them fall back
to logging the code to stdout (dev-only path — production
visitors will not receive their codes).
3. **SHOULD** announce the new email-based sign-in to existing
users. Wording suggestion: "You can now sign in by entering
your email and a one-time code we'll send you. Your old
account is linked automatically the first time you sign in."
4. **MAY** keep the existing OAuth callback as a fallback path.
The new login UI surfaces a small "Sign in with Gitea
(fallback)" link beneath the primary email/code form; a
deployment that prefers to hide it can override the Login
component in a future framework release that exposes the link
behind a feature flag. For v0.7.0, the link is hard-coded.
### New environment variables (all optional with defaults)
- `OTC_TTL_MINUTES` (default `10`) — how long a one-time code is
valid after issuance. Re-requesting invalidates the prior code
immediately regardless of TTL.
- `OTC_REQUEST_COOLDOWN_SECONDS` (default `60`) — per-email cooldown
between successive `/auth/otc/request` calls. The endpoint returns
HTTP 429 when the cooldown blocks a request (the loud-failure
shape; the abuse path is visible rather than swallowed).
No new secrets are required. The existing `SECRET_KEY` continues to
sign session cookies; OTC codes are bcrypt-hashed at rest using a
per-row salt the library generates.
### Added
- **`POST /auth/otc/request`** — body `{email}`. Generates a six-digit
code, stores its bcrypt hash with an expiry, and dispatches a plain
text email via the existing SMTP layer. Returns HTTP 200 (`{ok:true}`)
uniformly so allowlist state is not leaked. Returns HTTP 429 when
the per-email cooldown blocks the request.
- **`POST /auth/otc/verify`** — body `{email, code}`. Validates the
bcrypt hash against the most-recent unconsumed non-expired row, marks
the row consumed, provisions or links the `users` row by email, and
stores the session cookie. Returns HTTP 200 on success, HTTP 400 on
any failure (expired, consumed, wrong, unknown).
- **`backend/migrations/012_otc.sql`** — creates `otc_codes` and
rebuilds `users` with nullable `gitea_id` / `gitea_login` plus a
partial unique index on `email`.
- **`backend/app/otc.py`** — the OTC request/verify state machine and
the `provision_or_link_user` linker.
- **`backend/app/email_otc.py`** — outbound OTC mail composition. Reuses
the SMTP plumbing from `email.py` (`EmailConfig.from_env()`) and the
test buffer (`_SENT`) but writes its own envelope (no unsubscribe
footer, no quiet-hours hold — OTC mail carries a credential and
ignores notification preferences).
- **`frontend/src/components/Login.jsx`** — two-step sign-in surface
at `/login`. Step 1: enter email → request code. Step 2: enter
six-digit code → verify. Cmd/Ctrl+Enter on the code field submits.
The previous header "Sign in" link and the `Welcome` component's
inline link now route to `/login` instead of jumping straight to
the Gitea OAuth dance.
- **SPEC `§6.1` / `§6.2` / `§14.1` / `§17` / `§19.2`** corrections per
§19.3 rule-2 — see below.
- **`backend/tests/test_otc_vertical.py`** — 11 new tests covering
the happy path, expired/consumed/wrong codes, the per-email rate
limit (and its per-email isolation), the allowlist gate, the
migration link to OAuth-era users, fresh provisioning, and the
prior-code-invalidation behavior on re-request.
### Changed
- **`backend/app/auth.py#current_user`** — coerces NULL `gitea_id` and
NULL `gitea_login` to `0` / `""` so the `SessionUser` shape stays
stable for OTC-only users. The DB remains the source of truth for
"is this user OAuth-linked" (`gitea_id IS NOT NULL`).
- **`backend/requirements.txt`** — adds `bcrypt>=4.2` for OTC code
hashing. Pure-Python wheels are available on every platform the
deployment matrix targets; `pip install -r backend/requirements.txt`
picks it up.
- **`frontend/src/App.jsx`** — adds the `/login` route and replaces
the header "Sign in" `<a href="/auth/login">` with `<Link to="/login">`.
The `Welcome` component's inline sign-in link follows suit.
- **`frontend/src/components/Landing.jsx`** — the `/welcome` page's
primary action moves from "Sign in with Gitea" to "Sign in" pointing
at `/login`.
### Deferred to later releases
Per the v0.7.0 scope discipline (the foundation for items #6, #8, #9,
#10), several adjacent capabilities are intentionally not in this
release and surface as §19.2 candidates:
- **First-OTC profile capture** (first name, last name, "why") — item
#6, expected v0.8.0.
- **Open beta-access request flow** replacing the allowlist gate —
also item #6, v0.8.0.
- **Passcodes** (a long-term reauth token alternative) — item #8,
expected v0.10.0.
- **Device-trust 30-day skip** — item #9, expected v0.11.0.
- **Cloudflare Turnstile** on `/auth/otc/request` — item #10,
expected v0.12.0.
- **Removing the Gitea OAuth `/auth/callback` route entirely** — a
later release after every active user has signed in via OTC.
## 0.5.0 — 2026-05-27
**Minor — no operator action required.** This release wires the