Compare commits
24
Commits
fbcf436ef6
...
19383abaf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19383abaf8 | ||
|
|
dcdfd3ce52 | ||
|
|
142b99e408 | ||
|
|
14e9db606a | ||
|
|
3e341b17ec | ||
|
|
32fd11cde7 | ||
|
|
26825841fa | ||
|
|
d8597ee3ae | ||
|
|
f87626cf17 | ||
|
|
bd0639acfa | ||
|
|
e2ff0dace9 | ||
|
|
d54d8971b2 | ||
|
|
14a1cdb2b0 | ||
|
|
1445af11ab | ||
|
|
3fa469c303 | ||
|
|
2d75832ceb | ||
|
|
dfcfd1d3e2 | ||
|
|
9c60adc836 | ||
|
|
330c326fb5 | ||
|
|
25541345a8 | ||
|
|
b78a9b3832 | ||
|
|
9aee0a61aa | ||
|
|
0aabb664e2 | ||
|
|
e0468aa7b9 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+439
-119
File diff suppressed because it is too large
Load Diff
+12
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -36,6 +36,8 @@
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
"e2e",
|
||||
"playwright.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user