Compare commits

..
24 Commits
Author SHA1 Message Date
mrhid6 19383abaf8 fix(auth): final-review MFA fixes F1 F2 F3 F5 F7 and drop Ticket.Attempts
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 5m51s
- OIDC sessions minted through saveSignIn with AMR oidc and StepUpAt
- passwordless passkey uses ValidateDiscoverableLogin with owner-handle check
- TOTP replay guard burns the matched step, not the current one
- enrol-only tickets refused once the user already has a factor
- bootstrap owner session minted through mintSession
2026-09-16 14:26:42 +00:00
mrhid6 dcdfd3ce52 test(mfa): fix step-up test to assert the fresh-session path 2026-09-16 14:20:42 +00:00
mrhid6 142b99e408 test(mfa): end-to-end coverage, OpenAPI and documentation 2026-09-16 14:18:39 +00:00
mrhid6 14e9db606a fix(mfa): wire up webauthn step-up routes
The missing /me/step-up/webauthn/begin and /finish were a plan defect,
not an acceptable gap: a user whose only factor is a passkey was
offered only recovery codes for step-up, burning one every ten
minutes. Adds the handlers in package auth (session-authenticated,
not themselves behind RequireStepUp, modeled on
HandleMFAWebAuthnBegin/finishAssertion) and registers both routes
behind the same RateLimitAuth() as /me/step-up. StepUpModal now offers
"Use passkey" when the server names webauthn and the browser supports
WebAuthn.
2026-09-16 09:44:41 +00:00
mrhid6 3e341b17ec feat(web): step-up modal and MFA settings controls
Adds the global re-authentication modal for guarded routes and the
owner/admin MFA controls on the settings page.

request() in lib/api.ts now intercepts a 403 step_up_required response,
awaits re-authentication through a callback registered by StepUpModal
(lib/stepup.ts), and retries the original request exactly once. The
modal offers TOTP, recovery code and password, since the webauthn
step-up routes (/me/step-up/webauthn/begin and /finish) are not
registered server-side yet; it omits the passkey option rather than
calling a route that does not exist.

me.stepUp posts one factor to /api/me/step-up. The settings page gains
an owner-only "Require MFA" toggle and the members table gains an MFA
column and a "Reset MFA" action, both routed through the existing
PUT /api/settings and DELETE /api/org/users/:id/mfa.
2026-09-16 09:33:50 +00:00
mrhid6 32fd11cde7 feat(web): account security page and MFA enrolment wizard 2026-09-16 09:26:54 +00:00
mrhid6 26825841fa feat(web): MFA and passkey sign-in on the login page 2026-09-16 09:22:18 +00:00
mrhid6 d8597ee3ae feat(mfa): require_mfa policy, sign-in rate limit and MFA column 2026-09-16 09:14:16 +00:00
mrhid6 f87626cf17 feat(mfa): account MFA management, passkey registration and step-up 2026-09-16 09:08:24 +00:00
mrhid6 bd0639acfa fix(mfa): refuse rather than panic when RequireStepUp sees no session 2026-09-16 09:03:22 +00:00
mrhid6 e2ff0dace9 feat(mfa): step-up re-authentication on sensitive actions 2026-09-16 09:01:19 +00:00
mrhid6 d54d8971b2 feat(mfa): passwordless passkey sign-in 2026-09-16 08:58:26 +00:00
mrhid6 14a1cdb2b0 feat(mfa): passkey verification as a second factor 2026-09-16 08:48:39 +00:00
mrhid6 1445af11ab fix(mfa): mint session before deleting ticket, collapse dead invalid_code branch 2026-09-16 08:43:53 +00:00
mrhid6 3fa469c303 feat(mfa): forced TOTP enrolment at sign-in 2026-09-16 08:41:56 +00:00
mrhid6 2d75832ceb feat(mfa): second-factor sign-in with TOTP and recovery codes 2026-09-16 08:39:48 +00:00
mrhid6 dfcfd1d3e2 fix(mfa): make ticket attempt count atomic via Redis INCR 2026-09-16 08:37:07 +00:00
mrhid6 9c60adc836 feat(mfa): pending-login ticket store 2026-09-16 08:34:57 +00:00
mrhid6 330c326fb5 fix(mfa): key TOTP replay guard on time step, not the code 2026-09-16 08:33:02 +00:00
mrhid6 25541345a8 feat(mfa): TOTP secrets, recovery codes and factor lookup 2026-09-16 08:30:55 +00:00
mrhid6 b78a9b3832 feat(mfa): user_mfa and webauthn_credentials collections 2026-09-16 08:27:54 +00:00
mrhid6 9aee0a61aa chore(server): bump vantage-shared to v0.7.0 for require_mfa 2026-09-16 08:22:08 +00:00
mrhid6 0aabb664e2 docs: implementation plan for MFA on local sign-in 2026-09-16 08:15:46 +00:00
mrhid6 e0468aa7b9 docs: design for MFA on local sign-in (TOTP, passkeys, step-up) 2026-09-15 15:48:59 +00:00
52 changed files with 9998 additions and 216 deletions
+4
View File
@@ -13,6 +13,10 @@ installer/nssm.zip
installer/checksums-msi.txt
.next
*.tsbuildinfo
web/test-results
web/playwright-report
web/blob-report
web/playwright/.cache
graphify-out
docker-compose.live.yml
.claude
+99 -1
View File
@@ -757,6 +757,93 @@ reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
reference page has to work on an air-gapped install with no outbound access at
all - the same requirement licence verification already meets.
### Multi-factor authentication
Local and `hq`-sourced members can enrol TOTP and WebAuthn passkeys; OIDC users
are exempt (`auth_source == "oidc"`), since their IdP owns authentication.
**A password that checks out mints a pending-login ticket, not a session with
an `mfa_pending` flag.** The ticket is a Redis key (`km:mfa:<id>`, 5 min TTL)
referenced by a separate `km_mfa_pending` cookie; only the `/auth/mfa/*` and
`/auth/mfa/enrol/*` endpoints accept it, and it exchanges for a `km_session`
only on a completed second factor or completed enrolment. A flag on `Session`
would fail open - any route mounted under `auth.Middleware`, today's or a
future one, could serve a half-authenticated user by forgetting to check it.
A ticket fails closed: nothing under `auth.Middleware` recognises it at all,
because it is never a `*Session`. `Session` itself gained `AMR []string`
(`pwd`, `otp`, `webauthn`, `recovery`, `oidc`) and `StepUpAt *time.Time`; sign-in
counts as a step-up, so `StepUpAt` is set at session creation.
**The pending ticket's attempt counter is an atomic Redis counter
(`km:mfa:<id>:attempts`), not a field rewritten on the ticket document.** Two
requests racing to fail a guess would otherwise both read the same `attempts`
and both write it back incremented once, undercounting. `INCR` has no such
race; the fifth failure deletes the ticket.
**The TOTP replay guard is keyed on the time step, not the code**:
`km:totp:<user_id>:<step>`, `SET NX` with a 90s TTL. Keying on the code itself
would let the same 6 digits be replayed across two different steps that
happen to compute it (a 1-in-a-million collision, but a free one to close);
keying on the step means a given 30-second window can be spent exactly once,
which is what "single-use" actually means for a TOTP code.
**`user_mfa.totp_pending_enc` holds an unconfirmed TOTP secret and is
deliberately not in `vantage-shared`'s `backup.ciphertextFields["user_mfa"]`**,
which lists only `totp_secret_enc`. The confirmed secret is the one that
authenticates anyone; an abandoned setup attempt (scanned once, never
confirmed, replaced by the next `POST /me/mfa/totp/setup` call) is not worth
widening the backup contract's surface for. `vantagectl verify`'s live probe
therefore never touches it - this is intentional, not the same silent gap the
ciphertext-field mirror otherwise guards against.
**Two new collections**, both in `ScopedCollections` so an instance purge
removes them, both with a fatal index builder like `EnsureAuthIndexes`:
- `user_mfa` - one document per user who has started enrolment. Unique index
`{instance_id, user_id}`. `totp_confirmed_at: nil` means setup started but
TOTP is not active; "has MFA" means that field is set or the user owns a
passkey.
- `webauthn_credentials` - one document per passkey. Unique index
`{instance_id, credential_id}`, plus `{instance_id, user_id}`. `sign_count`
backs clone detection: a non-increasing non-zero count fails the assertion.
`require_mfa` (`models.Settings.RequireMFA bool`, `bson:"require_mfa"`)
shipped in `vantage-shared` v0.7.0 - a plain bool because absent must mean off.
Switching it on does not revoke existing sessions; they end at their normal
24h TTL, and the next sign-in enforces enrolment for anyone with no factor yet.
**WebAuthn's RP ID is the request host with any port stripped**, resolved
per-request rather than configured, the same way the org/host guard resolves
an instance from `<slug>.vantage.<tld>`. A passkey is bound to the host it was
registered on: moving a self-hosted instance to a new domain, or renaming a
cloud instance (see "A rename moves the host" above), invalidates every
passkey on it. TOTP and recovery codes are unaffected, since they carry no
host binding. The docs say so; there is no migration path for a passkey
across a host change.
**Step-up** (`auth.RequireStepUp()`) gates three existing sensitive routes -
`POST /api/secrets/:group/reveal`, `GET /api/keys/:id/private-key`,
`POST /api/console/connect` - plus the MFA-management endpoints that create or
remove a factor. It passes when `StepUpAt` is within the last **ten minutes**,
when the session's `AMR` contains `oidc` (the IdP's own session policy
governs), or **when the request authenticated with an API token**
(`TokenID != ""`). That last exemption is a known, accepted gap, not an
oversight: a token has no human present to prompt for a second factor, so a
token holding `secrets:read` or `keys:read` can reveal a secret or download a
private key with no re-authentication at all. The mitigation is scoped,
short-lived tokens, tracked separately in the gap review, not a code change
here - a later reviewer should not "fix" this silently. `POST /api/me/step-up`
takes `{totp}`, `{recovery}` or `{password}` (password only for a user with no
MFA); `POST /api/me/step-up/webauthn/begin` and `/finish` do the same with a
passkey. All three, like every unauthenticated MFA endpoint, sit behind
`RateLimitAuth()` - a fixed Redis window, 20 requests/minute per
`c.ClientIP()`, answering 429 with `Retry-After` - on the `RateLimitTokens`
pattern but for sign-in and re-authentication rather than API tokens.
Library versions: `github.com/pquerna/otp` for TOTP, `github.com/go-webauthn/webauthn`
**v0.18.1** for WebAuthn ceremonies, `qrcode` (npm) to draw the enrolment QR
client-side so an air-gapped install needs nothing external.
### The public host
**vantage.hostxtra.co.uk is not served by this repository.** The marketing site
@@ -967,6 +1054,11 @@ POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} - no issuer, client ID or secret
GET /api/secrets/:group/values # bearer token (ESO)
POST /auth/mfa/totp /auth/mfa/recovery # second factor against a pending-login ticket
POST /auth/mfa/webauthn/begin /finish
POST /auth/passkey/begin /auth/passkey/finish # passwordless sign-in
POST /auth/mfa/enrol/totp/setup /confirm # ticket-scoped forced enrolment
POST /auth/mfa/enrol/passkey/begin /finish
```
Session-authed under `/api`:
@@ -1012,6 +1104,12 @@ agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
licence GET /license · POST /license (POST: self-hosted only)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
DELETE /org/users/:id/mfa (owner|admin, step-up)
mfa GET /me/mfa · POST /me/mfa/totp/setup (step-up)
POST /me/mfa/totp/confirm · DELETE /me/mfa/totp (step-up)
POST /me/mfa/recovery/regenerate (step-up)
POST,PATCH,DELETE /me/passkeys[/begin,/finish,/:id] (step-up, except rename)
POST /me/step-up · POST /me/step-up/webauthn/begin /finish
providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id
POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin)
tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens/:id
@@ -1067,7 +1165,7 @@ plane, each of which this codebase enforces:
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `maintenance_windows` · `patch_policies` · `patch_runs` · `patch_run_outputs` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `maintenance_windows` · `patch_policies` · `patch_runs` · `patch_run_outputs` · `user_mfa` · `webauthn_credentials` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth - see `server/internal/models/`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
# MFA for local sign-in (TOTP and passkeys) - design
Date: 2026-09-15
Gap review entry: `#r-mfa` ("Build next", effort S - revised upward, see Scope)
## Problem
Local and HQ-projected members sign in with an email address and a password and
nothing else. MFA exists only through SSO, which is a paid feature. One phished
password on a Free instance gives an attacker root script execution across the
fleet, root consoles and decrypted private keys. MFA is a hard requirement on
most security questionnaires and must ship on every tier, with no licence gate.
## Scope
In:
- TOTP as a second factor, with 10 single-use recovery codes.
- WebAuthn passkeys, both as a second factor after a password and as
passwordless sign-in (discoverable credentials, user verification required).
- An owner setting, `require_mfa`, that forces enrolment for password users.
- Owner/admin reset of another member's MFA.
- Step-up re-authentication before three sensitive actions: secret reveal,
private key download, console connect.
- A per-IP rate limit on every unauthenticated sign-in endpoint.
Out:
- MFA owned by Vantage HQ and projected to instances. HQ users enrol per
instance, like local users.
- Step-up for API tokens (see Known limitations).
- Forcing OIDC users through their IdP again (`prompt=login`) for step-up.
- A grace period for `require_mfa`.
- SMS or email codes.
## Who MFA applies to
| `auth_source` | Can enrol | Covered by `require_mfa` | Step-up factor |
| ------------- | --------- | ------------------------ | --------------------------- |
| `local` | yes | yes | MFA if enrolled, else password |
| `hq` | yes | yes | MFA if enrolled, else password |
| `oidc` | no | no | none - passes through |
OIDC users are exempt because their IdP owns authentication; their session
carries `amr: ["oidc"]`.
## Approach: pending-login ticket
A password that checks out no longer mints a session when the user has MFA or
must enrol. It mints a **pending-login ticket** instead: a Redis key referenced
by a separate short-lived cookie. Only the MFA endpoints accept the ticket, and
only a completed second factor (or completed enrolment) exchanges it for a
`km_session`.
The reason for this shape over "a session with an `mfa_pending` flag" is that a
half-authenticated user never becomes a `*Session` at all, so no route mounted
under `auth.Middleware` - today's or a future one - can serve them by
forgetting a check. The flag design fails open; this one fails closed.
## Data model
### `user_mfa` (control plane, new)
One document per user who has started enrolment.
| Field | Type | Notes |
| ------------------- | ----------- | ----- |
| `instance_id` | string | tenant scope |
| `user_id` | string | unique index `{instance_id, user_id}` |
| `webauthn_handle` | binary(64) | random, never the `user_id` (WebAuthn user handle) |
| `totp_secret_enc` | string | AES-256-GCM via `services.encryptString`; empty when TOTP is off |
| `totp_confirmed_at` | *time | nil means setup started but not confirmed - TOTP not active |
| `recovery_codes` | []{hash, used_at} | 10 codes, SHA-256 of the normalised code |
| `updated_at` | time | |
A user "has MFA" when `totp_confirmed_at` is set or they own at least one
passkey. A pending unconfirmed TOTP secret is replaced on the next setup call.
### `webauthn_credentials` (control plane, new)
One document per passkey.
| Field | Type | Notes |
| --------------- | -------- | ----- |
| `instance_id` | string | |
| `user_id` | string | index `{instance_id, user_id}` |
| `credential_id` | binary | unique index `{instance_id, credential_id}` |
| `public_key` | binary | COSE key; not secret |
| `sign_count` | uint32 | clone detection: a non-increasing non-zero count fails the assertion |
| `aaguid` | binary | |
| `transports` | []string | |
| `name` | string | user-editable, default from AAGUID or "Passkey" |
| `created_at`, `last_used_at` | time | |
Both collections are added to `ScopedCollections`, with a test asserting it,
so an instance purge removes them. Their index builder is fatal on failure,
like `EnsureAuthIndexes` - the unique indexes are a security property.
### `vantage-shared` changes (one release, then bump `server`'s pin)
- `models.Settings.RequireMFA bool` (`bson:"require_mfa"`). A plain bool is
correct here: absent must mean off.
- `backup.ciphertextFields["user_mfa"] = []string{"totp_secret_enc"}`. Without
it `vantagectl verify` silently skips these secrets.
### Redis keys
| Key | TTL | Holds |
| --------------------------- | ----- | ----- |
| `km:mfa:<id>` | 5 min | pending ticket: `user_id`, `instance_id`, `methods`, `enrol_only`, `attempts` |
| `km:wa:<id>` | 5 min | WebAuthn session data (challenge) for one ceremony |
| `km:totp:<user_id>:<step>` | 90 s | `SET NX` replay guard: a TOTP code works once |
| `km:rl:auth:<ip>` | 1 min | fixed-window counter for the sign-in rate limit |
The pending ticket cookie is `km_mfa_pending`: HttpOnly, SameSite=Lax, Secure
under the same rule as `SetSessionCookie`, Path `/`, MaxAge 300. It is cleared
when exchanged or when the ticket is deleted.
### `Session` additions
- `AMR []string` - how the session was authenticated: `pwd`, `otp`, `webauthn`,
`recovery`, `oidc`. An HQ user's password sign-in is `pwd` like a local one.
- `StepUpAt *time.Time` - last successful step-up. Sign-in itself counts as a
step-up, so `StepUpAt` is set at session creation.
Updating `StepUpAt` rewrites the Redis value under the same session ID, keeping
the remaining TTL (`KEEPTTL`).
## Sign-in flows
### Password, then second factor
1. `POST /auth/login` runs unchanged up to `VerifyPassword`.
2. Then:
- user is `oidc`: unreachable (no local password).
- user has MFA: create a ticket with `methods` (`totp`, `webauthn`,
`recovery` as applicable), set `km_mfa_pending`, answer
`200 {mfa_required: true, methods: [...]}`.
- user has no MFA and `require_mfa` is on: create a ticket with
`enrol_only: true`, answer `200 {enrol_required: true}`.
- otherwise: mint the session as today, `amr: ["pwd"]`.
3. The client calls one of:
- `POST /auth/mfa/totp {code}`
- `POST /auth/mfa/recovery {code}`
- `POST /auth/mfa/webauthn/begin` then `POST /auth/mfa/webauthn/finish`
4. On success the ticket is deleted, the session is minted with
`amr: ["pwd", <factor>]`, `TouchLastLogin` runs, and the response matches
today's `{ok: true}`.
Each failure increments `attempts`; the fifth deletes the ticket.
### Forced enrolment
An `enrol_only` ticket is accepted only by:
- `POST /auth/mfa/enrol/totp/setup``{secret, otpauth_uri}`
- `POST /auth/mfa/enrol/totp/confirm {code}``{recovery_codes}` and the session
- `POST /auth/mfa/enrol/passkey/begin|finish``{recovery_codes}` and the session
It is refused by the plain `/auth/mfa/*` verify endpoints and vice versa, so a
user cannot skip enrolment by presenting the ticket elsewhere.
Existing sessions are not revoked when `require_mfa` is switched on; they end
at their 24h TTL and the next sign-in enforces enrolment.
### Passwordless passkey
1. `POST /auth/passkey/begin` - resolves the instance with
`resolveLoginInstance`, applies the locked-instance and
`LocalLoginPermitted` checks exactly as `/auth/login` does, and returns
assertion options with an empty `allowCredentials` and
`userVerification: "required"`.
2. `POST /auth/passkey/finish` - looks the credential up by
`{instance_id, credential_id}`, verifies the assertion including the UV flag,
and mints the session with `amr: ["webauthn"]`.
A user-verified passkey is possession plus biometric or PIN, so it satisfies
`require_mfa` on its own.
The discoverable lookup is scoped by `instance_id` from the host, never by the
credential alone - the same rule that makes `users` lookups instance-scoped.
### WebAuthn relying party
- RP ID: the request host with any port removed. On cloud that is the
instance subdomain; self-hosted, the install's own host. The reverse proxy
must preserve `Host`, which the instance host guard already requires.
- Expected origin: `https://<host>` (or `http://` only when the request itself
is plain HTTP on `localhost`, for development).
- A passkey is bound to its host. Moving an instance to a new address
invalidates its passkeys; TOTP and recovery codes still work. The docs say so.
## Step-up
`auth.RequireStepUp()` is new middleware, mounted after `auth.Middleware` on:
- `POST /api/secrets/:group/reveal`
- `GET /api/keys/:id/private-key`
- `POST /api/console/connect`
- the MFA-management endpoints marked "step-up" below
It passes when any of these holds:
- the request authenticated with an API token (`TokenID != ""`);
- `AMR` contains `oidc`;
- `StepUpAt` is within the last 10 minutes.
Otherwise it answers `403 {error: "re-authentication required", code:
"step_up_required", methods: [...]}`, where `methods` is the user's MFA factors,
or `["password"]` when they have none.
`POST /api/me/step-up` accepts `{totp}`, `{recovery}` or `{password}` (password
only for a user with no MFA), and `POST /api/me/step-up/webauthn/begin|finish`
does the same with a passkey. Success sets `StepUpAt`. Failures are rate
limited by the same per-IP limiter and audited.
## Account endpoints (session-authenticated, every role)
| Method and path | Step-up | Notes |
| --------------------------------------- | ------- | ----- |
| `GET /api/me/mfa` | no | TOTP on/off, passkeys, recovery codes remaining, `require_mfa` |
| `POST /api/me/mfa/totp/setup` | yes | returns secret and `otpauth://` URI; replaces an unconfirmed one |
| `POST /api/me/mfa/totp/confirm` | no | code must verify; returns recovery codes when none exist yet |
| `DELETE /api/me/mfa/totp` | yes | refused if last factor and `require_mfa` |
| `POST /api/me/mfa/recovery/regenerate` | yes | invalidates the old set, returns 10 new codes once |
| `POST /api/me/passkeys/begin`, `/finish`| yes | registers a discoverable credential, UV required |
| `PATCH /api/me/passkeys/:id` | no | rename |
| `DELETE /api/me/passkeys/:id` | yes | refused if last factor and `require_mfa` |
| `DELETE /api/org/users/:id/mfa` | yes | owner or admin; an admin cannot reset an owner; clears TOTP, passkeys, recovery codes |
The `/me/*` endpoints refuse OIDC users with 409 `mfa_not_applicable`.
The first factor enrolled from `/account/security` issues recovery codes;
later factors do not regenerate them.
`require_mfa` is set through the existing `PUT /api/settings`, owner only for
that field (admins may save other settings).
Every handler carries swag annotations; `openapi.json` is regenerated.
## Rate limiting
There is no rate limit on `/auth/login` today. A new `RateLimitAuth()` fixed
window, on the `RateLimitTokens` pattern, applies to `/auth/login`,
`/auth/mfa/*`, `/auth/passkey/*` and `/api/me/step-up*`: 20 requests per minute
per `c.ClientIP()`, answering 429 with `Retry-After`. The ticket's 5-attempt
cap still bounds guesses per ticket; the IP limit bounds tickets per attacker.
## Audit events
`mfa.enrolled` (factor), `mfa.removed` (factor), `mfa.reset` (target user),
`mfa.recovery_used`, `mfa.recovery_regenerated`, `mfa.failed` (factor, at
sign-in), `step_up.ok`, `step_up.failed`, `settings.require_mfa` (on/off).
Details never contain codes, secrets or credential IDs.
## UI (`web/`)
- **`/login`**
- "Sign in with passkey" button when `window.PublicKeyCredential` exists and
local login is enabled.
- After the password: a second-factor step with a 6-digit code input,
"Use passkey", and "Use a recovery code".
- `enrol_required`: an inline wizard - choose TOTP (QR code, secret as text,
confirm code) or passkey, then recovery codes shown once with copy and an
"I have saved these" checkbox before continuing.
- `mfa_ticket_expired` returns the form to the password step with a message.
- **`/account/security`** - new page, every role, linked from the sidebar user
menu. TOTP status and setup/remove, passkey list with rename and remove,
recovery codes remaining and regenerate. Hidden content with an explanation
for OIDC users.
- **`/settings`** - owner-only "Require MFA for password sign-in" toggle; the
users table gains an MFA column and a "Reset MFA" action.
- **`StepUpModal`** - `request()` in `web/lib/api.ts` catches 403 with
`code: "step_up_required"`, opens the modal with the offered methods, and on
success retries the original request once. Callers of the three sensitive
actions need no changes.
- QR codes are drawn client-side (the `qrcode` npm package); nothing is fetched
from outside, so air-gapped installs work.
## Errors
| Situation | Answer |
| ---------------------------------- | ------ |
| missing or expired ticket | 401 `mfa_ticket_expired` |
| wrong code | 401 `invalid_code`, `attempts_left` |
| fifth wrong code | ticket deleted, 401 `mfa_ticket_expired` |
| replayed TOTP code | 401 `invalid_code` |
| WebAuthn verification failure | 401 `invalid_assertion` |
| origin or RP ID mismatch | 400 `origin_mismatch` |
| removing last factor under policy | 409 `mfa_required_by_policy` |
| rate limited | 429 with `Retry-After` |
| Redis unavailable | 503; sign-in already depends on Redis |
TOTP: SHA-1, 6 digits, 30 s, ±1 step, issuer = instance name, account = email.
## Libraries
- `github.com/pquerna/otp` - TOTP generation and validation.
- `github.com/go-webauthn/webauthn` - WebAuthn ceremonies.
- `qrcode` (npm) - QR rendering in the browser.
## Testing
Go unit tests:
- TOTP verify, skew window, replay guard.
- Recovery codes: normalisation, single use, regenerate invalidates the old set.
- Ticket state machine: TTL, attempt cap, `enrol_only` scope in both directions.
- Login branching: no MFA, MFA, `require_mfa` without MFA, OIDC-exempt.
- `RequireStepUp`: inside and outside the window, token exemption, OIDC exemption.
- Last-factor refusal under `require_mfa`; admin cannot reset an owner.
- Passwordless passkey honours locked instances and `LocalLoginPermitted`.
- `user_mfa` and `webauthn_credentials` present in `ScopedCollections`.
- WebAuthn ceremonies against a software authenticator fixture.
Playwright:
- TOTP enrol and sign-in, with codes generated in the test.
- Passkey enrol, second-factor sign-in, passwordless sign-in and step-up using
Chrome's virtual authenticator (`WebAuthn.addVirtualAuthenticator` over CDP).
- Forced enrolment when `require_mfa` is on.
- Step-up modal on secret reveal, then no prompt within 10 minutes.
## Known limitations
- **API tokens bypass step-up.** A token with `secrets:read` or `keys:read` can
reveal secrets and download private keys without re-authentication. Tokens
have no human present to prompt. Scoped, short-lived tokens are the mitigation
and are tracked separately in the gap review.
- OIDC users are never prompted for step-up; their IdP's session policy governs.
- HQ users on several instances enrol once per instance.
- Passkeys stop working if an instance changes host.
## Documentation
- `CLAUDE.md`: a "Multi-factor authentication" subsystem section covering the
ticket design, step-up, the shared-module changes and `ciphertextFields`.
- `vantage-docs`: enrolling, recovery codes, owner reset, `require_mfa`,
passkeys bound to the host.
- Gap review: mark `#r-mfa` shipped, and the MFA row in the comparison table.
+5
View File
@@ -138,6 +138,10 @@ func runSchemaSetup() {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
if err := services.EnsureMFAIndexes(); err != nil {
log.Fatalf("failed to ensure mfa indexes: %v", err)
}
if err := services.EnsureAPITokenIndexes(); err != nil {
log.Fatalf("api token indexes: %v", err)
}
@@ -221,6 +225,7 @@ func serve() {
log.Fatalf("failed to connect to Redis: %v", err)
}
log.Println("connected to Redis")
services.RedisClient = auth.Redis()
// The bus carries agent commands and step results between replicas. It is
// not optional even on a single-replica deployment: dispatch takes the same
+12 -1
View File
@@ -6,12 +6,14 @@ require (
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57
github.com/coreos/go-oidc/v3 v3.21.0
github.com/gin-gonic/gin v1.12.0
github.com/go-webauthn/webauthn v0.18.1
github.com/google/uuid v1.6.0
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/opencontainers/image-spec v1.1.1
github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.22.0
github.com/robfig/cron/v3 v3.0.1
github.com/wwt/guac v1.3.2
@@ -23,12 +25,19 @@ require (
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.62.0 // indirect
github.com/samber/lo v1.53.0 // indirect
@@ -37,6 +46,8 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
@@ -47,7 +58,7 @@ require (
)
require (
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0
github.com/bytedance/sonic v1.15.3 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+24 -2
View File
@@ -1,9 +1,11 @@
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 h1:EtojZ1d3cN9foHpc/CAI3KzBewYGn4sKWdkWs2MV78Q=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0 h1:wwXvHfDKZB44EEj6BXl9O68hLC3kfPz3eak9wvupIRA=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -23,6 +25,8 @@ github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
@@ -43,6 +47,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.1 h1:KaQw6M+ODLvxHwddyeo6zFhhmicfLup/BClhigB6A+0=
github.com/go-webauthn/webauthn v0.18.1/go.mod h1:s4rZTQnKHWxIh6G3yEGqlxvtiLceA1jigll8FpqSgQ8=
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -53,6 +63,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
@@ -97,7 +111,11 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -135,12 +153,16 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
File diff suppressed because it is too large Load Diff
+48 -5
View File
@@ -42,7 +42,25 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
r.POST("/auth/bootstrap", auth.HandleBootstrap)
r.POST("/auth/login", auth.HandleLocalLogin)
// Every unauthenticated sign-in and enrolment step lives behind
// RateLimitAuth: without it, the five-attempt cap on a single ticket is
// trivially sidestepped by starting a fresh sign-in each time.
authGroup := r.Group("", RateLimitAuth())
{
authGroup.POST("/auth/login", auth.HandleLocalLogin)
authGroup.POST("/auth/mfa/totp", auth.HandleMFATOTP)
authGroup.POST("/auth/mfa/recovery", auth.HandleMFARecovery)
authGroup.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin)
authGroup.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish)
authGroup.POST("/auth/passkey/begin", auth.HandlePasskeyLoginBegin)
authGroup.POST("/auth/passkey/finish", auth.HandlePasskeyLoginFinish)
authGroup.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
authGroup.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
authGroup.POST("/auth/mfa/enrol/passkey/begin", auth.HandleEnrolPasskeyBegin)
authGroup.POST("/auth/mfa/enrol/passkey/finish", auth.HandleEnrolPasskeyFinish)
}
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/me", auth.HandleMe)
r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
@@ -91,6 +109,20 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/tokens", createToken)
apiGroup.DELETE("/tokens/:id", revokeToken)
apiGroup.GET("/me/mfa", getMyMFA)
apiGroup.POST("/me/mfa/totp/setup", auth.RequireStepUp(), setupTOTP)
apiGroup.POST("/me/mfa/totp/confirm", confirmTOTP)
apiGroup.DELETE("/me/mfa/totp", auth.RequireStepUp(), removeTOTP)
apiGroup.POST("/me/mfa/recovery/regenerate", auth.RequireStepUp(), regenerateRecoveryCodes)
apiGroup.POST("/me/passkeys/begin", auth.RequireStepUp(), auth.HandleRegisterPasskeyBegin)
apiGroup.POST("/me/passkeys/finish", auth.RequireStepUp(), auth.HandleRegisterPasskeyFinish)
apiGroup.PATCH("/me/passkeys/:id", renamePasskey)
apiGroup.DELETE("/me/passkeys/:id", auth.RequireStepUp(), deletePasskey)
apiGroup.POST("/me/step-up", RateLimitAuth(), stepUp)
apiGroup.POST("/me/step-up/webauthn/begin", RateLimitAuth(), auth.HandleStepUpWebAuthnBegin)
apiGroup.POST("/me/step-up/webauthn/finish", RateLimitAuth(), auth.HandleStepUpWebAuthnFinish)
apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA)
apiGroup.GET("/openapi.json", getOpenAPI)
apiGroup.GET("/docs", getAPIDocs)
apiGroup.GET("/docs/scalar.js", getScalarJS)
@@ -107,19 +139,19 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/secrets", createSecretGroup)
apiGroup.GET("/secrets/:group", getSecretGroup)
apiGroup.PUT("/secrets/:group", putSecretGroup)
apiGroup.POST("/secrets/:group/reveal", revealSecret)
apiGroup.POST("/secrets/:group/reveal", auth.RequireStepUp(), revealSecret)
apiGroup.DELETE("/secrets/:group", deleteSecretGroup)
apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey)
apiGroup.GET("/keys", listKeys)
apiGroup.POST("/keys", createKey)
apiGroup.GET("/keys/:id", getKey)
apiGroup.GET("/keys/:id/private-key", getPrivateKey)
apiGroup.GET("/keys/:id/private-key", auth.RequireStepUp(), getPrivateKey)
apiGroup.DELETE("/keys/:id", deleteKey)
apiGroup.POST("/keys/:id/assign", assignKey)
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
apiGroup.POST("/console/connect", auth.RequireStepUp(), RequireFeature("console"), consoleConnect)
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
// MCP is mounted inside /api so that bearer auth, rate limiting, licence
@@ -926,6 +958,7 @@ func saveSettings(c *gin.Context) {
Alerts models.AlertSettings `json:"alerts"`
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
LocalLoginEnabled *bool `json:"local_login_enabled"`
RequireMFA *bool `json:"require_mfa"`
APITokenMaxDays *int `json:"api_token_max_days"`
}
if err := c.ShouldBindJSON(&body); err != nil {
@@ -936,7 +969,13 @@ func saveSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil {
// The MFA requirement gates every future sign-in, so only an owner may
// change it; an admin can still save the rest of this endpoint's settings.
if body.RequireMFA != nil && auth.Role(c) != models.RoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change the MFA requirement"})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.RequireMFA, body.APITokenMaxDays); err != nil {
if errors.Is(err, services.ErrLockout) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
return
@@ -949,6 +988,10 @@ func saveSettings(c *gin.Context) {
services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "",
fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays))
}
if body.RequireMFA != nil {
services.LogEvent(auth.InstanceID(c), "settings.require_mfa", actorFromCtx(c), "", "",
fmt.Sprintf("enabled=%v", *body.RequireMFA))
}
c.JSON(http.StatusOK, SavedResponse{Saved: true})
}
+20 -1
View File
@@ -20,13 +20,32 @@ import (
// @Security cookieAuth
// @Security bearerAuth
// @Router /instance/users [get]
// instanceUserResponse wraps a member with whether they hold an MFA factor,
// for the settings page's member column and reset action. A wrapper rather
// than a field on models.User because User is shared with Vantage HQ.
type instanceUserResponse struct {
models.User `bson:",inline"`
MFAEnabled bool `json:"mfa_enabled"`
}
func listInstanceUsers(c *gin.Context) {
users, err := services.ListUsers(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
// One aggregate over two small collections beats N round trips for a
// member list that renders on every settings page load.
enabled, err := services.UsersWithMFA(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]instanceUserResponse, 0, len(users))
for _, u := range users {
out = append(out, instanceUserResponse{User: u, MFAEnabled: enabled[u.UserID]})
}
c.JSON(http.StatusOK, out)
}
func actorMayGrantOwner(c *gin.Context) bool {
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// oidcUser refuses MFA management for a user whose IdP owns authentication.
func oidcUser(c *gin.Context) bool {
u, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
return err == nil && u.AuthSource == models.AuthOIDC
}
// getMyMFA reports this user's factors.
//
// @Summary Get my MFA status
// @Tags mfa
// @Produce json
// @Success 200 {object} object{totp_enabled=bool,passkeys=[]models.WebAuthnCredential,recovery_remaining=int,require_mfa=bool,applicable=bool}
// @Router /me/mfa [get]
func getMyMFA(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
m, err := services.GetUserMFA(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
passkeys, err := services.ListPasskeys(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"totp_enabled": m != nil && m.TOTPConfirmedAt != nil,
"passkeys": passkeys,
"recovery_remaining": services.RecoveryCodesRemaining(m),
"require_mfa": services.RequireMFAForInstance(instanceID),
"applicable": !oidcUser(c),
})
}
// setupTOTP issues a new unconfirmed secret.
//
// @Summary Start TOTP setup
// @Tags mfa
// @Produce json
// @Success 200 {object} object{secret=string,otpauth_uri=string}
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/mfa/totp/setup [post]
func setupTOTP(c *gin.Context) {
if oidcUser(c) {
c.JSON(http.StatusConflict, gin.H{"error": "your identity provider manages sign-in", "code": "mfa_not_applicable"})
return
}
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
issuer := "Vantage"
if inst, err := services.GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" {
issuer = inst.Name
}
secret, uri, err := services.StartTOTPSetup(instanceID, userID, issuer, auth.GetSessionFromContext(c).Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
}
// confirmTOTP activates the pending secret and issues recovery codes if this
// is the user's first factor.
//
// @Summary Confirm TOTP setup
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /me/mfa/totp/confirm [post]
func confirmTOTP(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
return
}
if err := services.ConfirmTOTP(instanceID, userID, body.Code); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that code is not valid", "code": "invalid_code"})
return
}
services.LogEvent(instanceID, "mfa.enrolled", actorFromCtx(c), "", "", "factor=totp")
m, _ := services.GetUserMFA(instanceID, userID)
if services.RecoveryCodesRemaining(m) > 0 {
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
codes, err := services.IssueRecoveryCodes(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
}
// removeTOTP drops the TOTP factor. Step-up guarded at the route.
//
// @Summary Remove TOTP
// @Tags mfa
// @Produce json
// @Success 204
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/mfa/totp [delete]
func removeTOTP(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorTOTP); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
return
}
if err := services.RemoveTOTP(instanceID, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=totp")
c.Status(http.StatusNoContent)
}
// regenerateRecoveryCodes invalidates the old set. Step-up guarded.
//
// @Summary Regenerate recovery codes
// @Tags mfa
// @Produce json
// @Success 200 {object} object{recovery_codes=[]string}
// @Router /me/mfa/recovery/regenerate [post]
func regenerateRecoveryCodes(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
codes, err := services.IssueRecoveryCodes(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.recovery_regenerated", actorFromCtx(c), "", "", "")
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
}
// renamePasskey and deletePasskey work on the hex credential ID.
//
// @Summary Rename a passkey
// @Tags mfa
// @Accept json
// @Produce json
// @Param id path string true "Credential ID"
// @Param body body object{name=string} true "New name"
// @Success 204
// @Router /me/passkeys/{id} [patch]
func renamePasskey(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}
err := services.RenamePasskey(auth.InstanceID(c), auth.UserID(c), c.Param("id"), body.Name)
if errors.Is(err, services.ErrNoPasskey) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// @Summary Delete a passkey
// @Tags mfa
// @Produce json
// @Param id path string true "Credential ID"
// @Success 204
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/passkeys/{id} [delete]
func deletePasskey(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorWebAuthn); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
return
}
err := services.DeletePasskey(instanceID, userID, c.Param("id"))
if errors.Is(err, services.ErrNoPasskey) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=webauthn")
c.Status(http.StatusNoContent)
}
// resetUserMFA lets an owner or admin clear somebody else's factors.
//
// @Summary Reset another member's MFA
// @Tags mfa
// @Produce json
// @Param id path string true "User ID"
// @Success 204
// @Failure 403 {object} object{error=string}
// @Router /org/users/{id}/mfa [delete]
func resetUserMFA(c *gin.Context) {
instanceID := auth.InstanceID(c)
target, err := services.GetUserInInstance(instanceID, c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such member"})
return
}
// An admin resetting an owner's MFA would be a promotion path: clear the
// factor, phish the password, hold the instance.
if auth.Role(c) != models.RoleOwner && target.Role == models.RoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can reset an owner's MFA"})
return
}
if err := services.ClearMFA(instanceID, target.UserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.reset", actorFromCtx(c), "", "", "target="+target.Email)
c.Status(http.StatusNoContent)
}
// stepUp re-authenticates the current session.
//
// @Summary Re-authenticate before a sensitive action
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{totp=string,recovery=string,password=string} true "One factor"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /me/step-up [post]
func stepUp(c *gin.Context) {
sess := auth.GetSessionFromContext(c)
var body struct {
TOTP string `json:"totp"`
Recovery string `json:"recovery"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
return
}
var err error
switch {
case body.TOTP != "":
err = services.VerifyTOTPCode(sess.InstanceID, sess.UserID, body.TOTP)
case body.Recovery != "":
err = services.UseRecoveryCode(sess.InstanceID, sess.UserID, body.Recovery)
case body.Password != "":
// Password is offered only to a user with no MFA at all; accepting it
// from an enrolled user would demote step-up to what they already did.
has, herr := services.HasMFA(sess.InstanceID, sess.UserID)
if herr != nil || has {
err = services.ErrBadCode
} else {
u, uerr := services.GetUserInInstance(sess.InstanceID, sess.UserID)
if uerr != nil || !services.VerifyPassword(u, body.Password) {
err = services.ErrBadCode
}
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
return
}
if err != nil {
services.LogEvent(sess.InstanceID, "step_up.failed", actorFromCtx(c), "", "", "")
c.JSON(http.StatusUnauthorized, gin.H{"error": "that did not verify", "code": "invalid_code"})
return
}
if err := auth.TouchStepUpFromRequest(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
return
}
services.LogEvent(sess.InstanceID, "step_up.ok", actorFromCtx(c), "", "", "")
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"fmt"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// authRateLimit is per client address per minute. It bounds how many tickets an
// attacker can start; the ticket's own five-attempt cap bounds guesses inside
// one. Neither alone is enough.
const authRateLimit = 20
// RateLimitAuth guards every unauthenticated sign-in endpoint. Without it, the
// per-ticket cap is trivially sidestepped by starting a new sign-in each time.
func RateLimitAuth() gin.HandlerFunc {
return func(c *gin.Context) {
rdb := services.RedisClient
if rdb == nil {
c.Next()
return
}
window := time.Now().Unix() / 60
key := fmt.Sprintf("km:rl:auth:%s:%d", c.ClientIP(), window)
ctx := c.Request.Context()
n, err := rdb.Incr(ctx, key).Result()
if err != nil {
// A limiter that cannot reach Redis must not lock out sign-in: fail
// open rather than turn a Redis blip into a second outage.
c.Next()
return
}
if n == 1 {
rdb.Expire(ctx, key, time.Minute)
}
if n > authRateLimit {
c.Header("Retry-After", "60")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "too many sign-in attempts; try again in a minute",
})
return
}
c.Next()
}
}
+40 -12
View File
@@ -102,16 +102,48 @@ func HandleLocalLogin(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
requireMFA := services.RequireMFAForInstance(instanceID)
hasMFA, err := services.HasMFA(u.InstanceID, u.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"})
return
}
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.JSON(http.StatusOK, gin.H{"ok": true})
switch loginDecision(hasMFA, requireMFA) {
case "session":
if err := mintSession(c, u, []string{"pwd"}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
case "verify":
methods, err := services.MFAMethods(u.InstanceID, u.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"})
return
}
id, err := CreateTicket(c.Request.Context(), &Ticket{
UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, Methods: methods,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetPendingCookie(c, id)
c.JSON(http.StatusOK, gin.H{"mfa_required": true, "methods": methods})
case "enrol":
id, err := CreateTicket(c.Request.Context(), &Ticket{
UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, EnrolOnly: true,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetPendingCookie(c, id)
c.JSON(http.StatusOK, gin.H{"enrol_required": true})
}
}
// HandleListPublicProviders is unauthenticated: it is what the login page reads
@@ -227,14 +259,10 @@ func HandleBootstrap(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
if err := mintSession(c, u, []string{"pwd"}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetSessionCookie(c, sessionID)
c.JSON(http.StatusCreated, gin.H{
"instance": inst,
"slug": inst.Slug,
+233
View File
@@ -0,0 +1,233 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// enrolTicketStillValid refuses an enrol-only ticket once the user has any
// factor. Otherwise someone holding only the password who started sign-in
// before the real user enrolled could, within the ticket's lifetime, add
// their own passkey and replace the user's recovery codes. Answered as an
// expired ticket, the single indistinguishable error, and the ticket is
// destroyed. A lookup failure is treated the same way: fail closed.
//
// It runs before the factor is written, not in finishEnrolment: by then the
// factor being enrolled already counts, so HasMFA would always be true.
func enrolTicketStillValid(c *gin.Context, t *Ticket, ticketID string) bool {
has, err := services.HasMFA(t.InstanceID, t.UserID)
if err != nil || has {
_ = DeleteTicket(c.Request.Context(), ticketID)
abortTicketExpired(c)
return false
}
return true
}
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
// from, before they hold a session. Only an enrol-only ticket reaches it.
//
// @Summary Start forced TOTP enrolment during sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{secret=string,otpauth_uri=string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/totp/setup [post]
func HandleEnrolTOTPSetup(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
inst, err := services.GetInstance(t.InstanceID)
issuer := "Vantage"
if err == nil && inst != nil && inst.Name != "" {
issuer = inst.Name
}
secret, uri, err := services.StartTOTPSetup(t.InstanceID, t.UserID, issuer, t.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
}
// HandleEnrolTOTPConfirm finishes forced enrolment and signs the user in.
//
// @Summary Confirm forced TOTP enrolment and sign in
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/totp/confirm [post]
func HandleEnrolTOTPConfirm(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
return
}
if err := services.ConfirmTOTP(t.InstanceID, t.UserID, body.Code); err != nil {
left, ferr := FailTicket(c.Request.Context(), ticketID)
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
})
return
}
finishEnrolment(c, t, ticketID, services.FactorTOTP)
}
// finishEnrolment issues recovery codes, mints the session and audits, so the
// TOTP and passkey enrolment paths cannot drift apart.
func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
codes, err := services.IssueRecoveryCodes(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not issue recovery codes"})
return
}
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = DeleteTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor)
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
}
// HandleEnrolPasskeyBegin starts forced passkey enrolment during sign-in.
// Identical to HandleRegisterPasskeyBegin except the user comes from the
// enrol-only ticket rather than a session, since none exists yet.
//
// @Summary Begin forced passkey enrolment during sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/passkey/begin [post]
func HandleEnrolPasskeyBegin(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
existing, err := services.ListPasskeys(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
lib := make([]webauthn.Credential, 0, len(existing))
for _, cr := range existing {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
options, sessionData, err := w.BeginRegistration(
waUser{handle: handle, name: t.Email, credentials: lib},
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleEnrolPasskeyFinish stores the new credential and finishes forced
// enrolment, minting the session that HandleEnrolTOTPConfirm also produces.
//
// @Summary Complete forced passkey enrolment and sign in
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/passkey/finish [post]
func HandleEnrolPasskeyFinish(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
var body struct {
CeremonyID string `json:"ceremony_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
return
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that enrolment expired", "code": "mfa_ticket_expired"})
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
return
}
cred, err := w.CreateCredential(waUser{handle: handle, name: t.Email}, *sessionData, parsed)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
if !cred.Flags.UserVerified {
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
return
}
transports := make([]string, 0, len(parsed.Response.Transports))
for _, tr := range parsed.Response.Transports {
transports = append(transports, string(tr))
}
if err := services.SavePasskey(t.InstanceID, t.UserID, body.Name,
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
transports); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
return
}
finishEnrolment(c, t, ticketID, services.FactorWebAuthn)
}
+136
View File
@@ -0,0 +1,136 @@
package auth
import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// loginDecision is the branch a verified password takes.
func loginDecision(hasMFA, requireMFA bool) string {
switch {
case hasMFA:
return "verify"
case requireMFA:
return "enrol"
default:
return "session"
}
}
// newSession builds the session a sign-in produces. Sign-in counts as a
// step-up, so StepUpAt is always the moment of sign-in.
func newSession(u *models.User, amr []string, now time.Time) *Session {
return &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
AMR: amr, StepUpAt: &now,
}
}
// oidcSession is the session an SSO callback mints. AMR "oidc" is what exempts
// it from step-up: the IdP owns authentication policy, and these users have
// no password or local factor to step up with.
func oidcSession(u *models.User, name string, now time.Time) *Session {
s := newSession(u, []string{"oidc"}, now)
s.Name = name
return s
}
// mintSession is the single place a session is created from a user, so every
// path records amr and step-up freshness the same way.
func mintSession(c *gin.Context, u *models.User, amr []string) error {
return saveSignIn(c, newSession(u, amr, time.Now()))
}
// saveSignIn persists a sign-in session and sets its cookie. The pending-MFA
// cookie is cleared because a completed sign-in supersedes any ticket.
func saveSignIn(c *gin.Context, sess *Session) error {
sessionID, err := SaveSession(c.Request.Context(), sess)
if err != nil {
return err
}
_ = services.TouchLastLogin(sess.UserID)
ClearPendingCookie(c)
SetSessionCookie(c, sessionID)
return nil
}
// HandleMFATOTP completes a sign-in with a TOTP code.
//
// @Summary Complete sign-in with a TOTP code
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/totp [post]
func HandleMFATOTP(c *gin.Context) {
handleMFAVerify(c, services.FactorTOTP)
}
// HandleMFARecovery completes a sign-in with a recovery code.
//
// @Summary Complete sign-in with a recovery code
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Recovery code"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/recovery [post]
func HandleMFARecovery(c *gin.Context) {
handleMFAVerify(c, services.FactorRecovery)
}
func handleMFAVerify(c *gin.Context, factor string) {
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
return
}
var err error
switch factor {
case services.FactorTOTP:
err = services.VerifyTOTPCode(t.InstanceID, t.UserID, body.Code)
case services.FactorRecovery:
err = services.UseRecoveryCode(t.InstanceID, t.UserID, body.Code)
}
if err != nil {
left, ferr := FailTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor="+factor)
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
})
return
}
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = DeleteTicket(c.Request.Context(), ticketID)
if factor == services.FactorRecovery {
services.LogEvent(t.InstanceID, "mfa.recovery_used", u.Email, "", "", "")
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+27
View File
@@ -0,0 +1,27 @@
package auth
import "testing"
// The whole point of the feature is in this table: a user with MFA is never
// handed a session by a password alone, and require_mfa turns "no factor" into
// forced enrolment rather than a free pass.
func TestLoginDecision(t *testing.T) {
cases := []struct {
name string
hasMFA bool
requireMFA bool
want string
}{
{"no mfa, not required", false, false, "session"},
{"no mfa, required", false, true, "enrol"},
{"has mfa, not required", true, false, "verify"},
{"has mfa, required", true, true, "verify"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := loginDecision(tc.hasMFA, tc.requireMFA); got != tc.want {
t.Fatalf("loginDecision(%v,%v) = %q, want %q", tc.hasMFA, tc.requireMFA, got, tc.want)
}
})
}
}
+180
View File
@@ -0,0 +1,180 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
ticketTTL = 5 * time.Minute
maxTicketAttempts = 5
ticketPrefix = "km:mfa:"
pendingCookieName = "km_mfa_pending"
)
// ErrTicketExpired covers every unusable ticket - missing, timed out, or
// destroyed by too many wrong codes. They are one message on purpose: which of
// the three it was tells an attacker whether the password was right.
var ErrTicketExpired = errors.New("this sign-in attempt has expired; start again")
// Ticket is a password that verified but has not yet become a session. It is
// deliberately NOT a Session: nothing half-authenticated may reach a route
// under Middleware, and the way to guarantee that is for it never to be the
// type those routes read.
type Ticket struct {
UserID string `json:"user_id"`
InstanceID string `json:"instance_id"`
Email string `json:"email"`
Methods []string `json:"methods"`
EnrolOnly bool `json:"enrol_only"`
}
type ticketScope int
const (
scopeVerify ticketScope = iota
scopeEnrol
)
func (t *Ticket) allows(s ticketScope) bool {
if t.EnrolOnly {
return s == scopeEnrol
}
return s == scopeVerify
}
func attemptsLeft(attempts int) int {
if attempts >= maxTicketAttempts {
return 0
}
return maxTicketAttempts - attempts
}
// attemptsKey is the counter backing a ticket's brute-force cap. It is a
// separate key rather than a field on the ticket JSON so INCR can make the
// count atomic: two requests racing on the same ticket must each cost one
// attempt, not both read the same count and both write count+1.
func attemptsKey(id string) string {
return ticketPrefix + id + ":attempts"
}
func CreateTicket(ctx context.Context, t *Ticket) (string, error) {
id, err := randomHex(32)
if err != nil {
return "", err
}
data, err := json.Marshal(t)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, ticketPrefix+id, data, ticketTTL).Err(); err != nil {
return "", err
}
return id, nil
}
func LoadTicket(ctx context.Context, id string) (*Ticket, error) {
data, err := rdb.Get(ctx, ticketPrefix+id).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrTicketExpired
}
if err != nil {
return nil, err
}
var t Ticket
if err := json.Unmarshal(data, &t); err != nil {
return nil, ErrTicketExpired
}
return &t, nil
}
// FailTicket records a wrong code and returns how many attempts remain. The
// count is kept in its own INCR-backed key rather than the ticket JSON: a
// read-modify-write on the JSON lets requests racing on the same ticket all
// read the same count and all write count+1, which bypasses the cap instead
// of costing one attempt each. At zero the ticket is destroyed rather than
// left to time out.
func FailTicket(ctx context.Context, id string) (int, error) {
// Confirms the ticket exists first, so a missing/expired/destroyed ticket
// still answers with the one indistinguishable ErrTicketExpired rather
// than incrementing a counter for an id nobody holds.
if _, err := LoadTicket(ctx, id); err != nil {
return 0, err
}
key := attemptsKey(id)
n, err := rdb.Incr(ctx, key).Result()
if err != nil {
return 0, err
}
if n == 1 {
// Only the creator of the counter sets its expiry, so a later
// increment never extends it past the ticket's own window.
if err := rdb.Expire(ctx, key, ticketTTL).Err(); err != nil {
return 0, err
}
}
left := attemptsLeft(int(n))
if left == 0 {
_ = DeleteTicket(ctx, id)
return 0, nil
}
return left, nil
}
func DeleteTicket(ctx context.Context, id string) error {
return rdb.Del(ctx, ticketPrefix+id, attemptsKey(id)).Err()
}
func SetPendingCookie(c *gin.Context, id string) {
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: pendingCookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(ticketTTL.Seconds()),
})
}
func ClearPendingCookie(c *gin.Context) {
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: pendingCookieName, Value: "", Path: "/",
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
// ticketFromRequest resolves the pending ticket and enforces its scope. It
// writes the response and returns false when the ticket is unusable.
func ticketFromRequest(c *gin.Context, scope ticketScope) (*Ticket, string, bool) {
cookie, err := c.Request.Cookie(pendingCookieName)
if err != nil || cookie.Value == "" {
abortTicketExpired(c)
return nil, "", false
}
t, err := LoadTicket(c.Request.Context(), cookie.Value)
if err != nil {
abortTicketExpired(c)
return nil, "", false
}
if !t.allows(scope) {
abortTicketExpired(c)
return nil, "", false
}
return t, cookie.Value, true
}
func abortTicketExpired(c *gin.Context) {
ClearPendingCookie(c)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": ErrTicketExpired.Error(), "code": "mfa_ticket_expired",
})
}
+50
View File
@@ -0,0 +1,50 @@
package auth
import "testing"
// The ticket's attempt cap is the only per-ticket brute-force guard: five
// wrong codes must destroy it rather than let an attacker keep guessing
// against a single stolen password.
func TestAttemptsLeftCountsDownAndHitsZero(t *testing.T) {
cases := []struct {
attempts int
want int
}{
{0, 5}, {1, 4}, {4, 1}, {5, 0}, {9, 0},
}
for _, tc := range cases {
if got := attemptsLeft(tc.attempts); got != tc.want {
t.Errorf("attemptsLeft(%d) = %d, want %d", tc.attempts, got, tc.want)
}
}
}
// attemptsKey must derive deterministically from the ticket id and stay
// distinct from the ticket's own key, since FailTicket relies on INCR against
// it being the sole writer of the attempt count.
func TestAttemptsKeyIsDerivedFromTicketID(t *testing.T) {
got := attemptsKey("abc123")
want := "km:mfa:abc123:attempts"
if got != want {
t.Errorf("attemptsKey(%q) = %q, want %q", "abc123", got, want)
}
if attemptsKey("abc123") == ticketPrefix+"abc123" {
t.Error("attempts key must not collide with the ticket's own key")
}
}
// An enrol-only ticket exists because the instance requires MFA the user does
// not have. It must not satisfy a verification endpoint, and a verification
// ticket must not reach the enrolment endpoints - each would skip the other's
// purpose.
func TestTicketScopeIsEnforcedInBothDirections(t *testing.T) {
verify := &Ticket{Methods: []string{"totp"}}
enrol := &Ticket{EnrolOnly: true}
if !verify.allows(scopeVerify) || verify.allows(scopeEnrol) {
t.Error("a verification ticket must allow only verification")
}
if !enrol.allows(scopeEnrol) || enrol.allows(scopeVerify) {
t.Error("an enrolment ticket must allow only enrolment")
}
}
+2 -6
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
@@ -225,15 +226,10 @@ func completeSSOLogin(c *gin.Context, instanceID, email, name string) {
}
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: name,
})
if err != nil {
if err := saveSignIn(c, oidcSession(u, name, time.Now())); err != nil {
c.Redirect(http.StatusFound, "/login?error=session_failed")
return
}
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.Redirect(http.StatusFound, "/")
}
+109
View File
@@ -0,0 +1,109 @@
package auth
import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// HandlePasskeyLoginBegin starts a passwordless sign-in.
//
// It repeats every gate /auth/login applies - instance resolution, the locked
// instance refusal, and the local-login setting - because this is a second
// front door, and a front door that skips the locks is not a shortcut.
//
// @Summary Begin passwordless passkey sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 403 {object} object{error=string}
// @Router /auth/passkey/begin [post]
func HandlePasskeyLoginBegin(c *gin.Context) {
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !services.LocalLoginPermitted(instanceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
// Discoverable login: no allowCredentials, so the authenticator offers
// whichever resident credential it holds for this RP ID.
options, sessionData, err := w.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandlePasskeyLoginFinish verifies a discoverable assertion and mints a
// session. A user-verified passkey is possession plus a PIN or biometric, so it
// satisfies require_mfa on its own.
//
// @Summary Complete passwordless passkey sign-in
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/passkey/finish [post]
func HandlePasskeyLoginFinish(c *gin.Context) {
instanceID, err := resolveLoginInstance(c)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "sign-in is not available here"})
return
}
if !services.LocalLoginPermitted(instanceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
return
}
// finishAssertion is given no userID or email, so it resolves the owning
// user from the credential ID scoped to this instance and validates with
// the library's discoverable path, refusing a user handle that is not
// that owner's.
cred, err := finishAssertion(c, instanceID, "", "")
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that passkey could not be verified", "code": "invalid_assertion",
})
return
}
stored, err := services.GetPasskeyByCredentialID(instanceID, cred.ID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
u, err := services.GetUserInInstance(instanceID, stored.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{services.FactorWebAuthn}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = services.TouchPasskey(instanceID, cred.ID, cred.Authenticator.SignCount)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+20
View File
@@ -31,6 +31,14 @@ type Session struct {
TokenName string `json:"-"`
Scopes []string `json:"-"`
TokenScope map[string]string `json:"-"`
// AMR records how this session authenticated: pwd, otp, webauthn,
// recovery, oidc. Step-up reads it to exempt OIDC sessions, whose IdP owns
// authentication policy.
AMR []string `json:"amr,omitempty"`
// StepUpAt is the last successful re-authentication. Sign-in counts as one.
StepUpAt *time.Time `json:"step_up_at,omitempty"`
}
var rdb *redis.Client
@@ -103,3 +111,15 @@ func GetSession(ctx context.Context, id string) (*Session, error) {
func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
// TouchStepUp records a fresh re-authentication without disturbing the
// session's remaining lifetime.
func TouchStepUp(ctx context.Context, id string, sess *Session) error {
now := time.Now()
sess.StepUpAt = &now
data, err := json.Marshal(sess)
if err != nil {
return err
}
return rdb.Set(ctx, sessionPrefix+id, data, redis.KeepTTL).Err()
}
+70
View File
@@ -0,0 +1,70 @@
package auth
import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// StepUpWindow is how long one re-authentication covers. Ten minutes is long
// enough to open several consoles in a row and short enough that a walked-away
// laptop is not a fleet-wide credential.
const StepUpWindow = 10 * time.Minute
func stepUpFresh(sess *Session, now time.Time) bool {
if sess == nil {
return false
}
// An API token authenticates per request and has no human to prompt.
if sess.TokenID != "" {
return true
}
for _, a := range sess.AMR {
if a == "oidc" {
return true
}
}
return sess.StepUpAt != nil && now.Sub(*sess.StepUpAt) < StepUpWindow
}
// RequireStepUp guards the actions that hand out credentials rather than
// describe them: secret reveal, private key download, console connect.
//
// It answers a machine-readable code rather than a bare 403 so web/ can open
// the re-authentication modal and retry the original request.
func RequireStepUp() gin.HandlerFunc {
return func(c *gin.Context) {
sess := GetSessionFromContext(c)
if stepUpFresh(sess, time.Now()) {
c.Next()
return
}
if sess == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
methods, err := services.MFAMethods(sess.InstanceID, sess.UserID)
if err != nil || len(methods) == 0 {
methods = []string{services.FactorPassword}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "re-authentication required",
"code": "step_up_required",
"methods": methods,
})
}
}
// TouchStepUpFromRequest records a fresh step-up against the session the
// request arrived on. It lives in auth because the cookie name and the Redis
// key are this package's business.
func TouchStepUpFromRequest(c *gin.Context) error {
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
return err
}
sess := GetSessionFromContext(c)
return TouchStepUp(c.Request.Context(), cookie.Value, sess)
}
+76
View File
@@ -0,0 +1,76 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/gin-gonic/gin"
)
func TestStepUpFresh(t *testing.T) {
now := time.Now()
ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v }
cases := []struct {
name string
sess *Session
want bool
}{
{"just signed in", &Session{StepUpAt: ago(time.Minute)}, true},
{"nine minutes ago", &Session{StepUpAt: ago(9 * time.Minute)}, true},
{"eleven minutes ago", &Session{StepUpAt: ago(11 * time.Minute)}, false},
{"never", &Session{}, false},
// An API token has no human to prompt; the spec exempts it and records
// the bypass as a known limitation.
{"api token", &Session{TokenID: "tok_1"}, true},
// An OIDC session's IdP owns authentication policy.
{"oidc session", &Session{AMR: []string{"oidc"}, StepUpAt: ago(time.Hour)}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stepUpFresh(tc.sess, now); got != tc.want {
t.Fatalf("stepUpFresh = %v, want %v", got, tc.want)
}
})
}
}
// A missing session must refuse rather than panic dereferencing sess.
// auth.Middleware guarantees a non-nil session on every route today, but
// RequireStepUp must not rely on that holding forever.
func TestRequireStepUpNilSessionRefusesWithoutPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/console/connect", nil)
RequireStepUp()(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestOIDCSessionIsExemptFromStepUp(t *testing.T) {
now := time.Now()
u := &models.User{UserID: "u1", InstanceID: "i1", Role: "member", Email: "a@example.com"}
s := oidcSession(u, "Ann", now.Add(-24*time.Hour))
if !stepUpFresh(s, now) {
t.Fatal("an OIDC session must be exempt from step-up however old its sign-in")
}
if s.StepUpAt == nil || s.Name != "Ann" || s.UserID != "u1" || s.InstanceID != "i1" {
t.Fatalf("oidc session missing fields: %+v", s)
}
}
func TestSignInSessionIsFreshAtSignIn(t *testing.T) {
now := time.Now()
s := newSession(&models.User{UserID: "u1"}, []string{"pwd"}, now)
if !stepUpFresh(s, now) {
t.Fatal("a session is fresh at the moment of sign-in")
}
}
+466
View File
@@ -0,0 +1,466 @@
package auth
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/redis/go-redis/v9"
)
const (
ceremonyPrefix = "km:wa:"
ceremonyTTL = 5 * time.Minute
)
// rpConfig derives the relying party from the request. The RP ID is the host
// without its port - WebAuthn forbids a port there - while the origin keeps it.
//
// This is why the reverse proxy must preserve Host: a proxy rewriting it makes
// every passkey on the instance fail to verify, with no error that says so.
func rpConfig(c *gin.Context) (string, string) {
host := c.Request.Host
rpID := host
if h, _, err := net.SplitHostPort(host); err == nil {
rpID = h
}
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
// Only development is ever plain HTTP; WebAuthn permits it on localhost.
scheme = "http"
}
return rpID, scheme + "://" + host
}
func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) {
rpID, origin := rpConfig(c)
return webauthn.New(&webauthn.Config{
RPDisplayName: "Vantage",
RPID: rpID,
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
})
}
// waUser adapts our records to the library's interface. The handle is random
// and per-user: a resident credential hands its user handle to any origin that
// asks, so the user ID must not be it.
type waUser struct {
handle []byte
name string
credentials []webauthn.Credential
}
func (u waUser) WebAuthnID() []byte { return u.handle }
func (u waUser) WebAuthnName() string { return u.name }
func (u waUser) WebAuthnDisplayName() string { return u.name }
func (u waUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
func toLibCredential(c models.WebAuthnCredential) webauthn.Credential {
return webauthn.Credential{
ID: c.CredentialID,
PublicKey: c.PublicKey,
AttestationType: "none",
Authenticator: webauthn.Authenticator{
AAGUID: c.AAGUID,
SignCount: c.SignCount,
},
}
}
func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) {
id, err := randomHex(32)
if err != nil {
return "", err
}
blob, err := json.Marshal(data)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, ceremonyPrefix+id, blob, ceremonyTTL).Err(); err != nil {
return "", err
}
return id, nil
}
// loadCeremony consumes the challenge: a WebAuthn challenge is single use, so
// it is deleted as it is read.
func loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) {
blob, err := rdb.GetDel(ctx, ceremonyPrefix+id).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrTicketExpired
}
if err != nil {
return nil, err
}
var data webauthn.SessionData
if err := json.Unmarshal(blob, &data); err != nil {
return nil, ErrTicketExpired
}
return &data, nil
}
// HandleMFAWebAuthnBegin offers an assertion challenge to a pending sign-in.
//
// @Summary Begin passkey verification during sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/webauthn/begin [post]
func HandleMFAWebAuthnBegin(c *gin.Context) {
t, _, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
creds, err := services.ListPasskeys(t.InstanceID, t.UserID)
if err != nil || len(creds) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
lib := make([]webauthn.Credential, 0, len(creds))
for _, cr := range creds {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib},
webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleMFAWebAuthnFinish verifies the assertion and signs the user in.
//
// @Summary Complete sign-in with a passkey
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/webauthn/finish [post]
func HandleMFAWebAuthnFinish(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email)
if err != nil {
left, ferr := FailTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn")
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that passkey could not be verified", "code": "invalid_assertion", "attempts_left": left,
})
return
}
_ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount)
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{"pwd", services.FactorWebAuthn}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = DeleteTicket(c.Request.Context(), ticketID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// HandleRegisterPasskeyBegin starts registration for the signed-in user.
//
// @Summary Begin passkey registration
// @Tags mfa
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Router /me/passkeys/begin [post]
func HandleRegisterPasskeyBegin(c *gin.Context) {
sess := GetSessionFromContext(c)
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
existing, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
lib := make([]webauthn.Credential, 0, len(existing))
for _, cr := range existing {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
options, sessionData, err := w.BeginRegistration(
waUser{handle: handle, name: sess.Email, credentials: lib},
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleRegisterPasskeyFinish stores the new credential.
//
// @Summary Complete passkey registration
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Router /me/passkeys/finish [post]
func HandleRegisterPasskeyFinish(c *gin.Context) {
sess := GetSessionFromContext(c)
var body struct {
CeremonyID string `json:"ceremony_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
return
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that registration expired", "code": "mfa_ticket_expired"})
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
return
}
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
return
}
cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
if !cred.Flags.UserVerified {
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
return
}
transports := make([]string, 0, len(parsed.Response.Transports))
for _, t := range parsed.Response.Transports {
transports = append(transports, string(t))
}
if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name,
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
transports); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
return
}
services.LogEvent(sess.InstanceID, "mfa.enrolled", sess.Email, "", "", "factor=webauthn")
// A first factor earns recovery codes; later ones do not reissue them.
m, _ := services.GetUserMFA(sess.InstanceID, sess.UserID)
if services.RecoveryCodesRemaining(m) == 0 {
codes, err := services.IssueRecoveryCodes(sess.InstanceID, sess.UserID)
if err == nil {
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// HandleStepUpWebAuthnBegin offers an assertion challenge for step-up
// re-authentication: the signed-in user proving it is still them before a
// guarded action, rather than a pending ticket proving it during sign-in.
//
// @Summary Begin passkey step-up
// @Tags mfa
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 400 {object} object{error=string}
// @Router /me/step-up/webauthn/begin [post]
func HandleStepUpWebAuthnBegin(c *gin.Context) {
sess := GetSessionFromContext(c)
creds, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
if err != nil || len(creds) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
return
}
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
lib := make([]webauthn.Credential, 0, len(creds))
for _, cr := range creds {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: sess.Email, credentials: lib},
webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleStepUpWebAuthnFinish verifies the assertion and records a fresh
// step-up for the current session. Unlike HandleMFAWebAuthnFinish this does
// not mint a session: the caller is already signed in, this only proves they
// still hold the passkey. finishAssertion is called with the session's own
// user ID, so a credential belonging to somebody else is refused rather than
// stepping up this session on their behalf.
//
// @Summary Complete passkey step-up
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /me/step-up/webauthn/finish [post]
func HandleStepUpWebAuthnFinish(c *gin.Context) {
sess := GetSessionFromContext(c)
cred, err := finishAssertion(c, sess.InstanceID, sess.UserID, sess.Email)
if err != nil {
services.LogEvent(sess.InstanceID, "step_up.failed", sess.Email, "", "", "factor=webauthn")
c.JSON(http.StatusUnauthorized, gin.H{"error": "that passkey could not be verified", "code": "invalid_assertion"})
return
}
_ = services.TouchPasskey(sess.InstanceID, cred.ID, cred.Authenticator.SignCount)
if err := TouchStepUpFromRequest(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
return
}
services.LogEvent(sess.InstanceID, "step_up.ok", sess.Email, "", "", "factor=webauthn")
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// finishAssertion is shared by second-factor sign-in, passwordless sign-in and
// step-up, so the verification rules (user verification, clone detection,
// instance scope) exist once.
func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) {
var body struct {
CeremonyID string `json:"ceremony_id"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
return nil, errors.New("assertion required")
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
return nil, err
}
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(body.Credential))
if err != nil {
return nil, err
}
stored, err := services.GetPasskeyByCredentialID(instanceID, parsed.RawID)
if err != nil {
return nil, err
}
if userID != "" && stored.UserID != userID {
return nil, errors.New("credential belongs to another user")
}
handle, err := services.WebAuthnHandle(instanceID, stored.UserID)
if err != nil {
return nil, err
}
w, err := webAuthnFor(c)
if err != nil {
return nil, err
}
user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{toLibCredential(*stored)}}
var cred *webauthn.Credential
if userID == "" {
// Discoverable (passwordless) ceremony: the session carries no user,
// so the library asks us to resolve the authenticator's user handle.
// The only acceptable answer is the credential's own stored owner in
// this instance; any other handle is refused.
cred, err = w.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
if !discoverableHandleMatches(handle, userHandle) {
return nil, errors.New("user handle does not match the credential owner")
}
return user, nil
}, *sessionData, parsed)
} else {
cred, err = w.ValidateLogin(user, *sessionData, parsed)
}
if err != nil {
return nil, err
}
if !cred.Flags.UserVerified {
return nil, errors.New("user verification was not performed")
}
// A counter that fails to advance is the library's clone signal. Zero on
// both sides means the authenticator does not keep one, which is normal.
if cred.Authenticator.CloneWarning {
return nil, errors.New("authenticator may be cloned")
}
return cred, nil
}
// discoverableHandleMatches reports whether the user handle an authenticator
// returned belongs to the credential's stored owner. Empty never matches.
func discoverableHandleMatches(ownerHandle, userHandle []byte) bool {
return len(ownerHandle) > 0 && subtle.ConstantTimeCompare(ownerHandle, userHandle) == 1
}
+52
View File
@@ -0,0 +1,52 @@
package auth
import (
"bytes"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// A passkey is bound to its RP ID. Getting this wrong does not fail loudly - it
// silently makes every existing passkey unusable - so the port-stripping and
// scheme rules are pinned here.
func TestRPConfig(t *testing.T) {
cases := []struct {
name, host, proto string
wantID, wantOrig string
}{
{"plain host", "acme.vantage.example.com", "https", "acme.vantage.example.com", "https://acme.vantage.example.com"},
{"host with port", "vantage.acme.com:8443", "https", "vantage.acme.com", "https://vantage.acme.com:8443"},
{"localhost dev", "localhost:3000", "", "localhost", "http://localhost:3000"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/auth/passkey/begin", nil)
c.Request.Host = tc.host
if tc.proto != "" {
c.Request.Header.Set("X-Forwarded-Proto", tc.proto)
}
id, origin := rpConfig(c)
if id != tc.wantID || origin != tc.wantOrig {
t.Fatalf("rpConfig = (%q, %q), want (%q, %q)", id, origin, tc.wantID, tc.wantOrig)
}
})
}
}
func TestDiscoverableHandleMatches(t *testing.T) {
owner := bytes.Repeat([]byte{7}, 64)
if !discoverableHandleMatches(owner, bytes.Repeat([]byte{7}, 64)) {
t.Fatal("the owner's own handle must match")
}
other := bytes.Repeat([]byte{7}, 64)
other[63] = 8
if discoverableHandleMatches(owner, other) {
t.Fatal("another user's handle must not match")
}
if discoverableHandleMatches(nil, nil) || discoverableHandleMatches(owner, nil) {
t.Fatal("an empty handle must never match")
}
}
+4
View File
@@ -11,3 +11,7 @@ type (
// APITokenMaxDays re-exports shared.APITokenMaxDays so server/internal/services
// can read the token lifetime cap without importing shared/models directly.
func APITokenMaxDays(s *Settings) int { return shared.APITokenMaxDays(s) }
// RequireMFA re-exports shared.RequireMFA so services can read the MFA policy
// without importing shared/models directly.
func RequireMFA(s *Settings) bool { return shared.RequireMFA(s) }
+65
View File
@@ -0,0 +1,65 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// UserMFA is one user's multi-factor enrolment. It is a separate collection
// rather than fields on User because User lives in vantage-shared, which Vantage
// HQ also writes: MFA is a control-plane concern per instance.
type UserMFA struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
UserID string `bson:"user_id" json:"-"`
// WebAuthnHandle is a random 64 bytes, never the user ID: the handle is
// returned to any origin that asks a resident credential for it.
WebAuthnHandle []byte `bson:"webauthn_handle" json:"-"`
// TOTPSecretEnc is AES-256-GCM hex via services.encryptString. Mirrored in
// vantage-shared's backup.ciphertextFields - change one, change the other.
TOTPSecretEnc string `bson:"totp_secret_enc,omitempty" json:"-"`
// TOTPPendingEnc holds a secret from an unconfirmed StartTOTPSetup call.
// Transient: never mirrored into vantage-shared's backup.ciphertextFields.
TOTPPendingEnc string `bson:"totp_pending_enc,omitempty" json:"-"`
// TOTPConfirmedAt nil means setup was started but never confirmed, which
// does not count as an enrolled factor.
TOTPConfirmedAt *time.Time `bson:"totp_confirmed_at,omitempty" json:"totp_confirmed_at,omitempty"`
RecoveryCodes []RecoveryCode `bson:"recovery_codes,omitempty" json:"-"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// RecoveryCode stores only a SHA-256 hash: a leaked database yields no working
// codes, exactly as api_tokens and agent tokens do.
type RecoveryCode struct {
Hash string `bson:"hash"`
UsedAt *time.Time `bson:"used_at,omitempty"`
}
// WebAuthnCredential is one passkey. Nothing here is secret - a public key is
// public - so no field is encrypted.
type WebAuthnCredential struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
UserID string `bson:"user_id" json:"-"`
CredentialID []byte `bson:"credential_id" json:"-"`
PublicKey []byte `bson:"public_key" json:"-"`
SignCount uint32 `bson:"sign_count" json:"-"`
AAGUID []byte `bson:"aaguid" json:"-"`
Transports []string `bson:"transports,omitempty" json:"transports,omitempty"`
// CredentialIDHex is the browser-facing identifier for rename and delete.
// The raw bytes never reach a URL.
CredentialIDHex string `bson:"credential_id_hex" json:"id"`
Name string `bson:"name" json:"name"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
}
+25
View File
@@ -41,3 +41,28 @@ func EnsureAuthIndexes() error {
}
return nil
}
// EnsureMFAIndexes is fatal on failure like EnsureAuthIndexes, and for the same
// reason: these unique indexes are a security property, not an optimisation. A
// duplicate (instance_id, user_id) would make "this user's factors" ambiguous,
// and a duplicate credential_id would let an assertion resolve to two users.
func EnsureMFAIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := db.Col("user_mfa").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
_, err := db.Col("webauthn_credentials").Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "credential_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}}},
})
return err
}
+489
View File
@@ -0,0 +1,489 @@
package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/redis/go-redis/v9"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var (
ErrNoMFA = errors.New("no multi-factor authentication is enrolled")
ErrBadCode = errors.New("that code is not valid")
ErrCodeReplayed = errors.New("that code has already been used")
)
// Factor names travel to the browser, which chooses which prompt to draw.
const (
FactorTOTP = "totp"
FactorWebAuthn = "webauthn"
FactorRecovery = "recovery"
FactorPassword = "password"
)
const recoveryCodeCount = 10
// RedisClient is set by main.go after auth.InitRedis succeeds. services must
// not import auth: auth already imports services, and Go has no import
// cycles.
var RedisClient *redis.Client
func mfaCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// UsersWithMFA returns the set of user IDs in this instance holding a factor.
// One query per collection, not one per member, so the member list stays
// cheap however many users an instance has.
func UsersWithMFA(instanceID string) (map[string]bool, error) {
ctx, cancel := mfaCtx()
defer cancel()
out := map[string]bool{}
cur, err := db.Col("user_mfa").Find(ctx,
bson.M{"instance_id": instanceID, "totp_confirmed_at": bson.M{"$exists": true}})
if err != nil {
return nil, err
}
var rows []models.UserMFA
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
for _, r := range rows {
out[r.UserID] = true
}
var userIDs []string
if err := db.Col("webauthn_credentials").Distinct(ctx, "user_id",
bson.M{"instance_id": instanceID}).Decode(&userIDs); err != nil {
return nil, err
}
for _, id := range userIDs {
out[id] = true
}
return out, nil
}
// GenerateRecoveryCodes returns the codes to show the user once, and the
// hashed records to store. The plaintext is never persisted.
func GenerateRecoveryCodes() ([]string, []models.RecoveryCode, error) {
plain := make([]string, 0, recoveryCodeCount)
stored := make([]models.RecoveryCode, 0, recoveryCodeCount)
for i := 0; i < recoveryCodeCount; i++ {
b := make([]byte, 5)
if _, err := rand.Read(b); err != nil {
return nil, nil, err
}
// Crockford-ish base32 without padding: 8 characters, no case to get
// wrong when read off paper.
code := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
plain = append(plain, code)
stored = append(stored, models.RecoveryCode{Hash: HashRecoveryCode(code)})
}
return plain, stored, nil
}
// NormaliseRecoveryCode makes a code typed off paper comparable: no case, no
// spaces, no dashes.
func NormaliseRecoveryCode(code string) string {
r := strings.NewReplacer(" ", "", "-", "", "\t", "")
return strings.ToLower(r.Replace(strings.TrimSpace(code)))
}
func HashRecoveryCode(code string) string {
sum := sha256.Sum256([]byte(NormaliseRecoveryCode(code)))
return hex.EncodeToString(sum[:])
}
// ConsumeRecoveryCode reports which unused code matches, if any. It does not
// write: the caller marks the index used so the database update and the audit
// event stay in one place.
func ConsumeRecoveryCode(codes []models.RecoveryCode, input string, now time.Time) (int, bool) {
want := HashRecoveryCode(input)
for i, c := range codes {
if c.UsedAt != nil {
continue
}
if subtle.ConstantTimeCompare([]byte(c.Hash), []byte(want)) == 1 {
return i, true
}
}
return 0, false
}
func GetUserMFA(instanceID, userID string) (*models.UserMFA, error) {
ctx, cancel := mfaCtx()
defer cancel()
var m models.UserMFA
err := db.Col("user_mfa").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&m)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func CountPasskeys(instanceID, userID string) (int64, error) {
ctx, cancel := mfaCtx()
defer cancel()
return db.Col("webauthn_credentials").CountDocuments(ctx, bson.M{"instance_id": instanceID, "user_id": userID})
}
// hasFactor is the single definition of "this user has MFA". An unconfirmed
// TOTP secret does not count: a half-finished setup must not lock anyone out.
func hasFactor(m *models.UserMFA, passkeys int) bool {
if passkeys > 0 {
return true
}
return m != nil && m.TOTPConfirmedAt != nil
}
// MFAMethods lists the factors a user can present, newest-friendly order. An
// empty slice means they have no MFA at all.
func MFAMethods(instanceID, userID string) ([]string, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return nil, err
}
methods := []string{}
if passkeys > 0 {
methods = append(methods, FactorWebAuthn)
}
if m != nil && m.TOTPConfirmedAt != nil {
methods = append(methods, FactorTOTP)
}
if len(methods) > 0 && m != nil {
for _, c := range m.RecoveryCodes {
if c.UsedAt == nil {
methods = append(methods, FactorRecovery)
break
}
}
}
return methods, nil
}
func HasMFA(instanceID, userID string) (bool, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return false, err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return false, err
}
return hasFactor(m, int(passkeys)), nil
}
// WebAuthnHandle returns the user's stable random handle, creating the user_mfa
// document on first use.
func WebAuthnHandle(instanceID, userID string) ([]byte, error) {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
if m != nil && len(m.WebAuthnHandle) > 0 {
return m.WebAuthnHandle, nil
}
handle := make([]byte, 64)
if _, err := rand.Read(handle); err != nil {
return nil, err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$set": bson.M{"updated_at": time.Now()},
"$setOnInsert": bson.M{"webauthn_handle": handle},
},
options.UpdateOne().SetUpsert(true))
if err != nil {
return nil, err
}
// Re-read: a concurrent caller may have won the upsert.
m, err = GetUserMFA(instanceID, userID)
if err != nil {
return nil, err
}
return m.WebAuthnHandle, nil
}
// StartTOTPSetup writes an unconfirmed secret and returns it with its otpauth
// URI. An existing unconfirmed secret is replaced; a confirmed one is not
// touched until ConfirmTOTP succeeds against the new secret.
func StartTOTPSetup(instanceID, userID, issuer, account string) (string, string, error) {
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: account})
if err != nil {
return "", "", err
}
enc, err := encryptString(key.Secret())
if err != nil {
return "", "", err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{"$set": bson.M{"totp_pending_enc": enc, "updated_at": time.Now()}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return "", "", err
}
return key.Secret(), key.URL(), nil
}
// ConfirmTOTP promotes the pending secret to the live one.
func ConfirmTOTP(instanceID, userID, code string) error {
ctx, cancel := mfaCtx()
defer cancel()
var raw struct {
Pending string `bson:"totp_pending_enc"`
}
if err := db.Col("user_mfa").FindOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&raw); err != nil {
return ErrNoMFA
}
if raw.Pending == "" {
return ErrNoMFA
}
secret, err := decryptString(raw.Pending)
if err != nil {
return err
}
if !totp.Validate(code, secret) {
return ErrBadCode
}
now := time.Now()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$set": bson.M{"totp_secret_enc": raw.Pending, "totp_confirmed_at": now, "updated_at": now},
"$unset": bson.M{"totp_pending_enc": ""},
})
return err
}
// VerifyTOTPCode checks a code against the confirmed secret and burns it, so
// the same code cannot be replayed inside its 30-second window by an attacker
// who shoulder-surfed it.
func VerifyTOTPCode(instanceID, userID, code string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
if m == nil || m.TOTPConfirmedAt == nil || m.TOTPSecretEnc == "" {
return ErrNoMFA
}
secret, err := decryptString(m.TOTPSecretEnc)
if err != nil {
return err
}
step, ok := matchTOTPStep(code, secret, time.Now())
if !ok {
return ErrBadCode
}
return burnTOTPStep(userID, step)
}
// matchTOTPStep returns the 30-second time step the code was generated for,
// trying the current step and one either side (the accepted skew). The replay
// guard must burn this step, not the current one: burning the current step
// would let a code accepted as s-1 or s+1 be accepted again one step later.
func matchTOTPStep(code, secret string, now time.Time) (int64, bool) {
cur := now.Unix() / 30
for _, step := range []int64{cur - 1, cur, cur + 1} {
want, err := totp.GenerateCodeCustom(secret, time.Unix(step*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err == nil && subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return step, true
}
}
return 0, false
}
// ClearMFA removes every factor. Used by an owner or admin reset.
func ClearMFA(instanceID, userID string) error {
ctx, cancel := mfaCtx()
defer cancel()
if _, err := db.Col("user_mfa").DeleteOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID}); err != nil {
return err
}
_, err := db.Col("webauthn_credentials").DeleteMany(ctx,
bson.M{"instance_id": instanceID, "user_id": userID})
return err
}
// UseRecoveryCode consumes one unused code, marking it used by index so a
// concurrent second attempt with the same code finds it spent.
func UseRecoveryCode(instanceID, userID, input string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
if m == nil || len(m.RecoveryCodes) == 0 {
return ErrNoMFA
}
idx, ok := ConsumeRecoveryCode(m.RecoveryCodes, input, time.Now())
if !ok {
return ErrBadCode
}
ctx, cancel := mfaCtx()
defer cancel()
now := time.Now()
res, err := db.Col("user_mfa").UpdateOne(ctx,
bson.M{
"instance_id": instanceID, "user_id": userID,
"recovery_codes." + strconv.Itoa(idx) + ".used_at": bson.M{"$exists": false},
},
bson.M{"$set": bson.M{
"recovery_codes." + strconv.Itoa(idx) + ".used_at": now,
"updated_at": now,
}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrBadCode
}
return nil
}
// RequireMFAForInstance reads the policy, defaulting to off on any error: a
// database blip must not lock an entire instance out of its own control plane.
func RequireMFAForInstance(instanceID string) bool {
s, err := GetSettings(instanceID)
if err != nil {
return false
}
return models.RequireMFA(s)
}
// burnTOTPStep makes a step single-use for 90 seconds - longer than the +-1
// step window it could still validate in. Keyed on the time step the code
// matched, never on the code itself: a raw code sitting in a Redis key name
// would be a currently-valid credential readable by anything that can list
// keys. Redis is already required for sessions.
func burnTOTPStep(userID string, step int64) error {
if RedisClient == nil {
return nil
}
ctx, cancel := mfaCtx()
defer cancel()
key := "km:totp:" + userID + ":" + strconv.FormatInt(step, 10)
ok, err := RedisClient.SetNX(ctx, key, 1, 90*time.Second).Result()
if err != nil {
return err
}
if !ok {
return ErrCodeReplayed
}
return nil
}
// IssueRecoveryCodes replaces the user's set and returns the plaintext once.
// Callers must not persist or log the return value.
func IssueRecoveryCodes(instanceID, userID string) ([]string, error) {
plain, stored, err := GenerateRecoveryCodes()
if err != nil {
return nil, err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{"$set": bson.M{"recovery_codes": stored, "updated_at": time.Now()}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return nil, err
}
return plain, nil
}
var ErrMFARequiredByPolicy = errors.New("this instance requires multi-factor authentication; add another factor before removing this one")
// canRemoveFactor is pure so the rule is testable without a database.
// removing is FactorTOTP or FactorWebAuthn, and for a passkey it means one of
// the counted passkeys.
func canRemoveFactor(requireMFA, hasTOTP bool, passkeys int, removing string) error {
if !requireMFA {
return nil
}
remaining := 0
if hasTOTP && removing != FactorTOTP {
remaining++
}
switch removing {
case FactorWebAuthn:
remaining += passkeys - 1
default:
remaining += passkeys
}
if remaining > 0 {
return nil
}
return ErrMFARequiredByPolicy
}
// CheckCanRemoveFactor reads the current state and applies the rule.
func CheckCanRemoveFactor(instanceID, userID, removing string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return err
}
return canRemoveFactor(RequireMFAForInstance(instanceID), m != nil && m.TOTPConfirmedAt != nil, int(passkeys), removing)
}
// RemoveTOTP clears only the TOTP factor, leaving passkeys and recovery codes.
func RemoveTOTP(instanceID, userID string) error {
ctx, cancel := mfaCtx()
defer cancel()
_, err := db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$unset": bson.M{"totp_secret_enc": "", "totp_confirmed_at": "", "totp_pending_enc": ""},
"$set": bson.M{"updated_at": time.Now()},
})
return err
}
// RecoveryCodesRemaining counts unused codes for the account page.
func RecoveryCodesRemaining(m *models.UserMFA) int {
if m == nil {
return 0
}
n := 0
for _, c := range m.RecoveryCodes {
if c.UsedAt == nil {
n++
}
}
return n
}
@@ -0,0 +1,31 @@
package services
import "testing"
// Removing a factor under an MFA policy must leave at least one behind, or the
// user locks themselves out of an instance that will then demand enrolment
// they cannot complete without signing in.
func TestCanRemoveFactor(t *testing.T) {
cases := []struct {
name string
requireMFA bool
totp bool
passkeys int
removing string
wantRefusal bool
}{
{"policy off, last factor", false, true, 0, FactorTOTP, false},
{"policy on, totp plus passkey, drop totp", true, true, 1, FactorTOTP, false},
{"policy on, last totp", true, true, 0, FactorTOTP, true},
{"policy on, last passkey", true, false, 1, FactorWebAuthn, true},
{"policy on, two passkeys, drop one", true, false, 2, FactorWebAuthn, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := canRemoveFactor(tc.requireMFA, tc.totp, tc.passkeys, tc.removing)
if tc.wantRefusal != (err != nil) {
t.Fatalf("canRemoveFactor refusal = %v, want %v", err != nil, tc.wantRefusal)
}
})
}
}
@@ -0,0 +1,21 @@
package services
import "testing"
// A tenant-scoped collection missing from ScopedCollections outlives its
// instance when the instance is purged - here that means a former customer's
// TOTP secrets and passkeys stay in the database forever.
func TestMFACollectionsAreScoped(t *testing.T) {
for _, name := range []string{"user_mfa", "webauthn_credentials"} {
found := false
for _, got := range ScopedCollections {
if got == name {
found = true
break
}
}
if !found {
t.Errorf("%s is not in ScopedCollections", name)
}
}
}
+115
View File
@@ -0,0 +1,115 @@
package services
import (
"strings"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
func TestGenerateRecoveryCodesReturnsTenUniqueHashedCodes(t *testing.T) {
plain, stored, err := GenerateRecoveryCodes()
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(plain) != 10 || len(stored) != 10 {
t.Fatalf("want 10 codes, got %d plain and %d stored", len(plain), len(stored))
}
seen := map[string]bool{}
for i, p := range plain {
if seen[p] {
t.Fatalf("duplicate code %q", p)
}
seen[p] = true
if stored[i].Hash == p {
t.Fatal("code stored in plaintext")
}
if stored[i].Hash != HashRecoveryCode(p) {
t.Fatalf("stored hash does not match HashRecoveryCode for code %d", i)
}
if stored[i].UsedAt != nil {
t.Fatal("fresh code marked used")
}
}
}
func TestConsumeRecoveryCodeIsSingleUseAndFormatTolerant(t *testing.T) {
plain, stored, _ := GenerateRecoveryCodes()
now := time.Now()
idx, ok := ConsumeRecoveryCode(stored, plain[3], now)
if !ok || idx != 3 {
t.Fatalf("want index 3 consumed, got idx=%d ok=%v", idx, ok)
}
used := now
stored[3].UsedAt = &used
if _, ok := ConsumeRecoveryCode(stored, plain[3], now); ok {
t.Fatal("a used recovery code was accepted a second time")
}
// Users retype codes with different case and stray dashes or spaces.
messy := " " + strings.ToUpper(plain[4]) + " "
if _, ok := ConsumeRecoveryCode(stored, messy, now); !ok {
t.Fatal("normalisation rejected a valid code")
}
}
func TestConsumeRecoveryCodeRejectsUnknownCode(t *testing.T) {
_, stored, _ := GenerateRecoveryCodes()
if _, ok := ConsumeRecoveryCode(stored, "not-a-real-code", time.Now()); ok {
t.Fatal("unknown code accepted")
}
}
func TestHasFactorRequiresConfirmedTOTPOrAPasskey(t *testing.T) {
now := time.Now()
cases := []struct {
name string
mfa *models.UserMFA
passkeys int
want bool
}{
{"nothing", nil, 0, false},
{"unconfirmed totp only", &models.UserMFA{TOTPSecretEnc: "ab"}, 0, false},
{"confirmed totp", &models.UserMFA{TOTPSecretEnc: "ab", TOTPConfirmedAt: &now}, 0, true},
{"passkey only", &models.UserMFA{}, 1, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := hasFactor(tc.mfa, tc.passkeys); got != tc.want {
t.Fatalf("hasFactor = %v, want %v", got, tc.want)
}
})
}
}
func TestMatchTOTPStepReturnsTheStepTheCodeWasGeneratedFor(t *testing.T) {
const secret = "JBSWY3DPEHPK3PXP"
now := time.Unix(1_800_000_015, 0) // mid-step, well clear of a boundary
cur := now.Unix() / 30
for _, off := range []int64{-1, 0, 1} {
code, err := totp.GenerateCodeCustom(secret, time.Unix((cur+off)*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
t.Fatal(err)
}
step, ok := matchTOTPStep(code, secret, now)
if !ok || step != cur+off {
t.Fatalf("offset %d: got step %d ok=%v, want %d", off, step, ok, cur+off)
}
}
far, _ := totp.GenerateCodeCustom(secret, time.Unix((cur+5)*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if _, ok := matchTOTPStep(far, secret, now); ok {
t.Fatal("a code from five steps ahead must not match")
}
if _, ok := matchTOTPStep("000000x", secret, now); ok {
t.Fatal("a malformed code must not match")
}
}
@@ -52,6 +52,8 @@ var ScopedCollections = []string{
"patch_policies",
"patch_runs",
"patch_run_outputs",
"user_mfa",
"webauthn_credentials",
}
// collectionRenames maps the two collections whose names change. Ordered so the
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"encoding/hex"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var ErrNoPasskey = errors.New("no such passkey")
func ListPasskeys(instanceID, userID string) ([]models.WebAuthnCredential, error) {
ctx, cancel := mfaCtx()
defer cancel()
cur, err := db.Col("webauthn_credentials").Find(ctx,
bson.M{"instance_id": instanceID, "user_id": userID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
out := []models.WebAuthnCredential{}
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// GetPasskeyByCredentialID resolves a credential inside one instance. The
// instance scope is not optional: an unscoped lookup would let a credential
// registered on one tenant assert on another.
func GetPasskeyByCredentialID(instanceID string, credID []byte) (*models.WebAuthnCredential, error) {
ctx, cancel := mfaCtx()
defer cancel()
var c models.WebAuthnCredential
err := db.Col("webauthn_credentials").FindOne(ctx,
bson.M{"instance_id": instanceID, "credential_id": credID}).Decode(&c)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNoPasskey
}
if err != nil {
return nil, err
}
return &c, nil
}
func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []byte, signCount uint32, transports []string) error {
ctx, cancel := mfaCtx()
defer cancel()
if name == "" {
name = "Passkey"
}
_, err := db.Col("webauthn_credentials").InsertOne(ctx, models.WebAuthnCredential{
InstanceID: instanceID,
UserID: userID,
CredentialID: credID,
CredentialIDHex: hex.EncodeToString(credID),
PublicKey: publicKey,
AAGUID: aaguid,
SignCount: signCount,
Transports: transports,
Name: name,
CreatedAt: time.Now(),
})
return err
}
// TouchPasskey records use and the new signature counter. A counter that fails
// to advance can mean a cloned authenticator, so the caller checks it before
// calling this.
func TouchPasskey(instanceID string, credID []byte, signCount uint32) error {
ctx, cancel := mfaCtx()
defer cancel()
now := time.Now()
_, err := db.Col("webauthn_credentials").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "credential_id": credID},
bson.M{"$set": bson.M{"sign_count": signCount, "last_used_at": now}})
return err
}
func RenamePasskey(instanceID, userID, credIDHex, name string) error {
ctx, cancel := mfaCtx()
defer cancel()
res, err := db.Col("webauthn_credentials").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex},
bson.M{"$set": bson.M{"name": name}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrNoPasskey
}
return nil
}
func DeletePasskey(instanceID, userID, credIDHex string) error {
ctx, cancel := mfaCtx()
defer cancel()
res, err := db.Col("webauthn_credentials").DeleteOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrNoPasskey
}
return nil
}
+4 -1
View File
@@ -119,7 +119,7 @@ func ResolveSecretsReadToken(token string) (string, bool) {
return s.InstanceID, true
}
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, apiTokenMaxDays *int) error {
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, requireMFA *bool, apiTokenMaxDays *int) error {
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
@@ -149,6 +149,9 @@ func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays
if localLoginEnabled != nil {
set["local_login_enabled"] = *localLoginEnabled
}
if requireMFA != nil {
set["require_mfa"] = *requireMFA
}
if apiTokenMaxDays != nil {
set["api_token_max_days"] = *apiTokenMaxDays
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { me, type MePasskey } from "@/lib/api";
import { isPasskeySupported, toCreateOptions, credentialToJSON } from "@/lib/webauthn";
import { AsyncBoundary, Badge, Button, Card, CardHeader, CardTitle, ConfirmDialog, friendlyMessage, Modal, useToast } from "@/components/ui";
import { MfaEnrolWizard, type MfaEnrolEndpoints } from "@/components/mfa/MfaEnrolWizard";
import { RecoveryCodes } from "@/components/mfa/RecoveryCodes";
/**
* Adapts the /api/me/* client methods to the shape MfaEnrolWizard expects.
* The only mismatch is the field name: the server calls it `otpauth_uri`
* here (it is `otpauth_url` on the ticket-scoped enrolment endpoints from
* Task 10, which this page does not use).
*/
const SESSION_ENDPOINTS: MfaEnrolEndpoints = {
totpSetup: async () => {
const res = await me.totpSetup();
return { secret: res.secret, otpauth_url: res.otpauth_uri };
},
totpConfirm: (code) => me.totpConfirm(code),
passkeyBegin: () => me.passkeyRegisterBegin(),
passkeyFinish: (ceremonyId, credential, name) => me.passkeyRegisterFinish(ceremonyId, credential, name),
};
function formatDate(value?: string): string {
if (!value) return "Never";
return new Date(value).toLocaleString();
}
export default function SecurityPage() {
const queryClient = useQueryClient();
const toast = useToast();
const { data: status, isLoading, error } = useQuery({ queryKey: ["me-mfa"], queryFn: me.mfa });
const [wizardOpen, setWizardOpen] = useState(false);
const [removingTotp, setRemovingTotp] = useState(false);
const [removingPasskey, setRemovingPasskey] = useState<MePasskey | null>(null);
const [renamingPasskey, setRenamingPasskey] = useState<MePasskey | null>(null);
const [renameValue, setRenameValue] = useState("");
const [newRecoveryCodes, setNewRecoveryCodes] = useState<string[] | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["me-mfa"] });
const removeTotp = useMutation({
mutationFn: () => me.removeTotp(),
onSuccess: () => {
invalidate();
toast.success("Authenticator app removed.");
setRemovingTotp(false);
},
});
const deletePasskey = useMutation({
mutationFn: (id: string) => me.deletePasskey(id),
onSuccess: (_data, id) => {
invalidate();
toast.success("Passkey removed.");
if (removingPasskey?.id === id) setRemovingPasskey(null);
},
});
const renamePasskey = useMutation({
mutationFn: () => me.renamePasskey(renamingPasskey!.id, renameValue),
onSuccess: () => {
invalidate();
setRenamingPasskey(null);
},
});
const regenerateRecovery = useMutation({
mutationFn: () => me.regenerateRecovery(),
onSuccess: (res) => setNewRecoveryCodes(res.recovery_codes),
});
const addPasskeyDirect = useMutation({
mutationFn: async () => {
const { publicKey, ceremony_id } = await me.passkeyRegisterBegin();
const cred = (await navigator.credentials.create({ publicKey: toCreateOptions(publicKey) })) as PublicKeyCredential;
return me.passkeyRegisterFinish(ceremony_id, credentialToJSON(cred));
},
onSuccess: () => invalidate(),
});
function factorCount(s: NonNullable<typeof status>): number {
return (s.totp_enabled ? 1 : 0) + s.passkeys.length;
}
return (
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6 lg:p-8">
<h1 className="text-2xl font-bold text-text-primary">Security</h1>
<AsyncBoundary isLoading={isLoading} error={error}>
{status && !status.applicable ? (
<Card>
<p className="text-sm text-text-secondary">
Your sign-in is managed by this instance's identity provider. Two-factor authentication and passkeys are configured there, not here.
</p>
</Card>
) : status ? (
<>
{status.require_mfa && (
<div className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
This instance requires a second sign-in factor. You cannot remove your last one.
</div>
)}
<Card>
<CardHeader className="mb-4">
<CardTitle>Authenticator app</CardTitle>
<Badge variant={status.totp_enabled ? "success" : "neutral"}>{status.totp_enabled ? "Enabled" : "Not set up"}</Badge>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">Generates a 6-digit code every 30 seconds in an app like Google Authenticator or 1Password.</p>
{status.totp_enabled ? (
<Button
variant="danger"
size="sm"
disabled={status.require_mfa && factorCount(status) <= 1}
title={status.require_mfa && factorCount(status) <= 1 ? "This instance requires at least one factor." : undefined}
onClick={() => setRemovingTotp(true)}
>
Remove
</Button>
) : (
<Button variant="secondary" size="sm" onClick={() => setWizardOpen(true)}>
Set up
</Button>
)}
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>Passkeys</CardTitle>
{isPasskeySupported() && (
<Button variant="secondary" size="sm" loading={addPasskeyDirect.isPending} onClick={() => addPasskeyDirect.mutate()}>
Add a passkey
</Button>
)}
</CardHeader>
{addPasskeyDirect.error && <p className="mb-3 text-sm text-danger">{friendlyMessage(addPasskeyDirect.error)}</p>}
{status.passkeys.length === 0 ? (
<p className="text-sm text-text-secondary">No passkeys registered.</p>
) : (
<ul className="divide-y divide-border">
{status.passkeys.map((pk) => {
const lastFactor = status.require_mfa && !status.totp_enabled && status.passkeys.length <= 1;
return (
<li key={pk.id} className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-text-primary">{pk.name}</p>
<p className="text-xs text-text-secondary">
Added {formatDate(pk.created_at)} · Last used {formatDate(pk.last_used_at)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setRenamingPasskey(pk);
setRenameValue(pk.name);
}}
>
Rename
</Button>
<Button
variant="danger"
size="sm"
disabled={lastFactor}
title={lastFactor ? "This instance requires at least one factor." : undefined}
onClick={() => setRemovingPasskey(pk)}
>
Remove
</Button>
</div>
</li>
);
})}
</ul>
)}
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>Recovery codes</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">
{status.recovery_remaining} unused code{status.recovery_remaining === 1 ? "" : "s"} remaining. Regenerating invalidates every existing code.
</p>
<Button variant="secondary" size="sm" loading={regenerateRecovery.isPending} onClick={() => regenerateRecovery.mutate()}>
Regenerate
</Button>
{regenerateRecovery.error && <p className="mt-3 text-sm text-danger">{friendlyMessage(regenerateRecovery.error)}</p>}
</Card>
</>
) : null}
</AsyncBoundary>
<Modal open={wizardOpen} title="Set up a second factor" onClose={() => setWizardOpen(false)}>
<MfaEnrolWizard
mode="session"
endpoints={SESSION_ENDPOINTS}
onCancel={() => setWizardOpen(false)}
onComplete={() => {
invalidate();
setWizardOpen(false);
}}
/>
</Modal>
<Modal open={newRecoveryCodes !== null} title="New recovery codes" onClose={() => setNewRecoveryCodes(null)}>
{newRecoveryCodes && (
<RecoveryCodes
codes={newRecoveryCodes}
onAcknowledge={() => {
invalidate();
setNewRecoveryCodes(null);
}}
/>
)}
</Modal>
<Modal open={renamingPasskey !== null} title="Rename passkey" onClose={() => setRenamingPasskey(null)}>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
renamePasskey.mutate();
}}
>
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
{renamePasskey.error && <p className="text-sm text-danger">{friendlyMessage(renamePasskey.error)}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setRenamingPasskey(null)}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={renamePasskey.isPending} disabled={!renameValue.trim()}>
Save
</Button>
</div>
</form>
</Modal>
<ConfirmDialog
open={removingTotp}
title="Remove authenticator app"
confirmLabel="Remove"
loading={removeTotp.isPending}
error={removeTotp.error ? friendlyMessage(removeTotp.error) : null}
onClose={() => setRemovingTotp(false)}
onConfirm={() => removeTotp.mutate()}
body={<p>You will no longer be asked for a code from your authenticator app when signing in.</p>}
/>
<ConfirmDialog
open={removingPasskey !== null}
title="Remove passkey"
confirmLabel="Remove"
loading={deletePasskey.isPending}
error={deletePasskey.error ? friendlyMessage(deletePasskey.error) : null}
onClose={() => setRemovingPasskey(null)}
onConfirm={() => removingPasskey && deletePasskey.mutate(removingPasskey.id)}
body={
<p>
<span className="text-text-primary">{removingPasskey?.name}</span> will no longer be accepted for sign-in.
</p>
}
/>
</div>
);
}
+2
View File
@@ -1,5 +1,6 @@
import { AuthProvider } from "@/components/AuthProvider";
import { AppShell } from "@/components/AppShell";
import { StepUpModal } from "@/components/mfa/StepUpModal";
export default function AppLayout({
children,
@@ -9,6 +10,7 @@ export default function AppLayout({
return (
<AuthProvider>
<AppShell>{children}</AppShell>
<StepUpModal />
</AuthProvider>
);
}
+45 -1
View File
@@ -51,6 +51,18 @@ function DocumentIcon() {
);
}
function ShieldIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
}
function KeyIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -127,7 +139,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA
export default function SettingsPage() {
const queryClient = useQueryClient();
const { instance, isAdmin } = useAuth();
const { instance, isAdmin, user } = useAuth();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
@@ -145,6 +157,7 @@ export default function SettingsPage() {
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [offlineChannelIds, setOfflineChannelIds] = useState<string[]>([]);
const [apiTokenMaxDays, setApiTokenMaxDays] = useState(0);
const [requireMfa, setRequireMfa] = useState(false);
const toast = useToast();
useEffect(() => {
@@ -153,6 +166,7 @@ export default function SettingsPage() {
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
setOfflineChannelIds(settings.alerts.offline_channel_ids ?? []);
setApiTokenMaxDays(settings.api_token_max_days ?? 0);
setRequireMfa(settings.require_mfa ?? false);
}, [settings]);
// The one place the in-progress form is turned into a payload. Both the
@@ -235,6 +249,36 @@ export default function SettingsPage() {
save({ ...currentPayload(), local_login_enabled: v });
}}
/>
{user?.role === "owner" && (
<SectionCard
title="Require MFA"
description="Require a second factor for every password sign-in on this instance."
icon={<ShieldIcon />}
>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={requireMfa}
onChange={(e) => {
if (!settings) return;
const v = e.target.checked;
setRequireMfa(v);
// Same in-progress form state the main Save button
// submits, not the stale loaded `settings` object -
// otherwise an unsaved edit elsewhere on this page is
// silently reverted the moment this toggle is flipped.
save({ ...currentPayload(), require_mfa: v });
}}
className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
/>
Require MFA for password sign-in
</label>
<p className="mt-1.5 text-xs text-text-tertiary">
Members without a second factor already set up must enrol one at their next sign-in. Members who sign in through
a provider or a passkey are unaffected.
</p>
</SectionCard>
)}
</Group>
<Group label="Monitoring">
+182 -3
View File
@@ -2,11 +2,13 @@
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { auth, type PublicProvider } from "@/lib/api";
import { auth, ApiError, type PublicProvider } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Logo } from "@/components/Logo";
import { NetworkBackground } from "@/components/NetworkBackground";
import { ProviderIcon } from "@/components/settings/ProviderIcon";
import { MfaEnrolWizard } from "@/components/mfa/MfaEnrolWizard";
import { isPasskeySupported, toRequestOptions, credentialToJSON } from "@/lib/webauthn";
const ERROR_MESSAGES: Record<string, string> = {
oidc_unavailable: "Single sign-on is not available on this instance's plan.",
@@ -23,8 +25,27 @@ const ERROR_MESSAGES: Record<string, string> = {
state_failed: "Could not start sign-in. Please try again.",
unknown_host: "This address does not name a known instance.",
instance_locked: "Access to this instance is suspended.",
invalid_code: "That code was not correct.",
mfa_ticket_expired: "That sign-in attempt expired. Please sign in again.",
};
/** Turns a thrown error into the page's inline message, handling the rate limit specially. */
function describeError(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 429) {
return err.retryAfter ? `Too many attempts. Try again in ${err.retryAfter}s.` : "Too many attempts. Please wait a moment and try again.";
}
if (err.code === "invalid_code" && err.attemptsLeft !== undefined) {
return `That code was not correct. ${err.attemptsLeft} attempt${err.attemptsLeft === 1 ? "" : "s"} left.`;
}
if (err.code && ERROR_MESSAGES[err.code]) return ERROR_MESSAGES[err.code];
return err.message || "Sign-in failed. Please try again.";
}
return "Sign-in failed. Please try again.";
}
type Step = "credentials" | "factor" | "enrol";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -38,6 +59,15 @@ export default function LoginPage() {
// says so instead of drawing a form that cannot sign anyone in.
const [locked, setLocked] = useState(false);
const [step, setStep] = useState<Step>("credentials");
const [factorMethods, setFactorMethods] = useState<string[]>([]);
const [useRecovery, setUseRecovery] = useState(false);
const [factorCode, setFactorCode] = useState("");
const [factorError, setFactorError] = useState("");
const [factorBusy, setFactorBusy] = useState(false);
const [credentialsNotice, setCredentialsNotice] = useState("");
const [passkeyBusy, setPasskeyBusy] = useState(false);
useEffect(() => {
const code = new URLSearchParams(window.location.search).get("error");
if (code === "instance_locked") setLocked(true);
@@ -80,16 +110,98 @@ export default function LoginPage() {
error,
} = useMutation({
mutationFn: () => auth.login(email, password),
onSuccess: () => {
onSuccess: (res) => {
if ("mfa_required" in res && res.mfa_required) {
setFactorMethods(res.methods);
setUseRecovery(false);
setFactorCode("");
setFactorError("");
setStep("factor");
return;
}
if ("enrol_required" in res && res.enrol_required) {
setStep("enrol");
return;
}
window.location.href = "/";
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setCredentialsNotice("");
signIn();
}
function returnToCredentials(message: string) {
setStep("credentials");
setCredentialsNotice(message);
}
async function handleFactorSubmit(e: React.FormEvent) {
e.preventDefault();
setFactorError("");
setFactorBusy(true);
try {
if (useRecovery) {
await auth.mfaRecovery(factorCode);
} else {
await auth.mfaTotp(factorCode);
}
window.location.href = "/";
} catch (err) {
if (err instanceof ApiError && err.code === "mfa_ticket_expired") {
returnToCredentials("That sign-in attempt expired. Please sign in again.");
return;
}
setFactorError(describeError(err));
} finally {
setFactorBusy(false);
}
}
async function handleFactorPasskey() {
setFactorError("");
setFactorBusy(true);
try {
const { publicKey, ceremony_id } = await auth.mfaWebAuthnBegin();
const cred = (await navigator.credentials.get({
publicKey: toRequestOptions(publicKey),
})) as PublicKeyCredential;
await auth.mfaWebAuthnFinish(ceremony_id, credentialToJSON(cred));
window.location.href = "/";
} catch (err) {
if (err instanceof ApiError && err.code === "mfa_ticket_expired") {
returnToCredentials("That sign-in attempt expired. Please sign in again.");
return;
}
setFactorError(describeError(err));
} finally {
setFactorBusy(false);
}
}
async function handlePasswordlessPasskey() {
setCredentialsNotice("");
setPasskeyBusy(true);
try {
const { publicKey, ceremony_id } = await auth.passkeyLoginBegin();
const cred = (await navigator.credentials.get({
publicKey: toRequestOptions(publicKey),
})) as PublicKeyCredential;
await auth.passkeyLoginFinish(ceremony_id, credentialToJSON(cred));
window.location.href = "/";
} catch (err) {
setCredentialsNotice(describeError(err));
} finally {
setPasskeyBusy(false);
}
}
function handleEnrolComplete() {
window.location.href = "/";
}
const showLocal = localEnabled || providers.length === 0;
const showDivider = showLocal && providers.length > 0;
@@ -111,9 +223,70 @@ export default function LoginPage() {
Nobody can sign in, and its servers are not being managed, until this is resolved. If you manage this account, check your email from Vantage for details.
</p>
</Card>
) : step === "enrol" ? (
<Card>
<MfaEnrolWizard mode="ticket" onComplete={handleEnrolComplete} />
</Card>
) : step === "factor" ? (
<Card>
<form onSubmit={handleFactorSubmit} className="space-y-4">
<p className="text-sm text-text-secondary">{useRecovery ? "Enter one of your recovery codes." : "Enter the 6-digit code from your authenticator app."}</p>
<div>
<label htmlFor="factor-code" className="mb-1.5 block text-sm font-medium text-text-secondary">
{useRecovery ? "Recovery code" : "Verification code"}
</label>
<input
id="factor-code"
type="text"
inputMode={useRecovery ? "text" : "numeric"}
autoComplete="one-time-code"
required
autoFocus
value={factorCode}
onChange={(e) => setFactorCode(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
{factorError && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{factorError}</div>}
<Button type="submit" variant="primary" loading={factorBusy} className="w-full justify-center">
Verify
</Button>
{factorMethods.includes("webauthn") && (
<Button type="button" variant="secondary" loading={factorBusy} className="w-full justify-center" onClick={handleFactorPasskey}>
Use passkey
</Button>
)}
{factorMethods.includes("recovery") && (
<button
type="button"
className="w-full text-center text-sm text-text-secondary underline-offset-2 hover:underline"
onClick={() => {
setUseRecovery((v) => !v);
setFactorCode("");
setFactorError("");
}}
>
{useRecovery ? "Use a verification code instead" : "Use a recovery code instead"}
</button>
)}
<button
type="button"
className="w-full text-center text-sm text-text-tertiary underline-offset-2 hover:underline"
onClick={() => returnToCredentials("")}
>
Back
</button>
</form>
</Card>
) : (
<Card>
{ssoError && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{ssoError}</div>}
{credentialsNotice && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{credentialsNotice}</div>}
{showLocal && (
<form onSubmit={handleSubmit} className="space-y-4">
@@ -147,11 +320,17 @@ export default function LoginPage() {
/>
</div>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{describeError(error)}</div>}
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
Sign In
</Button>
{isPasskeySupported() && localEnabled && (
<Button type="button" variant="secondary" loading={passkeyBusy} className="w-full justify-center" onClick={handlePasswordlessPasskey}>
Sign in with passkey
</Button>
)}
</form>
)}
+1
View File
@@ -204,6 +204,7 @@ const navGroups: NavGroup[] = [
// keys, capped at their own role, so gating the page would hide a
// capability they have.
{ href: "/tokens", label: "API Keys", icon: <TokenIcon /> },
{ href: "/account/security", label: "Security", icon: <ShieldIcon /> },
],
},
{
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { auth } from "@/lib/api";
import { isPasskeySupported, toCreateOptions, credentialToJSON } from "@/lib/webauthn";
import { Button } from "@/components/ui";
import { RecoveryCodes } from "./RecoveryCodes";
/**
* The calls a wizard step makes to set up a second factor. Defaults to the
* ticket-scoped `/auth/mfa/enrol/*` endpoints (an in-progress, unauthenticated
* sign-in identified by the server's ticket cookie) for `mode="ticket"`.
*
* `mode="session"` is for an already-authenticated user managing their own
* factors (the account security page) and has no safe default: that page's
* endpoints do not exist yet, so its caller must pass `endpoints` explicitly.
*/
export interface MfaEnrolEndpoints {
totpSetup: () => Promise<{ secret: string; otpauth_url: string }>;
totpConfirm: (code: string) => Promise<{ ok: true; recovery_codes?: string[] }>;
passkeyBegin: () => Promise<{ publicKey: any; ceremony_id: string }>;
passkeyFinish: (ceremonyId: string, credential: unknown, name?: string) => Promise<{ ok: true; recovery_codes?: string[] }>;
}
const TICKET_ENDPOINTS: MfaEnrolEndpoints = {
totpSetup: () => auth.enrolTotpSetup(),
totpConfirm: (code) => auth.enrolTotpConfirm(code),
passkeyBegin: () => auth.enrolPasskeyBegin(),
passkeyFinish: (ceremonyId, credential, name) => auth.enrolPasskeyFinish(ceremonyId, credential, name),
};
interface MfaEnrolWizardProps {
mode: "session" | "ticket";
/** Required for mode="session"; defaults to the ticket-scoped endpoints for mode="ticket". */
endpoints?: MfaEnrolEndpoints;
onComplete: (recoveryCodes: string[]) => void;
/** Omit to make the wizard mandatory, as in forced enrolment during sign-in. */
onCancel?: () => void;
}
type Step = "choose" | "totp" | "passkey" | "recovery";
export function MfaEnrolWizard({ mode, endpoints, onComplete, onCancel }: MfaEnrolWizardProps) {
const api = endpoints ?? (mode === "ticket" ? TICKET_ENDPOINTS : undefined);
const [step, setStep] = useState<Step>("choose");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
// TOTP setup state
const [secret, setSecret] = useState("");
const [qrDataUrl, setQrDataUrl] = useState("");
const [totpCode, setTotpCode] = useState("");
// Recovery codes state
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
if (!api) {
return <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">This wizard was not given its endpoints for session mode.</div>;
}
async function startTotp() {
setError("");
setBusy(true);
try {
const { secret, otpauth_url } = await api!.totpSetup();
setSecret(secret);
setQrDataUrl(await QRCode.toDataURL(otpauth_url));
setStep("totp");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function confirmTotp(e: React.FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
const res = await api!.totpConfirm(totpCode);
setRecoveryCodes(res.recovery_codes ?? []);
setStep("recovery");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function startPasskey() {
setError("");
setBusy(true);
try {
const { publicKey, ceremony_id } = await api!.passkeyBegin();
const cred = (await navigator.credentials.create({
publicKey: toCreateOptions(publicKey),
})) as PublicKeyCredential;
const res = await api!.passkeyFinish(ceremony_id, credentialToJSON(cred));
setRecoveryCodes(res.recovery_codes ?? []);
setStep("recovery");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
if (step === "choose") {
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">This instance requires a second sign-in factor. Set one up to continue.</p>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div className="space-y-2">
<Button type="button" variant="primary" className="w-full justify-center" loading={busy} onClick={startTotp}>
Use an authenticator app
</Button>
{isPasskeySupported() && (
<Button type="button" variant="secondary" className="w-full justify-center" loading={busy} onClick={startPasskey}>
Use a passkey
</Button>
)}
</div>
{onCancel && (
<Button type="button" variant="ghost" className="w-full justify-center" onClick={onCancel}>
Cancel
</Button>
)}
</div>
);
}
if (step === "totp") {
return (
<form onSubmit={confirmTotp} className="space-y-4">
<p className="text-sm text-text-secondary">Scan this code with your authenticator app, or enter the key manually.</p>
{qrDataUrl && <img src={qrDataUrl} alt="Authenticator QR code" className="mx-auto h-40 w-40" />}
<p className="break-all rounded-lg border border-border bg-surface-2 px-3 py-2 text-center font-mono text-xs text-text-secondary">{secret}</p>
<div>
<label htmlFor="totp-code" className="mb-1.5 block text-sm font-medium text-text-secondary">
6-digit code
</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<Button type="submit" variant="primary" loading={busy} className="w-full justify-center">
Confirm
</Button>
<Button type="button" variant="ghost" className="w-full justify-center" onClick={() => setStep("choose")}>
Back
</Button>
</form>
);
}
// step === "recovery"
return <RecoveryCodes codes={recoveryCodes} onAcknowledge={() => onComplete(recoveryCodes)} />;
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui";
interface RecoveryCodesProps {
codes: string[];
onAcknowledge: () => void;
}
/**
* Shows a fresh batch of recovery codes exactly once, either at the end of
* enrolment (via MfaEnrolWizard) or after a regenerate on the security page.
* The server never returns a previously issued batch, so this is the only
* place in the app these codes are ever visible.
*/
export function RecoveryCodes({ codes, onAcknowledge }: RecoveryCodesProps) {
const [savedConfirmed, setSavedConfirmed] = useState(false);
const [copied, setCopied] = useState(false);
async function copyAll() {
await navigator.clipboard.writeText(codes.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function downloadAll() {
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "vantage-recovery-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">
Save these recovery codes somewhere safe. Each can be used once if you lose access to your other factor. They are shown here exactly once.
</p>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs text-text-primary">
{codes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={copyAll}>
{copied ? "Copied" : "Copy all"}
</Button>
<Button type="button" variant="secondary" size="sm" onClick={downloadAll}>
Download as .txt
</Button>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={savedConfirmed} onChange={(e) => setSavedConfirmed(e.target.checked)} className="h-4 w-4 rounded border-border" />
I have saved these codes
</label>
<Button type="button" variant="primary" className="w-full justify-center" disabled={!savedConfirmed} onClick={onAcknowledge}>
Continue
</Button>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { me } from "@/lib/api";
import { registerStepUpPrompt } from "@/lib/stepup";
import { isPasskeySupported, toRequestOptions, credentialToJSON } from "@/lib/webauthn";
import { Button, Modal, friendlyMessage } from "@/components/ui";
const SUPPORTED = ["totp", "recovery", "webauthn", "password"] as const;
type SupportedMethod = (typeof SUPPORTED)[number];
const LABELS: Record<SupportedMethod, string> = {
totp: "Authenticator app",
recovery: "Recovery code",
webauthn: "Passkey",
password: "Password",
};
const INPUT_LABELS: Record<SupportedMethod, string> = {
totp: "6-digit code",
recovery: "Recovery code",
webauthn: "",
password: "Password",
};
/**
* Mounted once in the (app) layout. Registers itself as the step-up prompt on
* mount, so `request()` in lib/api.ts can reach it without importing React.
*/
export function StepUpModal() {
const [open, setOpen] = useState(false);
const [methods, setMethods] = useState<SupportedMethod[]>([]);
const [method, setMethod] = useState<SupportedMethod | null>(null);
const [value, setValue] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const resolveRef = useRef<(() => void) | null>(null);
const rejectRef = useRef<((e: Error) => void) | null>(null);
useEffect(() => {
registerStepUpPrompt((requested) => {
// "webauthn" only counts as offered when this browser can actually
// complete it - otherwise it would sit there as a dead option.
const supported = requested.filter(
(m): m is SupportedMethod =>
(SUPPORTED as readonly string[]).includes(m) && (m !== "webauthn" || isPasskeySupported()),
);
return new Promise<void>((resolve, reject) => {
setMethods(supported);
setMethod(supported[0] ?? null);
setValue("");
setError("");
resolveRef.current = resolve;
rejectRef.current = reject;
setOpen(true);
});
});
// Unregistering on unmount matches "no prompt registered fails rather
// than hangs": outside the app shell there is nothing to reach.
return () => registerStepUpPrompt(null);
}, []);
function cancel(reason: string) {
setOpen(false);
rejectRef.current?.(new Error(reason));
resolveRef.current = null;
rejectRef.current = null;
}
function succeed() {
setOpen(false);
resolveRef.current?.();
resolveRef.current = null;
rejectRef.current = null;
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!method || method === "webauthn") return;
setBusy(true);
setError("");
try {
const factor = method === "totp" ? { totp: value } : method === "recovery" ? { recovery: value } : { password: value };
await me.stepUp(factor);
succeed();
} catch (err) {
setError(friendlyMessage(err));
} finally {
setBusy(false);
}
}
async function usePasskey() {
setBusy(true);
setError("");
try {
const { publicKey, ceremony_id } = await me.stepUpWebAuthnBegin();
const cred = (await navigator.credentials.get({ publicKey: toRequestOptions(publicKey) })) as PublicKeyCredential;
await me.stepUpWebAuthnFinish(ceremony_id, credentialToJSON(cred));
succeed();
} catch (err) {
setError(friendlyMessage(err));
} finally {
setBusy(false);
}
}
return (
<Modal open={open} title="Confirm it's you" onClose={() => cancel("re-authentication was cancelled")}>
<div className="space-y-4">
<p className="text-sm text-text-secondary">This action reveals a credential, so please confirm it is you.</p>
{methods.length === 0 ? (
<>
<p className="text-sm text-danger">No supported re-authentication method is available for this account.</p>
<div className="flex justify-end">
<Button type="button" variant="secondary" onClick={() => cancel("no re-authentication method available")}>
Close
</Button>
</div>
</>
) : (
<>
{methods.length > 1 && (
<div className="flex flex-wrap gap-2">
{methods.map((m) => (
<button
key={m}
type="button"
onClick={() => {
setMethod(m);
setValue("");
setError("");
}}
className={`rounded px-3 py-1.5 text-sm transition-colors ${
method === m ? "bg-accent text-white" : "bg-surface-2 text-text-secondary hover:text-text-primary"
}`}
>
{LABELS[m]}
</button>
))}
</div>
)}
{method === "webauthn" ? (
<div className="space-y-4">
<p className="text-sm text-text-secondary">Use your device's passkey to confirm it is you.</p>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => cancel("re-authentication was cancelled")}>
Cancel
</Button>
<Button type="button" variant="primary" loading={busy} onClick={usePasskey}>
Use passkey
</Button>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<div>
<label htmlFor="step-up-value" className="mb-1.5 block text-sm font-medium text-text-secondary">
{method ? INPUT_LABELS[method] : ""}
</label>
<input
id="step-up-value"
type={method === "password" ? "password" : "text"}
inputMode={method === "totp" ? "numeric" : undefined}
autoComplete={method === "password" ? "current-password" : "one-time-code"}
autoFocus
required
maxLength={method === "totp" ? 6 : undefined}
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => cancel("re-authentication was cancelled")}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={busy}>
Confirm
</Button>
</div>
</form>
)}
</>
)}
</div>
</Modal>
);
}
+65 -10
View File
@@ -38,6 +38,7 @@ export function MembersCard() {
const toast = useToast();
const [addOpen, setAddOpen] = useState(false);
const [removing, setRemoving] = useState<Member | null>(null);
const [resettingMfa, setResettingMfa] = useState<Member | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("member");
@@ -84,6 +85,20 @@ export function MembersCard() {
},
});
const {
mutate: resetMfa,
isPending: isResettingMfa,
error: resetMfaError,
reset: resetResetMfa,
} = useMutation({
mutationFn: (member: Member) => api.resetMemberMFA(member.id),
onSuccess: (_data, member) => {
invalidate();
toast.success(`Cleared MFA for ${member.email}.`);
setResettingMfa(null);
},
});
// Removal failures are shown inside the confirm dialog that raised them, so
// only the inline role change lands here - otherwise the same sentence
// appears twice on screen.
@@ -121,6 +136,7 @@ export function MembersCard() {
<Th>Email</Th>
<Th>Role</Th>
<Th>Sign-in</Th>
<Th>MFA</Th>
<Th>Last login</Th>
<Th className="text-right">Actions</Th>
</Tr>
@@ -158,6 +174,13 @@ export function MembersCard() {
<Td label="Sign-in">
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}</Badge>
</Td>
<Td label="MFA">
{managedByHQ ? (
<span className="text-xs text-text-tertiary">-</span>
) : (
<Badge variant={u.mfa_enabled ? "success" : "neutral"}>{u.mfa_enabled ? "Enabled" : "Not set up"}</Badge>
)}
</Td>
<Td label="Last login" className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
<Td label="Actions" className="text-right">
{managedByHQ ? (
@@ -169,16 +192,29 @@ export function MembersCard() {
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
Remove<span className="sr-only"> {u.email}</span>
</Button>
)
<div className="flex justify-end gap-2">
{/* Only an owner may reset an owner's MFA - the same rule the
server enforces, so admins never see a button that would 403. */}
{u.mfa_enabled && (isOwner || u.role !== "owner") && (
<Button
variant="ghost"
size="sm"
onClick={() => setResettingMfa({ id: u.user_id, email: u.email })}
>
Reset MFA<span className="sr-only"> for {u.email}</span>
</Button>
)}
{!locked && (
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
Remove<span className="sr-only"> {u.email}</span>
</Button>
)}
</div>
)}
</Td>
</Tr>
@@ -212,6 +248,25 @@ export function MembersCard() {
}
/>
<ConfirmDialog
open={resettingMfa !== null}
title="Reset MFA"
confirmLabel="Reset MFA"
loading={isResettingMfa}
error={resetMfaError ? friendlyMessage(resetMfaError) : null}
onClose={() => {
resetResetMfa();
setResettingMfa(null);
}}
onConfirm={() => resettingMfa && resetMfa(resettingMfa)}
body={
<p>
<span className="text-text-primary">{resettingMfa?.email}</span> loses every enrolled factor and recovery code. If this
instance requires MFA for password sign-in, they must set up a new factor the next time they sign in.
</p>
}
/>
<Modal open={addOpen} title="Add member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
+236
View File
@@ -0,0 +1,236 @@
import { test, expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test";
import { authenticator } from "otplib";
/**
* End-to-end coverage for Task 15 of the MFA design
* (docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md).
*
* Prerequisites: a running control plane (server + web) backed by MongoDB
* and Redis, reachable at `E2E_BASE_URL` (default http://localhost:3000)
* with the API at the same origin (the nginx fragment in
* deploy/docker/docker-compose.yml, or an equivalent dev proxy).
*
* The suite needs an existing owner session to create fresh member accounts
* per test, so state never leaks between tests. If the instance has never
* been bootstrapped, the first test bootstraps it and every other test reuses
* those owner credentials; set E2E_OWNER_EMAIL / E2E_OWNER_PASSWORD to point
* at an already-bootstrapped instance's owner instead.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000";
let ownerEmail = process.env.E2E_OWNER_EMAIL ?? "";
let ownerPassword = process.env.E2E_OWNER_PASSWORD ?? "";
/** Ensures an owner account exists and returns its credentials, bootstrapping the instance if needed. */
async function ensureOwner(request: APIRequestContext): Promise<{ email: string; password: string }> {
if (ownerEmail && ownerPassword) return { email: ownerEmail, password: ownerPassword };
const status = await request.get(`${BASE_URL}/auth/bootstrap-status`);
const body = await status.json();
if (!body.needs_setup) {
throw new Error(
"Instance is already bootstrapped and E2E_OWNER_EMAIL/E2E_OWNER_PASSWORD were not set - " +
"cannot create the owner account this suite needs to provision fresh members per test.",
);
}
ownerEmail = `e2e-owner-${Date.now()}@vantage.test`;
ownerPassword = "correct horse battery staple 1";
const res = await request.post(`${BASE_URL}/auth/bootstrap`, {
data: { instance_name: "MFA E2E", email: ownerEmail, password: ownerPassword },
});
if (!res.ok()) throw new Error(`bootstrap failed: ${res.status()} ${await res.text()}`);
return { email: ownerEmail, password: ownerPassword };
}
/** Creates a fresh, unique local member with no MFA enrolled, using an owner session. */
async function createMember(request: APIRequestContext): Promise<{ email: string; password: string }> {
const owner = await ensureOwner(request);
const login = await request.post(`${BASE_URL}/auth/login`, {
data: { email: owner.email, password: owner.password },
});
if (!login.ok()) throw new Error(`owner login failed: ${login.status()} ${await login.text()}`);
const email = `e2e-member-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@vantage.test`;
const password = "correct horse battery staple 1";
const res = await request.post(`${BASE_URL}/api/org/users`, {
data: { email, password, role: "member" },
});
if (!res.ok()) throw new Error(`create member failed: ${res.status()} ${await res.text()}`);
await request.post(`${BASE_URL}/auth/logout`);
return { email, password };
}
/** Registers Chrome's virtual authenticator (CTAP2, resident keys, internal UV) on this context. */
async function addVirtualAuthenticator(context: BrowserContext, page: Page): Promise<string> {
const client = await context.newCDPSession(page);
await client.send("WebAuthn.enable");
const { authenticatorId } = await client.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
return authenticatorId;
}
async function fillCredentials(page: Page, email: string, password: string) {
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign In" }).click();
}
test.describe("MFA end-to-end", () => {
test("TOTP sign-in", async ({ page, request }) => {
const { email, password } = await createMember(request);
// Sign in, land on the account security page, enrol TOTP.
await fillCredentials(page, email, password);
await expect(page).toHaveURL("/");
await page.goto("/account/security");
await page.getByRole("button", { name: "Set up" }).click();
await page.getByRole("button", { name: "Use an authenticator app" }).click();
const secret = await page.locator("p.font-mono").innerText();
const code = authenticator.generate(secret.trim());
await page.getByLabel("6-digit code").fill(code);
await page.getByRole("button", { name: "Confirm" }).click();
// Recovery codes step - acknowledge and finish.
await page.getByLabel("I have saved these codes").check();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Enabled")).toBeVisible();
// Sign out and sign back in - TOTP is now required.
await page.request.post(`${BASE_URL}/auth/logout`);
await fillCredentials(page, email, password);
await expect(page.getByText("Enter the 6-digit code")).toBeVisible();
const nextCode = authenticator.generate(secret.trim());
await page.getByLabel("Verification code").fill(nextCode);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL("/");
});
test("passkey second factor", async ({ page, context, request }) => {
const { email, password } = await createMember(request);
await addVirtualAuthenticator(context, page);
await fillCredentials(page, email, password);
await expect(page).toHaveURL("/");
await page.goto("/account/security");
await page.getByRole("button", { name: "Set up" }).click();
await page.getByRole("button", { name: "Use a passkey" }).click();
await page.getByLabel("I have saved these codes").check();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("No passkeys registered.")).toHaveCount(0);
await expect(page.getByText(/Added .* · Last used/)).toBeVisible();
await page.request.post(`${BASE_URL}/auth/logout`);
await fillCredentials(page, email, password);
await expect(page.getByText("Enter the 6-digit code")).toBeVisible();
await page.getByRole("button", { name: "Use passkey" }).click();
await expect(page).toHaveURL("/");
});
test("passwordless passkey sign-in", async ({ page, context, request }) => {
const { email, password } = await createMember(request);
await addVirtualAuthenticator(context, page);
// Enrol a passkey first (typing the password once, during setup only).
await fillCredentials(page, email, password);
await expect(page).toHaveURL("/");
await page.goto("/account/security");
await page.getByRole("button", { name: "Add a passkey" }).click();
await page.request.post(`${BASE_URL}/auth/logout`);
// Sign back in with no password typed at all.
await page.goto("/login");
await page.getByRole("button", { name: "Sign in with passkey" }).click();
await expect(page).toHaveURL("/");
});
test("forced enrolment when require_mfa is on", async ({ page, request }) => {
const owner = await ensureOwner(request);
const { email, password } = await createMember(request);
// Owner enables the policy.
const ownerLogin = await request.post(`${BASE_URL}/auth/login`, {
data: { email: owner.email, password: owner.password },
});
expect(ownerLogin.ok()).toBeTruthy();
const settingsRes = await request.put(`${BASE_URL}/api/settings`, {
data: { require_mfa: true },
});
expect(settingsRes.ok()).toBeTruthy();
await request.post(`${BASE_URL}/auth/logout`);
// The member, who has no MFA yet, must enrol before reaching the app.
await fillCredentials(page, email, password);
await expect(page.getByText("This instance requires a second sign-in factor. Set one up to continue.")).toBeVisible();
await page.getByRole("button", { name: "Use an authenticator app" }).click();
const secret = await page.locator("p.font-mono").innerText();
const code = authenticator.generate(secret.trim());
await page.getByLabel("6-digit code").fill(code);
await page.getByRole("button", { name: "Confirm" }).click();
await page.getByLabel("I have saved these codes").check();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveURL("/");
// Clean up: turn the policy back off so it doesn't affect other tests.
await request.post(`${BASE_URL}/auth/login`, { data: { email: owner.email, password: owner.password } });
await request.put(`${BASE_URL}/api/settings`, { data: { require_mfa: false } });
await request.post(`${BASE_URL}/auth/logout`);
});
test("fresh sign-in reveals a secret without a step-up prompt", async ({ page, request }) => {
const { email, password } = await createMember(request);
// Enrol TOTP so the account has a factor - not exercised in this test,
// since sign-in itself sets StepUpAt and the window has not gone stale.
await fillCredentials(page, email, password);
await page.goto("/account/security");
await page.getByRole("button", { name: "Set up" }).click();
await page.getByRole("button", { name: "Use an authenticator app" }).click();
const secret = await page.locator("p.font-mono").innerText();
await page.getByLabel("6-digit code").fill(authenticator.generate(secret.trim()));
await page.getByRole("button", { name: "Confirm" }).click();
await page.getByLabel("I have saved these codes").check();
await page.getByRole("button", { name: "Continue" }).click();
// Sign-in counts as a step-up (mintSession sets StepUpAt), and that is
// valid for ten minutes - a browser test cannot wait that long or age a
// session, so this only asserts the fresh-session path: no prompt on the
// first reveal, and none on a second reveal right after. The stale path
// (StepUpAt older than ten minutes) is covered by TestStepUpFresh in
// server/internal/auth/stepup_test.go.
await request.post(`${BASE_URL}/api/secrets`, { data: { group: "e2e-stepup", values: { KEY: "value" } } });
await page.goto("/secrets/e2e-stepup");
await page.getByRole("button", { name: "Reveal" }).click();
await expect(page.getByText("Confirm it's you")).toHaveCount(0);
await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible();
await page.getByRole("button", { name: "Hide" }).click().catch(() => {});
await page.getByRole("button", { name: "Reveal" }).click();
await expect(page.getByText("Confirm it's you")).toHaveCount(0);
await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible();
});
});
+185 -7
View File
@@ -1,3 +1,5 @@
import { requestStepUp } from "./stepup";
export type ServerStatus = "pending" | "active" | "offline";
export type KeySource = "uploaded" | "generated";
@@ -297,6 +299,8 @@ export interface Settings {
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
api_token_max_days?: number | null;
/** Owner-only: members without a factor must enrol at their next sign-in. */
require_mfa?: boolean;
}
export type ApiToken = {
@@ -564,6 +568,7 @@ export interface InstanceUser {
hq_user_id?: string;
created_at: string;
last_login?: string;
mfa_enabled: boolean;
}
export interface OrgUserInput {
@@ -628,17 +633,23 @@ export interface AuthProviderUpdate {
order?: number;
}
class ApiError extends Error {
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public code?: string,
public retryAfter?: number,
public attemptsLeft?: number,
) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
/** RequestInit plus the internal flag that stops a step-up retry from looping. */
type ApiRequestInit = RequestInit & { __retried?: boolean };
async function request<T>(path: string, options?: ApiRequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: "include",
headers: {
@@ -652,11 +663,28 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
const text = await res.text().catch(() => "");
let message = text || res.statusText || `HTTP ${res.status}`;
let code: string | undefined;
let methods: string[] | undefined;
try {
const body = JSON.parse(text);
const body = JSON.parse(text || "{}");
if (body?.error) message = body.error;
if (body?.code) code = body.code;
if (Array.isArray(body?.methods)) methods = body.methods;
} catch {}
throw new ApiError(res.status, message);
// A guarded route answers 403 with the factors that would satisfy it.
// Re-authenticate once through the shared modal, then retry the
// original request exactly once - a loop here would prompt forever
// against a server that keeps refusing. This is outside the parse
// try/catch above: a cancelled step-up rejects, and that rejection
// must propagate, not be swallowed as a JSON parse failure.
if (res.status === 403 && code === "step_up_required" && !options?.__retried) {
await requestStepUp(methods ?? ["password"]);
return request<T>(path, { ...options, __retried: true });
}
const retryAfter = res.status === 429 ? Number(res.headers.get("Retry-After")) : undefined;
throw new ApiError(res.status, message, code, Number.isFinite(retryAfter) ? retryAfter : undefined);
}
if (res.status === 204) {
@@ -675,11 +703,16 @@ async function authRequest<T>(path: string, options?: RequestInit): Promise<T> {
if (!res.ok) {
let message = `HTTP ${res.status}`;
let code: string | undefined;
let attemptsLeft: number | undefined;
try {
const body = await res.json();
if (body?.error) message = body.error;
if (body?.code) code = body.code;
if (typeof body?.attempts_left === "number") attemptsLeft = body.attempts_left;
} catch {}
throw new ApiError(res.status, message);
const retryAfter = res.status === 429 ? Number(res.headers.get("Retry-After")) : undefined;
throw new ApiError(res.status, message, code, Number.isFinite(retryAfter) ? retryAfter : undefined, attemptsLeft);
}
if (res.status === 204) {
@@ -701,8 +734,8 @@ export const auth = {
});
},
login(email: string, password: string): Promise<{ ok: boolean }> {
return authRequest<{ ok: boolean }>("/auth/login", {
login(email: string, password: string): Promise<{ ok?: true } | { mfa_required: true; methods: string[] } | { enrol_required: true }> {
return authRequest("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
@@ -712,6 +745,61 @@ export const auth = {
return authRequest<void>("/auth/logout", { method: "POST" });
},
// --- second factor (an in-progress login, identified by the server-side ticket cookie) ---
mfaTotp(code: string): Promise<{ ok: true }> {
return authRequest("/auth/mfa/totp", { method: "POST", body: JSON.stringify({ code }) });
},
mfaRecovery(code: string): Promise<{ ok: true }> {
return authRequest("/auth/mfa/recovery", { method: "POST", body: JSON.stringify({ code }) });
},
mfaWebAuthnBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/mfa/webauthn/begin", { method: "POST" });
},
mfaWebAuthnFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> {
return authRequest("/auth/mfa/webauthn/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential }),
});
},
// --- passwordless passkey sign-in ---
passkeyLoginBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/passkey/begin", { method: "POST" });
},
passkeyLoginFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> {
return authRequest("/auth/passkey/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential }),
});
},
// --- forced enrolment (a fresh account that has not set up a second factor yet) ---
enrolTotpSetup(): Promise<{ secret: string; otpauth_url: string }> {
return authRequest("/auth/mfa/enrol/totp/setup", { method: "POST" });
},
enrolTotpConfirm(code: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return authRequest("/auth/mfa/enrol/totp/confirm", { method: "POST", body: JSON.stringify({ code }) });
},
enrolPasskeyBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return authRequest("/auth/mfa/enrol/passkey/begin", { method: "POST" });
},
enrolPasskeyFinish(ceremonyId: string, credential: unknown, name?: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return authRequest("/auth/mfa/enrol/passkey/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential, name }),
});
},
me(): Promise<MeResponse> {
return authRequest<MeResponse>("/auth/me");
},
@@ -727,6 +815,90 @@ export const auth = {
},
};
export interface MePasskey {
id: string;
name: string;
created_at: string;
last_used_at?: string;
transports?: string[];
}
export interface MeMfaStatus {
totp_enabled: boolean;
passkeys: MePasskey[];
recovery_remaining: number;
/** The instance's require_mfa policy: a removal leaving no factor is refused. */
require_mfa: boolean;
/** False when an identity provider owns this user's authentication. */
applicable: boolean;
}
/**
* The signed-in user's own factors, at /api/me/*. Several of these are
* guarded by step-up and answer 403 `step_up_required`; that interception is
* handled globally elsewhere, not here.
*/
export const me = {
mfa(): Promise<MeMfaStatus> {
return request<MeMfaStatus>("/me/mfa");
},
totpSetup(): Promise<{ secret: string; otpauth_uri: string }> {
return request("/me/mfa/totp/setup", { method: "POST" });
},
totpConfirm(code: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return request("/me/mfa/totp/confirm", { method: "POST", body: JSON.stringify({ code }) });
},
removeTotp(): Promise<void> {
return request<void>("/me/mfa/totp", { method: "DELETE" });
},
regenerateRecovery(): Promise<{ recovery_codes: string[] }> {
return request("/me/mfa/recovery/regenerate", { method: "POST" });
},
passkeyRegisterBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return request("/me/passkeys/begin", { method: "POST" });
},
passkeyRegisterFinish(ceremonyId: string, credential: unknown, name?: string): Promise<{ ok: true; recovery_codes?: string[] }> {
return request("/me/passkeys/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential, name }),
});
},
renamePasskey(id: string, name: string): Promise<void> {
return request<void>(`/me/passkeys/${id}`, { method: "PATCH", body: JSON.stringify({ name }) });
},
deletePasskey(id: string): Promise<void> {
return request<void>(`/me/passkeys/${id}`, { method: "DELETE" });
},
/**
* Re-authenticates the current session against exactly one factor. Called
* by the StepUpModal, and by `request()` internally never - the modal is
* the only caller, so the retry-once guarantee lives entirely in `request`.
*/
stepUp(factor: { totp: string } | { recovery: string } | { password: string }): Promise<{ ok: true }> {
return request("/me/step-up", { method: "POST", body: JSON.stringify(factor) });
},
stepUpWebAuthnBegin(): Promise<{ publicKey: any; ceremony_id: string }> {
return request("/me/step-up/webauthn/begin", { method: "POST" });
},
stepUpWebAuthnFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> {
return request("/me/step-up/webauthn/finish", {
method: "POST",
body: JSON.stringify({ ceremony_id: ceremonyId, credential }),
});
},
};
export const api = {
listInstanceUsers(): Promise<InstanceUser[]> {
return request<InstanceUser[]>("/instance/users");
@@ -747,6 +919,11 @@ export const api = {
return request<void>(`/instance/users/${userId}`, { method: "DELETE" });
},
/** Owner or admin; an admin cannot reset an owner's MFA (403). Step-up guarded. */
resetMemberMFA(userId: string): Promise<void> {
return request<void>(`/org/users/${userId}/mfa`, { method: "DELETE" });
},
listAuthPresets(): Promise<AuthPreset[]> {
return request<AuthPreset[]>("/auth/presets");
},
@@ -1025,6 +1202,7 @@ export const api = {
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
api_token_max_days?: number | null;
require_mfa?: boolean;
}): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",
+18
View File
@@ -0,0 +1,18 @@
// The step-up broker. `request()` in api.ts lives outside React, so it cannot
// render a dialog itself; instead it calls back into whatever prompt the
// StepUpModal registered when it mounted inside the (app) layout.
type Prompt = (methods: string[]) => Promise<void>;
let prompt: Prompt | null = null;
// The provider registers the real prompt at mount. Before that, or outside the
// app shell, step-up simply fails rather than hanging forever.
export function registerStepUpPrompt(fn: Prompt | null) {
prompt = fn;
}
export async function requestStepUp(methods: string[]): Promise<void> {
if (!prompt) throw new Error("re-authentication is required");
return prompt(methods);
}
+68
View File
@@ -0,0 +1,68 @@
// Browser-side WebAuthn helpers. The server sends and expects base64url;
// the browser's credential APIs need ArrayBuffers.
function b64urlToBuffer(value: string): ArrayBuffer {
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), "="));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToB64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function isPasskeySupported(): boolean {
return typeof window !== "undefined" && !!window.PublicKeyCredential;
}
export function toCreateOptions(options: any): PublicKeyCredentialCreationOptions {
return {
...options,
challenge: b64urlToBuffer(options.challenge),
user: { ...options.user, id: b64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials ?? []).map((c: any) => ({
...c,
id: b64urlToBuffer(c.id),
})),
};
}
export function toRequestOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options,
challenge: b64urlToBuffer(options.challenge),
allowCredentials: (options.allowCredentials ?? []).map((c: any) => ({
...c,
id: b64urlToBuffer(c.id),
})),
};
}
// credentialToJSON produces the shape go-webauthn's parsers read.
export function credentialToJSON(cred: PublicKeyCredential): unknown {
const response = cred.response as AuthenticatorAttestationResponse & AuthenticatorAssertionResponse;
const json: any = {
id: cred.id,
rawId: bufferToB64url(cred.rawId),
type: cred.type,
clientExtensionResults: cred.getClientExtensionResults(),
response: { clientDataJSON: bufferToB64url(response.clientDataJSON) },
};
if (response.attestationObject) {
json.response.attestationObject = bufferToB64url(response.attestationObject);
if (typeof response.getTransports === "function") {
json.response.transports = response.getTransports();
}
}
if (response.authenticatorData) {
json.response.authenticatorData = bufferToB64url(response.authenticatorData);
json.response.signature = bufferToB64url(response.signature);
json.response.userHandle = response.userHandle ? bufferToB64url(response.userHandle) : null;
}
return json;
}
+439 -119
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -6,25 +6,30 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test:e2e": "playwright test"
},
"dependencies": {
"next": "16.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1",
"next": "16.2.9",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"otplib": "^12.0.1",
"@types/node": "^20.14.11",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9"
"typescript": "^5.5.3"
}
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Minimal Playwright setup for the MFA end-to-end coverage (Task 15).
*
* There was no Playwright config in this repository before this task, so
* this is the smallest one that works: one project (Chromium, the only
* browser that implements the WebAuthn virtual authenticator CDP domain the
* passkey tests need), pointed at an already-running stack.
*
* Prerequisites (not started by this config - the stack needs MongoDB and
* Redis, which `webServer` cannot provision):
* docker compose -f ../deploy/docker/docker-compose.yml up -d
* npm run dev # or the built `server`/`web`, whichever is already running
*
* Run with: npx playwright test e2e/mfa.spec.ts
*/
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: "list",
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+3 -1
View File
@@ -36,6 +36,8 @@
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"e2e",
"playwright.config.ts"
]
}