Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41b0c6af99 | |||
| b3f1b15f65 | |||
| 6fb68a95c7 |
+438
-1
@@ -23,7 +23,186 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
that.
|
||||||
|
|
||||||
## 0.14.0 — 2026-05-28
|
## 0.17.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — schema migration required; no new env vars; no new secrets.**
|
||||||
|
This release lands admin-create user with role assignment + invite
|
||||||
|
email (roadmap item #16, §6.1). From the v0.9.0 `/admin/users` surface,
|
||||||
|
an admin can now type first name, last name, email, role, and an
|
||||||
|
optional custom message; the framework provisions the `users` row with
|
||||||
|
the chosen role and `permission_state='granted'` (the admin's hand is
|
||||||
|
the grant) and sends an invite email carrying a single-use claim link.
|
||||||
|
The invitee clicks through to `/invites/claim?token=…`, the token is
|
||||||
|
verified and consumed, the session is established, and the user is
|
||||||
|
routed to the passcode-set screen (per v0.10.0) on first sign-in.
|
||||||
|
|
||||||
|
Distinct from #12 (which ships in parallel in this wave): #12 is
|
||||||
|
per-RFC contribution/discussion membership and uses
|
||||||
|
`rfc_invitations` (slot 018). v0.17.0 is platform-level access
|
||||||
|
provisioning by an admin and uses `user_invite_tokens` (slot 019).
|
||||||
|
Both can coexist; both surface in the same SMTP relay but with
|
||||||
|
distinct email templates.
|
||||||
|
|
||||||
|
Design decisions documented inline (see `backend/app/invites.py`'s
|
||||||
|
module docstring + the migration's header comment):
|
||||||
|
|
||||||
|
* **Token shape: opaque DB token, not JWT.** 256 bits of CSPRNG
|
||||||
|
entropy (`secrets.token_urlsafe(32)`), bcrypt-hashed at rest.
|
||||||
|
Opaque chosen over JWT because revocation is then a single SQL
|
||||||
|
UPDATE — admin-issued invites are exactly the kind of thing an
|
||||||
|
admin should be able to yank back without rotating a signing
|
||||||
|
key. The raw token only ever lives in the outbound email link
|
||||||
|
and the inbound claim body.
|
||||||
|
* **TTL: 7 days, hard-coded constant** (`INVITE_TOKEN_TTL_DAYS`
|
||||||
|
in `backend/app/invites.py`). Env-var configurability is a
|
||||||
|
§19.2 candidate; the constant is exposed as a single point of
|
||||||
|
edit if a deployment wants to override.
|
||||||
|
* **Immediate send, no admin-review-then-send queue.** Matches
|
||||||
|
how the v0.9.0 beta-request admin notification works (single
|
||||||
|
SMTP path). Admin-preview-before-send is a future enhancement.
|
||||||
|
* **Bulk-invite (CSV paste) deferred.** v0.17.0 is one-at-a-time;
|
||||||
|
a follow-up release can layer bulk on top of the same
|
||||||
|
`POST /api/admin/users` body shape with minimal disruption.
|
||||||
|
* **OTC skipped on first sign-in.** Per the roadmap: clicking the
|
||||||
|
unique token in the email is itself proof of email control, so
|
||||||
|
the claim flow signs the invitee in directly. Subsequent
|
||||||
|
sign-ins go through the standard OTC / passcode paths.
|
||||||
|
* **No `users` table changes.** The brief floated
|
||||||
|
`first_sign_in_at` / `last_seen_at IS NULL` as the
|
||||||
|
"(pending invite)" discriminator, but the existing
|
||||||
|
`users.last_seen_at` column is NOT NULL with a `datetime('now')`
|
||||||
|
default (migrations/001) and no `first_sign_in_at` column
|
||||||
|
exists. Rather than land a schema migration to introduce one,
|
||||||
|
the discriminator is the existence of an active (not-claimed,
|
||||||
|
not-expired) row in `user_invite_tokens` joined on
|
||||||
|
`invited_user_id`. The admin user-listing carries a
|
||||||
|
`pending_invite` field populated via that join; on claim, the
|
||||||
|
badge clears naturally as the invite row's `claimed_at`
|
||||||
|
populates.
|
||||||
|
* **Admin-create vs. self-flip refusals.** Self-invite is refused
|
||||||
|
422 (use the role-change channel for self-edits). Duplicate
|
||||||
|
email is refused 409 (use the existing role / grant gestures on
|
||||||
|
the existing user). Owner-grant by a non-owner admin is refused
|
||||||
|
422 (§6.1's owner-zero is the only owner bootstrap path; the
|
||||||
|
sitting owner must issue the invite).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`POST /api/admin/users`** (`backend/app/api_admin.py`) — admin-only.
|
||||||
|
Body: `{ email, first_name?, last_name?, role, custom_message? }`.
|
||||||
|
Provisions the `users` row with the chosen role and writes the
|
||||||
|
`user_invite_tokens` row + dispatches the invite email + writes a
|
||||||
|
`permission_events` row with `event_kind='user_invited'`. Returns
|
||||||
|
`{ ok, invite_id, invited_user_id, email, role }` on success;
|
||||||
|
surfaces the four refusals (403/422/409/422) per their distinct
|
||||||
|
paths.
|
||||||
|
- **`GET /api/admin/users/invites`** — admin-only. Lists active
|
||||||
|
(not claimed, not expired) invites with the admin who created them
|
||||||
|
joined through for display. Powers the "I sent these but they
|
||||||
|
haven't been claimed yet" admin view.
|
||||||
|
- **`POST /api/invites/claim`** (`backend/app/main.py` — alongside
|
||||||
|
`/auth/otc/verify` and `/auth/device-trust/start` since it shares
|
||||||
|
the device-trust cookie helpers). Anonymous-reachable. Body:
|
||||||
|
`{ token, trust_device? }`. Validates the token, consumes the
|
||||||
|
invite row, signs the user in, optionally mints a device-trust
|
||||||
|
cookie, and returns `{ ok, user, needs_passcode }`. The
|
||||||
|
`needs_passcode` hint drives the frontend's route-to-passcode-set
|
||||||
|
vs. route-to-home decision. Maps token-failure modes to distinct
|
||||||
|
HTTP statuses: expired/claimed → 410, unknown/invalid → 400.
|
||||||
|
- **`backend/app/invites.py`** — the create + claim + list module.
|
||||||
|
Mirrors the `device_trust.py` shape: opaque-token issuance with
|
||||||
|
bcrypt-at-rest, candidate-set walk on lookup, dataclass-bracketed
|
||||||
|
outcomes (`CreateOutcome` / `ClaimOutcome` / `PendingInviteRow`).
|
||||||
|
Carries the `INVITE_TOKEN_TTL_DAYS = 7` constant and the
|
||||||
|
`CUSTOM_MESSAGE_MAX_LENGTH = 500` mirror of the API-side bound.
|
||||||
|
- **`backend/app/email_invite.py`** — sibling of `email_otc.py`. Reuses
|
||||||
|
`EmailConfig.from_env()` for the SMTP plumbing + From identity;
|
||||||
|
composes a separate template (subject "You're invited to <app> by
|
||||||
|
<admin>"; body names the inviter, embeds the optional custom
|
||||||
|
message in a clearly-delimited indented block if present, and
|
||||||
|
carries the claim URL). Dev / no-SMTP path logs the envelope to
|
||||||
|
the shared `_SENT` buffer so backend tests can assert on the
|
||||||
|
outbound shape.
|
||||||
|
- **Schema migration `019_user_invite_tokens.sql`** — new
|
||||||
|
`user_invite_tokens` table (id, email, role, first_name, last_name,
|
||||||
|
custom_message, token_hash, expires_at, created_at,
|
||||||
|
created_by_admin_id, claimed_at, claimed_by_user_id,
|
||||||
|
invited_user_id). Three indexes: unique on `token_hash`
|
||||||
|
(documents the no-collision invariant); `(email, claimed_at)` for
|
||||||
|
the "is this email already invited?" pre-check; and
|
||||||
|
`(created_by_admin_id, created_at DESC)` for the per-admin
|
||||||
|
invites listing. Slot 018 is reserved for the parallel #12
|
||||||
|
release shipping in the same wave; slot 016 stays
|
||||||
|
reserved-and-skipped per Session K's v0.9.0 integration.
|
||||||
|
- **`frontend/src/components/InviteClaim.jsx`** — the
|
||||||
|
`/invites/claim?token=…` landing page. Reads the token from the
|
||||||
|
URL, renders a "Claim my account" CTA with an optional
|
||||||
|
"trust this device for 30 days" checkbox, calls
|
||||||
|
`POST /api/invites/claim` on submit, and routes onward
|
||||||
|
(`/settings/notifications#sign-in` if `needs_passcode`, else `/`)
|
||||||
|
on success. Anonymous-reachable.
|
||||||
|
- **"Create user + invite" affordance** on `/admin/users`
|
||||||
|
(`frontend/src/components/Admin.jsx`). A header button opens a
|
||||||
|
modal with email / first / last / role / custom-message inputs
|
||||||
|
(the textarea shows a "chars left" counter against the 500-char
|
||||||
|
ceiling). On submit, the modal calls
|
||||||
|
`POST /api/admin/users` and refreshes the user listing.
|
||||||
|
- **"(pending invite)" badge** inline on the user-listing's
|
||||||
|
per-row handle (rendered when the row's `pending_invite` field
|
||||||
|
is populated by the backend's join through `user_invite_tokens`).
|
||||||
|
Clears automatically on claim as the invite row's `claimed_at`
|
||||||
|
populates.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/api_admin.py`** — imports `invites` + `email_invite`
|
||||||
|
+ `EmailConfig`; adds the two new endpoints alongside the existing
|
||||||
|
`set_role` / `set_permission` neighbors; extends `list_users` to
|
||||||
|
join through `user_invite_tokens` and emit the `pending_invite`
|
||||||
|
field on each row. The new `CreateUserInviteBody` pydantic model
|
||||||
|
carries the body bounds (320-char email, 120-char first/last,
|
||||||
|
regex-pinned role, 500-char custom_message) so malformed input
|
||||||
|
fails at the body bound (422) instead of at the SQL layer.
|
||||||
|
- **`backend/app/main.py`** — imports `invites as invites_mod`;
|
||||||
|
adds the `InviteClaimBody` pydantic model alongside
|
||||||
|
`PasscodeVerifyBody`; mounts the `POST /api/invites/claim`
|
||||||
|
endpoint in the OAuth router so it can reuse the
|
||||||
|
`_set_device_trust_cookie` helper.
|
||||||
|
- **`frontend/src/api.js`** — exports `createUserInvite()`,
|
||||||
|
`listUserInvites()`, `claimInvite()`. Same fetch shape as the
|
||||||
|
rest of the v0.9.0 / v0.10.0 admin neighborhood.
|
||||||
|
- **`frontend/src/App.jsx`** — imports `InviteClaim`; registers the
|
||||||
|
`/invites/claim` route alongside `/beta-pending` (both are
|
||||||
|
anonymous-reachable auth-shape landings).
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- **`backend/migrations/019_user_invite_tokens.sql`** — auto-applied
|
||||||
|
on next backend start. Single new table with three indexes; no
|
||||||
|
changes to existing tables.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.14.0)
|
||||||
|
|
||||||
|
- You **MUST** apply schema migration `019_user_invite_tokens.sql`.
|
||||||
|
The migration creates a single new table with three indexes; the
|
||||||
|
framework runs migrations automatically at process start, so no
|
||||||
|
manual step is required beyond restarting the backend so the
|
||||||
|
migration runner picks the file up.
|
||||||
|
- You **MUST** rebuild the frontend and restart the backend after
|
||||||
|
upgrading. `frontend/package.json#version` and `VERSION` both
|
||||||
|
move to `0.17.0`. No new env vars; no new secrets (the invite
|
||||||
|
email rides the existing SMTP relay configured for v0.7.0's OTC
|
||||||
|
mail).
|
||||||
|
- You **MAY** announce the new admin-create gesture to existing
|
||||||
|
admins. Existing user rows are unaffected — the
|
||||||
|
`user_invite_tokens` table is empty post-migration, and the
|
||||||
|
user-listing's new `pending_invite` field is null on every
|
||||||
|
existing row. The bootstrap shape for the very first admin
|
||||||
|
account stays the v0.9.0 path (DB-level role flip on an OTC-
|
||||||
|
provisioned row); the v0.17.0 admin-create gesture works
|
||||||
|
end-to-end once at least one admin exists.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**Minor — no operator action required; new optional env var.** This
|
**Minor — no operator action required; new optional env var.** This
|
||||||
release ships `DOCS.md` and the `/docs` route — a public-facing user
|
release ships `DOCS.md` and the `/docs` route — a public-facing user
|
||||||
@@ -197,6 +376,264 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from
|
|||||||
(because their `cookie_consent` row does not yet exist); their
|
(because their `cookie_consent` row does not yet exist); their
|
||||||
current sessions remain valid.
|
current sessions remain valid.
|
||||||
|
|
||||||
|
## 0.12.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — operator action required (new secret + new overlay).**
|
||||||
|
CloudFlare Turnstile gates the email-entry step of the OTC sign-in
|
||||||
|
flow against automated abuse (roadmap item #10, SPEC §6.2 / §19.2-
|
||||||
|
settled). Since v0.7.0 made `/auth/otc/request` the primary human-
|
||||||
|
auth path and v0.8.0 opened the request endpoint to any valid email,
|
||||||
|
the OTC dispatch became the natural target for distributed scrapers
|
||||||
|
fanning out to harvest "this email is admitted vs. this email is
|
||||||
|
not" timing/bounce signals. The per-email cooldown stops the trivial
|
||||||
|
back-to-back loop; the Turnstile challenge stops the distributed one
|
||||||
|
by costing the attacker a browser-side proof-of-humanness on every
|
||||||
|
request. The challenge runs before the bcrypt hash + SMTP send so a
|
||||||
|
failed verify spends no rate budget and produces no envelope.
|
||||||
|
|
||||||
|
Scope: the widget renders on the email-entry step of `/login` only.
|
||||||
|
The OTC verify step (where the user pastes the six-digit code) is
|
||||||
|
already bottlenecked on email delivery and protected by the
|
||||||
|
five-minute TTL + single-use consume on the row; a second challenge
|
||||||
|
there would double the rate budget against the same abuse path
|
||||||
|
without measurably more protection. If bots adapt to defeat the
|
||||||
|
email-entry challenge specifically — pushing the abuse vector onto
|
||||||
|
the verify step — a future release adds the second widget. The
|
||||||
|
widget also renders on the passcode step's "Use a code instead"
|
||||||
|
fallback dispatch since that route also calls `/auth/otc/request`.
|
||||||
|
|
||||||
|
Default policy: `TURNSTILE_REQUIRED=false`. The gate stays open when
|
||||||
|
the secret is absent — the dev / test path, and the pre-rollout
|
||||||
|
path while the operator is wiring the secret. Once the secret is in
|
||||||
|
GCP Secret Manager and the site key is in the overlay, the operator
|
||||||
|
**MAY** flip `TURNSTILE_REQUIRED=true` so a future config drift on
|
||||||
|
the secret fails loudly (HTTP 500 "auth misconfigured") instead of
|
||||||
|
silently disabling abuse defense.
|
||||||
|
|
||||||
|
No schema migration — Turnstile siteverify is stateless.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`backend/app/turnstile.py`** — the siteverify caller. POSTs
|
||||||
|
`secret` + `response` (+ optional `remoteip`) to
|
||||||
|
`https://challenges.cloudflare.com/turnstile/v0/siteverify` and
|
||||||
|
returns a `VerifyOutcome` (`ok` boolean + `reason` enum:
|
||||||
|
`ok` / `skipped` / `misconfigured` / `missing-token` / `failed` /
|
||||||
|
`network`). Tunables read from env at call time so tests
|
||||||
|
monkeypatch cleanly: `CLOUDFLARE_TURNSTILE_SECRET`,
|
||||||
|
`TURNSTILE_REQUIRED`, and (test-only) `TURNSTILE_SITEVERIFY_URL`.
|
||||||
|
- **`frontend/src/components/TurnstileWidget.jsx`** — the React
|
||||||
|
wrapper around the official CloudFlare Turnstile JS API. Reads the
|
||||||
|
site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`; renders
|
||||||
|
nothing when the var is unset (the form still submits and the
|
||||||
|
backend's `TURNSTILE_REQUIRED` policy decides admission). Loads
|
||||||
|
the CloudFlare script once per page on first widget mount. Cleans
|
||||||
|
up the widget instance on unmount via `turnstile.remove()` so a
|
||||||
|
remount produces a fresh challenge rather than reusing a stale,
|
||||||
|
already-consumed token.
|
||||||
|
- **Backend tests** (`backend/tests/test_turnstile_vertical.py`) —
|
||||||
|
five vertical scenarios: happy path (secret + valid token →
|
||||||
|
admit), siteverify rejects → 400 + no envelope, missing-token →
|
||||||
|
400 + no envelope, missing-secret-soft (default) → admit, and
|
||||||
|
missing-secret-hard (`TURNSTILE_REQUIRED=true`) → 500
|
||||||
|
"misconfigured". All five mock the siteverify HTTP call via
|
||||||
|
`monkeypatch.setattr(turnstile.httpx, "post", …)`; no real
|
||||||
|
CloudFlare keys are ever embedded.
|
||||||
|
- **SPEC `§6.2`** — names the Turnstile gate on the OTC dispatch as
|
||||||
|
the v0.12.0 settled shape; the §19.2 candidate from v0.7.0 closes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/main.py`** — `OtcRequestBody` grows an optional
|
||||||
|
`turnstile_token` field. The `/auth/otc/request` handler calls
|
||||||
|
`turnstile.verify_token` first, before `otc.request_code`, so a
|
||||||
|
failed challenge spends no rate budget and produces no envelope.
|
||||||
|
The handler maps `misconfigured` → HTTP 500, all other failures
|
||||||
|
(`missing-token`, `failed`, `network`) → uniform HTTP 400 so the
|
||||||
|
response does not enumerate which leg of the challenge broke.
|
||||||
|
- **`frontend/src/api.js`** — `requestOtc` accepts a second arg
|
||||||
|
`{ turnstileToken }` and threads it into the request body. The
|
||||||
|
positional signature stays backwards-compatible so calls that pass
|
||||||
|
only an email still type-check.
|
||||||
|
- **`frontend/src/components/Login.jsx`** — the email step and the
|
||||||
|
passcode step both render `<TurnstileWidget>`. The submit button
|
||||||
|
on the email step is disabled until the widget produces a token
|
||||||
|
(when the widget is enabled at build time); the "Use a code
|
||||||
|
instead" link on the passcode step has the same gate. A 400 from
|
||||||
|
`/auth/otc/request` clears the token and surfaces a "couldn't
|
||||||
|
verify you're human, please retry" status. The fallback-from-
|
||||||
|
passcode path bounces back to the email step on 400 so the user
|
||||||
|
gets a fresh challenge in the natural place.
|
||||||
|
- **`backend/.env.example`** — documents `CLOUDFLARE_TURNSTILE_SECRET`
|
||||||
|
and `TURNSTILE_REQUIRED` alongside the existing OTC tunables.
|
||||||
|
- **`frontend/.env.example`** — documents `VITE_TURNSTILE_SITE_KEY`
|
||||||
|
with the operator wire-up procedure (dash.cloudflare.com →
|
||||||
|
Turnstile → Add site).
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.10.0)
|
||||||
|
|
||||||
|
The operator **MUST** create a CloudFlare Turnstile site
|
||||||
|
(dash.cloudflare.com → Turnstile → Add site, choose "Managed" widget
|
||||||
|
mode), obtain the site key (public) and secret key (private), and:
|
||||||
|
|
||||||
|
- You **MUST** `flotilla secret set ohm-rfc-app CLOUDFLARE_TURNSTILE_SECRET`
|
||||||
|
(paste the secret key when prompted) before the v0.12.0 deploy.
|
||||||
|
The framework reads the secret at request time; deploying v0.12.0
|
||||||
|
without the secret leaves the gate in its default soft-fail state
|
||||||
|
(every request admitted regardless of token), which means abuse
|
||||||
|
defense is silently off.
|
||||||
|
- You **MUST** `flotilla overlay set ohm-rfc-app VITE_TURNSTILE_SITE_KEY <site-key>`
|
||||||
|
so the frontend build embeds the site key and the widget renders
|
||||||
|
on `/login`. The site key is public — it travels in the bundle and
|
||||||
|
appears in every browser — so this is the overlay (non-secret)
|
||||||
|
layer per the §3 invariant 1 split. Skipping this step leaves
|
||||||
|
`/login` with no widget; even after the operator sets the secret,
|
||||||
|
the backend would refuse every request as `missing-token` once
|
||||||
|
`TURNSTILE_REQUIRED=true` flips.
|
||||||
|
- You **MUST** rebuild the frontend and restart the backend after
|
||||||
|
upgrading. `frontend/package.json#version` and `VERSION` both move
|
||||||
|
to `0.12.0`. No schema migration; Turnstile siteverify is
|
||||||
|
stateless. The site-key embed is build-time, so the rebuild after
|
||||||
|
the `flotilla overlay set` is what actually wires the widget into
|
||||||
|
the bundle the deploy serves.
|
||||||
|
- You **MAY** `flotilla overlay set ohm-rfc-app TURNSTILE_REQUIRED true`
|
||||||
|
once you've confirmed a real sign-in works end-to-end with the
|
||||||
|
widget. The default (`false`) keeps the gate in soft-fail mode so
|
||||||
|
a missing-secret regression admits requests rather than 500ing
|
||||||
|
every sign-in attempt; flipping to `true` makes a future config
|
||||||
|
drift on the secret fail loudly with HTTP 500 instead of silently
|
||||||
|
disabling abuse defense. The framework's tested path is the
|
||||||
|
flipped-to-true production shape; the default `false` exists for
|
||||||
|
the dev / pre-rollout window only.
|
||||||
|
- You **MAY** customize the Turnstile widget mode (Managed /
|
||||||
|
Non-interactive / Invisible) from the dashboard at any time
|
||||||
|
without redeploying — the site key stays the same, and the widget
|
||||||
|
picks up the mode change on the next page load. The framework's
|
||||||
|
tested path is "Managed" because it gives the operator a visible
|
||||||
|
challenge surface to debug against.
|
||||||
|
|
||||||
|
If either of the two **MUST** secret/overlay steps is skipped, the
|
||||||
|
deploy still boots and `/login` still serves; the failure mode is
|
||||||
|
that abuse defense is off (default `TURNSTILE_REQUIRED=false`) or
|
||||||
|
every sign-in attempt 500s (`TURNSTILE_REQUIRED=true` flipped while
|
||||||
|
the secret is unset). The driver pauses the wave at the secret/
|
||||||
|
overlay gesture so the operator confirms both are in place before
|
||||||
|
the framework version pin moves.
|
||||||
|
|
||||||
|
## 0.11.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — schema migration required; no new env vars.** This release
|
||||||
|
ships the "trust this device for 30 days" gesture (roadmap item #9,
|
||||||
|
SPEC §6.2). After a successful OTC or passcode sign-in, the user
|
||||||
|
can check a single checkbox to mint a server-issued opaque
|
||||||
|
device-trust token; the token rides as a long-lived HttpOnly +
|
||||||
|
Secure + SameSite=Lax cookie, and the matching row's hash lives in a
|
||||||
|
new `device_trust` table. On a subsequent visit, the cookie is
|
||||||
|
presented at `POST /auth/device-trust/start` — if a non-expired,
|
||||||
|
non-revoked row matches, the session is re-established without
|
||||||
|
another OTC / passcode roundtrip. A new `/settings/notifications`
|
||||||
|
"Trusted devices" section lists active rows (created-at, last-seen,
|
||||||
|
expiry, rough UA label) with per-row "Revoke" and a "Revoke all
|
||||||
|
devices" button. The cookie is "essential" per the v0.13.0 cookie-
|
||||||
|
consent contract — it is part of authentication, not analytics — and
|
||||||
|
is set regardless of the user's analytics / other-cookies choice.
|
||||||
|
|
||||||
|
The session model gains a cookie, not a session-store change: the
|
||||||
|
existing `rfc_session` cookie still carries the in-flight session
|
||||||
|
state; the new `rfc_device_trust` cookie is consulted only by
|
||||||
|
`/auth/device-trust/start` to bootstrap a fresh session on a return
|
||||||
|
visit. The raw token only ever lives in the outbound `Set-Cookie`
|
||||||
|
header and the inbound `Cookie` header; server-side storage is the
|
||||||
|
bcrypt hash; constant-time comparison via `bcrypt.checkpw` on the
|
||||||
|
candidate walk. The raw token is never logged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`device_trust` table** (`backend/migrations/017_device_trust.sql`).
|
||||||
|
Per-row id, `user_id` (FK with cascade), `device_token_hash`
|
||||||
|
(bcrypt at rest, unique index documents the no-collision
|
||||||
|
invariant), `created_at`, `expires_at` (`created_at + 30 days`),
|
||||||
|
`user_agent` (verbatim, app-layer-truncated to 1024 chars),
|
||||||
|
`last_seen_at` (refreshed on every successful lookup), `revoked_at`
|
||||||
|
(NULL means active). Secondary index on `(user_id, revoked_at)` so
|
||||||
|
the /settings list query is a covering walk.
|
||||||
|
- **`backend/app/device_trust.py`** — sibling of `otc.py` and
|
||||||
|
`passcode.py`. Carries `issue(user_id, user_agent)`,
|
||||||
|
`lookup(raw_token)`, `list_for_user(user_id)`, `revoke(user_id,
|
||||||
|
row_id)`, and `revoke_all(user_id)`. The 30-day window and the
|
||||||
|
cookie name (`rfc_device_trust`) live as module-level constants;
|
||||||
|
env-ifying them is a §19.2 candidate.
|
||||||
|
- **`§17` endpoints**
|
||||||
|
- `POST /auth/device-trust/start` — anonymous-reachable. Reads the
|
||||||
|
`rfc_device_trust` cookie; on a hit, signs the user in. On a
|
||||||
|
miss (expired, revoked, or unknown), clears the stale cookie and
|
||||||
|
returns 401.
|
||||||
|
- `GET /api/auth/me/devices` — list active trusted devices for
|
||||||
|
the signed-in user.
|
||||||
|
- `DELETE /api/auth/me/devices/{id}` — revoke a single row.
|
||||||
|
User-id scope enforced in SQL so a hostile client cannot
|
||||||
|
revoke another user's row by guessing ids.
|
||||||
|
- `DELETE /api/auth/me/devices` — revoke every active row.
|
||||||
|
- **OTC and passcode verify bodies** gain an optional
|
||||||
|
`trust_device: bool` field (default false). When true and verify
|
||||||
|
succeeds, the endpoint mints a fresh device-trust row and sets
|
||||||
|
the cookie on the response. Pre-v0.11.0 clients that omit the
|
||||||
|
field continue to behave as before.
|
||||||
|
- **Login.jsx** gains a "Trust this device for 30 days" checkbox
|
||||||
|
on both the OTC and passcode verify steps, plus a silent on-mount
|
||||||
|
call to `POST /auth/device-trust/start` so a returning user with
|
||||||
|
a valid cookie skips the email step entirely. A failure is
|
||||||
|
intentionally invisible — the user proceeds to the normal email
|
||||||
|
step.
|
||||||
|
- **`/settings/notifications` "Trusted devices" section** — lists
|
||||||
|
active rows with per-row "Revoke" + a "Revoke all devices" button
|
||||||
|
(with a `confirm()` prompt because the gesture is broad). The
|
||||||
|
surface intentionally does not single out the row whose cookie
|
||||||
|
the current request carries so a user can revoke "this device"
|
||||||
|
alongside any other from one place.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/main.py`** — the OTC and passcode verify endpoints
|
||||||
|
now also accept the `trust_device` flag and accept an injected
|
||||||
|
`Response` so they can attach the cookie. Two helpers
|
||||||
|
(`_set_device_trust_cookie`, `_clear_device_trust_cookie`) carry
|
||||||
|
the cookie attribute set in one place so the contract is
|
||||||
|
consistent across endpoints. The `Response` import is added
|
||||||
|
alongside the existing FastAPI re-exports.
|
||||||
|
- **`backend/app/api.py`** — imports `device_trust as device_trust_mod`
|
||||||
|
alongside `auth`/`db`; mounts the three `/api/auth/me/devices*`
|
||||||
|
endpoints immediately after `/api/auth/me/beta-request` so the
|
||||||
|
auth-shaped neighborhood stays clustered.
|
||||||
|
- **`frontend/src/api.js`** — exports `startDeviceTrust()`,
|
||||||
|
`listMyDevices()`, `revokeMyDevice(id)`, `revokeAllMyDevices()`.
|
||||||
|
`verifyOtc` and `verifyPasscode` accept an optional
|
||||||
|
`{ trustDevice }` argument that rides on the POST body.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.10.0)
|
||||||
|
|
||||||
|
- You **MUST** apply schema migration `017_device_trust.sql`. The
|
||||||
|
migration creates a single new table with one secondary index;
|
||||||
|
the framework runs migrations automatically at process start, so
|
||||||
|
no manual step is required beyond restarting the backend so the
|
||||||
|
migration runner picks the file up.
|
||||||
|
- You **MUST** rebuild the frontend and restart the backend after
|
||||||
|
upgrading. `frontend/package.json#version` and `VERSION` both
|
||||||
|
move to `0.11.0` and the new `Set-Cookie` shape requires the
|
||||||
|
backend to be on the matching version.
|
||||||
|
- You **MUST** serve the deployment over HTTPS. The
|
||||||
|
`rfc_device_trust` cookie is set with `Secure=True` — a
|
||||||
|
cleartext deployment will never receive the cookie back from
|
||||||
|
the browser, so the trust gesture will appear to silently fail.
|
||||||
|
Production OHM deployments already serve over HTTPS; local
|
||||||
|
development against `http://localhost` is unaffected (no cookie
|
||||||
|
is set, the OTC/passcode paths continue to work).
|
||||||
|
- You **MAY** announce the new feature to your users. Existing
|
||||||
|
signed-in sessions are unaffected — the device-trust cookie is
|
||||||
|
opt-in on the next sign-in, and a user who never checks the box
|
||||||
|
keeps the v0.10.0 behavior verbatim.
|
||||||
|
|
||||||
|
|
||||||
## 0.10.0 — 2026-05-28
|
## 0.10.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; new auth path is additive.**
|
**Minor — schema migration required; new auth path is additive.**
|
||||||
|
|||||||
@@ -339,6 +339,16 @@ and exact columns are illustrative; the implementing session can adjust.
|
|||||||
on first write and updated on every change. Absence of a row means
|
on first write and updated on every change. Absence of a row means
|
||||||
"no choice yet" — the banner shows. Anonymous viewers persist their
|
"no choice yet" — the banner shows. Anonymous viewers persist their
|
||||||
choice in `localStorage` only, with no corresponding row here.
|
choice in `localStorage` only, with no corresponding row here.
|
||||||
|
- `device_trust` — per-row record of the §6.2 device-trust gesture
|
||||||
|
(v0.11.0, roadmap item #9). One row per `(user, trusted device)`
|
||||||
|
pair; a user with three trusted devices has three rows. Columns:
|
||||||
|
`id`, `user_id` (FK users, ON DELETE CASCADE), `device_token_hash`
|
||||||
|
(bcrypt at rest, with a unique index documenting the no-collision
|
||||||
|
invariant of the 256-bit CSPRNG token space), `created_at`,
|
||||||
|
`expires_at` (`created_at + 30 days`), `user_agent` (verbatim,
|
||||||
|
application-layer-truncated to 1024 chars), `last_seen_at`
|
||||||
|
(refreshed on every successful lookup), `revoked_at` (NULL means
|
||||||
|
active). The raw token never lives in this table — only the hash.
|
||||||
|
|
||||||
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
||||||
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
||||||
@@ -374,7 +384,24 @@ them:
|
|||||||
separate "forgot passcode" flow. The user can remove the passcode
|
separate "forgot passcode" flow. The user can remove the passcode
|
||||||
at any time from the §6.2 sign-in settings tab, returning to
|
at any time from the §6.2 sign-in settings tab, returning to
|
||||||
OTC-only.
|
OTC-only.
|
||||||
3. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
|
3. **Device trust (cookie-only, 30 days).** Added in v0.11.0
|
||||||
|
(roadmap item #9). After a successful OTC or passcode sign-in,
|
||||||
|
the visitor may check "trust this device for 30 days." The
|
||||||
|
framework then mints a server-issued opaque token, hashes it
|
||||||
|
(bcrypt) into the `device_trust` table, and sets a long-lived
|
||||||
|
HttpOnly + Secure + SameSite=Lax cookie carrying the raw token.
|
||||||
|
On a subsequent visit, `POST /auth/device-trust/start` resolves
|
||||||
|
the cookie and re-establishes the session without an OTC /
|
||||||
|
passcode roundtrip. The user can list and revoke their trusted
|
||||||
|
devices from the `/settings/notifications` "Trusted devices"
|
||||||
|
section; a revoked or expired cookie is cleared on the next
|
||||||
|
request. The cookie is "essential" per §14.5 — it is part of
|
||||||
|
authentication, not analytics, and is set regardless of the
|
||||||
|
user's analytics / other-cookies choice. The raw token only
|
||||||
|
ever lives in the outbound `Set-Cookie` header and the inbound
|
||||||
|
`Cookie` header; server-side storage is the hash, with
|
||||||
|
constant-time comparison on lookup.
|
||||||
|
4. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
|
||||||
callback remains functional during the v0.7.0 window, with a
|
callback remains functional during the v0.7.0 window, with a
|
||||||
small "Sign in with Gitea (fallback)" link on `/login` so users
|
small "Sign in with Gitea (fallback)" link on `/login` so users
|
||||||
with active OAuth sessions or older invite paths still have a
|
with active OAuth sessions or older invite paths still have a
|
||||||
@@ -2761,9 +2788,19 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
silently if the email wasn't on the `allowed_emails` list (the
|
silently if the email wasn't on the `allowed_emails` list (the
|
||||||
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
|
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
|
||||||
admission moved to `permission_state` on the freshly-provisioned
|
admission moved to `permission_state` on the freshly-provisioned
|
||||||
`users` row, asserted at the contributor gate. Per §19.2's
|
`users` row, asserted at the contributor gate. v0.12.0 (item #10)
|
||||||
expected next session, this endpoint is the lead-up to the
|
gates this endpoint behind a CloudFlare Turnstile siteverify call:
|
||||||
Cloudflare-Turnstile abuse-mitigation overlay.
|
the body carries an optional `turnstile_token` field, the server
|
||||||
|
POSTs `secret` + `response` to `challenges.cloudflare.com/turnstile/
|
||||||
|
v0/siteverify` before the bcrypt hash + SMTP send, and a failed
|
||||||
|
challenge refuses with HTTP 400 spending no rate budget. Two env
|
||||||
|
vars drive the policy: `CLOUDFLARE_TURNSTILE_SECRET` (Secret
|
||||||
|
Manager) and `TURNSTILE_REQUIRED` (overlay, default `false`). When
|
||||||
|
the secret is unset and `TURNSTILE_REQUIRED=false`, the gate is
|
||||||
|
open (the dev / pre-rollout path); when the secret is unset and
|
||||||
|
`TURNSTILE_REQUIRED=true`, the endpoint refuses with HTTP 500
|
||||||
|
"auth misconfigured" so a future config drift fails loudly
|
||||||
|
instead of silently disabling abuse defense.
|
||||||
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
|
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
|
||||||
`code`. Validates the bcrypt hash against the most-recent unconsumed
|
`code`. Validates the bcrypt hash against the most-recent unconsumed
|
||||||
non-expired row for the email, marks the row consumed, provisions
|
non-expired row for the email, marks the row consumed, provisions
|
||||||
@@ -2823,6 +2860,28 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
return HTTP 400 with a generic message; the no-passcode-set
|
return HTTP 400 with a generic message; the no-passcode-set
|
||||||
failure also collapses to 400 so the response does not enumerate
|
failure also collapses to 400 so the response does not enumerate
|
||||||
account state. v0.10.0.
|
account state. v0.10.0.
|
||||||
|
- `POST /auth/device-trust/start` — unauthenticated. Reads the
|
||||||
|
`rfc_device_trust` cookie (set previously by an OTC or passcode
|
||||||
|
verify with `trust_device: true`). On a non-expired, non-revoked
|
||||||
|
match, re-establishes the session and returns HTTP 200 with the
|
||||||
|
minimal user payload. On a miss (no cookie, expired, revoked, or
|
||||||
|
unknown), returns HTTP 401 and clears the stale cookie via the
|
||||||
|
response's `Set-Cookie` header. The failure modes collapse to
|
||||||
|
one shape so a probing client cannot enumerate "your row was
|
||||||
|
revoked" vs. "this token never existed". v0.11.0.
|
||||||
|
- `GET /api/auth/me/devices` — authenticated. Returns the active
|
||||||
|
(`revoked_at IS NULL` AND `expires_at > now`) device-trust rows
|
||||||
|
for the signed-in user: `id`, `created_at`, `expires_at`,
|
||||||
|
`last_seen_at`, `user_agent`. The bcrypt hash is structurally
|
||||||
|
private and is never surfaced. v0.11.0.
|
||||||
|
- `DELETE /api/auth/me/devices/{id}` — authenticated. Stamps
|
||||||
|
`revoked_at` on the row with id `{id}` belonging to the
|
||||||
|
signed-in user. The user-id scope is enforced in SQL so a
|
||||||
|
hostile client cannot revoke another user's row by guessing
|
||||||
|
ids; a row that does not match returns HTTP 404. v0.11.0.
|
||||||
|
- `DELETE /api/auth/me/devices` — authenticated. Revokes every
|
||||||
|
active row for the signed-in user; returns the count revoked.
|
||||||
|
v0.11.0.
|
||||||
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
||||||
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
||||||
search, sort, filter chips, and the `unclaimed` predicate.
|
search, sort, filter chips, and the `unclaimed` predicate.
|
||||||
@@ -3903,37 +3962,77 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
|
|||||||
message), and whether the `/auth/login` and `/auth/callback`
|
message), and whether the `/auth/login` and `/auth/callback`
|
||||||
routes get a tombstone redirect to `/login` or just 404. Earns
|
routes get a tombstone redirect to `/login` or just 404. Earns
|
||||||
its session once the OTC adoption curve flattens.
|
its session once the OTC adoption curve flattens.
|
||||||
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
|
- **Device trust (30-day skip).** *Settled in v0.11.0 (roadmap
|
||||||
signed-in cookie already lasts 30 days via SessionMiddleware,
|
item #9). The shape: a distinct `rfc_device_trust` cookie
|
||||||
but every sign-in still requires a fresh OTC or passcode.* The
|
(HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) carrying a
|
||||||
roadmap item-#9 candidate adds a "trust this device" affordance
|
server-issued opaque token, keyed against a `device_trust` table
|
||||||
on the verify step that issues a longer-lived rotating token,
|
whose rows store the bcrypt hash. `POST /auth/device-trust/start`
|
||||||
so returning visitors on the same device skip both the OTC and
|
resolves a presented cookie at next visit. A
|
||||||
the passcode step. The shape question is whether the trust is a
|
`/settings/notifications` "Trusted devices" section lists active
|
||||||
signed cookie distinct from the session, a row in a `device_trust`
|
rows with per-row + bulk revoke. The trust outlives a sign-out
|
||||||
table keyed by a random device-id, or a property of the session
|
(sign-out clears the session cookie, not the device-trust
|
||||||
itself; and whether the trust survives password-equivalent events
|
cookie) and is not affected by passcode set/change/clear — the
|
||||||
— v0.10.0's passcode-change and passcode-clear gestures are the
|
next two items below carry the remaining open questions.*
|
||||||
v1 instances — or only survives explicit logout. Earns its
|
- **Cross-device session revocation surface.** v0.11.0's
|
||||||
session as the v0.11.0 design pass.
|
`/settings/notifications → Trusted devices` revokes the
|
||||||
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
long-lived device-trust grants. What it does NOT revoke is an
|
||||||
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
active session cookie sitting in another browser, or the
|
||||||
path.* Per-email cooldown stops the trivial loop; what it
|
v0.10.0 passcode-failure-counter shape, or a stale
|
||||||
doesn't stop is a distributed scrape that fans out across a
|
password-equivalent that some future release ships. The natural
|
||||||
large invitee list to harvest the "this email is admitted vs.
|
next step is a single "active sessions and devices" surface
|
||||||
this email is not" signal indirectly (timing differences, SMTP
|
that lists everything currently authenticating as this user —
|
||||||
bounce-rate observation). The roadmap item-#10 candidate gates
|
device-trust rows + active session cookies (if/when the
|
||||||
the request endpoint behind a one-step browser-side challenge
|
framework moves to server-side sessions) + future credential
|
||||||
before the bcrypt hash + SMTP send. Open questions: which
|
shapes — and lets the user kill any of them with one gesture.
|
||||||
provider (Turnstile is the default since it's free and
|
Earns its session when a second cross-cutting concern lands
|
||||||
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
|
(the most likely first trigger: future Yubikey / WebAuthn
|
||||||
how the deployment configures it (`TURNSTILE_SITE_KEY` +
|
support, which surfaces another credential to revoke).
|
||||||
`TURNSTILE_SECRET_KEY` env vars, gated by `if
|
- **Password-equivalent change invalidates device trust.** v0.11.0
|
||||||
config.turnstile_site_key:` at the handler so existing
|
intentionally leaves device-trust rows live across a passcode
|
||||||
deployments don't break); whether the verify endpoint also
|
set / change / clear. The argument is structural: the user has
|
||||||
gets a challenge (probably yes for parity); and how the test
|
the v0.10.0 lockout, the v0.11.0 per-device revoke list, and a
|
||||||
harness mocks the challenge. Earns its session as the v0.12.0
|
fresh sign-in path via OTC, so the cookie is not a high-value
|
||||||
design pass.
|
bypass relative to the keys-to-the-account a passcode change
|
||||||
|
signals. The argument against is the conventional "changing a
|
||||||
|
password should kill every active session" expectation users
|
||||||
|
bring from other systems. This earns its own session once the
|
||||||
|
evidence is in: either a security-review finding that says
|
||||||
|
"this is the wrong default," or user feedback that says "I
|
||||||
|
expected my old laptop to sign out when I changed my passcode."
|
||||||
|
- **Device-trust window tunables via env.** v0.11.0 hard-codes
|
||||||
|
the 30-day window in `backend/app/device_trust.py`
|
||||||
|
(`TRUST_DURATION_DAYS = 30`). Surfacing it as an env var
|
||||||
|
(`DEVICE_TRUST_DURATION_DAYS`?) is small and obvious; deferring
|
||||||
|
follows the same pattern as the v0.10.0 passcode-lockout
|
||||||
|
hard-coding — name the tunable when a deployment wants it
|
||||||
|
different rather than shipping a knob that has no operator
|
||||||
|
asking for it.
|
||||||
|
- **Cloudflare Turnstile on `/auth/otc/request`.** *Settled in
|
||||||
|
v0.12.0 (roadmap item #10).* The OTC request endpoint is now
|
||||||
|
gated behind a Turnstile siteverify call: the frontend renders
|
||||||
|
the official widget on the `/login` email-entry step (and on the
|
||||||
|
passcode step for the "Use a code instead" fallback dispatch),
|
||||||
|
the captured token rides in the request body as
|
||||||
|
`turnstile_token`, and the backend POSTs `secret` + `response`
|
||||||
|
to `challenges.cloudflare.com/turnstile/v0/siteverify` before
|
||||||
|
the bcrypt hash + SMTP send. The widget renders only on the
|
||||||
|
email-entry / passcode-fallback dispatch points — the OTC
|
||||||
|
verify step is bottlenecked on email delivery and protected by
|
||||||
|
the five-minute TTL + single-use row consume, so a second
|
||||||
|
challenge there would double the rate budget against the same
|
||||||
|
abuse path without measurably more protection (revisit if bots
|
||||||
|
adapt to the email-entry challenge specifically). Configured
|
||||||
|
via `CLOUDFLARE_TURNSTILE_SECRET` (Secret Manager) and
|
||||||
|
`VITE_TURNSTILE_SITE_KEY` (frontend build-time overlay); a
|
||||||
|
third var `TURNSTILE_REQUIRED` (default `false`) lets the
|
||||||
|
operator flip from "soft-fail when secret unset" (the dev /
|
||||||
|
pre-rollout shape) to "fail-closed when secret unset" (HTTP 500
|
||||||
|
"auth misconfigured", the production-locked shape). Tests mock
|
||||||
|
the siteverify HTTP call at the `httpx.post` boundary in
|
||||||
|
`app.turnstile`. The hCaptcha / reCAPTCHA alternatives noted in
|
||||||
|
the v0.7.0 surfacing are still viable substitutes for a future
|
||||||
|
deployment that wants them but the framework's tested path is
|
||||||
|
Turnstile.
|
||||||
|
|
||||||
Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
|
Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
|
||||||
roadmap item #8):
|
roadmap item #8):
|
||||||
|
|||||||
@@ -92,3 +92,21 @@ OTC_TTL_MINUTES=10
|
|||||||
# loud-failure shape so the abuse path is visible). Set to 0 to
|
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||||
# disable the cooldown — useful for tests but never in production.
|
# disable the cooldown — useful for tests but never in production.
|
||||||
OTC_REQUEST_COOLDOWN_SECONDS=60
|
OTC_REQUEST_COOLDOWN_SECONDS=60
|
||||||
|
|
||||||
|
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
|
||||||
|
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||||
|
# site. The site key (public) goes in `frontend/.env` as
|
||||||
|
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
|
||||||
|
# what the backend POSTs to /siteverify alongside the user's response
|
||||||
|
# token. Leave both unset for dev/test paths; the gate stays open when
|
||||||
|
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
|
||||||
|
CLOUDFLARE_TURNSTILE_SECRET=
|
||||||
|
|
||||||
|
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
|
||||||
|
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
|
||||||
|
# (the default), a missing secret skips verification — useful in dev
|
||||||
|
# and during the pre-rollout window when the operator hasn't wired
|
||||||
|
# the secret yet. Flip to `true` once the secret is wired so a future
|
||||||
|
# config drift surfaces as a loud 500 rather than a silent abuse-
|
||||||
|
# defense disablement.
|
||||||
|
TURNSTILE_REQUIRED=false
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from . import (
|
|||||||
api_prs,
|
api_prs,
|
||||||
auth,
|
auth,
|
||||||
db,
|
db,
|
||||||
|
device_trust as device_trust_mod,
|
||||||
docs as docs_mod,
|
docs as docs_mod,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
@@ -252,6 +253,68 @@ def make_router(
|
|||||||
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||||
|
#
|
||||||
|
# The mint path lives on the OAuth router (issuing the cookie is
|
||||||
|
# coupled to OTC/passcode verify). This module owns the read/revoke
|
||||||
|
# surface the /settings/devices page calls.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/auth/me/devices")
|
||||||
|
async def list_my_devices(request: Request) -> dict[str, Any]:
|
||||||
|
"""Active device-trust rows for the signed-in user.
|
||||||
|
|
||||||
|
Active = not revoked, not expired. The current request's
|
||||||
|
device (if any) is *not* singled out here — the surface
|
||||||
|
shows the same row shape for every device so the user can
|
||||||
|
revoke any of them without the page leaking which row
|
||||||
|
carries the cookie they're using right now.
|
||||||
|
"""
|
||||||
|
user = auth.require_user(request)
|
||||||
|
rows = device_trust_mod.list_for_user(user.user_id)
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"created_at": r.created_at,
|
||||||
|
"expires_at": r.expires_at,
|
||||||
|
"last_seen_at": r.last_seen_at,
|
||||||
|
"user_agent": r.user_agent,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/api/auth/me/devices/{device_id}")
|
||||||
|
async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]:
|
||||||
|
"""Revoke a single device-trust row for the signed-in user.
|
||||||
|
|
||||||
|
The user-id scope is enforced in SQL so a hostile client
|
||||||
|
cannot revoke another user's row by guessing ids. A row that
|
||||||
|
doesn't exist, doesn't belong to this user, or is already
|
||||||
|
revoked reads as 404 — the wrong-vs-already-revoked
|
||||||
|
distinction would only help a probing client enumerate ids.
|
||||||
|
"""
|
||||||
|
user = auth.require_user(request)
|
||||||
|
ok = device_trust_mod.revoke(user.user_id, device_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(404, "Device not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.delete("/api/auth/me/devices")
|
||||||
|
async def revoke_all_my_devices(request: Request) -> dict[str, Any]:
|
||||||
|
"""Revoke every active device-trust row for the signed-in user.
|
||||||
|
|
||||||
|
The user's current request stays authenticated via its
|
||||||
|
session cookie; the device-trust cookie carried on the
|
||||||
|
current device is also revoked, but `rfc_session` keeps the
|
||||||
|
request flow alive until sign-out / expiry.
|
||||||
|
"""
|
||||||
|
user = auth.require_user(request)
|
||||||
|
count = device_trust_mod.revoke_all(user.user_id)
|
||||||
|
return {"ok": True, "revoked": count}
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# §7: the catalog
|
# §7: the catalog
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
+265
-1
@@ -11,6 +11,8 @@ The endpoints in this module:
|
|||||||
- `GET /api/admin/users` — list users with role + mute
|
- `GET /api/admin/users` — list users with role + mute
|
||||||
- `POST /api/admin/users/<id>/role` — set role per §6.1
|
- `POST /api/admin/users/<id>/role` — set role per §6.1
|
||||||
- `POST /api/admin/users/<id>/mute` — set the §6.2 write-mute
|
- `POST /api/admin/users/<id>/mute` — set the §6.2 write-mute
|
||||||
|
- `POST /api/admin/users` — v0.17.0: create user + invite
|
||||||
|
- `GET /api/admin/users/invites` — v0.17.0: pending invites
|
||||||
- `GET /api/admin/audit` — paged `actions` log
|
- `GET /api/admin/audit` — paged `actions` log
|
||||||
- `GET /api/admin/permission-events` — paged `permission_events` log
|
- `GET /api/admin/permission-events` — paged `permission_events` log
|
||||||
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
|
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
|
||||||
@@ -33,8 +35,9 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from . import auth, db
|
from . import auth, db, email_invite, invites
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .email import EmailConfig
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -65,6 +68,32 @@ class AllowlistAddBody(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=200)
|
note: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserInviteBody(BaseModel):
|
||||||
|
"""v0.17.0 / roadmap item #16 — admin-create user + invite email.
|
||||||
|
|
||||||
|
The admin types these fields on the "Create user + invite" modal on
|
||||||
|
`/admin/users`. The email + role are required; first/last name and
|
||||||
|
the optional custom message round out the body.
|
||||||
|
|
||||||
|
Bounds mirror the rest of the codebase:
|
||||||
|
* `email`: 320 chars — RFC 5321 envelope limit, same as
|
||||||
|
`OtcRequestBody` / `BetaRequestBody` / `AllowlistAddBody`.
|
||||||
|
* `first_name` / `last_name`: 120 chars — same as the v0.8.0
|
||||||
|
`BetaRequestBody` capture form.
|
||||||
|
* `role`: pydantic regex pinned to the §6.1 set so an unknown
|
||||||
|
role fails at the body bound (422) instead of landing as a
|
||||||
|
CHECK constraint violation in the migration.
|
||||||
|
* `custom_message`: 500 chars — the brief calls this out as
|
||||||
|
the max. The frontend modal shows a "remaining chars"
|
||||||
|
counter to match.
|
||||||
|
"""
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
first_name: str = Field(default="", max_length=120)
|
||||||
|
last_name: str = Field(default="", max_length=120)
|
||||||
|
role: str = Field(pattern="^(owner|admin|contributor)$")
|
||||||
|
custom_message: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Router
|
# Router
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -115,6 +144,32 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
u.display_name COLLATE NOCASE
|
u.display_name COLLATE NOCASE
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
# v0.17.0 / roadmap item #16: a user row whose `last_seen_at`
|
||||||
|
# is NULL is one of two things — a brand-new row that was just
|
||||||
|
# provisioned (rare, and the v0.7.0 OTC verify path stamps
|
||||||
|
# last_seen_at on the same call that creates the row), or an
|
||||||
|
# admin-created invite-pending row (v0.17.0 — created by
|
||||||
|
# `POST /api/admin/users`). We surface a `pending_invite_id`
|
||||||
|
# field by joining through `user_invite_tokens` so the
|
||||||
|
# Users tab can render a "(pending invite)" badge alongside
|
||||||
|
# the role/state controls. Filters to invites that are
|
||||||
|
# neither expired nor claimed — once the invitee clicks
|
||||||
|
# through, the badge clears (and `last_seen_at` populates).
|
||||||
|
pending_invite_rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT invited_user_id, id AS invite_id, expires_at
|
||||||
|
FROM user_invite_tokens
|
||||||
|
WHERE claimed_at IS NULL
|
||||||
|
AND datetime(expires_at) > datetime('now')
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
pending_invites = {
|
||||||
|
r["invited_user_id"]: {
|
||||||
|
"invite_id": r["invite_id"],
|
||||||
|
"expires_at": r["expires_at"],
|
||||||
|
}
|
||||||
|
for r in pending_invite_rows
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
@@ -133,6 +188,215 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
"permission_decided_at": r["permission_decided_at"],
|
"permission_decided_at": r["permission_decided_at"],
|
||||||
"permission_decided_by_login": r["decided_by_login"],
|
"permission_decided_by_login": r["decided_by_login"],
|
||||||
"permission_decided_by_display": r["decided_by_display"],
|
"permission_decided_by_display": r["decided_by_display"],
|
||||||
|
# v0.17.0: present iff the row is invited-but-not-
|
||||||
|
# claimed-yet. The frontend renders a "(pending
|
||||||
|
# invite)" badge when this is non-null.
|
||||||
|
"pending_invite": pending_invites.get(r["id"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----- Create user + invite (v0.17.0 / roadmap item #16) -----
|
||||||
|
|
||||||
|
@router.post("/api/admin/users")
|
||||||
|
async def create_user_with_invite(
|
||||||
|
body: CreateUserInviteBody, request: Request,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Provision a fresh `users` row with a pre-assigned role + send
|
||||||
|
an invite email carrying a claim link.
|
||||||
|
|
||||||
|
Refusals:
|
||||||
|
* `422` — the admin tries to invite their own email (no
|
||||||
|
self-invite; symmetric to `set_permission`'s self-flip
|
||||||
|
refusal and `set_role`'s self-downgrade refusal). Use
|
||||||
|
the existing role-change channel for self-edits.
|
||||||
|
* `422` — the admin tries to grant `owner` without being
|
||||||
|
owner themselves. §6.1: owner-zero is the only owner
|
||||||
|
bootstrap path; new owners come from a sitting owner's
|
||||||
|
hand. A 422 here matches the message shape; a 403 would
|
||||||
|
also be defensible, but staying with 422 keeps the
|
||||||
|
"your input is bad" framing.
|
||||||
|
* `409` — the email already maps to a `users` row. The
|
||||||
|
admin should use the existing role / grant gestures on
|
||||||
|
the existing user, not create a duplicate.
|
||||||
|
* `422` — pydantic-level: malformed email, role outside
|
||||||
|
the §6.1 set, custom_message over 500 chars.
|
||||||
|
|
||||||
|
On success:
|
||||||
|
1. The invitee `users` row lands with the chosen role and
|
||||||
|
`permission_state='granted'` (admin's hand is the grant)
|
||||||
|
and `last_seen_at IS NULL` (the "(pending invite)"
|
||||||
|
discriminator the listing surface joins through).
|
||||||
|
2. The `user_invite_tokens` row lands with the bcrypt-
|
||||||
|
hashed opaque token; the raw token rides only in the
|
||||||
|
email link.
|
||||||
|
3. The invite email dispatches with subject "You're
|
||||||
|
invited to <app> by <admin>" and the custom message
|
||||||
|
embedded in a clearly-delimited block if present.
|
||||||
|
4. A `permission_events` row records the admin-create
|
||||||
|
gesture so the §6.5 / `permissions` admin tab carries
|
||||||
|
the audit trail alongside the existing grant/revoke
|
||||||
|
flips.
|
||||||
|
"""
|
||||||
|
viewer = auth.require_admin(request)
|
||||||
|
email_clean = body.email.strip().lower()
|
||||||
|
if "@" not in email_clean or len(email_clean.split("@")[-1]) < 2:
|
||||||
|
raise HTTPException(422, "Email looks malformed")
|
||||||
|
|
||||||
|
# Self-invite refusal. Compare the admin's own email
|
||||||
|
# case-insensitively against the invite target.
|
||||||
|
viewer_row = db.conn().execute(
|
||||||
|
"SELECT email FROM users WHERE id = ?", (viewer.user_id,)
|
||||||
|
).fetchone()
|
||||||
|
viewer_email = (viewer_row["email"] or "").strip().lower() if viewer_row else ""
|
||||||
|
if viewer_email and viewer_email == email_clean:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"You cannot invite yourself — use the role-change channel "
|
||||||
|
"if you need to edit your own row",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Owner-grant refusal: §6.1 says only a sitting owner can mint
|
||||||
|
# a new owner. An admin trying to invite-as-owner is refused
|
||||||
|
# at 422; the admin should ask the owner to issue the invite,
|
||||||
|
# or invite as `admin` and let the owner promote later.
|
||||||
|
if body.role == "owner" and viewer.role != "owner":
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"Only an owner can invite a new owner — invite as admin and "
|
||||||
|
"ask the owner to promote, or have the owner issue this invite",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Duplicate-email refusal. A pre-existing row (regardless of
|
||||||
|
# permission_state) means the admin should use the existing
|
||||||
|
# role / grant gestures, not create a parallel user.
|
||||||
|
existing = db.conn().execute(
|
||||||
|
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
|
||||||
|
(email_clean,),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(409, "A user with this email already exists")
|
||||||
|
|
||||||
|
# Create the invitee row + token row + send the email.
|
||||||
|
outcome = invites.create_invite(
|
||||||
|
email=email_clean,
|
||||||
|
first_name=body.first_name,
|
||||||
|
last_name=body.last_name,
|
||||||
|
role=body.role,
|
||||||
|
custom_message=body.custom_message,
|
||||||
|
created_by_admin_id=viewer.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit row in permission_events so the admin Permissions tab
|
||||||
|
# carries the gesture. The before-state is "n/a" (the row
|
||||||
|
# did not exist); the after-state is the granted role. We
|
||||||
|
# use a new `event_kind='user_invited'` so the existing
|
||||||
|
# grant/revoke kinds stay scoped to their flip surface.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO permission_events
|
||||||
|
(actor_user_id, subject_user_id, event_kind, details)
|
||||||
|
VALUES (?, ?, 'user_invited', ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
viewer.user_id,
|
||||||
|
outcome.invited_user_id,
|
||||||
|
json.dumps({
|
||||||
|
"email": email_clean,
|
||||||
|
"role": body.role,
|
||||||
|
"invite_id": outcome.invite_id,
|
||||||
|
"custom_message_chars": len(body.custom_message or ""),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the claim URL using the same APP_URL the email module
|
||||||
|
# reads. The token rides as a query-string param to the
|
||||||
|
# frontend route `/invites/claim?token=…`; the frontend POSTs
|
||||||
|
# it back to `/api/invites/claim` which consumes the row.
|
||||||
|
cfg = EmailConfig.from_env()
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
claim_url = f"{cfg.app_url}/invites/claim?{urlencode({'token': outcome.raw_token})}"
|
||||||
|
|
||||||
|
# Fetch the inviter display so the email body can render
|
||||||
|
# "Ben Stull (ben@example.com) has invited you to …". We
|
||||||
|
# read off the row fresh rather than trusting the session
|
||||||
|
# cookie's cached display_name.
|
||||||
|
inviter_row = db.conn().execute(
|
||||||
|
"SELECT display_name, email FROM users WHERE id = ?",
|
||||||
|
(viewer.user_id,),
|
||||||
|
).fetchone()
|
||||||
|
inviter_display = (
|
||||||
|
(inviter_row["display_name"] if inviter_row else "") or viewer.display_name or "An admin"
|
||||||
|
)
|
||||||
|
inviter_email_for_body = (inviter_row["email"] if inviter_row else "") or viewer.email or ""
|
||||||
|
|
||||||
|
email_invite.send_invite_email(
|
||||||
|
to_address=email_clean,
|
||||||
|
claim_url=claim_url,
|
||||||
|
inviter_display=inviter_display,
|
||||||
|
inviter_email=inviter_email_for_body,
|
||||||
|
custom_message=body.custom_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"invite_id": outcome.invite_id,
|
||||||
|
"invited_user_id": outcome.invited_user_id,
|
||||||
|
"email": email_clean,
|
||||||
|
"role": body.role,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/api/admin/users/invites")
|
||||||
|
async def list_user_invites(request: Request) -> dict[str, Any]:
|
||||||
|
"""List active (not claimed, not expired) admin-issued invites.
|
||||||
|
|
||||||
|
Powers the admin's "I sent these but they haven't been claimed
|
||||||
|
yet" view. The frontend uses this alongside `list_users` —
|
||||||
|
the user-listing's `pending_invite` field carries the per-row
|
||||||
|
flag; this endpoint carries the full invite shape for a
|
||||||
|
dedicated drill-in surface.
|
||||||
|
"""
|
||||||
|
auth.require_admin(request)
|
||||||
|
rows = invites.list_pending_invites()
|
||||||
|
# Join through to the admin display names so the surface can
|
||||||
|
# render "invited by @ben" without a second client call.
|
||||||
|
admin_ids = {r.created_by_admin_id for r in rows}
|
||||||
|
admin_lookup: dict[int, dict[str, str]] = {}
|
||||||
|
if admin_ids:
|
||||||
|
placeholders = ",".join("?" * len(admin_ids))
|
||||||
|
admin_rows = db.conn().execute(
|
||||||
|
f"SELECT id, gitea_login, display_name FROM users "
|
||||||
|
f"WHERE id IN ({placeholders})",
|
||||||
|
tuple(admin_ids),
|
||||||
|
).fetchall()
|
||||||
|
admin_lookup = {
|
||||||
|
ar["id"]: {
|
||||||
|
"gitea_login": ar["gitea_login"] or "",
|
||||||
|
"display_name": ar["display_name"] or "",
|
||||||
|
}
|
||||||
|
for ar in admin_rows
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"email": r.email,
|
||||||
|
"role": r.role,
|
||||||
|
"first_name": r.first_name,
|
||||||
|
"last_name": r.last_name,
|
||||||
|
"custom_message": r.custom_message,
|
||||||
|
"created_at": r.created_at,
|
||||||
|
"expires_at": r.expires_at,
|
||||||
|
"invited_user_id": r.invited_user_id,
|
||||||
|
"created_by_admin_id": r.created_by_admin_id,
|
||||||
|
"created_by_login": admin_lookup.get(
|
||||||
|
r.created_by_admin_id, {}
|
||||||
|
).get("gitea_login", ""),
|
||||||
|
"created_by_display": admin_lookup.get(
|
||||||
|
r.created_by_admin_id, {}
|
||||||
|
).get("display_name", ""),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
||||||
|
|
||||||
|
After a successful OTC or passcode sign-in, a contributor may check
|
||||||
|
"trust this device for 30 days." The framework then issues a
|
||||||
|
server-issued opaque token, hashes it (bcrypt) for storage in the
|
||||||
|
`device_trust` table, and sets a long-lived cookie carrying the raw
|
||||||
|
token. On a subsequent visit, the cookie is presented at
|
||||||
|
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
|
||||||
|
the session is re-established without another OTC / passcode round
|
||||||
|
trip.
|
||||||
|
|
||||||
|
The shape:
|
||||||
|
|
||||||
|
* `issue(user_id, user_agent)` — mint a fresh CSPRNG token, hash it,
|
||||||
|
insert a row, and return the raw token + row id so the endpoint
|
||||||
|
can set the cookie. The 30-day expiry is the only knob; the
|
||||||
|
`revoked_at` column stays NULL.
|
||||||
|
* `lookup(raw_token)` — walk the user's active rows (the unique
|
||||||
|
index keys on the hash, so we read a small candidate set), check
|
||||||
|
the bcrypt hash in constant time, drop any row whose `expires_at`
|
||||||
|
has passed or whose `revoked_at` is non-NULL, and return the
|
||||||
|
matched row or None. On a hit, refresh `last_seen_at`.
|
||||||
|
* `list_for_user(user_id)` — return the active rows for the
|
||||||
|
/settings/devices surface. Revoked + expired rows are filtered out
|
||||||
|
so the surface only shows live trust grants.
|
||||||
|
* `revoke(user_id, row_id)` — stamp `revoked_at` on the row. The
|
||||||
|
next lookup refuses the cookie token (the row is dead).
|
||||||
|
* `revoke_all(user_id)` — bulk-revoke every active row for the user.
|
||||||
|
The /settings/devices surface's "revoke all" button calls this.
|
||||||
|
|
||||||
|
Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
|
||||||
|
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
|
||||||
|
server-side storage is the hash. The cookie is "essential" per the
|
||||||
|
v0.13.0 cookie-consent banner (it is part of authentication, not
|
||||||
|
analytics), so the framework sets it regardless of analytics /
|
||||||
|
other-cookies choices.
|
||||||
|
|
||||||
|
Constant-time comparison: bcrypt's `checkpw` is already constant-time
|
||||||
|
over the hash bytes. We walk the candidate set linearly with `_check`
|
||||||
|
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
|
||||||
|
which row was the match.
|
||||||
|
|
||||||
|
The raw token never appears in a log line or an exception message;
|
||||||
|
the helpers carry the token only as a parameter and forget it after
|
||||||
|
hashing.
|
||||||
|
|
||||||
|
The cookie sits orthogonal to the §6.1 `permission_state` gate: a
|
||||||
|
revoked or pending user with a valid device-trust cookie still
|
||||||
|
re-establishes their session (the cookie identifies the user, not
|
||||||
|
their admission state), and the existing `require_contributor` /
|
||||||
|
`require_admin` dependencies in `auth.py` continue to refuse the
|
||||||
|
unrelated write surfaces.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .auth import SessionUser
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TRUST_DURATION_DAYS = 30
|
||||||
|
COOKIE_NAME = "rfc_device_trust"
|
||||||
|
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
|
||||||
|
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
|
||||||
|
# URL-safe characters; the bcrypt hash is what's stored, so the raw
|
||||||
|
# token only ever lives in the cookie.
|
||||||
|
TOKEN_BYTES = 32
|
||||||
|
# User-Agent header values seen in the wild can be unbounded; clamp
|
||||||
|
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
|
||||||
|
USER_AGENT_MAX_LENGTH = 1024
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Issue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IssueOutcome:
|
||||||
|
"""The shape returned from `issue`.
|
||||||
|
|
||||||
|
`raw_token` is the cookie value to send to the client; it never
|
||||||
|
appears in storage. `row_id` is the surrogate key for the
|
||||||
|
/settings/devices UI to address the row by id.
|
||||||
|
"""
|
||||||
|
raw_token: str
|
||||||
|
row_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def _new_token() -> str:
|
||||||
|
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(token: str) -> str:
|
||||||
|
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _check(token: str, token_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_user_agent(ua: str) -> str:
|
||||||
|
ua = (ua or "").strip()
|
||||||
|
if len(ua) > USER_AGENT_MAX_LENGTH:
|
||||||
|
return ua[:USER_AGENT_MAX_LENGTH]
|
||||||
|
return ua
|
||||||
|
|
||||||
|
|
||||||
|
def issue(user_id: int, user_agent: str) -> IssueOutcome:
|
||||||
|
"""Mint a fresh device-trust token + row for `user_id`.
|
||||||
|
|
||||||
|
The row's expiry is set 30 days in the future. The hash, not the
|
||||||
|
raw token, lands in the database. The caller (the endpoint) sets
|
||||||
|
the cookie with the raw token returned here.
|
||||||
|
"""
|
||||||
|
raw = _new_token()
|
||||||
|
h = _hash(raw)
|
||||||
|
ua = _trim_user_agent(user_agent)
|
||||||
|
cur = db.conn().execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
|
||||||
|
VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
|
||||||
|
""",
|
||||||
|
(user_id, h, ua),
|
||||||
|
)
|
||||||
|
row_id = cur.lastrowid
|
||||||
|
return IssueOutcome(raw_token=raw, row_id=row_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LookupOutcome:
|
||||||
|
"""The result of `lookup`.
|
||||||
|
|
||||||
|
`user` is populated only on a hit. `reason` distinguishes the
|
||||||
|
failure modes so the endpoint can decide whether to clear the
|
||||||
|
cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
user: SessionUser | None
|
||||||
|
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
|
||||||
|
row_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(raw_token: str) -> LookupOutcome:
|
||||||
|
"""Resolve a presented cookie token to a user.
|
||||||
|
|
||||||
|
A hit refreshes `last_seen_at` on the matched row. A miss returns
|
||||||
|
a reason so the endpoint can clear the stale cookie if the row
|
||||||
|
was revoked or expired (vs. simply unknown, which probably means
|
||||||
|
the cookie was forged or the row was wiped by a /settings/devices
|
||||||
|
revoke from another browser).
|
||||||
|
"""
|
||||||
|
raw = (raw_token or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||||
|
|
||||||
|
# The unique index on `device_token_hash` would let us SELECT by
|
||||||
|
# hash if bcrypt were a stable hash, but bcrypt incorporates a
|
||||||
|
# per-row salt — equal tokens produce different hashes. We walk
|
||||||
|
# the candidate set instead. In practice the set is small (a
|
||||||
|
# human has a handful of trusted devices) and bcrypt is cheap on
|
||||||
|
# the order of milliseconds; the walk is bounded by the user's
|
||||||
|
# active device count.
|
||||||
|
#
|
||||||
|
# We don't pre-filter by `revoked_at IS NULL` here so that a
|
||||||
|
# token presented for a recently-revoked row produces a
|
||||||
|
# 'revoked' outcome (the endpoint surfaces a different shape).
|
||||||
|
# Same for expired: we let the walk hit and classify after.
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, user_id, device_token_hash, expires_at, revoked_at
|
||||||
|
FROM device_trust
|
||||||
|
ORDER BY id DESC
|
||||||
|
""",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
matched = None
|
||||||
|
for row in rows:
|
||||||
|
if _check(raw, row["device_token_hash"]):
|
||||||
|
matched = row
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched is None:
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="unknown")
|
||||||
|
|
||||||
|
if matched["revoked_at"] is not None:
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])
|
||||||
|
|
||||||
|
expired = db.conn().execute(
|
||||||
|
"SELECT datetime(?) < datetime('now') AS expired",
|
||||||
|
(matched["expires_at"],),
|
||||||
|
).fetchone()["expired"]
|
||||||
|
if expired:
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])
|
||||||
|
|
||||||
|
# Refresh last-seen so the /settings/devices surface can show the
|
||||||
|
# user when each device was last active. This is the only write
|
||||||
|
# the lookup path does on the hot read.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
|
||||||
|
(matched["id"],),
|
||||||
|
)
|
||||||
|
user_row = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(matched["user_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if user_row is None:
|
||||||
|
# The user row was deleted but the device_trust row hadn't
|
||||||
|
# cascaded yet (shouldn't happen under the FK ON DELETE
|
||||||
|
# CASCADE — be defensive anyway). Treat as 'unknown' so the
|
||||||
|
# endpoint clears the cookie.
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])
|
||||||
|
|
||||||
|
# Also stamp last_seen_at on the user row so the user's overall
|
||||||
|
# activity stamp keeps pace with cookie-only sign-ins.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||||
|
(matched["user_id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
return LookupOutcome(
|
||||||
|
ok=True,
|
||||||
|
user=SessionUser(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
gitea_id=user_row["gitea_id"] or 0,
|
||||||
|
gitea_login=user_row["gitea_login"] or "",
|
||||||
|
display_name=user_row["display_name"],
|
||||||
|
email=user_row["email"] or "",
|
||||||
|
avatar_url=user_row["avatar_url"] or "",
|
||||||
|
role=user_row["role"],
|
||||||
|
permission_state=user_row["permission_state"] or "granted",
|
||||||
|
),
|
||||||
|
reason="ok",
|
||||||
|
row_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List / revoke (for the /settings/devices surface)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceRow:
|
||||||
|
"""The shape the /settings/devices endpoint returns.
|
||||||
|
|
||||||
|
Note the absence of `device_token_hash` — the hash is structurally
|
||||||
|
private, and the surface has no use for it.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
created_at: str
|
||||||
|
expires_at: str
|
||||||
|
last_seen_at: str
|
||||||
|
user_agent: str
|
||||||
|
|
||||||
|
|
||||||
|
def list_for_user(user_id: int) -> list[DeviceRow]:
|
||||||
|
"""Active device-trust rows for the user, freshest first.
|
||||||
|
|
||||||
|
Filters out revoked rows and rows whose expiry has passed; the
|
||||||
|
surface only shows live trust grants. A user wondering "which
|
||||||
|
devices are signed in" gets the answer that matches what the
|
||||||
|
framework would actually accept on a presented cookie.
|
||||||
|
"""
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, created_at, expires_at, last_seen_at, user_agent
|
||||||
|
FROM device_trust
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND datetime(expires_at) > datetime('now')
|
||||||
|
ORDER BY last_seen_at DESC, id DESC
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
DeviceRow(
|
||||||
|
id=row["id"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
expires_at=row["expires_at"],
|
||||||
|
last_seen_at=row["last_seen_at"],
|
||||||
|
user_agent=row["user_agent"] or "",
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(user_id: int, row_id: int) -> bool:
|
||||||
|
"""Revoke a single device-trust row for the given user.
|
||||||
|
|
||||||
|
Returns True iff a row was matched (still active, belongs to the
|
||||||
|
user). The user-id scope is enforced in SQL so a hostile client
|
||||||
|
cannot revoke another user's row by guessing ids.
|
||||||
|
"""
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_trust
|
||||||
|
SET revoked_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
""",
|
||||||
|
(row_id, user_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_all(user_id: int) -> int:
|
||||||
|
"""Revoke every active device-trust row for the user. Returns the
|
||||||
|
count of rows touched.
|
||||||
|
|
||||||
|
The /settings/devices "revoke all" button calls this. The user's
|
||||||
|
current request stays authenticated via its session cookie; the
|
||||||
|
device-trust cookie on the current device is also revoked, but
|
||||||
|
the session middleware's `rfc_session` cookie keeps the request
|
||||||
|
flow alive until the user signs out or the session cookie
|
||||||
|
expires.
|
||||||
|
"""
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_trust
|
||||||
|
SET revoked_at = datetime('now')
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Outbound admin-invite email — a thin wrapper over the existing SMTP layer.
|
||||||
|
|
||||||
|
v0.17.0 / roadmap item #16: when an admin uses `POST /api/admin/users` to
|
||||||
|
create-with-invite, this module composes and sends the invite envelope.
|
||||||
|
|
||||||
|
Structurally distinct from:
|
||||||
|
|
||||||
|
* `email_otc.py` (v0.7.0) — that one carries a credential the user
|
||||||
|
just requested; this one carries a credential the admin is sending
|
||||||
|
unsolicited.
|
||||||
|
* `email.py` (§15.4 notification mailer) — that one is inbox-driven,
|
||||||
|
bundled, with category opt-outs; this one is a single transactional
|
||||||
|
outbound to a person who does not yet have an inbox.
|
||||||
|
* v0.9.0's `new_beta_request` admin notification — that one is
|
||||||
|
invitee-to-admin (an existing pending user asking to be let in);
|
||||||
|
this one is admin-to-invitee (an admin reaching out to seed access).
|
||||||
|
|
||||||
|
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
|
||||||
|
and the From identity, but writes its own envelope. In dev (no
|
||||||
|
SMTP_HOST set), the envelope is logged at INFO level and pushed to
|
||||||
|
the same `_SENT` buffer the notification mailer uses, so the
|
||||||
|
integration tests can assert on the outbound shape without standing
|
||||||
|
up an SMTP server.
|
||||||
|
|
||||||
|
The send is synchronous. The admin endpoint returns 200 on the
|
||||||
|
create-row half regardless of send outcome — a transient SMTP
|
||||||
|
failure should not roll back the invite (an admin can re-send via a
|
||||||
|
future "resend invite" gesture, deferred to a follow-up release).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
|
||||||
|
from .email import EmailConfig, _SENT
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def send_invite_email(
|
||||||
|
*,
|
||||||
|
to_address: str,
|
||||||
|
claim_url: str,
|
||||||
|
inviter_display: str,
|
||||||
|
inviter_email: str,
|
||||||
|
custom_message: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""Compose and send the admin-invite email. Returns True on the
|
||||||
|
happy path; False on SMTP failure. The notifier-side buffer
|
||||||
|
`_SENT` is appended either way so tests can assert on content.
|
||||||
|
|
||||||
|
The body names the inviting admin, embeds the optional custom
|
||||||
|
message in a clearly delimited block if present, and ships the
|
||||||
|
claim link. The subject names the inviter so the recipient can
|
||||||
|
recognize the sender at a glance in their inbox preview.
|
||||||
|
"""
|
||||||
|
cfg = EmailConfig.from_env()
|
||||||
|
subject = _subject(inviter_display, cfg)
|
||||||
|
body = _body(claim_url, inviter_display, inviter_email, custom_message, cfg)
|
||||||
|
envelope = {
|
||||||
|
"to": to_address,
|
||||||
|
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"kind": "invite",
|
||||||
|
}
|
||||||
|
_SENT.append(envelope)
|
||||||
|
|
||||||
|
if not cfg.enabled:
|
||||||
|
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||||
|
return True
|
||||||
|
if not cfg.smtp_host:
|
||||||
|
# Dev fallback: surface the claim URL at INFO so the operator can
|
||||||
|
# complete a claim flow without an SMTP relay. In production
|
||||||
|
# SMTP_HOST is always set per OHM's overlay.
|
||||||
|
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = envelope["from"]
|
||||||
|
msg["To"] = to_address
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(body)
|
||||||
|
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||||
|
try:
|
||||||
|
if cfg.smtp_starttls:
|
||||||
|
smtp.starttls()
|
||||||
|
if cfg.smtp_user:
|
||||||
|
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
finally:
|
||||||
|
smtp.quit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
log.exception("invite email send failed: to=%s", to_address)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _subject(inviter_display: str, cfg: EmailConfig) -> str:
|
||||||
|
"""e.g. "You're invited to Wiggleverse by Ben Stull"."""
|
||||||
|
inviter = inviter_display or "an admin"
|
||||||
|
return f"You're invited to {cfg.from_name} by {inviter}"
|
||||||
|
|
||||||
|
|
||||||
|
def _body(
|
||||||
|
claim_url: str,
|
||||||
|
inviter_display: str,
|
||||||
|
inviter_email: str,
|
||||||
|
custom_message: str,
|
||||||
|
cfg: EmailConfig,
|
||||||
|
) -> str:
|
||||||
|
inviter = inviter_display or "An admin"
|
||||||
|
inviter_suffix = f" ({inviter_email})" if inviter_email else ""
|
||||||
|
message_block = ""
|
||||||
|
if custom_message.strip():
|
||||||
|
# Indent the custom message so it reads as a clearly-delimited
|
||||||
|
# quote rather than running together with the framework's
|
||||||
|
# framing text. Per-line indent keeps multi-line messages
|
||||||
|
# visually grouped in plain-text mail clients.
|
||||||
|
indented = "\n".join(f" {line}" for line in custom_message.strip().splitlines())
|
||||||
|
message_block = f"\nA personal note from {inviter}:\n\n{indented}\n"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{inviter}{inviter_suffix} has invited you to {cfg.from_name}.\n"
|
||||||
|
f"{message_block}\n"
|
||||||
|
f"Click the link below to claim your account and sign in.\n"
|
||||||
|
f"This link is single-use and expires in 7 days.\n\n"
|
||||||
|
f" {claim_url}\n\n"
|
||||||
|
f"If you weren't expecting this invitation, you can ignore this\n"
|
||||||
|
f"email — no account becomes active until you click the link.\n\n"
|
||||||
|
f"---\n"
|
||||||
|
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||||
|
)
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""§6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
|
||||||
|
|
||||||
|
Distinguishes from the v0.8.0 self-serve beta-access flow:
|
||||||
|
|
||||||
|
* **Self-serve (v0.8.0)** — anyone with an email can request OTC sign-in;
|
||||||
|
a fresh `users` row lands in `permission_state='pending'`; an admin
|
||||||
|
grants or revokes via the v0.9.0 user-management page.
|
||||||
|
|
||||||
|
* **Admin-create (v0.17.0)** — an admin types first/last/email/role
|
||||||
|
*before* the invitee has signed in. The framework provisions the
|
||||||
|
`users` row with the chosen role and `permission_state='granted'`
|
||||||
|
(the admin's hand is the grant) and `last_seen_at IS NULL` as the
|
||||||
|
"invited but not yet arrived" discriminator. An invite-token row
|
||||||
|
lands in `user_invite_tokens`; the admin's chosen `custom_message`
|
||||||
|
(if any) rides in the email body alongside the claim link.
|
||||||
|
|
||||||
|
* **Claim flow** — the invitee clicks the link, which lands them at
|
||||||
|
`/invites/claim?token=…`. The page POSTs `/api/invites/claim` with
|
||||||
|
the token. The framework verifies the token (not expired, not
|
||||||
|
claimed, hash matches), marks the row claimed, signs the user in,
|
||||||
|
and returns a payload telling the frontend whether to route to
|
||||||
|
passcode-set (if v0.10.0 passcode flow is in play and the user has
|
||||||
|
no passcode yet) or to `/`. **No OTC roundtrip** — clicking the
|
||||||
|
unique token in the email is itself proof of email control, per
|
||||||
|
the roadmap. This is the intentional UX shortcut for first
|
||||||
|
sign-in; subsequent sign-ins use the standard OTC / passcode
|
||||||
|
paths.
|
||||||
|
|
||||||
|
The shape:
|
||||||
|
|
||||||
|
* `create_invite(...)` — provision the invitee `users` row + the
|
||||||
|
`user_invite_tokens` row, return the raw token for the admin
|
||||||
|
endpoint to put in the outbound email link.
|
||||||
|
* `claim(raw_token)` — validate the token, mark it claimed, return
|
||||||
|
the `SessionUser` the endpoint signs in. Distinguishes the failure
|
||||||
|
modes (`expired`, `claimed`, `unknown`, `invalid`) so the endpoint
|
||||||
|
can map them to HTTP 410 vs HTTP 404 cleanly.
|
||||||
|
* `list_pending_invites()` — return active invites for the admin
|
||||||
|
listing surface. Filters out claimed + expired rows so the surface
|
||||||
|
only shows live invites.
|
||||||
|
|
||||||
|
Token shape: opaque DB token (256 bits of CSPRNG entropy via
|
||||||
|
`secrets.token_urlsafe(32)`), bcrypt-hashed at rest. Opaque chosen
|
||||||
|
over JWT because revocation is then a single SQL UPDATE — a JWT
|
||||||
|
would be stateless but harder to invalidate, and admin-issued
|
||||||
|
invites are exactly the kind of thing an admin should be able to
|
||||||
|
yank back. The raw token only ever lives in the outbound email link
|
||||||
|
and the inbound claim body; server-side storage is the hash.
|
||||||
|
|
||||||
|
TTL: hard-coded to 7 days via `INVITE_TOKEN_TTL_DAYS`. Env-var
|
||||||
|
configurability is a §19.2 candidate — the constant is exposed
|
||||||
|
here as a single point of edit if a deployment wants to override.
|
||||||
|
|
||||||
|
The 500-char ceiling on `custom_message` is enforced at the
|
||||||
|
Pydantic body level in `api_admin.py`; this module trusts what
|
||||||
|
the endpoint hands it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .auth import SessionUser
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tunables — intentionally hard-coded in v0.17.0 (§19.2 candidate to env-ify).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INVITE_TOKEN_TTL_DAYS = 7
|
||||||
|
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
|
||||||
|
# URL-safe characters; the bcrypt hash is what's stored, so the raw
|
||||||
|
# token only ever lives in the outbound email link.
|
||||||
|
TOKEN_BYTES = 32
|
||||||
|
# Free-text ceiling for the admin's optional custom message. Matched
|
||||||
|
# at the Pydantic body bound in `api_admin.py`; mentioned here so the
|
||||||
|
# bound is documented in one place.
|
||||||
|
CUSTOM_MESSAGE_MAX_LENGTH = 500
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token + hash helpers (mirror device_trust.py shape)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _new_token() -> str:
|
||||||
|
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(token: str) -> str:
|
||||||
|
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _check(token: str, token_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CreateOutcome:
|
||||||
|
"""The shape returned from `create_invite`.
|
||||||
|
|
||||||
|
`raw_token` is what the admin endpoint puts in the outbound email
|
||||||
|
link; it never appears in storage. `invite_id` is the surrogate
|
||||||
|
key for the admin's "invites I've sent" listing. `invited_user_id`
|
||||||
|
is the freshly-provisioned `users` row id so the admin surface can
|
||||||
|
join through to the user-management page.
|
||||||
|
"""
|
||||||
|
raw_token: str
|
||||||
|
invite_id: int
|
||||||
|
invited_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def create_invite(
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
first_name: str,
|
||||||
|
last_name: str,
|
||||||
|
role: str,
|
||||||
|
custom_message: str,
|
||||||
|
created_by_admin_id: int,
|
||||||
|
) -> CreateOutcome:
|
||||||
|
"""Provision the invitee `users` row + the `user_invite_tokens` row.
|
||||||
|
|
||||||
|
Caller (`api_admin.py`) is responsible for the admin-only auth check,
|
||||||
|
the self-email refusal (422), and the duplicate-email refusal (409).
|
||||||
|
This function trusts what it's handed and writes both rows
|
||||||
|
transactionally — the v0.10.0 `passcode.py` / v0.11.0 `device_trust.py`
|
||||||
|
helpers follow the same separation-of-concerns pattern.
|
||||||
|
|
||||||
|
The invitee `users` row is provisioned with:
|
||||||
|
* `permission_state='granted'` — the admin's hand is the grant;
|
||||||
|
the v0.8.0 self-serve `pending` queue is for the other path.
|
||||||
|
* `last_seen_at = NULL` — the discriminator for "invited but
|
||||||
|
not yet arrived" per the §16 / roadmap design. Every sign-in
|
||||||
|
path stamps `last_seen_at` to now, so a NULL value means the
|
||||||
|
invited user has not clicked through yet.
|
||||||
|
* `gitea_id = NULL`, `gitea_login = NULL` — same as a v0.7.0
|
||||||
|
OTC-provisioned user; the OAuth identity is grandfathered if
|
||||||
|
the user ever lands through that path.
|
||||||
|
* `display_name` defaults to "<first> <last>" (or local-part of
|
||||||
|
email if both are empty) so the user-management page reads a
|
||||||
|
sensible label before the user has signed in.
|
||||||
|
* `first_name` / `last_name` / `beta_request_reason` — the
|
||||||
|
first two from the admin's typed values; reason stays blank
|
||||||
|
(this user did not self-request access).
|
||||||
|
"""
|
||||||
|
email_clean = email.strip()
|
||||||
|
first_clean = (first_name or "").strip()
|
||||||
|
last_clean = (last_name or "").strip()
|
||||||
|
display = " ".join(p for p in (first_clean, last_clean) if p).strip()
|
||||||
|
if not display:
|
||||||
|
display = email_clean.split("@", 1)[0] or email_clean
|
||||||
|
|
||||||
|
# 1. Provision the invitee users row. The grant is the admin's
|
||||||
|
# hand; no permission_events row is necessary for the grant itself
|
||||||
|
# (we are not transitioning from pending → granted, we are landing
|
||||||
|
# a fresh row directly into granted).
|
||||||
|
#
|
||||||
|
# Note on the "pending invite" discriminator: the brief floated
|
||||||
|
# `first_sign_in_at NULL` / `last_seen_at NULL` as the marker the
|
||||||
|
# admin user-management page reads off the row to render the
|
||||||
|
# "(pending invite)" badge. The schema didn't cooperate — the
|
||||||
|
# existing `users.last_seen_at` column is NOT NULL with a
|
||||||
|
# `datetime('now')` default (see `migrations/001_users_and_audit.sql`),
|
||||||
|
# and there is no `first_sign_in_at` column. Rather than introduce
|
||||||
|
# a schema migration to add one (the brief explicitly said "likely
|
||||||
|
# no `users` table changes"), the discriminator is the existence of
|
||||||
|
# an active row in `user_invite_tokens` joined on `invited_user_id`.
|
||||||
|
# The admin listing's `pending_invite` field joins through that
|
||||||
|
# table; the claim flow stamps `claimed_at` on the invite row,
|
||||||
|
# which clears the badge naturally. This shape keeps the
|
||||||
|
# discriminator scoped to the v0.17.0 surface and avoids
|
||||||
|
# double-tracking against an existing column.
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
gitea_id, gitea_login, email, display_name, avatar_url,
|
||||||
|
role, permission_state, first_name, last_name
|
||||||
|
)
|
||||||
|
VALUES (NULL, NULL, ?, ?, '', ?, 'granted', ?, ?)
|
||||||
|
""",
|
||||||
|
(email_clean, display, role, first_clean, last_clean),
|
||||||
|
)
|
||||||
|
invited_user_id = cur.lastrowid
|
||||||
|
|
||||||
|
# 2. Mint the token, hash it, write the invite row.
|
||||||
|
raw = _new_token()
|
||||||
|
h = _hash(raw)
|
||||||
|
cur = db.conn().execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO user_invite_tokens (
|
||||||
|
email, role, first_name, last_name, custom_message,
|
||||||
|
token_hash, expires_at, created_by_admin_id, invited_user_id
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+{INVITE_TOKEN_TTL_DAYS} days'), ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
email_clean,
|
||||||
|
role,
|
||||||
|
first_clean,
|
||||||
|
last_clean,
|
||||||
|
(custom_message or "").strip(),
|
||||||
|
h,
|
||||||
|
created_by_admin_id,
|
||||||
|
invited_user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
invite_id = cur.lastrowid
|
||||||
|
return CreateOutcome(
|
||||||
|
raw_token=raw,
|
||||||
|
invite_id=invite_id,
|
||||||
|
invited_user_id=invited_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Claim
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClaimOutcome:
|
||||||
|
"""The result of `claim`.
|
||||||
|
|
||||||
|
`user` is populated only on success. `reason` distinguishes the
|
||||||
|
failure modes so the endpoint can return distinct HTTP statuses
|
||||||
|
(HTTP 410 for expired/claimed — the token is dead; HTTP 400 for
|
||||||
|
unknown/invalid — the request shape is wrong).
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
user: SessionUser | None
|
||||||
|
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'claimed'
|
||||||
|
invite_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def claim(raw_token: str) -> ClaimOutcome:
|
||||||
|
"""Validate the presented token and consume it.
|
||||||
|
|
||||||
|
Walks the active invite rows looking for a bcrypt hash match.
|
||||||
|
Mirrors `device_trust.lookup`: bcrypt's per-row salt means we
|
||||||
|
cannot SELECT by hash, but the set is small (a deployment's
|
||||||
|
outstanding invites at any moment) and bcrypt is cheap on the
|
||||||
|
order of milliseconds.
|
||||||
|
|
||||||
|
On a hit:
|
||||||
|
* Mark the row claimed (stamp `claimed_at = now`,
|
||||||
|
`claimed_by_user_id = invited_user_id` — the admin's
|
||||||
|
pre-provisioned row is the claimant).
|
||||||
|
* Stamp `last_seen_at = now` on the user row so the v0.9.0
|
||||||
|
admin user-management page no longer shows "(pending invite)".
|
||||||
|
* Return a populated `SessionUser` for the endpoint to sign in.
|
||||||
|
|
||||||
|
On a miss:
|
||||||
|
* `unknown` — no row matched. The token may have been forged or
|
||||||
|
the invite was admin-revoked.
|
||||||
|
* `expired` — row matched but `expires_at` is in the past.
|
||||||
|
* `claimed` — row matched but `claimed_at` is non-NULL. The
|
||||||
|
token was already consumed; the user must contact the admin
|
||||||
|
for a fresh invite.
|
||||||
|
* `invalid` — the token string itself was empty or unparseable.
|
||||||
|
"""
|
||||||
|
raw = (raw_token or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ClaimOutcome(ok=False, user=None, reason="invalid")
|
||||||
|
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, token_hash, expires_at, claimed_at, invited_user_id, role
|
||||||
|
FROM user_invite_tokens
|
||||||
|
ORDER BY id DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
matched = None
|
||||||
|
for row in rows:
|
||||||
|
if _check(raw, row["token_hash"]):
|
||||||
|
matched = row
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched is None:
|
||||||
|
return ClaimOutcome(ok=False, user=None, reason="unknown")
|
||||||
|
|
||||||
|
if matched["claimed_at"] is not None:
|
||||||
|
return ClaimOutcome(
|
||||||
|
ok=False, user=None, reason="claimed", invite_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
expired = db.conn().execute(
|
||||||
|
"SELECT datetime(?) < datetime('now') AS expired",
|
||||||
|
(matched["expires_at"],),
|
||||||
|
).fetchone()["expired"]
|
||||||
|
if expired:
|
||||||
|
return ClaimOutcome(
|
||||||
|
ok=False, user=None, reason="expired", invite_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Consume the row before signing in so a parallel claim of the same
|
||||||
|
# token cannot double-sign-in. (Mirrors `otc.verify_code`'s consume-
|
||||||
|
# before-provision shape.)
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_invite_tokens
|
||||||
|
SET claimed_at = datetime('now'),
|
||||||
|
claimed_by_user_id = invited_user_id
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(matched["id"],),
|
||||||
|
)
|
||||||
|
# Stamp last_seen_at on the user row so the user's activity stamp
|
||||||
|
# is current after the claim (mirroring otc.verify_code's
|
||||||
|
# last-seen update on the provision path). The "(pending invite)"
|
||||||
|
# badge's clear is driven by the invite row's `claimed_at`
|
||||||
|
# transition above; this update is for the general user-listing's
|
||||||
|
# recency ordering.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||||
|
(matched["invited_user_id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_row = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url,
|
||||||
|
role, permission_state
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(matched["invited_user_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if user_row is None:
|
||||||
|
# The invitee user row was deleted between create_invite and
|
||||||
|
# claim (shouldn't happen under the FK ON DELETE CASCADE — the
|
||||||
|
# cascade would drop the invite row too — be defensive anyway).
|
||||||
|
return ClaimOutcome(
|
||||||
|
ok=False, user=None, reason="unknown", invite_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return ClaimOutcome(
|
||||||
|
ok=True,
|
||||||
|
user=SessionUser(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
gitea_id=user_row["gitea_id"] or 0,
|
||||||
|
gitea_login=user_row["gitea_login"] or "",
|
||||||
|
display_name=user_row["display_name"],
|
||||||
|
email=user_row["email"] or "",
|
||||||
|
avatar_url=user_row["avatar_url"] or "",
|
||||||
|
role=user_row["role"],
|
||||||
|
permission_state=user_row["permission_state"] or "granted",
|
||||||
|
),
|
||||||
|
reason="ok",
|
||||||
|
invite_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List pending invites — for the admin's review surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingInviteRow:
|
||||||
|
"""The shape the `GET /api/admin/users/invites` endpoint returns.
|
||||||
|
|
||||||
|
Note the absence of `token_hash` — the hash is structurally private,
|
||||||
|
and the surface has no use for it. The raw token is also not on
|
||||||
|
the listing; it lives only in the email link.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
custom_message: str
|
||||||
|
created_at: str
|
||||||
|
expires_at: str
|
||||||
|
created_by_admin_id: int
|
||||||
|
invited_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending_invites() -> list[PendingInviteRow]:
|
||||||
|
"""Active invites (not claimed, not expired), freshest first.
|
||||||
|
|
||||||
|
The admin's "I sent these but they haven't been claimed yet" view.
|
||||||
|
Filters mirror the `device_trust.list_for_user` shape: the surface
|
||||||
|
only shows live records the framework would actually accept on a
|
||||||
|
presented token.
|
||||||
|
"""
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, email, role, first_name, last_name, custom_message,
|
||||||
|
created_at, expires_at, created_by_admin_id, invited_user_id
|
||||||
|
FROM user_invite_tokens
|
||||||
|
WHERE claimed_at IS NULL
|
||||||
|
AND datetime(expires_at) > datetime('now')
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
PendingInviteRow(
|
||||||
|
id=row["id"],
|
||||||
|
email=row["email"],
|
||||||
|
role=row["role"],
|
||||||
|
first_name=row["first_name"] or "",
|
||||||
|
last_name=row["last_name"] or "",
|
||||||
|
custom_message=row["custom_message"] or "",
|
||||||
|
created_at=row["created_at"],
|
||||||
|
expires_at=row["expires_at"],
|
||||||
|
created_by_admin_id=row["created_by_admin_id"],
|
||||||
|
invited_user_id=row["invited_user_id"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
+273
-6
@@ -10,8 +10,8 @@ import logging
|
|||||||
import secrets
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
@@ -20,12 +20,15 @@ from . import (
|
|||||||
auth,
|
auth,
|
||||||
cache,
|
cache,
|
||||||
db,
|
db,
|
||||||
|
device_trust as device_trust_mod,
|
||||||
digest,
|
digest,
|
||||||
email_otc,
|
email_otc,
|
||||||
hygiene,
|
hygiene,
|
||||||
|
invites as invites_mod,
|
||||||
otc,
|
otc,
|
||||||
passcode as passcode_mod,
|
passcode as passcode_mod,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
|
turnstile,
|
||||||
webhooks,
|
webhooks,
|
||||||
)
|
)
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
@@ -38,11 +41,25 @@ log = logging.getLogger("rfc_app")
|
|||||||
|
|
||||||
class OtcRequestBody(BaseModel):
|
class OtcRequestBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
|
||||||
|
# frontend widget. Optional in the body so a deployment that has
|
||||||
|
# not yet wired the Turnstile site key (or a dev environment with
|
||||||
|
# the widget intentionally skipped) still routes through the same
|
||||||
|
# endpoint; the backend turnstile.verify_token call decides whether
|
||||||
|
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
|
||||||
|
# the secret.
|
||||||
|
turnstile_token: str | None = Field(default=None, max_length=4096)
|
||||||
|
|
||||||
|
|
||||||
class OtcVerifyBody(BaseModel):
|
class OtcVerifyBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
code: str = Field(min_length=1, max_length=16)
|
code: str = Field(min_length=1, max_length=16)
|
||||||
|
# v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx
|
||||||
|
# OTC step. When true and verify succeeds, the server issues a fresh
|
||||||
|
# device-trust row and sets the `rfc_device_trust` cookie on the
|
||||||
|
# response. Defaults to false so existing clients that don't send
|
||||||
|
# the flag continue to behave the way they did pre-v0.11.0.
|
||||||
|
trust_device: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PasscodeSetBody(BaseModel):
|
class PasscodeSetBody(BaseModel):
|
||||||
@@ -52,6 +69,27 @@ class PasscodeSetBody(BaseModel):
|
|||||||
class PasscodeVerifyBody(BaseModel):
|
class PasscodeVerifyBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
passcode: str = Field(min_length=1, max_length=64)
|
passcode: str = Field(min_length=1, max_length=64)
|
||||||
|
# v0.11.0 — same trust-device opt-in as the OTC verify body.
|
||||||
|
trust_device: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class InviteClaimBody(BaseModel):
|
||||||
|
"""v0.17.0 / roadmap item #16 — claim an admin-issued invite token.
|
||||||
|
|
||||||
|
The frontend `/invites/claim?token=…` page reads the token from
|
||||||
|
the URL and POSTs it here. The body bound matches the
|
||||||
|
`secrets.token_urlsafe(32)` output shape (~43 URL-safe chars);
|
||||||
|
the upper bound stays generous in case `TOKEN_BYTES` is ever
|
||||||
|
raised. The token-shape is opaque to this layer — `invites.claim`
|
||||||
|
bcrypt-checks it against the active candidate set.
|
||||||
|
"""
|
||||||
|
token: str = Field(min_length=1, max_length=512)
|
||||||
|
# v0.11.0-style opt-in: the claim flow's "trust this device" gesture
|
||||||
|
# is bundled here so the invitee can land trusted on first sign-in
|
||||||
|
# without an extra roundtrip. Defaults to false so the gesture is
|
||||||
|
# explicit (the frontend modal renders a checkbox alongside the
|
||||||
|
# claim CTA).
|
||||||
|
trust_device: bool = False
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -117,6 +155,48 @@ def create_app() -> FastAPI:
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
|
||||||
|
"""Attach the v0.11.0 device-trust cookie to the response.
|
||||||
|
|
||||||
|
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
|
||||||
|
cookie value is the raw token; server-side storage is the hash.
|
||||||
|
The cookie is "essential" per the v0.13.0 cookie-consent contract
|
||||||
|
(it is part of authentication), so we set it regardless of the
|
||||||
|
user's analytics / other-cookies choice.
|
||||||
|
|
||||||
|
Secure=True means the cookie is only ever sent over HTTPS. The
|
||||||
|
SessionMiddleware in `create_app` keeps `https_only=False` for
|
||||||
|
dev parity, but the device-trust cookie holds a 30-day credential
|
||||||
|
and must not travel cleartext — production deployments serve over
|
||||||
|
HTTPS, so Secure on the device-trust cookie is non-negotiable.
|
||||||
|
"""
|
||||||
|
response.set_cookie(
|
||||||
|
key=device_trust_mod.COOKIE_NAME,
|
||||||
|
value=raw_token,
|
||||||
|
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
|
||||||
|
path="/",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_device_trust_cookie(response: Response) -> None:
|
||||||
|
"""Delete the device-trust cookie on the response.
|
||||||
|
|
||||||
|
Used when the framework detects a presented cookie that is
|
||||||
|
expired, revoked, or otherwise stale — the next request from
|
||||||
|
this device will not carry a dead token.
|
||||||
|
"""
|
||||||
|
response.delete_cookie(
|
||||||
|
key=device_trust_mod.COOKIE_NAME,
|
||||||
|
path="/",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _oauth_router(config) -> APIRouter:
|
def _oauth_router(config) -> APIRouter:
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -164,7 +244,27 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/auth/otc/request")
|
@router.post("/auth/otc/request")
|
||||||
async def otc_request(body: OtcRequestBody):
|
async def otc_request(body: OtcRequestBody, request: Request):
|
||||||
|
# v0.12.0 / roadmap item #10: gate the request on a successful
|
||||||
|
# Turnstile siteverify before the bcrypt hash + SMTP send. The
|
||||||
|
# check runs first so a failed challenge spends no rate budget
|
||||||
|
# and produces no envelope. When the operator has not wired the
|
||||||
|
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
|
||||||
|
# opens — see `backend/app/turnstile.py` for the full matrix.
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
|
||||||
|
if not ts.ok:
|
||||||
|
if ts.reason == "misconfigured":
|
||||||
|
# TURNSTILE_REQUIRED=true but the secret is unset. This
|
||||||
|
# is an operator/config problem, not a client problem;
|
||||||
|
# surface as 500 so the operator notices in their logs
|
||||||
|
# rather than blaming the user's browser.
|
||||||
|
raise HTTPException(500, "auth misconfigured")
|
||||||
|
# missing-token / failed / network → uniform 400 so the
|
||||||
|
# response does not enumerate which leg of the challenge
|
||||||
|
# broke. The reason is in the server logs.
|
||||||
|
raise HTTPException(400, "verification failed")
|
||||||
|
|
||||||
outcome = otc.request_code(body.email)
|
outcome = otc.request_code(body.email)
|
||||||
if outcome.reason == "cooldown":
|
if outcome.reason == "cooldown":
|
||||||
# Loud failure per the rate-limit primitive — the abuse
|
# Loud failure per the rate-limit primitive — the abuse
|
||||||
@@ -177,7 +277,7 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/auth/otc/verify")
|
@router.post("/auth/otc/verify")
|
||||||
async def otc_verify(body: OtcVerifyBody, request: Request):
|
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
||||||
result = otc.verify_code(body.email, body.code)
|
result = otc.verify_code(body.email, body.code)
|
||||||
if not result.ok or result.user is None:
|
if not result.ok or result.user is None:
|
||||||
raise HTTPException(400, "Invalid or expired code")
|
raise HTTPException(400, "Invalid or expired code")
|
||||||
@@ -202,6 +302,18 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
and not last_name
|
and not last_name
|
||||||
and not beta_request_reason
|
and not beta_request_reason
|
||||||
)
|
)
|
||||||
|
# v0.11.0 — opt-in device trust. The checkbox lives on the
|
||||||
|
# Login.jsx OTC step; when true, the server mints a fresh
|
||||||
|
# device-trust row and sets the long-lived cookie. The cookie
|
||||||
|
# is "essential" per the v0.13.0 consent contract (it is part
|
||||||
|
# of authentication, not analytics) so it lands regardless of
|
||||||
|
# the user's analytics / other-cookies choice. We capture the
|
||||||
|
# User-Agent at issuance so the /settings/devices surface can
|
||||||
|
# render a rough device label.
|
||||||
|
if body.trust_device:
|
||||||
|
ua = request.headers.get("user-agent", "")
|
||||||
|
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||||
|
_set_device_trust_cookie(response, outcome.raw_token)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -254,12 +366,16 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/auth/passcode/verify")
|
@router.post("/auth/passcode/verify")
|
||||||
async def passcode_verify(body: PasscodeVerifyBody, request: Request):
|
async def passcode_verify(body: PasscodeVerifyBody, request: Request, response: Response):
|
||||||
"""Sign in with email + passcode. Returns the standard session
|
"""Sign in with email + passcode. Returns the standard session
|
||||||
payload on success; HTTP 423 with `locked_until` when the
|
payload on success; HTTP 423 with `locked_until` when the
|
||||||
account is in the lockout window; HTTP 400 for every other
|
account is in the lockout window; HTTP 400 for every other
|
||||||
failure (the wrong-vs-unknown distinction is intentionally
|
failure (the wrong-vs-unknown distinction is intentionally
|
||||||
collapsed so a probing client cannot enumerate emails)."""
|
collapsed so a probing client cannot enumerate emails).
|
||||||
|
|
||||||
|
v0.11.0: the body's `trust_device` flag, if true, mints a
|
||||||
|
fresh device-trust row and sets the long-lived cookie. Same
|
||||||
|
opt-in contract as `/auth/otc/verify`."""
|
||||||
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
||||||
if result.reason == "locked":
|
if result.reason == "locked":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -272,6 +388,10 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
if not result.ok or result.user is None:
|
if not result.ok or result.user is None:
|
||||||
raise HTTPException(400, "Invalid passcode")
|
raise HTTPException(400, "Invalid passcode")
|
||||||
auth.store_session(request, result.user)
|
auth.store_session(request, result.user)
|
||||||
|
if body.trust_device:
|
||||||
|
ua = request.headers.get("user-agent", "")
|
||||||
|
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||||
|
_set_device_trust_cookie(response, outcome.raw_token)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -282,4 +402,151 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
|
||||||
|
#
|
||||||
|
# The admin-create surface lives at POST /api/admin/users (see
|
||||||
|
# api_admin.py); this endpoint is the corresponding claim path the
|
||||||
|
# invitee hits when they click the link in their invite email.
|
||||||
|
# The frontend route `/invites/claim?token=…` reads the token from
|
||||||
|
# the URL and POSTs it here.
|
||||||
|
#
|
||||||
|
# The claim itself is the first-sign-in for the invitee: clicking
|
||||||
|
# the unique token in the email is proof of email control per the
|
||||||
|
# roadmap, so this endpoint skips the OTC step entirely on first
|
||||||
|
# sign-in. The session cookie lands; the response tells the
|
||||||
|
# frontend whether to route to passcode-set (if v0.10.0 passcode
|
||||||
|
# flow is in play and the user has not yet set a passcode) or to
|
||||||
|
# home.
|
||||||
|
#
|
||||||
|
# The endpoint is anonymous-reachable: the entire point is to
|
||||||
|
# establish the session, so we do not gate it on `require_user`.
|
||||||
|
# The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
|
||||||
|
# contract (the body's `trust_device` flag, when true, mints a
|
||||||
|
# fresh device-trust row on the same response so the invitee
|
||||||
|
# lands trusted on their first device).
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/invites/claim")
|
||||||
|
async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
|
||||||
|
result = invites_mod.claim(body.token)
|
||||||
|
if result.reason == "expired":
|
||||||
|
# The token's TTL window passed without a claim. HTTP 410
|
||||||
|
# (Gone) so the frontend can render a "this invite has
|
||||||
|
# expired — please contact the admin for a fresh one"
|
||||||
|
# message distinct from the generic invalid-token shape.
|
||||||
|
raise HTTPException(410, "This invite has expired")
|
||||||
|
if result.reason == "claimed":
|
||||||
|
# The token was already consumed. HTTP 410 for the same
|
||||||
|
# reason — the row is dead either way.
|
||||||
|
raise HTTPException(410, "This invite has already been claimed")
|
||||||
|
if not result.ok or result.user is None:
|
||||||
|
# 'unknown' / 'invalid' — the token does not match any
|
||||||
|
# active invite row. HTTP 400 so it reads distinct from
|
||||||
|
# the dead-token shape above.
|
||||||
|
raise HTTPException(400, "Invalid invite token")
|
||||||
|
|
||||||
|
# Establish the session. From here on the invitee is signed
|
||||||
|
# in as the pre-provisioned user row carrying their
|
||||||
|
# pre-assigned role.
|
||||||
|
auth.store_session(request, result.user)
|
||||||
|
|
||||||
|
# v0.11.0 — opt-in device trust on the claim response. Same
|
||||||
|
# contract as OTC/passcode verify: when the body's flag is
|
||||||
|
# true, the server mints a fresh device-trust row and sets
|
||||||
|
# the long-lived cookie, so the invitee skips the email step
|
||||||
|
# on subsequent visits to the same browser.
|
||||||
|
if body.trust_device:
|
||||||
|
ua = request.headers.get("user-agent", "")
|
||||||
|
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||||
|
_set_device_trust_cookie(response, outcome.raw_token)
|
||||||
|
|
||||||
|
# Has the user already set a passcode? (Could only happen via
|
||||||
|
# an admin pre-population path that doesn't exist yet, but
|
||||||
|
# the response shape mirrors `/api/auth/me` so the frontend
|
||||||
|
# can read it without a second call.) If `needs_passcode` is
|
||||||
|
# true and v0.10.0 passcode flow is in play, the frontend
|
||||||
|
# routes to /settings/notifications#sign-in to set a passcode
|
||||||
|
# immediately; otherwise it routes to /.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_hash FROM users WHERE id = ?",
|
||||||
|
(result.user.user_id,),
|
||||||
|
).fetchone()
|
||||||
|
has_passcode = bool(row and row["passcode_hash"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": {
|
||||||
|
"id": result.user.user_id,
|
||||||
|
"display_name": result.user.display_name,
|
||||||
|
"email": result.user.email,
|
||||||
|
"role": result.user.role,
|
||||||
|
"permission_state": result.user.permission_state,
|
||||||
|
},
|
||||||
|
# Roadmap §16: the claim flow skips OTC entirely; the
|
||||||
|
# natural next step is passcode-set (so the invitee can
|
||||||
|
# sign back in without needing an email roundtrip on their
|
||||||
|
# second visit). The frontend uses this hint to decide
|
||||||
|
# whether to route to the passcode-set screen or to home.
|
||||||
|
"needs_passcode": not has_passcode,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||||
|
#
|
||||||
|
# The /auth/device-trust/start endpoint resolves a presented
|
||||||
|
# `rfc_device_trust` cookie. If it matches a non-expired,
|
||||||
|
# non-revoked row, the session is re-established and the client
|
||||||
|
# is told to skip OTC/passcode entry. A stale cookie (expired or
|
||||||
|
# revoked) is cleared on the response. A miss is structurally
|
||||||
|
# silent — the client falls back to the email step.
|
||||||
|
#
|
||||||
|
# The endpoint is anonymous-reachable: a returning visitor with
|
||||||
|
# the cookie hits this before the email step. We do not gate it
|
||||||
|
# on a session because the entire point is to establish one.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/auth/device-trust/start")
|
||||||
|
async def device_trust_start(request: Request):
|
||||||
|
"""Sign in via a presented device-trust cookie.
|
||||||
|
|
||||||
|
On a hit, re-establishes the session in the cookie store and
|
||||||
|
returns a user payload shaped like /auth/otc/verify (minus
|
||||||
|
`needs_profile`, which a returning device-trust user is
|
||||||
|
structurally past — they signed in at least once before).
|
||||||
|
On a miss, returns 401 + clears the stale cookie. An
|
||||||
|
'unknown' miss (cookie present but no row matches) also
|
||||||
|
clears, since the token is dead to the server either way.
|
||||||
|
|
||||||
|
Note on response construction: we return a `JSONResponse`
|
||||||
|
directly rather than raising `HTTPException` on the miss
|
||||||
|
path because FastAPI's exception handler builds a new
|
||||||
|
response from scratch and would drop any `set_cookie` /
|
||||||
|
`delete_cookie` calls. The hand-built `JSONResponse` lets
|
||||||
|
us attach the cookie-clear header alongside the 401.
|
||||||
|
"""
|
||||||
|
raw = request.cookies.get(device_trust_mod.COOKIE_NAME, "")
|
||||||
|
if not raw:
|
||||||
|
return JSONResponse({"detail": "No device trust"}, status_code=401)
|
||||||
|
outcome = device_trust_mod.lookup(raw)
|
||||||
|
if not outcome.ok or outcome.user is None:
|
||||||
|
# Clear the stale cookie so subsequent requests don't
|
||||||
|
# keep replaying a dead token. We surface 401 in all
|
||||||
|
# cases so a probing client can't tell "your row was
|
||||||
|
# revoked" from "this token never existed".
|
||||||
|
response = JSONResponse({"detail": "Device trust invalid"}, status_code=401)
|
||||||
|
_clear_device_trust_cookie(response)
|
||||||
|
return response
|
||||||
|
auth.store_session(request, outcome.user)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": {
|
||||||
|
"id": outcome.user.user_id,
|
||||||
|
"display_name": outcome.user.display_name,
|
||||||
|
"email": outcome.user.email,
|
||||||
|
"role": outcome.user.role,
|
||||||
|
"permission_state": outcome.user.permission_state,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.
|
||||||
|
|
||||||
|
The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
|
||||||
|
of the auth surface since v0.7.0 — the per-email cooldown stops the
|
||||||
|
trivial back-to-back loop, but it does not stop a distributed scraper
|
||||||
|
that fans out across a large invitee list to harvest the "this email
|
||||||
|
is admitted vs. this email is not" signal indirectly (timing
|
||||||
|
differences, SMTP bounce-rate observation). v0.12.0 gates the request
|
||||||
|
endpoint behind a one-step browser-side Turnstile challenge before the
|
||||||
|
bcrypt hash + SMTP send.
|
||||||
|
|
||||||
|
Stateless: no DB writes, no schema change. The siteverify call to
|
||||||
|
CloudFlare lives entirely in this module; the endpoint handler in
|
||||||
|
`main.py` thin-wraps `verify_token`.
|
||||||
|
|
||||||
|
Tunables (read at call time so tests can monkeypatch):
|
||||||
|
|
||||||
|
* `CLOUDFLARE_TURNSTILE_SECRET` — the operator-provisioned secret
|
||||||
|
key from the Turnstile dashboard. Lives in GCP Secret Manager in
|
||||||
|
production; absent in tests (which monkeypatch the siteverify
|
||||||
|
transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
|
||||||
|
- `TURNSTILE_REQUIRED=true` → fail closed (`misconfigured`).
|
||||||
|
- `TURNSTILE_REQUIRED=false` (default) → skip verification entirely
|
||||||
|
and admit the request. This is the dev/test path and the
|
||||||
|
"operator hasn't wired the secret yet" path; production
|
||||||
|
deployments **should** set `TURNSTILE_REQUIRED=true` once the
|
||||||
|
secret is in place so a regression in the secret wiring fails
|
||||||
|
loudly instead of silently disabling abuse defense.
|
||||||
|
|
||||||
|
* `TURNSTILE_REQUIRED` — `true` / `false` (default `false`).
|
||||||
|
When `false` and the secret is absent, the gate is open. When
|
||||||
|
`true` and the secret is absent, the endpoint refuses with a
|
||||||
|
misconfigured-auth shape rather than silently letting requests
|
||||||
|
through.
|
||||||
|
|
||||||
|
* `TURNSTILE_SITEVERIFY_URL` — points at the real CloudFlare
|
||||||
|
endpoint by default. Override in tests to redirect at a mock
|
||||||
|
URL when `httpx.MockTransport` isn't ergonomic for the case.
|
||||||
|
|
||||||
|
The siteverify contract is documented at
|
||||||
|
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
|
||||||
|
We POST `secret` + `response` (and optionally `remoteip`) as form
|
||||||
|
fields and read back `{"success": true|false, ...}`. Any network /
|
||||||
|
parse failure on the siteverify call is treated as a verification
|
||||||
|
failure (`network`) — the abuse path is to skip the challenge, so
|
||||||
|
"can't reach CloudFlare" defaults to "refuse the request" when
|
||||||
|
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> str:
|
||||||
|
return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _required() -> bool:
|
||||||
|
raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
|
||||||
|
return raw in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _siteverify_url() -> str:
|
||||||
|
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VerifyOutcome:
|
||||||
|
"""Result of a Turnstile siteverify call.
|
||||||
|
|
||||||
|
`ok`: the request **may proceed**. True both for "siteverify said
|
||||||
|
success" and for "no secret configured AND not required" (the
|
||||||
|
dev/test soft-fail path).
|
||||||
|
|
||||||
|
`reason`: one of
|
||||||
|
* 'ok' — siteverify returned success.
|
||||||
|
* 'skipped' — no secret configured, TURNSTILE_REQUIRED=false.
|
||||||
|
The gate is open; the endpoint admits the request.
|
||||||
|
* 'misconfigured' — TURNSTILE_REQUIRED=true but no secret in env.
|
||||||
|
The endpoint fails closed with 500.
|
||||||
|
* 'missing-token' — the client did not send a token at all and
|
||||||
|
verification is required.
|
||||||
|
* 'failed' — siteverify returned success=false. The
|
||||||
|
endpoint refuses with 400.
|
||||||
|
* 'network' — siteverify call raised. Treated as a failure
|
||||||
|
under TURNSTILE_REQUIRED=true.
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
|
||||||
|
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
|
||||||
|
|
||||||
|
Returns a VerifyOutcome describing whether the calling endpoint
|
||||||
|
should proceed. The endpoint maps `ok=False` to an HTTP status per
|
||||||
|
the `reason`:
|
||||||
|
|
||||||
|
* 'misconfigured' → 500 "auth misconfigured"
|
||||||
|
* 'missing-token' / 'failed' / 'network' → 400 "verification failed"
|
||||||
|
|
||||||
|
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
|
||||||
|
+ a MockTransport client) to avoid touching the real CloudFlare
|
||||||
|
endpoint. No real keys are ever embedded in tests.
|
||||||
|
"""
|
||||||
|
secret = _secret()
|
||||||
|
required = _required()
|
||||||
|
|
||||||
|
if not secret:
|
||||||
|
if required:
|
||||||
|
log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
|
||||||
|
return VerifyOutcome(ok=False, reason="misconfigured")
|
||||||
|
# Dev/test/soft-fail path: no secret, not required → gate is open.
|
||||||
|
return VerifyOutcome(ok=True, reason="skipped")
|
||||||
|
|
||||||
|
if not token or not token.strip():
|
||||||
|
# Secret is set, so verification is in force. A missing token
|
||||||
|
# is a hard refuse — the frontend should have rendered the
|
||||||
|
# widget and collected one.
|
||||||
|
return VerifyOutcome(ok=False, reason="missing-token")
|
||||||
|
|
||||||
|
data = {"secret": secret, "response": token.strip()}
|
||||||
|
if client_ip:
|
||||||
|
data["remoteip"] = client_ip
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
|
||||||
|
payload = response.json()
|
||||||
|
except Exception as exc: # network, JSON parse, etc.
|
||||||
|
log.warning("Turnstile siteverify call failed: %s", exc)
|
||||||
|
return VerifyOutcome(ok=False, reason="network")
|
||||||
|
|
||||||
|
if payload.get("success") is True:
|
||||||
|
return VerifyOutcome(ok=True, reason="ok")
|
||||||
|
|
||||||
|
# `error-codes` is a list of strings on failure; we log the codes
|
||||||
|
# for the operator without surfacing them to the client.
|
||||||
|
log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
|
||||||
|
return VerifyOutcome(ok=False, reason="failed")
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
||||||
|
--
|
||||||
|
-- After a successful OTC or passcode sign-in, the user can check
|
||||||
|
-- "trust this device for 30 days." The framework then issues a
|
||||||
|
-- server-issued opaque device-trust token, stores its hash on this
|
||||||
|
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
|
||||||
|
-- cookie carrying the raw token. On a subsequent visit, the cookie is
|
||||||
|
-- presented at `/auth/device-trust/start`; if the server can match the
|
||||||
|
-- hash to a non-expired non-revoked row, the user is signed in without
|
||||||
|
-- another OTC / passcode round-trip.
|
||||||
|
--
|
||||||
|
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
|
||||||
|
-- in `backend/app/device_trust.py`; raising or lowering it (or making
|
||||||
|
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
|
||||||
|
-- session-revocation surface this table will eventually share with the
|
||||||
|
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
|
||||||
|
--
|
||||||
|
-- Storage shape:
|
||||||
|
--
|
||||||
|
-- * `id` — surrogate key. Lets the revoke-device UI address a single
|
||||||
|
-- row by id without leaking the token shape.
|
||||||
|
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
|
||||||
|
-- user automatically loses every trusted device.
|
||||||
|
-- * `device_token_hash` — bcrypt hash of the random opaque token
|
||||||
|
-- issued at trust-time. The raw token only ever lives in the
|
||||||
|
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
|
||||||
|
-- server-side storage is the hash, so a DB compromise does not
|
||||||
|
-- hand attackers a stash of valid device tokens.
|
||||||
|
-- * `created_at` — when the row was issued.
|
||||||
|
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
|
||||||
|
-- is dead; the lookup path refuses it without further checks.
|
||||||
|
-- * `user_agent` — the User-Agent header captured at issuance.
|
||||||
|
-- Stored verbatim (truncated to 1024 chars at the application
|
||||||
|
-- layer) so the revoke-device UI can show a rough device label.
|
||||||
|
-- Not used for any auth decision — purely a hint to the user
|
||||||
|
-- reviewing their device list.
|
||||||
|
-- * `last_seen_at` — refreshed every time the row authenticates a
|
||||||
|
-- request. Lets the revoke-device UI surface "last used 3 days
|
||||||
|
-- ago" so the user can tell which row corresponds to which
|
||||||
|
-- device.
|
||||||
|
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
|
||||||
|
-- (or admin) revoked the row. Lookups treat any non-NULL value
|
||||||
|
-- as "this row is dead" without consulting the expiry; the
|
||||||
|
-- revoke gesture is intentionally one-way (a revoked device must
|
||||||
|
-- re-trust to come back online).
|
||||||
|
--
|
||||||
|
-- Indexing: a unique index on `device_token_hash` so collisions are
|
||||||
|
-- detectable at insert time (the token space is 256 bits of CSPRNG
|
||||||
|
-- entropy, so a collision is structurally impossible, but the
|
||||||
|
-- declaration documents the invariant). A separate index on
|
||||||
|
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
|
||||||
|
-- a covering walk.
|
||||||
|
--
|
||||||
|
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
|
||||||
|
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
|
||||||
|
--
|
||||||
|
-- The cookie shape: `rfc_device_trust` carries the raw token,
|
||||||
|
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
|
||||||
|
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
|
||||||
|
-- authentication, not analytics), so it is set regardless of the
|
||||||
|
-- user's analytics / other-cookies choices.
|
||||||
|
|
||||||
|
CREATE TABLE device_trust (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device_token_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
|
||||||
|
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- §6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
|
||||||
|
--
|
||||||
|
-- Distinguishes from the v0.8.0 / v0.9.0 self-serve beta-access shape:
|
||||||
|
-- here an *admin* creates a `users` row *before* the invited person has
|
||||||
|
-- ever signed in, assigns them a role at creation time, and sends them
|
||||||
|
-- an invite email carrying a claim link. The invitee clicks the link,
|
||||||
|
-- the claim flow consumes the token (which is itself proof of email
|
||||||
|
-- control), the row is marked claimed, and the user is signed in
|
||||||
|
-- inheriting the pre-set role.
|
||||||
|
--
|
||||||
|
-- Migration slot 019 is allocated to this release. Slot 018 is reserved
|
||||||
|
-- for the parallel #12 release (per-RFC invitation, owner-only) shipping
|
||||||
|
-- in the same wave; the two features live in distinct tables
|
||||||
|
-- (`user_invite_tokens` here vs. `rfc_invitations` there) so they
|
||||||
|
-- coexist cleanly. Slot 016 was reserved+skipped by Session K during
|
||||||
|
-- v0.9.0 integration; slot 017 is the v0.11.0 device-trust table.
|
||||||
|
--
|
||||||
|
-- Open-question decisions settled in this release (see CHANGELOG):
|
||||||
|
-- * No `users` table changes — the brief floated `first_sign_in_at`
|
||||||
|
-- / `last_seen_at IS NULL` as the "(pending invite)" discriminator,
|
||||||
|
-- but the existing `users.last_seen_at` is NOT NULL with a
|
||||||
|
-- `datetime('now')` default (migrations/001) and there is no
|
||||||
|
-- `first_sign_in_at` column. Rather than land a schema migration to
|
||||||
|
-- introduce one, the discriminator is the existence of an active
|
||||||
|
-- (not-claimed, not-expired) row in `user_invite_tokens` joined on
|
||||||
|
-- `invited_user_id`. The admin user-listing carries a
|
||||||
|
-- `pending_invite` field populated via that join; on claim, the
|
||||||
|
-- invite row's `claimed_at` populates and the badge clears.
|
||||||
|
-- No new `permission_state` value is introduced either.
|
||||||
|
-- * The token is opaque (random URL-safe string, bcrypt-hashed at
|
||||||
|
-- rest), not a JWT, so admin revocation by row UPDATE works
|
||||||
|
-- without distributing a key-rotation gesture.
|
||||||
|
-- * The TTL is a constant (`INVITE_TOKEN_TTL_DAYS = 7` in
|
||||||
|
-- `backend/app/invites.py`); env-var configurability is a follow-up.
|
||||||
|
-- * Immediate-send (no admin-review-then-send queue) ships in this
|
||||||
|
-- release; admin-preview is a future enhancement.
|
||||||
|
-- * Bulk-invite (CSV paste) is deferred to a follow-up release;
|
||||||
|
-- v0.17.0 is one-at-a-time.
|
||||||
|
--
|
||||||
|
-- Storage shape:
|
||||||
|
--
|
||||||
|
-- * `id` — surrogate key. Lets the admin "pending invites" listing
|
||||||
|
-- address a row without leaking the token shape.
|
||||||
|
-- * `email` — the address the invite was sent to (case-insensitive
|
||||||
|
-- match at claim time, persisted verbatim for the audit trail).
|
||||||
|
-- * `role` — the role the invitee inherits on first sign-in. Pinned
|
||||||
|
-- via CHECK to the same set the §6.1 role flip accepts
|
||||||
|
-- (`owner` / `admin` / `contributor`) so a future role-set drift
|
||||||
|
-- fails loudly at insert rather than provisioning a ghost role.
|
||||||
|
-- * `first_name` / `last_name` — captured at create time so the
|
||||||
|
-- invitee skips the v0.8.0 capture-form step on first sign-in.
|
||||||
|
-- * `custom_message` — optional free-text from the admin (max 500
|
||||||
|
-- chars enforced at the API layer); embedded verbatim in the
|
||||||
|
-- email body if present.
|
||||||
|
-- * `token_hash` — bcrypt hash of the random opaque token. The
|
||||||
|
-- raw token only ever lives in the outbound email link and the
|
||||||
|
-- inbound claim body; server-side storage is the hash.
|
||||||
|
-- * `expires_at` — `created_at + 7 days` (default at the app layer
|
||||||
|
-- via `INVITE_TOKEN_TTL_DAYS`). A row past this stamp is dead;
|
||||||
|
-- the claim path refuses with HTTP 410.
|
||||||
|
-- * `created_at` — when the admin issued the invite.
|
||||||
|
-- * `created_by_admin_id` — FK into users(id) for the admin who
|
||||||
|
-- created the invite (no cascade; if the admin's row is deleted
|
||||||
|
-- the invite history stays so the audit trail survives).
|
||||||
|
-- * `claimed_at` — non-NULL once the invitee successfully claims.
|
||||||
|
-- A second claim attempt against an already-claimed row returns
|
||||||
|
-- HTTP 410.
|
||||||
|
-- * `claimed_by_user_id` — FK into users(id) for the user row
|
||||||
|
-- that consumed the token. In the common case this equals the
|
||||||
|
-- freshly-provisioned row that was created at invite time; the
|
||||||
|
-- FK lets the admin's "claimed" list join through.
|
||||||
|
-- * `invited_user_id` — FK into users(id) for the pre-provisioned
|
||||||
|
-- row. Created at invite time with `last_seen_at IS NULL` so the
|
||||||
|
-- v0.9.0 admin user-management page can render a "(pending
|
||||||
|
-- invite)" badge alongside existing users.
|
||||||
|
--
|
||||||
|
-- Indexing:
|
||||||
|
-- * Unique index on `token_hash` documents the no-collision
|
||||||
|
-- invariant (256 bits of CSPRNG entropy; collision is
|
||||||
|
-- structurally impossible, the unique constraint catches a
|
||||||
|
-- bug at insert time).
|
||||||
|
-- * Index on `(email, claimed_at)` so the "is this email already
|
||||||
|
-- invited?" pre-check the admin endpoint runs is a covering walk.
|
||||||
|
-- * Index on `(created_by_admin_id, created_at DESC)` for the
|
||||||
|
-- admin's "invites I've sent" listing.
|
||||||
|
|
||||||
|
CREATE TABLE user_invite_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
|
||||||
|
first_name TEXT NOT NULL DEFAULT '',
|
||||||
|
last_name TEXT NOT NULL DEFAULT '',
|
||||||
|
custom_message TEXT NOT NULL DEFAULT '',
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_by_admin_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
claimed_at TEXT,
|
||||||
|
claimed_by_user_id INTEGER REFERENCES users(id),
|
||||||
|
invited_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_user_invite_tokens_hash ON user_invite_tokens (token_hash);
|
||||||
|
CREATE INDEX idx_user_invite_tokens_email ON user_invite_tokens (email, claimed_at);
|
||||||
|
CREATE INDEX idx_user_invite_tokens_admin ON user_invite_tokens (created_by_admin_id, created_at DESC);
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
"""End-to-end integration tests for v0.17.0's admin-create user +
|
||||||
|
invite-email + claim-flow vertical (roadmap item #16, §6.1).
|
||||||
|
|
||||||
|
The release lands three halves of the same surface:
|
||||||
|
|
||||||
|
* **Admin-create user** at `POST /api/admin/users`. The admin types
|
||||||
|
email, first/last name, role, and an optional custom message. The
|
||||||
|
framework provisions the invitee `users` row (granted, with the
|
||||||
|
chosen role) and writes a `user_invite_tokens` row carrying the
|
||||||
|
bcrypt-hashed opaque token. The "pending invite" discriminator is
|
||||||
|
the active `user_invite_tokens` row joined on `invited_user_id`,
|
||||||
|
not a NULL column on `users` (the existing `last_seen_at` column
|
||||||
|
is NOT NULL). An invite email dispatches via the existing SMTP
|
||||||
|
relay.
|
||||||
|
|
||||||
|
* **Pending-invite admin listing** at `GET /api/admin/users/invites`.
|
||||||
|
Lists active (not claimed, not expired) invites for the admin's
|
||||||
|
"I sent these but they haven't been claimed yet" view.
|
||||||
|
|
||||||
|
* **Claim** at `POST /api/invites/claim`. The invitee POSTs the token
|
||||||
|
they got via email; the framework verifies, marks the row claimed,
|
||||||
|
signs them in (skipping OTC on first sign-in per the roadmap), and
|
||||||
|
returns a `needs_passcode` hint for the frontend to route to the
|
||||||
|
passcode-set screen.
|
||||||
|
|
||||||
|
The tests prove:
|
||||||
|
|
||||||
|
* The happy path: admin creates → invite row + email envelope land →
|
||||||
|
invitee claims with the token → session is established.
|
||||||
|
* Non-admin caller is refused 403.
|
||||||
|
* Self-invite is refused 422.
|
||||||
|
* Duplicate email is refused 409.
|
||||||
|
* Owner-grant by non-owner is refused 422.
|
||||||
|
* Malformed role is refused 422 (pydantic regex).
|
||||||
|
* Custom message over 500 chars is refused 422 (pydantic max_length).
|
||||||
|
* Claim with valid token: signs in + marks row claimed.
|
||||||
|
* Claim with expired token: HTTP 410.
|
||||||
|
* Claim with already-claimed token: HTTP 410.
|
||||||
|
* Claim with unknown token: HTTP 400.
|
||||||
|
* The admin-create gesture writes a `permission_events` row with
|
||||||
|
event_kind='user_invited'.
|
||||||
|
* The user listing surfaces the `pending_invite` field for invited-
|
||||||
|
but-not-yet-claimed users, and clears it after claim.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_outbound():
|
||||||
|
from app import email as email_mod
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
|
||||||
|
|
||||||
|
def _outbound_invite_envelopes(to_address: str | None = None) -> list[dict]:
|
||||||
|
"""Pull the invite-kind envelopes off the shared notifier buffer.
|
||||||
|
|
||||||
|
Mirrors the OTC code-extraction helper in
|
||||||
|
test_admin_users_vertical.py — invite emails land in the same
|
||||||
|
`_SENT` buffer with `kind='invite'`.
|
||||||
|
"""
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "invite":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
out.append(env)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_claim_url(envelope: dict) -> str:
|
||||||
|
"""Pull the claim URL out of the invite email body."""
|
||||||
|
for line in envelope["body"].splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("http") and "/invites/claim" in line:
|
||||||
|
return line
|
||||||
|
raise AssertionError(f"no claim URL in envelope body: {envelope['body']!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_claim_token(envelope: dict) -> str:
|
||||||
|
"""Pull the `token` query-string param out of the claim URL."""
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
url = _extract_claim_url(envelope)
|
||||||
|
qs = parse_qs(urlparse(url).query)
|
||||||
|
return qs["token"][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin create + invite — happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_happy_path(app_with_fake_gitea):
|
||||||
|
"""Admin creates → user row + invite-token row + email envelope all
|
||||||
|
land; the response carries the created ids and the inviter is the
|
||||||
|
admin who issued the gesture."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=100, login="adminzero", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=100, gitea_login="adminzero",
|
||||||
|
display_name="Admin Zero", role="admin",
|
||||||
|
email="adminzero@test",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "invitee@example.com",
|
||||||
|
"first_name": "Inv",
|
||||||
|
"last_name": "Tee",
|
||||||
|
"role": "contributor",
|
||||||
|
"custom_message": "We chatted at the conference — welcome!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["email"] == "invitee@example.com"
|
||||||
|
assert body["role"] == "contributor"
|
||||||
|
assert body["invite_id"] > 0
|
||||||
|
assert body["invited_user_id"] > 0
|
||||||
|
|
||||||
|
# User row exists with the chosen role + granted. The "pending
|
||||||
|
# invite" discriminator is the active `user_invite_tokens` row,
|
||||||
|
# not a NULL column on `users` — see the invites.create_invite
|
||||||
|
# docstring for the reasoning.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT role, permission_state, first_name, last_name "
|
||||||
|
"FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
("invitee@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row["role"] == "contributor"
|
||||||
|
assert row["permission_state"] == "granted"
|
||||||
|
assert row["first_name"] == "Inv"
|
||||||
|
assert row["last_name"] == "Tee"
|
||||||
|
|
||||||
|
# Invite-token row exists with the matching ids and the custom
|
||||||
|
# message persisted verbatim.
|
||||||
|
invite = db.conn().execute(
|
||||||
|
"SELECT email, role, custom_message, created_by_admin_id, "
|
||||||
|
"invited_user_id, claimed_at FROM user_invite_tokens WHERE id = ?",
|
||||||
|
(body["invite_id"],),
|
||||||
|
).fetchone()
|
||||||
|
assert invite is not None
|
||||||
|
assert invite["email"] == "invitee@example.com"
|
||||||
|
assert invite["role"] == "contributor"
|
||||||
|
assert invite["custom_message"] == "We chatted at the conference — welcome!"
|
||||||
|
assert invite["created_by_admin_id"] == 100
|
||||||
|
assert invite["invited_user_id"] == body["invited_user_id"]
|
||||||
|
assert invite["claimed_at"] is None
|
||||||
|
|
||||||
|
# Email envelope landed with the invite kind and embeds the
|
||||||
|
# custom message + claim URL. The inviter display name comes
|
||||||
|
# off the DB row (which provision_user_row sets to
|
||||||
|
# login.capitalize()), not the sign_in_as cookie payload.
|
||||||
|
envelopes = _outbound_invite_envelopes(to_address="invitee@example.com")
|
||||||
|
assert len(envelopes) == 1
|
||||||
|
env = envelopes[0]
|
||||||
|
assert "Adminzero" in env["subject"] or "Adminzero" in env["body"]
|
||||||
|
assert "We chatted at the conference — welcome!" in env["body"]
|
||||||
|
# Claim URL is well-formed.
|
||||||
|
url = _extract_claim_url(env)
|
||||||
|
assert "/invites/claim?token=" in url
|
||||||
|
|
||||||
|
# `permission_events` row landed with event_kind='user_invited'.
|
||||||
|
ev = db.conn().execute(
|
||||||
|
"SELECT actor_user_id, subject_user_id, event_kind, details "
|
||||||
|
"FROM permission_events WHERE event_kind = 'user_invited'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(ev) == 1
|
||||||
|
assert ev[0]["actor_user_id"] == 100
|
||||||
|
assert ev[0]["subject_user_id"] == body["invited_user_id"]
|
||||||
|
details = json.loads(ev[0]["details"])
|
||||||
|
assert details["email"] == "invitee@example.com"
|
||||||
|
assert details["role"] == "contributor"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Refusals on the admin-create endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_non_admin(app_with_fake_gitea):
|
||||||
|
"""A contributor caller is refused 403; an anonymous caller 401."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=110, login="contrib", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=110, gitea_login="contrib",
|
||||||
|
display_name="Contrib", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"email": "x@y.com", "role": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"email": "x@y.com", "role": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_self_email(app_with_fake_gitea):
|
||||||
|
"""An admin trying to invite their own email is refused 422 —
|
||||||
|
self-invite is the wrong channel; the role-change endpoint exists
|
||||||
|
for self-edits."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=120, login="adm", role="admin")
|
||||||
|
# Manually set the admin's email since provision_user_row's
|
||||||
|
# fixture uses login@test; this is what we'll try to self-invite.
|
||||||
|
from app import db
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET email = ? WHERE id = ?",
|
||||||
|
("selfinviter@example.com", 120),
|
||||||
|
)
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=120, gitea_login="adm",
|
||||||
|
display_name="Adm", role="admin",
|
||||||
|
email="selfinviter@example.com",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "selfinviter@example.com",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert "yourself" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_duplicate_email(app_with_fake_gitea):
|
||||||
|
"""An admin trying to invite an email that already maps to a users
|
||||||
|
row is refused 409 — the existing role / grant gestures are the
|
||||||
|
right surface for an existing user."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=130, login="adminD", role="admin")
|
||||||
|
provision_user_row(user_id=131, login="existingone", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=130, gitea_login="adminD",
|
||||||
|
display_name="Admin D", role="admin",
|
||||||
|
)
|
||||||
|
# provision_user_row sets email to <login>@test, so:
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "existingone@test",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_owner_grant_refused_for_non_owner(app_with_fake_gitea):
|
||||||
|
"""An admin (not owner) trying to invite a fresh user as `owner` is
|
||||||
|
refused 422 — §6.1's owner-zero is the only bootstrap path."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=140, login="adminNoOwner", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=140, gitea_login="adminNoOwner",
|
||||||
|
display_name="Admin", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "wouldbeowner@example.com",
|
||||||
|
"role": "owner",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_owner_can_invite_as_owner(app_with_fake_gitea):
|
||||||
|
"""A sitting owner can invite a fresh user as `owner` — the §6.1
|
||||||
|
role-grant channel. Sanity check that the owner-grant path itself
|
||||||
|
works, paired with the refusal above."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=150, login="ownerzero", role="owner")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=150, gitea_login="ownerzero",
|
||||||
|
display_name="Owner Zero", role="owner",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "newowner@example.com",
|
||||||
|
"role": "owner",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT role FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
("newowner@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row["role"] == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_malformed_role(app_with_fake_gitea):
|
||||||
|
"""The pydantic regex refuses any role outside the §6.1 set."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=160, login="adminR", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=160, gitea_login="adminR",
|
||||||
|
display_name="Admin R", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "ok@example.com",
|
||||||
|
"role": "superuser",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_long_custom_message(app_with_fake_gitea):
|
||||||
|
"""Custom message over the 500-char ceiling is refused 422."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=170, login="adminM", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=170, gitea_login="adminM",
|
||||||
|
display_name="Admin M", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "ok@example.com",
|
||||||
|
"role": "contributor",
|
||||||
|
"custom_message": "x" * 501,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Claim flow
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_valid_token_signs_in_and_marks_claimed(app_with_fake_gitea):
|
||||||
|
"""End-to-end: admin creates → invitee posts the token to
|
||||||
|
/api/invites/claim → session lands + row marked claimed +
|
||||||
|
last_seen_at stamps on the user row (the pending-invite
|
||||||
|
discriminator clears)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=200, login="adminC", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=200, gitea_login="adminC",
|
||||||
|
display_name="Admin C", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "claimant@example.com",
|
||||||
|
"first_name": "Clai",
|
||||||
|
"last_name": "Mant",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
invite_id = r.json()["invite_id"]
|
||||||
|
invited_user_id = r.json()["invited_user_id"]
|
||||||
|
|
||||||
|
env = _outbound_invite_envelopes("claimant@example.com")[0]
|
||||||
|
token = _extract_claim_token(env)
|
||||||
|
|
||||||
|
# The invitee's request is anonymous (they have no session
|
||||||
|
# yet). We clear the admin's session cookie to simulate this.
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/invites/claim",
|
||||||
|
json={"token": token},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["user"]["id"] == invited_user_id
|
||||||
|
assert body["user"]["role"] == "contributor"
|
||||||
|
assert body["user"]["permission_state"] == "granted"
|
||||||
|
# The user has no passcode set yet → frontend should route to
|
||||||
|
# passcode-set per the roadmap.
|
||||||
|
assert body["needs_passcode"] is True
|
||||||
|
|
||||||
|
# Row marked claimed; last_seen_at populated.
|
||||||
|
invite = db.conn().execute(
|
||||||
|
"SELECT claimed_at, claimed_by_user_id FROM user_invite_tokens "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(invite_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert invite["claimed_at"] is not None
|
||||||
|
assert invite["claimed_by_user_id"] == invited_user_id
|
||||||
|
|
||||||
|
user_row = db.conn().execute(
|
||||||
|
"SELECT last_seen_at FROM users WHERE id = ?",
|
||||||
|
(invited_user_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert user_row["last_seen_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_expired_token_returns_410(app_with_fake_gitea):
|
||||||
|
"""A token whose `expires_at` has passed surfaces as HTTP 410."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db, invites
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=210, login="adminE", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=210, gitea_login="adminE",
|
||||||
|
display_name="Admin E", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
# Create the invite, then back-date the expires_at to the past.
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "expired@example.com",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
invite_id = r.json()["invite_id"]
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(invite_id,),
|
||||||
|
)
|
||||||
|
env = _outbound_invite_envelopes("expired@example.com")[0]
|
||||||
|
token = _extract_claim_token(env)
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token})
|
||||||
|
assert r.status_code == 410, r.text
|
||||||
|
assert "expired" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_already_claimed_token_returns_410(app_with_fake_gitea):
|
||||||
|
"""Re-claiming an already-consumed token surfaces as HTTP 410."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=220, login="adminA", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=220, gitea_login="adminA",
|
||||||
|
display_name="Admin A", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "twice@example.com",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
env = _outbound_invite_envelopes("twice@example.com")[0]
|
||||||
|
token = _extract_claim_token(env)
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
# First claim succeeds.
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Second claim, with the same token, refuses with 410.
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token})
|
||||||
|
assert r.status_code == 410, r.text
|
||||||
|
assert "already" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_unknown_token_returns_400(app_with_fake_gitea):
|
||||||
|
"""A token that doesn't match any active invite is HTTP 400."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# No invite ever created; the token is whatever the attacker
|
||||||
|
# types in. The endpoint should refuse without disclosing
|
||||||
|
# whether the token "looked" right.
|
||||||
|
r = client.post(
|
||||||
|
"/api/invites/claim",
|
||||||
|
json={"token": "totally-made-up-token-string-that-is-not-real"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pending-invite admin listing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_invites_listing_shows_active_invites_only(app_with_fake_gitea):
|
||||||
|
"""The `GET /api/admin/users/invites` listing filters to active
|
||||||
|
invites — claimed and expired rows do not surface here (the admin
|
||||||
|
user-listing carries the per-row pending-invite badge for the
|
||||||
|
living rows; once claimed, the badge clears)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=300, login="adminL", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=300, gitea_login="adminL",
|
||||||
|
display_name="Admin L", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
# Create three invites: one stays pending, one we'll claim, one
|
||||||
|
# we'll back-date to expired.
|
||||||
|
for email in ("alive@ex.co", "claimed@ex.co", "expired@ex.co"):
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"email": email, "role": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# Claim the middle one.
|
||||||
|
env = _outbound_invite_envelopes("claimed@ex.co")[0]
|
||||||
|
token_claim = _extract_claim_token(env)
|
||||||
|
|
||||||
|
# Expire the third one.
|
||||||
|
from app import db
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
|
||||||
|
"WHERE email = 'expired@ex.co'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The admin's session is still on the cookie. Claim works
|
||||||
|
# anonymously; we clear and restore.
|
||||||
|
admin_cookie = client.cookies.get("rfc_session")
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token_claim})
|
||||||
|
assert r.status_code == 200
|
||||||
|
client.cookies.set("rfc_session", admin_cookie)
|
||||||
|
|
||||||
|
r = client.get("/api/admin/users/invites")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
emails = sorted(i["email"] for i in items)
|
||||||
|
assert emails == ["alive@ex.co"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_invite_badge_clears_after_claim(app_with_fake_gitea):
|
||||||
|
"""The `/api/admin/users` listing surfaces `pending_invite` while
|
||||||
|
the invite is unclaimed; after the invitee claims, the row's
|
||||||
|
pending_invite is null."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=310, login="adminB", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=310, gitea_login="adminB",
|
||||||
|
display_name="Admin B", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"email": "badgey@ex.co", "role": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
invited_id = r.json()["invited_user_id"]
|
||||||
|
|
||||||
|
# Before claim — pending_invite is populated.
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 200
|
||||||
|
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
|
||||||
|
assert row["pending_invite"] is not None
|
||||||
|
assert row["pending_invite"]["invite_id"] > 0
|
||||||
|
|
||||||
|
# Claim.
|
||||||
|
env = _outbound_invite_envelopes("badgey@ex.co")[0]
|
||||||
|
token = _extract_claim_token(env)
|
||||||
|
admin_cookie = client.cookies.get("rfc_session")
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token})
|
||||||
|
assert r.status_code == 200
|
||||||
|
client.cookies.set("rfc_session", admin_cookie)
|
||||||
|
|
||||||
|
# After claim — pending_invite is null.
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 200
|
||||||
|
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
|
||||||
|
assert row["pending_invite"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_invites_listing_admin_only(app_with_fake_gitea):
|
||||||
|
"""The listing requires admin/owner; contributor gets 403."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=320, login="contribL", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=320, gitea_login="contribL",
|
||||||
|
display_name="Contrib L", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.get("/api/admin/users/invites")
|
||||||
|
assert r.status_code == 403
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""End-to-end integration tests for the v0.11.0 trust-device vertical
|
||||||
|
(§6.2, roadmap item #9).
|
||||||
|
|
||||||
|
After a successful OTC or passcode sign-in with `trust_device=true`
|
||||||
|
on the body, the server mints a fresh `device_trust` row and sets the
|
||||||
|
`rfc_device_trust` cookie. On a subsequent visit, the cookie carries
|
||||||
|
a session re-established by `POST /auth/device-trust/start`. The
|
||||||
|
tests below prove:
|
||||||
|
|
||||||
|
* `trust_device=false` (default, including omitted) on OTC verify
|
||||||
|
does NOT set the device-trust cookie and does NOT insert a row.
|
||||||
|
* `trust_device=true` on OTC verify DOES set the cookie (HttpOnly +
|
||||||
|
Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row.
|
||||||
|
The row's hash is NOT the raw token; only the hash lives in the
|
||||||
|
database.
|
||||||
|
* Same shape for passcode verify.
|
||||||
|
* On a returning visit with the cookie, `POST /auth/device-trust/start`
|
||||||
|
re-establishes the session — `GET /api/auth/me` reads the right
|
||||||
|
user without an OTC roundtrip.
|
||||||
|
* `last_seen_at` refreshes on a successful lookup.
|
||||||
|
* `POST /auth/device-trust/start` with no cookie returns 401.
|
||||||
|
* `POST /auth/device-trust/start` with a forged / unknown cookie
|
||||||
|
returns 401 + clears the cookie.
|
||||||
|
* A revoked row refuses the cookie (401) and clears it.
|
||||||
|
* An expired row refuses the cookie (401) and clears it.
|
||||||
|
* `GET /api/auth/me/devices` lists the user's active rows.
|
||||||
|
* `DELETE /api/auth/me/devices/{id}` revokes a single row.
|
||||||
|
* `DELETE /api/auth/me/devices/{id}` for another user's row reads 404.
|
||||||
|
* `DELETE /api/auth/me/devices` revokes every active row.
|
||||||
|
* Constant-time path: bcrypt.checkpw guards lookup; the raw token
|
||||||
|
is never written to logs or to the DB.
|
||||||
|
|
||||||
|
The fakes from `test_propose_vertical` give us a working app harness.
|
||||||
|
The OTC envelope buffer from `test_otc_vertical` is reused for the
|
||||||
|
OTC roundtrips this suite needs.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers — mirror the OTC suite's outbound-buffer helpers.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
COOKIE_NAME = "rfc_device_trust"
|
||||||
|
|
||||||
|
# The device-trust cookie is set with Secure=True, which httpx (the
|
||||||
|
# TestClient's underlying transport) will only return on an https
|
||||||
|
# scheme. We use a `base_url="https://testserver"` so the cookie
|
||||||
|
# roundtrips faithfully — that mirrors how production deployments
|
||||||
|
# serve the framework (per the v0.11.0 upgrade-step requiring HTTPS).
|
||||||
|
HTTPS_BASE = "https://testserver"
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_outbound():
|
||||||
|
from app import email as email_mod
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
|
||||||
|
|
||||||
|
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "otc":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
for line in env["body"].splitlines():
|
||||||
|
tok = line.strip()
|
||||||
|
if tok.isdigit() and len(tok) == 6:
|
||||||
|
out.append(tok)
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_in_via_otc(client, email: str, *, trust_device: bool = False) -> None:
|
||||||
|
r = client.post("/auth/otc/request", json={"email": email})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes(email)[-1]
|
||||||
|
body = {"email": email, "code": code, "trust_device": trust_device}
|
||||||
|
r = client.post("/auth/otc/verify", json=body)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def _device_rows_for_email(email: str) -> list[dict]:
|
||||||
|
from app import db
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT dt.*
|
||||||
|
FROM device_trust dt
|
||||||
|
JOIN users u ON u.id = dt.user_id
|
||||||
|
WHERE u.email = ? COLLATE NOCASE
|
||||||
|
ORDER BY dt.id
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# trust_device flag controls cookie issuance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=False)
|
||||||
|
# No cookie set on the response.
|
||||||
|
assert COOKIE_NAME not in {c.name for c in client.cookies.jar}
|
||||||
|
# No row inserted.
|
||||||
|
assert _device_rows_for_email("alice@example.com") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Cookie present on the response.
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
# Cookie attribute set asserts the spec'd shape. Starlette emits
|
||||||
|
# the attribute names case-insensitively (`samesite=lax`,
|
||||||
|
# `httponly`); we normalize when asserting.
|
||||||
|
lower = set_cookie.lower()
|
||||||
|
assert "httponly" in lower
|
||||||
|
assert "secure" in lower
|
||||||
|
assert "samesite=lax" in lower
|
||||||
|
assert "max-age=" in lower
|
||||||
|
|
||||||
|
# Row inserted; hash is not the raw token.
|
||||||
|
rows = _device_rows_for_email("alice@example.com")
|
||||||
|
assert len(rows) == 1
|
||||||
|
row = rows[0]
|
||||||
|
assert row["revoked_at"] is None
|
||||||
|
assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)"
|
||||||
|
cookie_token = client.cookies.get(COOKIE_NAME)
|
||||||
|
assert cookie_token
|
||||||
|
assert cookie_token != row["device_token_hash"]
|
||||||
|
# bcrypt hash shape (starts with $2)
|
||||||
|
assert row["device_token_hash"].startswith("$2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com")
|
||||||
|
|
||||||
|
# Set a passcode.
|
||||||
|
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Sign out so the passcode verify path is the active sign-in.
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Passcode verify with trust_device=true issues a row.
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True},
|
||||||
|
headers={"User-Agent": "Test/Phone"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
|
||||||
|
rows = _device_rows_for_email("alice@example.com")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["user_agent"] == "Test/Phone"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /auth/device-trust/start
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
# Trust the device.
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
trust_cookie = client.cookies.get(COOKIE_NAME)
|
||||||
|
assert trust_cookie
|
||||||
|
|
||||||
|
# Clear the session cookie so only the device-trust cookie is in
|
||||||
|
# play. We keep `rfc_device_trust` and drop `rfc_session`.
|
||||||
|
for cookie in list(client.cookies.jar):
|
||||||
|
if cookie.name != COOKIE_NAME:
|
||||||
|
client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name)
|
||||||
|
|
||||||
|
# The session cookie is gone — /api/auth/me reads anonymous.
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is False
|
||||||
|
|
||||||
|
# Hit the trust-start endpoint; the cookie re-establishes the session.
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["user"]["email"] == "alice@example.com"
|
||||||
|
|
||||||
|
# /api/auth/me now reads authenticated.
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is True
|
||||||
|
assert me["user"]["email"] == "alice@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Force the existing row's last_seen_at into the past so we can
|
||||||
|
# assert the refresh moved it forward.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_trust
|
||||||
|
SET last_seen_at = datetime('now', '-7 days')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hit the start endpoint.
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# last_seen_at is now recent (within the last minute).
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert row["fresh"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Revoke the row out-of-band.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE device_trust SET revoked_at = datetime('now')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now the start endpoint refuses + clears the cookie.
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 401
|
||||||
|
# The cookie is cleared via a Set-Cookie header with Max-Age=0
|
||||||
|
# (Starlette's `delete_cookie` shape).
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Backdate the expiry into the past.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE device_trust SET expires_at = datetime('now', '-1 day')"
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 401
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
# No real row; just paste a cookie value.
|
||||||
|
client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx")
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 401
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/auth/me/devices — list + revoke
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_devices_requires_session(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
r = client.get("/api/auth/me/devices")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_devices_returns_active_rows_only(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||||
|
|
||||||
|
# Add a second trusted device by re-running the verify flow.
|
||||||
|
# OTC has a per-email cooldown, so drop the cooldown rather
|
||||||
|
# than waiting it out.
|
||||||
|
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
headers={"User-Agent": "Test/Tablet"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Revoke one row directly.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
# /api/auth/me/devices returns only the un-revoked one.
|
||||||
|
r = client.get("/api/auth/me/devices")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["user_agent"] == "Test/Tablet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_single_device_kills_the_row(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||||
|
|
||||||
|
r = client.get("/api/auth/me/devices")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
device_id = items[0]["id"]
|
||||||
|
|
||||||
|
# Revoke it.
|
||||||
|
r = client.delete(f"/api/auth/me/devices/{device_id}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# List is empty.
|
||||||
|
r = client.get("/api/auth/me/devices")
|
||||||
|
assert r.json()["items"] == []
|
||||||
|
|
||||||
|
# The row in the table has revoked_at populated.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["revoked_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_other_users_device_reads_404(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
# Alice trusts a device.
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||||
|
alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"]
|
||||||
|
|
||||||
|
# Bob signs in (without a trusted device of his own).
|
||||||
|
client.cookies.clear()
|
||||||
|
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||||
|
_sign_in_via_otc(client, "bob@example.com", trust_device=False)
|
||||||
|
|
||||||
|
# Bob tries to revoke Alice's row by id.
|
||||||
|
r = client.delete(f"/api/auth/me/devices/{alice_device_id}")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# Alice's row is still active.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["revoked_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||||
|
|
||||||
|
# Add a second device.
|
||||||
|
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Two active rows.
|
||||||
|
assert len(client.get("/api/auth/me/devices").json()["items"]) == 2
|
||||||
|
|
||||||
|
# Revoke all.
|
||||||
|
r = client.delete("/api/auth/me/devices")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["revoked"] == 2
|
||||||
|
|
||||||
|
# List is empty.
|
||||||
|
assert client.get("/api/auth/me/devices").json()["items"] == []
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile
|
||||||
|
gate on `/auth/otc/request` (§6.2 / roadmap item #10).
|
||||||
|
|
||||||
|
The release gates the OTC request endpoint behind a one-step
|
||||||
|
browser-side Turnstile challenge before the bcrypt hash + SMTP send.
|
||||||
|
The tests prove:
|
||||||
|
|
||||||
|
* Happy path: with the secret set, a valid token admits the request
|
||||||
|
and the OTC envelope lands.
|
||||||
|
* Failure path: with the secret set, a token siteverify rejects
|
||||||
|
refuses the request with 400 and produces no envelope.
|
||||||
|
* Missing-token: with the secret set, a request without a token
|
||||||
|
refuses with 400.
|
||||||
|
* Missing-secret-soft: with the secret unset AND
|
||||||
|
`TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request
|
||||||
|
admits — this is the dev / "operator hasn't wired it yet" path.
|
||||||
|
* Missing-secret-hard: with the secret unset AND
|
||||||
|
`TURNSTILE_REQUIRED=true`, the request refuses with 500
|
||||||
|
"auth misconfigured" — the production fail-closed path once
|
||||||
|
the operator has flipped the policy.
|
||||||
|
|
||||||
|
The Turnstile siteverify call is mocked at the `httpx.post` boundary
|
||||||
|
inside `app.turnstile` so no real keys are needed and no real
|
||||||
|
CloudFlare call is made. The Gitea fakes from `test_propose_vertical`
|
||||||
|
remain in scope so the rest of the app boots cleanly.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_outbound():
|
||||||
|
from app import email as email_mod
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
|
||||||
|
|
||||||
|
def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "otc":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
out.append(env)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
|
||||||
|
"""Replace `httpx.post` inside `app.turnstile` with a stub that
|
||||||
|
returns the requested success shape. The stub does not touch the
|
||||||
|
real CloudFlare endpoint and never sees a real secret.
|
||||||
|
"""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, *, data=None, timeout=None, **kwargs):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["data"] = data
|
||||||
|
body = {"success": bool(success)}
|
||||||
|
if error_codes is not None:
|
||||||
|
body["error-codes"] = error_codes
|
||||||
|
return SimpleNamespace(json=lambda: body)
|
||||||
|
|
||||||
|
from app import turnstile as turnstile_mod
|
||||||
|
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Happy path: secret set, token valid → admit + OTC envelope lands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||||
|
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||||
|
captured = _patch_siteverify(monkeypatch, success=True)
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/request",
|
||||||
|
json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# The siteverify call was made with the secret + the token we sent.
|
||||||
|
assert captured["data"]["secret"] == "test-secret-not-real"
|
||||||
|
assert captured["data"]["response"] == "fake-token-abc"
|
||||||
|
# And the OTC dispatch ran — exactly one envelope to the address.
|
||||||
|
envs = _outbound_otc_envelopes("alice@example.com")
|
||||||
|
assert len(envs) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Failure path: secret set, siteverify says success=false → 400 + no envelope
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||||
|
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||||
|
_patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"])
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/request",
|
||||||
|
json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
# The OTC bcrypt + SMTP path did not run — no envelope was buffered.
|
||||||
|
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Missing-token: secret set, no token → 400 + no envelope
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||||
|
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||||
|
# Even though we patch httpx.post, the missing-token check fires
|
||||||
|
# before the siteverify call — so the patch is here only as a
|
||||||
|
# safety net in case the implementation regresses to making the
|
||||||
|
# network call anyway.
|
||||||
|
_patch_siteverify(monkeypatch, success=False)
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/request",
|
||||||
|
json={"email": "alice@example.com"}, # no turnstile_token field at all
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch):
|
||||||
|
"""v0.12.0 default: the operator has not yet wired the Turnstile
|
||||||
|
secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays
|
||||||
|
open — this is the dev / test / pre-rollout path. Once the
|
||||||
|
operator confirms the secret is in place and flips
|
||||||
|
`TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed
|
||||||
|
(covered in test_otc_request_refuses_when_required_but_secret_unset).
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||||
|
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||||
|
# The httpx.post inside turnstile must not be called in this path —
|
||||||
|
# patch it to a sentinel that explodes if it ever runs.
|
||||||
|
from app import turnstile as turnstile_mod
|
||||||
|
|
||||||
|
def must_not_be_called(*a, **kw):
|
||||||
|
raise AssertionError("siteverify should not run when no secret is configured")
|
||||||
|
|
||||||
|
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/request",
|
||||||
|
json={"email": "alice@example.com"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# The OTC path ran end-to-end — one envelope to the address.
|
||||||
|
assert len(_outbound_otc_envelopes("alice@example.com")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch):
|
||||||
|
"""Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock
|
||||||
|
down production, a missing secret stops being a soft-fail and
|
||||||
|
becomes a fail-closed 500. This is the regression-detection shape
|
||||||
|
the §20.4 upgrade-steps MAY block calls out — flip the flag once
|
||||||
|
the secret is wired so a future config drift fails loudly instead
|
||||||
|
of silently disabling abuse defense.
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||||
|
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/request",
|
||||||
|
json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 500, r.text
|
||||||
|
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||||
@@ -49,3 +49,16 @@ VITE_PRIVACY_POLICY_URL=
|
|||||||
# Examples:
|
# Examples:
|
||||||
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
||||||
VITE_COOKIES_POLICY_URL=
|
VITE_COOKIES_POLICY_URL=
|
||||||
|
|
||||||
|
# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public).
|
||||||
|
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||||
|
# site. The site key (this var) is embedded into the frontend bundle at
|
||||||
|
# build time and rendered by the Turnstile widget on the /login email-
|
||||||
|
# entry step. The secret key (private) lives in the backend env as
|
||||||
|
# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset
|
||||||
|
# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy
|
||||||
|
# decides what happens to a tokenless request.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
|
||||||
|
VITE_TURNSTILE_SITE_KEY=
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.0",
|
"version": "0.17.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -455,6 +455,65 @@
|
|||||||
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
|
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
|
||||||
.otc-fallback-sep { color: #ccc; }
|
.otc-fallback-sep { color: #ccc; }
|
||||||
|
|
||||||
|
/* v0.11.0 — "trust this device for 30 days" checkbox on the verify
|
||||||
|
step. Sits above the action row, padded so it doesn't crowd the
|
||||||
|
passcode/code input. */
|
||||||
|
.otc-trust-device {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 13px; color: #444;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.otc-trust-device input[type="checkbox"] {
|
||||||
|
width: auto; margin: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.11.0 — /settings/devices revoke-device UI. */
|
||||||
|
.device-list {
|
||||||
|
list-style: none; padding: 0; margin: 12px 0 0;
|
||||||
|
}
|
||||||
|
.device-list-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #eee; border-radius: 6px;
|
||||||
|
padding: 10px 12px; margin: 0 0 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.device-list-item .device-meta {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.device-list-item .device-ua {
|
||||||
|
font-size: 13px; color: #1a1a1a;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.device-list-item .device-stamps {
|
||||||
|
font-size: 12px; color: #777;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.device-list-item button {
|
||||||
|
font-size: 12px; padding: 4px 10px;
|
||||||
|
border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
background: white; cursor: pointer;
|
||||||
|
}
|
||||||
|
.device-list-item button:hover:not(:disabled) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.device-revoke-all {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px; padding: 6px 12px;
|
||||||
|
border: 1px solid #cb6a6a; border-radius: 4px;
|
||||||
|
background: white; color: #cb6a6a; cursor: pointer;
|
||||||
|
}
|
||||||
|
.device-revoke-all:hover:not(:disabled) {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
.device-empty {
|
||||||
|
font-size: 13px; color: #777;
|
||||||
|
background: #fafafa; border: 1px solid #eee; border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Beta-pending page (post-OAuth-rejection) --- */
|
/* --- Beta-pending page (post-OAuth-rejection) --- */
|
||||||
|
|
||||||
.beta-pending {
|
.beta-pending {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Philosophy from './components/Philosophy.jsx'
|
|||||||
import Docs from './components/Docs.jsx'
|
import Docs from './components/Docs.jsx'
|
||||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
|
import InviteClaim from './components/InviteClaim.jsx'
|
||||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||||
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
||||||
import Privacy from './pages/Privacy.jsx'
|
import Privacy from './pages/Privacy.jsx'
|
||||||
@@ -156,6 +157,10 @@ export default function App() {
|
|||||||
<Route path="/welcome" element={<Landing />} />
|
<Route path="/welcome" element={<Landing />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||||
|
{/* v0.17.0 — roadmap item #16. The claim landing page for
|
||||||
|
admin-issued invites. Anonymous-reachable; the call
|
||||||
|
itself establishes the session on success. */}
|
||||||
|
<Route path="/invites/claim" element={<InviteClaim />} />
|
||||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||||
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
||||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||||
|
|||||||
+95
-6
@@ -31,20 +31,33 @@ export async function getMe() {
|
|||||||
// migration — the new UI just no longer points at it primarily. These
|
// migration — the new UI just no longer points at it primarily. These
|
||||||
// two helpers drive the Login.jsx surface.
|
// two helpers drive the Login.jsx surface.
|
||||||
|
|
||||||
export async function requestOtc(email) {
|
export async function requestOtc(email, { turnstileToken } = {}) {
|
||||||
|
// v0.12.0 / roadmap item #10: when the Turnstile widget has produced
|
||||||
|
// a token, send it alongside the email so the backend can siteverify
|
||||||
|
// before the OTC dispatch. The backend treats a missing token as
|
||||||
|
// either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
|
||||||
|
// or hard-fail (verification required) — the frontend stays
|
||||||
|
// uninvolved in the policy.
|
||||||
|
const body = { email }
|
||||||
|
if (turnstileToken) body.turnstile_token = turnstileToken
|
||||||
const res = await fetch('/auth/otc/request', {
|
const res = await fetch('/auth/otc/request', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyOtc(email, code) {
|
export async function verifyOtc(email, code, { trustDevice = false } = {}) {
|
||||||
|
// v0.11.0 — `trustDevice` is the "trust this device for 30 days"
|
||||||
|
// checkbox on the Login.jsx OTC step. When true, the server mints
|
||||||
|
// a fresh device-trust row and sets the long-lived cookie; on
|
||||||
|
// subsequent visits, the cookie skips the OTC roundtrip via
|
||||||
|
// `startDeviceTrust()`.
|
||||||
const res = await fetch('/auth/otc/verify', {
|
const res = await fetch('/auth/otc/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, code }),
|
body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
@@ -82,15 +95,44 @@ export async function checkPasscode(email) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyPasscode(email, passcode) {
|
export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) {
|
||||||
|
// v0.11.0 — same trust-device opt-in as `verifyOtc`.
|
||||||
const res = await fetch('/auth/passcode/verify', {
|
const res = await fetch('/auth/passcode/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, passcode }),
|
body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ─────────────
|
||||||
|
//
|
||||||
|
// On a returning visit with a valid device-trust cookie, `startDeviceTrust`
|
||||||
|
// re-establishes the session without an OTC / passcode roundtrip. The
|
||||||
|
// cookie is HttpOnly so the client cannot read it; the call is a pure POST
|
||||||
|
// that the browser attaches the cookie to automatically.
|
||||||
|
//
|
||||||
|
// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the
|
||||||
|
// /settings/devices revoke-device UI. The signed-in user is the implicit
|
||||||
|
// subject; the cookie carries the session.
|
||||||
|
|
||||||
|
export async function startDeviceTrust() {
|
||||||
|
const res = await fetch('/auth/device-trust/start', { method: 'POST' })
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMyDevices() {
|
||||||
|
return jsonOrThrow(await fetch('/api/auth/me/devices'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeMyDevice(deviceId) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAllMyDevices() {
|
||||||
|
return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' }))
|
||||||
|
}
|
||||||
|
|
||||||
export async function setPasscode(passcode) {
|
export async function setPasscode(passcode) {
|
||||||
// Requires an active session — the server returns 401 if not signed
|
// Requires an active session — the server returns 401 if not signed
|
||||||
// in. The signed-in user is the implicit subject; the body carries
|
// in. The signed-in user is the implicit subject; the body carries
|
||||||
@@ -715,6 +757,53 @@ export async function removeAllowlistEmail(email) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.17.0 — roadmap item #16. Admin-create user + invite email with
|
||||||
|
// optional custom message. The frontend modal on /admin/users wires
|
||||||
|
// these two helpers; the claim helper drives the /invites/claim page
|
||||||
|
// that the invitee lands on when they click the email link.
|
||||||
|
//
|
||||||
|
// `createUserInvite` returns `{ ok, invite_id, invited_user_id, email,
|
||||||
|
// role }`. The 409 path (duplicate email) and 422 path (self-invite,
|
||||||
|
// owner-grant-by-non-owner, malformed input) surface as thrown errors
|
||||||
|
// via `jsonOrThrow` so the modal can render the server's message.
|
||||||
|
//
|
||||||
|
// `listUserInvites` returns the active-invites list for the admin's
|
||||||
|
// "I sent these but they haven't been claimed yet" view. Active means
|
||||||
|
// not claimed and not expired; once the invitee clicks through, the
|
||||||
|
// row clears here and the user-listing's `pending_invite` badge
|
||||||
|
// vanishes alongside.
|
||||||
|
|
||||||
|
export async function createUserInvite({ email, first_name, last_name, role, custom_message }) {
|
||||||
|
return jsonOrThrow(await fetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
first_name: first_name || '',
|
||||||
|
last_name: last_name || '',
|
||||||
|
role,
|
||||||
|
custom_message: custom_message || '',
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserInvites() {
|
||||||
|
return jsonOrThrow(await fetch('/api/admin/users/invites'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim an admin-issued invite token. Anonymous endpoint — the invitee
|
||||||
|
// is not yet signed in; this call establishes the session on success.
|
||||||
|
// `trustDevice` mirrors the v0.11.0 OTC/passcode opt-in: when true,
|
||||||
|
// the server mints a fresh device-trust row + sets the long-lived
|
||||||
|
// cookie so the invitee skips OTC on their next visit.
|
||||||
|
export async function claimInvite(token, { trustDevice = false } = {}) {
|
||||||
|
return jsonOrThrow(await fetch('/api/invites/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, trust_device: !!trustDevice }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchUsers(q) {
|
export async function searchUsers(q) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (q) params.set('q', q)
|
if (q) params.set('q', q)
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ import {
|
|||||||
listAllowlist,
|
listAllowlist,
|
||||||
addAllowlistEmail,
|
addAllowlistEmail,
|
||||||
removeAllowlistEmail,
|
removeAllowlistEmail,
|
||||||
|
createUserInvite,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
|
|
||||||
|
// v0.17.0 — roadmap item #16. The max length the backend enforces
|
||||||
|
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
|
||||||
|
// here so the modal's "remaining chars" counter stays in lockstep
|
||||||
|
// with the server-side bound.
|
||||||
|
const CUSTOM_MESSAGE_MAX_LENGTH = 500
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ path: 'users', label: 'Users' },
|
{ path: 'users', label: 'Users' },
|
||||||
{ path: 'allowlist', label: 'Allowlist' },
|
{ path: 'allowlist', label: 'Allowlist' },
|
||||||
@@ -89,6 +96,10 @@ function UsersTab() {
|
|||||||
const [busy, setBusy] = useState({})
|
const [busy, setBusy] = useState({})
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [stateFilter, setStateFilter] = useState('all')
|
const [stateFilter, setStateFilter] = useState('all')
|
||||||
|
// v0.17.0 — roadmap item #16. The "Create user + invite" modal's
|
||||||
|
// open/closed state. The modal is local to UsersTab (it only opens
|
||||||
|
// from the header button) and refreshes the listing on success.
|
||||||
|
const [inviteModalOpen, setInviteModalOpen] = useState(false)
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -172,8 +183,28 @@ function UsersTab() {
|
|||||||
retain their v0.7.0 semantics — promote to admin to remove a
|
retain their v0.7.0 semantics — promote to admin to remove a
|
||||||
user's ability to write without silencing them.
|
user's ability to write without silencing them.
|
||||||
</p>
|
</p>
|
||||||
|
{/* v0.17.0 — roadmap item #16. The "Create user + invite"
|
||||||
|
affordance opens a modal that provisions a fresh users row
|
||||||
|
with the chosen role and sends an invite email with a
|
||||||
|
single-use claim link. */}
|
||||||
|
<div className="admin-tab-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => setInviteModalOpen(true)}
|
||||||
|
>Create user + invite</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{error && <p className="settings-note warning">{error}</p>}
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
{inviteModalOpen && (
|
||||||
|
<CreateUserInviteModal
|
||||||
|
onClose={() => setInviteModalOpen(false)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setInviteModalOpen(false)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="admin-filter-chips">
|
<div className="admin-filter-chips">
|
||||||
{STATE_CHIPS.map(chip => (
|
{STATE_CHIPS.map(chip => (
|
||||||
@@ -224,12 +255,26 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
|
|||||||
const state = u.permission_state || 'granted'
|
const state = u.permission_state || 'granted'
|
||||||
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||||||
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
||||||
|
// v0.17.0 — roadmap item #16. The user's row may also be the
|
||||||
|
// "(pending invite)" shape: admin-created via POST /api/admin/users,
|
||||||
|
// not yet claimed via /api/invites/claim. The backend's user-listing
|
||||||
|
// surfaces this via `pending_invite` (object with invite_id +
|
||||||
|
// expires_at) or null. The badge sits inline next to the handle so
|
||||||
|
// the admin sees at a glance which rows are real users vs. unclaimed
|
||||||
|
// invites.
|
||||||
|
const pendingInvite = u.pending_invite
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div className="user-cell">
|
<div className="user-cell">
|
||||||
<span className="user-handle">{handle}</span>
|
<span className="user-handle">{handle}</span>
|
||||||
|
{pendingInvite && (
|
||||||
|
<span
|
||||||
|
className="invite-badge"
|
||||||
|
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
|
||||||
|
>(pending invite)</span>
|
||||||
|
)}
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
{fullName || u.display_name}
|
{fullName || u.display_name}
|
||||||
{u.email ? ` · ${u.email}` : ''}
|
{u.email ? ` · ${u.email}` : ''}
|
||||||
@@ -319,6 +364,162 @@ function PermissionCell({ user: u, busy, onFlipPermission }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Create user + invite modal (v0.17.0 / roadmap item #16) ────────────────
|
||||||
|
//
|
||||||
|
// The "Create user + invite" affordance on the Users tab opens this
|
||||||
|
// modal. Admin types email, first name, last name, role, and (optionally)
|
||||||
|
// a custom message to embed in the invite email. On submit, calls
|
||||||
|
// `POST /api/admin/users` which provisions the row + sends the email.
|
||||||
|
// The 409 path (duplicate email) and 422 path (self-invite, owner-
|
||||||
|
// grant-by-non-owner, malformed input) surface the server's message
|
||||||
|
// inline; the success path closes the modal and refreshes the listing.
|
||||||
|
//
|
||||||
|
// The modal lives in this file rather than a separate component
|
||||||
|
// because it has one caller (UsersTab), reuses the existing modal
|
||||||
|
// stylesheet from /admin's chrome, and shares the
|
||||||
|
// CUSTOM_MESSAGE_MAX_LENGTH constant defined at the top of the file.
|
||||||
|
|
||||||
|
function CreateUserInviteModal({ onClose, onSuccess }) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [role, setRole] = useState('contributor')
|
||||||
|
const [customMessage, setCustomMessage] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [success, setSuccess] = useState(null)
|
||||||
|
|
||||||
|
const remaining = CUSTOM_MESSAGE_MAX_LENGTH - customMessage.length
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const trimmedEmail = email.trim()
|
||||||
|
if (!trimmedEmail) {
|
||||||
|
setError('Email is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const result = await createUserInvite({
|
||||||
|
email: trimmedEmail,
|
||||||
|
first_name: firstName.trim(),
|
||||||
|
last_name: lastName.trim(),
|
||||||
|
role,
|
||||||
|
custom_message: customMessage,
|
||||||
|
})
|
||||||
|
setSuccess(`Invite sent to ${result.email} (${result.role}).`)
|
||||||
|
// Brief delay so the admin sees the success state, then close
|
||||||
|
// and let the parent refresh the listing.
|
||||||
|
setTimeout(() => { onSuccess?.() }, 600)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Unable to send invite')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal-panel" onClick={e => e.stopPropagation()}>
|
||||||
|
<header className="modal-header">
|
||||||
|
<h3>Create user + invite</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link-quiet"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
</header>
|
||||||
|
<p className="muted">
|
||||||
|
Provisions a fresh user row with the chosen role and sends an
|
||||||
|
invite email carrying a single-use claim link. The link
|
||||||
|
expires in 7 days. The invitee clicks through to claim
|
||||||
|
their account — no OTC roundtrip is required on first sign-in.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="create-user-invite-form">
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
maxLength={320}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
<span>First name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={e => setFirstName(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Last name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={e => setLastName(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Role</span>
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={e => setRole(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<option value="contributor">Contributor</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="owner">Owner (owner-only)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>
|
||||||
|
Custom message (optional){' '}
|
||||||
|
<span className={`muted${remaining < 0 ? ' warning' : ''}`}>
|
||||||
|
{remaining} chars left
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={customMessage}
|
||||||
|
onChange={e => setCustomMessage(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
rows={4}
|
||||||
|
maxLength={CUSTOM_MESSAGE_MAX_LENGTH}
|
||||||
|
placeholder="Optional — embedded in the invite email."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
{success && <p className="settings-note success">{success}</p>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" onClick={onClose} disabled={busy}>Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={busy || !email.trim() || remaining < 0}
|
||||||
|
>
|
||||||
|
{busy ? 'Sending…' : 'Send invite'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────
|
// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────
|
||||||
|
|
||||||
function AllowlistTab() {
|
function AllowlistTab() {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// v0.17.0 — roadmap item #16. The claim flow's landing page.
|
||||||
|
//
|
||||||
|
// The admin's invite email carries a link to /invites/claim?token=…;
|
||||||
|
// the invitee clicks through and lands here. The page reads the
|
||||||
|
// token from the URL, posts it to /api/invites/claim, and on success
|
||||||
|
// routes either to the passcode-set screen (if v0.10.0 passcode flow
|
||||||
|
// is in play and the user has no passcode yet) or to home.
|
||||||
|
//
|
||||||
|
// Anonymous-reachable: the entire point of the call is to establish
|
||||||
|
// the session; we do not pre-check authentication.
|
||||||
|
//
|
||||||
|
// Failure modes the backend distinguishes:
|
||||||
|
// * 410 — token is expired or already claimed (the row is dead).
|
||||||
|
// * 400 — token doesn't match any active invite (forged, revoked,
|
||||||
|
// or wiped).
|
||||||
|
//
|
||||||
|
// We surface both as the same "this invite link isn't valid" shape
|
||||||
|
// for the invitee — the detail message from the server reads
|
||||||
|
// distinctively enough that the admin can debug from logs, and the
|
||||||
|
// invitee just needs to know they should contact the admin for a
|
||||||
|
// fresh link.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { claimInvite } from '../api.js'
|
||||||
|
|
||||||
|
export default function InviteClaim() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [status, setStatus] = useState('working') // 'working' | 'ok' | 'failed'
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [trustDevice, setTrustDevice] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [needsPasscode, setNeedsPasscode] = useState(false)
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const token = params.get('token') || ''
|
||||||
|
|
||||||
|
async function performClaim() {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('failed')
|
||||||
|
setError('No invite token in the URL.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitted(true)
|
||||||
|
setStatus('working')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await claimInvite(token, { trustDevice })
|
||||||
|
setUser(result.user)
|
||||||
|
setNeedsPasscode(!!result.needs_passcode)
|
||||||
|
setStatus('ok')
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('failed')
|
||||||
|
setError(e.message || 'Unable to claim invite')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight: if the URL has no token at all, fail fast so the
|
||||||
|
// invitee sees the missing-token shape immediately rather than
|
||||||
|
// an empty form.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('failed')
|
||||||
|
setError('This claim link is missing its token.')
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
// On a successful claim, route the user onward. The brief calls
|
||||||
|
// this out: route to passcode-set if v0.10.0 passcode flow is in
|
||||||
|
// play and the user has no passcode yet; otherwise route to home.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'ok') return
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (needsPasscode) {
|
||||||
|
navigate('/settings/notifications#sign-in', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
}, 1200)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [status, needsPasscode, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="invite-claim-page">
|
||||||
|
<div className="invite-claim-panel">
|
||||||
|
<h1>Claim your account</h1>
|
||||||
|
{!submitted && status === 'working' && token && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
You've been invited to this deployment. Click the button below
|
||||||
|
to claim your account and sign in. This link is single-use and
|
||||||
|
expires 7 days after it was sent.
|
||||||
|
</p>
|
||||||
|
<label className="claim-trust-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trustDevice}
|
||||||
|
onChange={e => setTrustDevice(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{' '}Trust this device for 30 days (skip the email step on
|
||||||
|
your next visit from this browser).
|
||||||
|
</label>
|
||||||
|
<div className="claim-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={performClaim}
|
||||||
|
>Claim my account</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{submitted && status === 'working' && (
|
||||||
|
<p>Claiming…</p>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<>
|
||||||
|
<p className="settings-note success">
|
||||||
|
Welcome{user?.display_name ? `, ${user.display_name}` : ''}!
|
||||||
|
You're signed in.
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
{needsPasscode
|
||||||
|
? 'Redirecting you to set a passcode so you can sign in without an email roundtrip next time…'
|
||||||
|
: 'Redirecting you to the home page…'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'failed' && (
|
||||||
|
<>
|
||||||
|
<p className="settings-note warning">
|
||||||
|
{error || "This invite link isn't valid."}
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
If you believe this is a mistake, contact the admin who
|
||||||
|
sent you the invite — they can issue a fresh link.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
// Login.jsx — the composed sign-in surface (§6.2) after the v0.10.0
|
// Login.jsx — the composed sign-in surface (§6.2) after the v0.12.0
|
||||||
// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request
|
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
|
||||||
// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5)
|
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
|
||||||
// established the email + OTC scaffolding both releases extended.
|
// (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0
|
||||||
|
// (roadmap item #5) established the email + OTC scaffolding the later
|
||||||
|
// releases extended.
|
||||||
|
//
|
||||||
|
// v0.12.0: the email-entry step renders a Turnstile widget. The token
|
||||||
|
// it produces is sent to `/auth/otc/request` alongside the email. The
|
||||||
|
// passcode step also renders a widget for the "Use a code instead"
|
||||||
|
// fallback dispatch (same backend endpoint, same gate). When the
|
||||||
|
// `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders
|
||||||
|
// nothing — the form still submits and the backend's TURNSTILE_REQUIRED
|
||||||
|
// policy decides admission.
|
||||||
//
|
//
|
||||||
// Four-to-six-step flow (most users see three; the longest path is
|
// Four-to-six-step flow (most users see three; the longest path is
|
||||||
// pending-user with no passcode, who never sees the passcode steps):
|
// pending-user with no passcode, who never sees the passcode steps):
|
||||||
@@ -72,7 +82,9 @@ import {
|
|||||||
checkPasscode,
|
checkPasscode,
|
||||||
verifyPasscode,
|
verifyPasscode,
|
||||||
setPasscode as apiSetPasscode,
|
setPasscode as apiSetPasscode,
|
||||||
|
startDeviceTrust,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
||||||
@@ -84,12 +96,31 @@ export default function Login() {
|
|||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [passcode, setPasscode] = useState('')
|
const [passcode, setPasscode] = useState('')
|
||||||
const [newPasscode, setNewPasscode] = useState('')
|
const [newPasscode, setNewPasscode] = useState('')
|
||||||
|
// v0.11.0 — "trust this device for 30 days" checkbox, shared by the
|
||||||
|
// OTC and passcode verify steps. The flag rides on the verify POST;
|
||||||
|
// a checked box mints a device-trust row server-side and sets the
|
||||||
|
// long-lived `rfc_device_trust` cookie. Defaults off so the user
|
||||||
|
// makes an explicit choice — auth credentials shouldn't persist by
|
||||||
|
// default.
|
||||||
|
const [trustDevice, setTrustDevice] = useState(false)
|
||||||
// v0.8.0 — capture-profile fields.
|
// v0.8.0 — capture-profile fields.
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = useState('')
|
const [lastName, setLastName] = useState('')
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
// v0.12.0: Turnstile token captured by the widget. `null` means no
|
||||||
|
// challenge solved yet (or the site key is unset, in which case the
|
||||||
|
// widget surfaces null on mount). The token is single-use; we clear
|
||||||
|
// it back to null right after we send it so a second request on the
|
||||||
|
// same form remount re-challenges. `turnstileReady` is true once the
|
||||||
|
// widget has produced a token OR the widget is not configured at
|
||||||
|
// build time (no site key) — the submit button reads from it so the
|
||||||
|
// form locks up when the operator has wired Turnstile but the user
|
||||||
|
// hasn't solved the challenge yet.
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState(null)
|
||||||
|
const turnstileOn = turnstileEnabled()
|
||||||
|
const turnstileReady = !turnstileOn || !!turnstileToken
|
||||||
const emailRef = useRef(null)
|
const emailRef = useRef(null)
|
||||||
const codeRef = useRef(null)
|
const codeRef = useRef(null)
|
||||||
const passcodeRef = useRef(null)
|
const passcodeRef = useRef(null)
|
||||||
@@ -105,6 +136,28 @@ export default function Login() {
|
|||||||
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
||||||
}, [step])
|
}, [step])
|
||||||
|
|
||||||
|
// v0.11.0 — on mount, try the device-trust cookie path. If the
|
||||||
|
// browser still carries a valid `rfc_device_trust` cookie from a
|
||||||
|
// prior "trust this device" gesture, the server re-establishes the
|
||||||
|
// session without any user input and we redirect home. The cookie
|
||||||
|
// is HttpOnly so we can't peek at it; we just call the endpoint and
|
||||||
|
// see whether it returns 200. 401 (no cookie / invalid / revoked)
|
||||||
|
// is the structural-silent case — the user proceeds to the email
|
||||||
|
// step normally. We do not surface any UI about the attempt; a
|
||||||
|
// failure should be invisible.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
await startDeviceTrust()
|
||||||
|
if (!cancelled) window.location.assign('/')
|
||||||
|
} catch (_) {
|
||||||
|
// No trusted device — fall through to the email step.
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function submitEmail(e) {
|
async function submitEmail(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!email.trim() || !email.includes('@')) {
|
if (!email.trim() || !email.includes('@')) {
|
||||||
@@ -119,13 +172,22 @@ export default function Login() {
|
|||||||
setStep('passcode')
|
setStep('passcode')
|
||||||
setStatus('')
|
setStatus('')
|
||||||
} else {
|
} else {
|
||||||
await requestOtc(email.trim())
|
await requestOtc(email.trim(), { turnstileToken })
|
||||||
|
// v0.12.0: the token is single-use; drop it so a re-request
|
||||||
|
// from the code step (via "Use a different email" → back to
|
||||||
|
// email) starts with a fresh challenge.
|
||||||
|
setTurnstileToken(null)
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Any failure consumes the token from CloudFlare's side; clear
|
||||||
|
// so the widget re-renders a fresh challenge on retry.
|
||||||
|
setTurnstileToken(null)
|
||||||
if (err.status === 429) {
|
if (err.status === 429) {
|
||||||
setStatus('Slow down — wait a minute before requesting another code.')
|
setStatus('Slow down — wait a minute before requesting another code.')
|
||||||
|
} else if (err.status === 400) {
|
||||||
|
setStatus("Couldn't verify you're human. Please retry the challenge.")
|
||||||
} else {
|
} else {
|
||||||
setStatus(err.message || 'Could not start sign-in. Try again.')
|
setStatus(err.message || 'Could not start sign-in. Try again.')
|
||||||
}
|
}
|
||||||
@@ -143,7 +205,7 @@ export default function Login() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
await verifyPasscode(email.trim(), passcode.trim())
|
await verifyPasscode(email.trim(), passcode.trim(), { trustDevice })
|
||||||
// Reload so App.jsx's getMe() picks up the fresh session. A
|
// Reload so App.jsx's getMe() picks up the fresh session. A
|
||||||
// returning passcode user is by definition already past the
|
// returning passcode user is by definition already past the
|
||||||
// §6.1 capture step (they couldn't have set a passcode while
|
// §6.1 capture step (they couldn't have set a passcode while
|
||||||
@@ -156,17 +218,29 @@ export default function Login() {
|
|||||||
// fresh code in the user's inbox immediately.
|
// fresh code in the user's inbox immediately.
|
||||||
setPasscode('')
|
setPasscode('')
|
||||||
try {
|
try {
|
||||||
await requestOtc(email.trim())
|
// v0.12.0: pass whatever token the widget on the passcode
|
||||||
|
// step has produced. If the operator has Turnstile required
|
||||||
|
// and the user hasn't solved the passcode-step widget, the
|
||||||
|
// backend refuses and we bounce them back to email-entry
|
||||||
|
// with a clear status (see catch below).
|
||||||
|
await requestOtc(email.trim(), { turnstileToken })
|
||||||
|
setTurnstileToken(null)
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
||||||
)
|
)
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
|
setTurnstileToken(null)
|
||||||
if (e2.status === 429) {
|
if (e2.status === 429) {
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
|
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
|
||||||
)
|
)
|
||||||
|
} else if (e2.status === 400) {
|
||||||
|
setStep('email')
|
||||||
|
setStatus(
|
||||||
|
'Too many failed attempts. Solve the challenge below to receive a one-time code.',
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
|
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
|
||||||
@@ -189,7 +263,7 @@ export default function Login() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
await verifyOtc(email.trim(), code.trim())
|
await verifyOtc(email.trim(), code.trim(), { trustDevice })
|
||||||
// OTC verified — the server has signed in the user. Fetch the
|
// OTC verified — the server has signed in the user. Fetch the
|
||||||
// canonical /api/auth/me to decide where to land:
|
// canonical /api/auth/me to decide where to land:
|
||||||
// * needs_profile → §6.1 capture (then /beta-pending).
|
// * needs_profile → §6.1 capture (then /beta-pending).
|
||||||
@@ -314,17 +388,27 @@ export default function Login() {
|
|||||||
|
|
||||||
async function fallbackToOtc() {
|
async function fallbackToOtc() {
|
||||||
// Manual "Use a code instead" from the passcode step. Same shape
|
// Manual "Use a code instead" from the passcode step. Same shape
|
||||||
// as the email-step OTC dispatch.
|
// as the email-step OTC dispatch. v0.12.0: pass through whatever
|
||||||
|
// Turnstile token the passcode-step widget has produced (or null
|
||||||
|
// when the widget is disabled at build time).
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
await requestOtc(email.trim())
|
await requestOtc(email.trim(), { turnstileToken })
|
||||||
|
setTurnstileToken(null)
|
||||||
setPasscode('')
|
setPasscode('')
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setTurnstileToken(null)
|
||||||
if (err.status === 429) {
|
if (err.status === 429) {
|
||||||
setStatus('Slow down — wait a minute before requesting another code.')
|
setStatus('Slow down — wait a minute before requesting another code.')
|
||||||
|
} else if (err.status === 400) {
|
||||||
|
// v0.12.0: the widget rejected or no token was sent. Bounce
|
||||||
|
// the user back to the email step so they get a fresh
|
||||||
|
// challenge alongside the email input.
|
||||||
|
setStep('email')
|
||||||
|
setStatus("Couldn't verify you're human. Please retry the challenge.")
|
||||||
} else {
|
} else {
|
||||||
setStatus(err.message || 'Could not request a code. Try again.')
|
setStatus(err.message || 'Could not request a code. Try again.')
|
||||||
}
|
}
|
||||||
@@ -357,7 +441,18 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={busy || !email.trim()}>
|
{/*
|
||||||
|
v0.12.0: CloudFlare Turnstile widget. Renders nothing
|
||||||
|
when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
|
||||||
|
defaults to true in that case so the submit gate doesn't
|
||||||
|
lock up). On every challenge the widget calls onToken
|
||||||
|
with the fresh token; we feed it to /auth/otc/request.
|
||||||
|
*/}
|
||||||
|
<TurnstileWidget onToken={setTurnstileToken} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !email.trim() || !turnstileReady}
|
||||||
|
>
|
||||||
{busy ? 'Checking…' : 'Continue'}
|
{busy ? 'Checking…' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -378,6 +473,30 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
{/* v0.11.0 — trust device for 30 days. The checkbox lives
|
||||||
|
on the verify step so the user makes the trust gesture
|
||||||
|
in the same breath as signing in. Off by default; the
|
||||||
|
user opts in deliberately. */}
|
||||||
|
<label className="otc-trust-device">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trustDevice}
|
||||||
|
onChange={e => setTrustDevice(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span>Trust this device for 30 days</span>
|
||||||
|
</label>
|
||||||
|
{/*
|
||||||
|
v0.12.0: a second Turnstile widget for the
|
||||||
|
"Use a code instead" fallback dispatch. The passcode
|
||||||
|
verify path does not consume a Turnstile token (it has
|
||||||
|
its own 5-attempt lockout shape from v0.10.0), but if
|
||||||
|
the user falls back to OTC the same /auth/otc/request
|
||||||
|
endpoint runs and needs a token. We render the widget
|
||||||
|
on this step too so the fallback works without bouncing
|
||||||
|
back to email-entry first.
|
||||||
|
*/}
|
||||||
|
<TurnstileWidget onToken={setTurnstileToken} />
|
||||||
<div className="otc-actions">
|
<div className="otc-actions">
|
||||||
<button type="submit" disabled={busy || !passcode.trim()}>
|
<button type="submit" disabled={busy || !passcode.trim()}>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
@@ -386,7 +505,7 @@ export default function Login() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn-link-quiet"
|
className="btn-link-quiet"
|
||||||
onClick={fallbackToOtc}
|
onClick={fallbackToOtc}
|
||||||
disabled={busy}
|
disabled={busy || !turnstileReady}
|
||||||
>
|
>
|
||||||
Use a code instead
|
Use a code instead
|
||||||
</button>
|
</button>
|
||||||
@@ -420,6 +539,17 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
{/* v0.11.0 — trust device for 30 days. Same shape as the
|
||||||
|
passcode step; the user opts in deliberately. */}
|
||||||
|
<label className="otc-trust-device">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trustDevice}
|
||||||
|
onChange={e => setTrustDevice(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span>Trust this device for 30 days</span>
|
||||||
|
</label>
|
||||||
<div className="otc-actions">
|
<div className="otc-actions">
|
||||||
<button type="submit" disabled={busy || code.length !== 6}>
|
<button type="submit" disabled={busy || code.length !== 6}>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
getMe,
|
getMe,
|
||||||
setPasscode,
|
setPasscode,
|
||||||
clearPasscode,
|
clearPasscode,
|
||||||
|
listMyDevices,
|
||||||
|
revokeMyDevice,
|
||||||
|
revokeAllMyDevices,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
||||||
|
|
||||||
@@ -54,11 +57,126 @@ export default function NotificationSettings({ viewer }) {
|
|||||||
<WatchesSection />
|
<WatchesSection />
|
||||||
<MutesSection viewer={viewer} />
|
<MutesSection viewer={viewer} />
|
||||||
<SignInSection />
|
<SignInSection />
|
||||||
|
<DevicesSection />
|
||||||
<PrivacyCookiesSection />
|
<PrivacyCookiesSection />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.11.0: trusted devices (§6.2, roadmap item #9) ──────────────────────
|
||||||
|
//
|
||||||
|
// Lists the user's active device-trust rows and lets them revoke any
|
||||||
|
// or all. A revoke marks the row dead server-side; the matching
|
||||||
|
// device's next visit will be refused and the cookie cleared. The
|
||||||
|
// surface intentionally does not single out the row whose cookie the
|
||||||
|
// current request carries — every row reads identically, so the user
|
||||||
|
// can revoke "this device" alongside any other from a single page.
|
||||||
|
|
||||||
|
function DevicesSection() {
|
||||||
|
const [devices, setDevices] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const { items } = await listMyDevices()
|
||||||
|
setDevices(items || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not load trusted devices.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRevoke(deviceId) {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await revokeMyDevice(deviceId)
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not revoke device.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRevokeAll() {
|
||||||
|
if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await revokeAllMyDevices()
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not revoke devices.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionShell
|
||||||
|
title="Trusted devices"
|
||||||
|
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
|
||||||
|
>
|
||||||
|
{devices === null && <p className="settings-note">Loading…</p>}
|
||||||
|
{devices !== null && devices.length === 0 && (
|
||||||
|
<p className="device-empty">
|
||||||
|
No trusted devices. Sign in and check “Trust this device for 30 days”
|
||||||
|
to add the device you're on now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{devices !== null && devices.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ul className="device-list">
|
||||||
|
{devices.map(d => (
|
||||||
|
<li key={d.id} className="device-list-item">
|
||||||
|
<div className="device-meta">
|
||||||
|
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
|
||||||
|
<div className="device-stamps">
|
||||||
|
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRevoke(d.id)}
|
||||||
|
disabled={busy}
|
||||||
|
title="Revoke trust on this device"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="device-revoke-all"
|
||||||
|
onClick={onRevokeAll}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Revoke all devices
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
</SectionShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStamp(stamp) {
|
||||||
|
// The server emits SQLite `datetime('now')` strings (UTC, no
|
||||||
|
// timezone marker). Parse defensively; fall back to the raw stamp
|
||||||
|
// if Date can't make sense of it.
|
||||||
|
if (!stamp) return '—'
|
||||||
|
const d = new Date(stamp.replace(' ', 'T') + 'Z')
|
||||||
|
if (Number.isNaN(d.getTime())) return stamp
|
||||||
|
return d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
|
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
|
||||||
|
|
||||||
function SignInSection() {
|
function SignInSection() {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// TurnstileWidget.jsx — v0.12.0 / roadmap item #10.
|
||||||
|
//
|
||||||
|
// Renders the CloudFlare Turnstile JS widget on the email-entry step of
|
||||||
|
// `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`
|
||||||
|
// (Vite convention — VITE_* prefix is build-time embedded). When the
|
||||||
|
// site key is unset/empty, this component renders nothing and reports
|
||||||
|
// a `null` token through `onToken` so the parent form can still submit.
|
||||||
|
// The backend's `TURNSTILE_REQUIRED` policy decides what happens to a
|
||||||
|
// request that arrives without a token; the frontend is intentionally
|
||||||
|
// not in that loop. See `backend/app/turnstile.py` for the matrix.
|
||||||
|
//
|
||||||
|
// The CloudFlare script is loaded once per page on first widget mount.
|
||||||
|
// Subsequent mounts (e.g. user goes back to email-entry after a failed
|
||||||
|
// OTC request) reuse the script tag and re-render the widget on the
|
||||||
|
// fresh container `div`. Unmounting removes the widget instance via
|
||||||
|
// `turnstile.remove(widgetId)` so a remount produces a new challenge
|
||||||
|
// rather than reusing a stale, already-consumed token.
|
||||||
|
//
|
||||||
|
// Turnstile contract:
|
||||||
|
// * `data-callback` fires with the token string on a successful
|
||||||
|
// challenge; the token is single-use and expires after ~5 minutes.
|
||||||
|
// * `data-error-callback` fires on a failed challenge (network,
|
||||||
|
// blocked, etc.); we surface a `null` token so the parent shows
|
||||||
|
// a retry hint.
|
||||||
|
// * `data-expired-callback` fires when the token times out before
|
||||||
|
// submission; we also drop to `null` and re-render so the user
|
||||||
|
// gets a fresh challenge on retry.
|
||||||
|
//
|
||||||
|
// We do **not** import the CloudFlare script at build time; loading it
|
||||||
|
// dynamically here keeps the bundle clean of an external request the
|
||||||
|
// page may not need (anonymous viewers reading RFCs never see Login).
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||||
|
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||||
|
|
||||||
|
// Promise-keyed: only one script tag, only one resolution chain.
|
||||||
|
let scriptLoadPromise = null
|
||||||
|
|
||||||
|
function loadTurnstileScript() {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve(null)
|
||||||
|
if (window.turnstile) return Promise.resolve(window.turnstile)
|
||||||
|
if (scriptLoadPromise) return scriptLoadPromise
|
||||||
|
|
||||||
|
scriptLoadPromise = new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`)
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve(window.turnstile))
|
||||||
|
existing.addEventListener('error', reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = TURNSTILE_SCRIPT_URL
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
script.addEventListener('load', () => resolve(window.turnstile))
|
||||||
|
script.addEventListener('error', reject)
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
return scriptLoadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function turnstileEnabled() {
|
||||||
|
return !!SITE_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TurnstileWidget({ onToken, theme = 'auto' }) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const widgetIdRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!SITE_KEY) {
|
||||||
|
// No site key configured → surface a null token immediately so
|
||||||
|
// the parent form's submit-disabled gate doesn't lock up
|
||||||
|
// waiting on a challenge that will never arrive. The backend
|
||||||
|
// decides whether a tokenless request is admitted.
|
||||||
|
onToken?.(null)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
loadTurnstileScript()
|
||||||
|
.then(turnstile => {
|
||||||
|
if (cancelled || !turnstile || !containerRef.current) return
|
||||||
|
widgetIdRef.current = turnstile.render(containerRef.current, {
|
||||||
|
sitekey: SITE_KEY,
|
||||||
|
theme,
|
||||||
|
callback: token => onToken?.(token),
|
||||||
|
'error-callback': () => onToken?.(null),
|
||||||
|
'expired-callback': () => onToken?.(null),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Script load failure — surface null so the parent can decide
|
||||||
|
// what to do (today: still let submit through; the backend
|
||||||
|
// policy decides admission).
|
||||||
|
if (!cancelled) onToken?.(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (widgetIdRef.current && window.turnstile) {
|
||||||
|
try {
|
||||||
|
window.turnstile.remove(widgetIdRef.current)
|
||||||
|
} catch (_) {
|
||||||
|
// Already gone or never registered — nothing to clean up.
|
||||||
|
}
|
||||||
|
widgetIdRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We intentionally do not list `onToken` in the dependency array;
|
||||||
|
// a parent re-rendering with a fresh closure should not tear down
|
||||||
|
// and rebuild the widget (which would consume a fresh challenge).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!SITE_KEY) return null
|
||||||
|
return <div ref={containerRef} className="turnstile-widget" />
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user