Add FIDO2/WebAuthn passkey authentication
Phase 14: Full WebAuthn support for passwordless passkey login and hardware security key 2FA. - go-webauthn/webauthn v0.16.1 dependency - WebAuthnConfig with RPID/RPOrigin/DisplayName validation - Migration 000009: webauthn_credentials table - DB CRUD with ownership checks and admin operations - internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM - REST: register begin/finish, login begin/finish, list, delete - Web UI: profile enrollment, login passkey button, admin management - gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs - mciasdb: webauthn list/delete/reset subcommands - OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema - Policy: self-service enrollment rule, admin remove via wildcard - Tests: DB CRUD, adapter round-trip, interface compliance - Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14 Security: Credential IDs and public keys encrypted at rest with AES-256-GCM via vault master key. Challenge ceremonies use 128-bit nonces with 120s TTL in sync.Map. Sign counter validated on each assertion to detect cloned authenticators. Password re-auth required for registration (SEC-01 pattern). No credential material in API responses or logs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
110
ARCHITECTURE.md
110
ARCHITECTURE.md
@@ -128,7 +128,8 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
|
||||
**Human accounts** — interactive users. Can authenticate via:
|
||||
- Username + password (Argon2id hash stored in DB)
|
||||
- Optional TOTP (RFC 6238); if enrolled, required on every login
|
||||
- Future: FIDO2/WebAuthn, Yubikey (not in scope for v1)
|
||||
- Optional FIDO2/WebAuthn passkeys and security keys; discoverable credentials
|
||||
enable passwordless login, non-discoverable credentials serve as 2FA
|
||||
|
||||
**System accounts** — non-interactive service identities. Have:
|
||||
- A single active bearer token at a time (rotating the token revokes the old one)
|
||||
@@ -420,6 +421,17 @@ value in an HTMX fragment or flash message.
|
||||
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
|
||||
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) |
|
||||
|
||||
### WebAuthn Endpoints
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/v1/auth/webauthn/register/begin` | bearer JWT | Begin WebAuthn registration (requires password re-auth) |
|
||||
| POST | `/v1/auth/webauthn/register/finish` | bearer JWT | Complete WebAuthn registration |
|
||||
| POST | `/v1/auth/webauthn/login/begin` | none | Begin WebAuthn login (discoverable or username-scoped) |
|
||||
| POST | `/v1/auth/webauthn/login/finish` | none | Complete WebAuthn login, returns JWT |
|
||||
| GET | `/v1/accounts/{id}/webauthn` | admin JWT | List WebAuthn credential metadata |
|
||||
| DELETE | `/v1/accounts/{id}/webauthn/{credentialId}` | admin JWT | Remove WebAuthn credential |
|
||||
|
||||
### Postgres Credential Endpoints
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
@@ -697,14 +709,36 @@ CREATE INDEX idx_sa_delegates_account ON service_account_delegates (account_id);
|
||||
CREATE INDEX idx_sa_delegates_grantee ON service_account_delegates (grantee_id);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- WebAuthn credentials (migration 000009)
|
||||
CREATE TABLE webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credential_id_enc BLOB NOT NULL,
|
||||
credential_id_nonce BLOB NOT NULL,
|
||||
public_key_enc BLOB NOT NULL,
|
||||
public_key_nonce BLOB NOT NULL,
|
||||
aaguid TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
discoverable INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_webauthn_credentials_account ON webauthn_credentials(account_id);
|
||||
```
|
||||
|
||||
### Schema Notes
|
||||
|
||||
- Passwords are stored as PHC-format Argon2id strings (e.g.,
|
||||
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
|
||||
parameters. Future parameter upgrades are transparent.
|
||||
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a
|
||||
master key held only in server memory (derived at startup from a passphrase
|
||||
or keyfile). The nonce is stored adjacent to the ciphertext.
|
||||
- TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
|
||||
encrypted with AES-256-GCM using a master key held only in server memory
|
||||
(derived at startup from a passphrase or keyfile). The nonce is stored
|
||||
adjacent to the ciphertext.
|
||||
- The master key salt is stored in `server_config.master_key_salt` so the
|
||||
Argon2id KDF produces the same key on every restart. Generated on first run.
|
||||
- The signing key encryption is layered: the Ed25519 private key is wrapped
|
||||
@@ -782,7 +816,7 @@ mcias/
|
||||
│ ├── config/ # config file parsing and validation
|
||||
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
||||
│ ├── db/ # SQLite access layer (schema, migrations, queries)
|
||||
│ │ └── migrations/ # numbered SQL migrations (currently 8)
|
||||
│ │ └── migrations/ # numbered SQL migrations (currently 9)
|
||||
│ ├── grpcserver/ # gRPC handler implementations
|
||||
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
|
||||
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
|
||||
@@ -791,7 +825,8 @@ mcias/
|
||||
│ ├── token/ # JWT issuance, validation, revocation
|
||||
│ ├── ui/ # web UI context, CSRF, session, template handlers
|
||||
│ ├── validate/ # input validation helpers (username, password strength)
|
||||
│ └── vault/ # master key lifecycle: seal/unseal state, key derivation
|
||||
│ ├── vault/ # master key lifecycle: seal/unseal state, key derivation
|
||||
│ └── webauthn/ # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface)
|
||||
├── web/
|
||||
│ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
|
||||
│ ├── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||
@@ -1830,3 +1865,66 @@ the "Token Issue Access" section.
|
||||
| `token_delegate_granted` | Admin granted a human account token-issue access for a system account |
|
||||
| `token_delegate_revoked` | Admin revoked token-issue delegation |
|
||||
| `token_issued` | Token issued (existing event, also fires for delegate-issued tokens) |
|
||||
|
||||
## 22. FIDO2/WebAuthn Authentication
|
||||
|
||||
### Overview
|
||||
|
||||
WebAuthn support enables two credential modes:
|
||||
- **Discoverable credentials (passkeys)** — passwordless login. The authenticator
|
||||
stores a resident credential; the user clicks "Sign in with passkey" and the
|
||||
browser prompts for the credential directly.
|
||||
- **Non-discoverable credentials (security keys)** — 2FA alongside
|
||||
username+password. The server supplies allowCredentials for the account.
|
||||
|
||||
Either WebAuthn or TOTP satisfies the 2FA requirement. If both are enrolled the
|
||||
UI offers passkey first.
|
||||
|
||||
### Credential Storage
|
||||
|
||||
Credential IDs and public keys are encrypted at rest with AES-256-GCM using
|
||||
the vault master key, consistent with TOTP secrets and PG credentials. The
|
||||
nonce is stored alongside the ciphertext in the `webauthn_credentials` table.
|
||||
|
||||
Metadata (name, AAGUID, sign count, discoverable flag, transports, timestamps)
|
||||
is stored in plaintext for display and management.
|
||||
|
||||
### Challenge (Ceremony) Management
|
||||
|
||||
Registration and login ceremonies use an in-memory `sync.Map` with 120-second
|
||||
TTL, consistent with the `pendingLogins` and `tokenDownloads` patterns. Each
|
||||
ceremony is keyed by a 128-bit random nonce. Ceremonies are single-use:
|
||||
consumed on finish, expired entries cleaned by a background goroutine.
|
||||
|
||||
Separate ceremony stores exist for REST API (`internal/server`) and web UI
|
||||
(`internal/ui`) to maintain independent lifecycle management.
|
||||
|
||||
### Sign Counter Validation
|
||||
|
||||
On each assertion the stored sign counter is compared to the authenticator's
|
||||
reported value. If the reported counter is less than or equal to the stored
|
||||
counter (and both are non-zero), the assertion is rejected as a potential
|
||||
cloned authenticator. This mirrors the TOTP replay protection pattern.
|
||||
|
||||
### Audit Events
|
||||
|
||||
| Event | Description |
|
||||
|---|---|
|
||||
| `webauthn_enrolled` | New WebAuthn credential registered |
|
||||
| `webauthn_removed` | WebAuthn credential removed (self-service or admin) |
|
||||
| `webauthn_login_ok` | Successful WebAuthn authentication |
|
||||
| `webauthn_login_fail` | Failed WebAuthn authentication attempt |
|
||||
|
||||
### Configuration
|
||||
|
||||
WebAuthn is enabled by adding a `[webauthn]` section to the TOML config:
|
||||
|
||||
```toml
|
||||
[webauthn]
|
||||
rp_id = "mcias.metacircular.net"
|
||||
rp_origin = "https://mcias.metacircular.net:8443"
|
||||
display_name = "MCIAS"
|
||||
```
|
||||
|
||||
If the section is omitted, WebAuthn endpoints return 404 and the UI hides
|
||||
passkey-related controls.
|
||||
|
||||
24
PROGRESS.md
24
PROGRESS.md
@@ -2,7 +2,29 @@
|
||||
|
||||
Source of truth for current development state.
|
||||
---
|
||||
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only).
|
||||
Phases 0–14 complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||
|
||||
### 2026-03-16 — Phase 14: FIDO2/WebAuthn and Passkey Authentication
|
||||
|
||||
**Task:** Add FIDO2/WebAuthn support for passwordless passkey login and security key 2FA.
|
||||
|
||||
**Changes:**
|
||||
- **Dependency:** `github.com/go-webauthn/webauthn v0.16.1`
|
||||
- **Config:** `WebAuthnConfig` struct with RPID, RPOrigin, DisplayName; validation; `WebAuthnEnabled()` method
|
||||
- **Model:** `WebAuthnCredential` struct with encrypted credential fields; 4 audit events; 2 policy actions
|
||||
- **Migration 000009:** `webauthn_credentials` table with encrypted credential ID/pubkey, sign counter, discoverable flag
|
||||
- **DB layer:** Full CRUD in `internal/db/webauthn.go` (create, get, delete with ownership, admin delete, delete all, sign count, last used, has, count)
|
||||
- **Adapter:** `internal/webauthn/` package — library initialization, `AccountUser` interface, AES-256-GCM encrypt/decrypt round-trip
|
||||
- **Policy:** Default rule -8 for self-service enrollment
|
||||
- **REST API:** 6 endpoints (register begin/finish, login begin/finish, list credentials, delete credential) with `sync.Map` ceremony store
|
||||
- **Web UI:** Profile page enrollment+management, login page passkey button, admin account detail passkeys section, CSP-compliant `webauthn.js`
|
||||
- **gRPC:** `ListWebAuthnCredentials` and `RemoveWebAuthnCredential` RPCs with handler
|
||||
- **mciasdb:** `webauthn list/delete/reset` subcommands and `account reset-webauthn` alias
|
||||
- **OpenAPI:** All 6 endpoints documented; `WebAuthnCredentialInfo` schema; `webauthn_enabled`/`webauthn_count` on Account
|
||||
- **Tests:** DB CRUD tests, adapter encrypt/decrypt round-trip, interface compliance, wrong-key rejection
|
||||
- **Docs:** ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14, PROGRESS.md
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-16 — Documentation sync (ARCHITECTURE.md, PROJECT_PLAN.md)
|
||||
|
||||
|
||||
@@ -744,6 +744,79 @@ See ARCHITECTURE.md §21 (Token Issuance Delegation) for design details.
|
||||
|
||||
---
|
||||
|
||||
## Phase 14 — FIDO2/WebAuthn and Passkey Authentication
|
||||
|
||||
**Goal:** Add FIDO2/WebAuthn support for passwordless passkey login and hardware
|
||||
security key 2FA. Discoverable credentials enable passwordless login;
|
||||
non-discoverable credentials serve as 2FA. Either WebAuthn or TOTP satisfies
|
||||
the 2FA requirement.
|
||||
|
||||
### Step 14.1: Dependency, config, and model types
|
||||
**Acceptance criteria:**
|
||||
- `github.com/go-webauthn/webauthn` dependency added
|
||||
- `WebAuthnConfig` struct in config with RPID, RPOrigin, DisplayName
|
||||
- Validation: if any field set, RPID+RPOrigin required; RPOrigin must be HTTPS
|
||||
- `WebAuthnCredential` model type with encrypted-at-rest fields
|
||||
- Audit events: `webauthn_enrolled`, `webauthn_removed`, `webauthn_login_ok`, `webauthn_login_fail`
|
||||
- Policy actions: `ActionEnrollWebAuthn`, `ActionRemoveWebAuthn`
|
||||
|
||||
### Step 14.2: Database migration and CRUD
|
||||
**Acceptance criteria:**
|
||||
- Migration 000009: `webauthn_credentials` table with encrypted credential fields
|
||||
- Full CRUD: Create, Get (by ID, by account), Delete (ownership-checked and admin),
|
||||
DeleteAll, UpdateSignCount, UpdateLastUsed, Has, Count
|
||||
- DB tests for all operations including ownership checks and cascade behavior
|
||||
|
||||
### Step 14.3: WebAuthn adapter package
|
||||
**Acceptance criteria:**
|
||||
- `internal/webauthn/` package with adapter, user, and converter
|
||||
- `NewWebAuthn(cfg)` factory wrapping library initialization
|
||||
- `AccountUser` implementing `webauthn.User` interface
|
||||
- `EncryptCredential`/`DecryptCredential`/`DecryptCredentials` round-trip encryption
|
||||
- Tests for encrypt/decrypt, interface compliance, wrong-key rejection
|
||||
|
||||
### Step 14.4: REST endpoints
|
||||
**Acceptance criteria:**
|
||||
- `POST /v1/auth/webauthn/register/begin` — password re-auth, returns creation options
|
||||
- `POST /v1/auth/webauthn/register/finish` — completes registration, encrypts credential
|
||||
- `POST /v1/auth/webauthn/login/begin` — discoverable and username-scoped flows
|
||||
- `POST /v1/auth/webauthn/login/finish` — validates assertion, issues JWT
|
||||
- `GET /v1/accounts/{id}/webauthn` — admin, returns metadata only
|
||||
- `DELETE /v1/accounts/{id}/webauthn/{credentialId}` — admin remove
|
||||
- Challenge store: `sync.Map` with 120s TTL, background cleanup
|
||||
|
||||
### Step 14.5: Web UI
|
||||
**Acceptance criteria:**
|
||||
- Profile page: passkey enrollment form, credential list with delete
|
||||
- Login page: "Sign in with passkey" button with discoverable flow
|
||||
- Account detail page: passkey section with admin remove
|
||||
- CSP-compliant `webauthn.js` (external script, base64url helpers)
|
||||
- Empty state handling for zero credentials
|
||||
|
||||
### Step 14.6: gRPC handlers
|
||||
**Acceptance criteria:**
|
||||
- Proto messages and RPCs: `ListWebAuthnCredentials`, `RemoveWebAuthnCredential`
|
||||
- gRPC handler implementation delegating to shared packages
|
||||
- Regenerated protobuf stubs
|
||||
|
||||
### Step 14.7: mciasdb offline management
|
||||
**Acceptance criteria:**
|
||||
- `mciasdb webauthn list --id UUID`
|
||||
- `mciasdb webauthn delete --id UUID --credential-id N`
|
||||
- `mciasdb webauthn reset --id UUID` (deletes all)
|
||||
- `mciasdb account reset-webauthn --id UUID` alias
|
||||
- All operations write audit events
|
||||
|
||||
### Step 14.8: OpenAPI and documentation
|
||||
**Acceptance criteria:**
|
||||
- All 6 REST endpoints documented in openapi.yaml
|
||||
- `WebAuthnCredentialInfo` schema, `webauthn_enabled`/`webauthn_count` on Account
|
||||
- ARCHITECTURE.md §22 with design details
|
||||
- PROJECT_PLAN.md Phase 14
|
||||
- PROGRESS.md updated
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
@@ -760,7 +833,9 @@ Phase 0 → Phase 1 (1.1, 1.2, 1.3, 1.4 in parallel or sequence)
|
||||
→ Phase 11 (interleaved with Phase 3–4)
|
||||
→ Phase 12 (post Phase 3)
|
||||
→ Phase 13 (post Phase 3 and 11)
|
||||
→ Phase 14 (post v1.0.0)
|
||||
```
|
||||
|
||||
Each step must have passing tests before the next step begins.
|
||||
All phases complete as of v1.0.0 (2026-03-15).
|
||||
Phases 0–13 complete as of v1.0.0 (2026-03-15).
|
||||
Phase 14 complete as of 2026-03-16.
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
func (t *tool) runAccount(args []string) {
|
||||
if len(args) == 0 {
|
||||
fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp")
|
||||
fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp, reset-webauthn")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
@@ -28,6 +28,8 @@ func (t *tool) runAccount(args []string) {
|
||||
t.accountSetStatus(args[1:])
|
||||
case "reset-totp":
|
||||
t.accountResetTOTP(args[1:])
|
||||
case "reset-webauthn":
|
||||
t.webauthnReset(args[1:])
|
||||
default:
|
||||
fatalf("unknown account subcommand %q", args[0])
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ func main() {
|
||||
tool.runAudit(subArgs)
|
||||
case "pgcreds":
|
||||
tool.runPGCreds(subArgs)
|
||||
case "webauthn":
|
||||
tool.runWebAuthn(subArgs)
|
||||
case "rekey":
|
||||
tool.runRekey(subArgs)
|
||||
default:
|
||||
@@ -245,6 +247,11 @@ Commands:
|
||||
account set-password --id UUID (prompts interactively)
|
||||
account set-status --id UUID --status active|inactive|deleted
|
||||
account reset-totp --id UUID
|
||||
account reset-webauthn --id UUID
|
||||
|
||||
webauthn list --id UUID
|
||||
webauthn delete --id UUID --credential-id N
|
||||
webauthn reset --id UUID
|
||||
|
||||
role list --id UUID
|
||||
role grant --id UUID --role ROLE
|
||||
|
||||
121
cmd/mciasdb/webauthn.go
Normal file
121
cmd/mciasdb/webauthn.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (t *tool) runWebAuthn(args []string) {
|
||||
if len(args) == 0 {
|
||||
fatalf("webauthn requires a subcommand: list, delete, reset")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
t.webauthnList(args[1:])
|
||||
case "delete":
|
||||
t.webauthnDelete(args[1:])
|
||||
case "reset":
|
||||
t.webauthnReset(args[1:])
|
||||
default:
|
||||
fatalf("unknown webauthn subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tool) webauthnList(args []string) {
|
||||
fs := flag.NewFlagSet("webauthn list", flag.ExitOnError)
|
||||
id := fs.String("id", "", "account UUID (required)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *id == "" {
|
||||
fatalf("webauthn list: --id is required")
|
||||
}
|
||||
|
||||
a, err := t.db.GetAccountByUUID(*id)
|
||||
if err != nil {
|
||||
fatalf("get account: %v", err)
|
||||
}
|
||||
|
||||
creds, err := t.db.GetWebAuthnCredentials(a.ID)
|
||||
if err != nil {
|
||||
fatalf("list webauthn credentials: %v", err)
|
||||
}
|
||||
|
||||
if len(creds) == 0 {
|
||||
fmt.Printf("No WebAuthn credentials for account %s\n", a.Username)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("WebAuthn credentials for %s:\n\n", a.Username)
|
||||
fmt.Printf("%-6s %-20s %-12s %-8s %-20s %-20s\n",
|
||||
"ID", "NAME", "DISCOVERABLE", "COUNT", "CREATED", "LAST USED")
|
||||
fmt.Println(strings.Repeat("-", 96))
|
||||
for _, c := range creds {
|
||||
disc := "no"
|
||||
if c.Discoverable {
|
||||
disc = "yes"
|
||||
}
|
||||
lastUsed := "never"
|
||||
if c.LastUsedAt != nil {
|
||||
lastUsed = c.LastUsedAt.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
fmt.Printf("%-6d %-20s %-12s %-8d %-20s %-20s\n",
|
||||
c.ID, c.Name, disc, c.SignCount,
|
||||
c.CreatedAt.UTC().Format("2006-01-02 15:04:05"), lastUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tool) webauthnDelete(args []string) {
|
||||
fs := flag.NewFlagSet("webauthn delete", flag.ExitOnError)
|
||||
id := fs.String("id", "", "account UUID (required)")
|
||||
credID := fs.Int64("credential-id", 0, "credential DB row ID (required)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *id == "" || *credID == 0 {
|
||||
fatalf("webauthn delete: --id and --credential-id are required")
|
||||
}
|
||||
|
||||
a, err := t.db.GetAccountByUUID(*id)
|
||||
if err != nil {
|
||||
fatalf("get account: %v", err)
|
||||
}
|
||||
|
||||
if err := t.db.DeleteWebAuthnCredential(*credID, a.ID); err != nil {
|
||||
fatalf("delete webauthn credential: %v", err)
|
||||
}
|
||||
|
||||
if err := t.db.WriteAuditEvent("webauthn_removed", nil, &a.ID, "",
|
||||
fmt.Sprintf(`{"actor":"mciasdb","credential_id":%d}`, *credID)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("WebAuthn credential %d deleted from account %s\n", *credID, a.Username)
|
||||
}
|
||||
|
||||
func (t *tool) webauthnReset(args []string) {
|
||||
fs := flag.NewFlagSet("webauthn reset", flag.ExitOnError)
|
||||
id := fs.String("id", "", "account UUID (required)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *id == "" {
|
||||
fatalf("webauthn reset: --id is required")
|
||||
}
|
||||
|
||||
a, err := t.db.GetAccountByUUID(*id)
|
||||
if err != nil {
|
||||
fatalf("get account: %v", err)
|
||||
}
|
||||
|
||||
count, err := t.db.DeleteAllWebAuthnCredentials(a.ID)
|
||||
if err != nil {
|
||||
fatalf("delete all webauthn credentials: %v", err)
|
||||
}
|
||||
|
||||
if err := t.db.WriteAuditEvent("webauthn_removed", nil, &a.ID, "",
|
||||
fmt.Sprintf(`{"actor":"mciasdb","action":"reset_webauthn","count":%d}`, count)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed %d WebAuthn credential(s) from account %s\n", count, a.Username)
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.4
|
||||
// protoc v3.20.3
|
||||
// source: mcias/v1/account.proto
|
||||
|
||||
package mciasv1
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v6.33.4
|
||||
// - protoc v3.20.3
|
||||
// source: mcias/v1/account.proto
|
||||
|
||||
package mciasv1
|
||||
|
||||
@@ -569,6 +569,288 @@ func (*RemoveTOTPResponse) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
// ListWebAuthnCredentialsRequest lists metadata for an account's WebAuthn credentials.
|
||||
type ListWebAuthnCredentialsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsRequest) Reset() {
|
||||
*x = ListWebAuthnCredentialsRequest{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListWebAuthnCredentialsRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ListWebAuthnCredentialsRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListWebAuthnCredentialsRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ListWebAuthnCredentialsRequest) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsRequest) GetAccountId() string {
|
||||
if x != nil {
|
||||
return x.AccountId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WebAuthnCredentialInfo holds metadata about a stored WebAuthn credential.
|
||||
// Credential material (IDs, public keys) is never included.
|
||||
type WebAuthnCredentialInfo struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Aaguid string `protobuf:"bytes,3,opt,name=aaguid,proto3" json:"aaguid,omitempty"`
|
||||
SignCount uint32 `protobuf:"varint,4,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"`
|
||||
Discoverable bool `protobuf:"varint,5,opt,name=discoverable,proto3" json:"discoverable,omitempty"`
|
||||
Transports string `protobuf:"bytes,6,opt,name=transports,proto3" json:"transports,omitempty"`
|
||||
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||
LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) Reset() {
|
||||
*x = WebAuthnCredentialInfo{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*WebAuthnCredentialInfo) ProtoMessage() {}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use WebAuthnCredentialInfo.ProtoReflect.Descriptor instead.
|
||||
func (*WebAuthnCredentialInfo) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetId() int64 {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetName() string {
|
||||
if x != nil {
|
||||
return x.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetAaguid() string {
|
||||
if x != nil {
|
||||
return x.Aaguid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetSignCount() uint32 {
|
||||
if x != nil {
|
||||
return x.SignCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetDiscoverable() bool {
|
||||
if x != nil {
|
||||
return x.Discoverable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetTransports() string {
|
||||
if x != nil {
|
||||
return x.Transports
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetCreatedAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.CreatedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetLastUsedAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.LastUsedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWebAuthnCredentialsResponse returns credential metadata.
|
||||
type ListWebAuthnCredentialsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Credentials []*WebAuthnCredentialInfo `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsResponse) Reset() {
|
||||
*x = ListWebAuthnCredentialsResponse{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListWebAuthnCredentialsResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ListWebAuthnCredentialsResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListWebAuthnCredentialsResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ListWebAuthnCredentialsResponse) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsResponse) GetCredentials() []*WebAuthnCredentialInfo {
|
||||
if x != nil {
|
||||
return x.Credentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveWebAuthnCredentialRequest removes a specific WebAuthn credential (admin).
|
||||
type RemoveWebAuthnCredentialRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID
|
||||
CredentialId int64 `protobuf:"varint,2,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) Reset() {
|
||||
*x = RemoveWebAuthnCredentialRequest{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RemoveWebAuthnCredentialRequest) ProtoMessage() {}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use RemoveWebAuthnCredentialRequest.ProtoReflect.Descriptor instead.
|
||||
func (*RemoveWebAuthnCredentialRequest) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) GetAccountId() string {
|
||||
if x != nil {
|
||||
return x.AccountId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) GetCredentialId() int64 {
|
||||
if x != nil {
|
||||
return x.CredentialId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RemoveWebAuthnCredentialResponse confirms removal.
|
||||
type RemoveWebAuthnCredentialResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialResponse) Reset() {
|
||||
*x = RemoveWebAuthnCredentialResponse{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[16]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RemoveWebAuthnCredentialResponse) ProtoMessage() {}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[16]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use RemoveWebAuthnCredentialResponse.ProtoReflect.Descriptor instead.
|
||||
func (*RemoveWebAuthnCredentialResponse) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{16}
|
||||
}
|
||||
|
||||
var File_mcias_v1_auth_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_mcias_v1_auth_proto_rawDesc = "" +
|
||||
@@ -601,7 +883,31 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
|
||||
"\x11RemoveTOTPRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\"\x14\n" +
|
||||
"\x12RemoveTOTPResponse2\xab\x03\n" +
|
||||
"\x12RemoveTOTPResponse\"?\n" +
|
||||
"\x1eListWebAuthnCredentialsRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\"\xb0\x02\n" +
|
||||
"\x16WebAuthnCredentialInfo\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
||||
"\x06aaguid\x18\x03 \x01(\tR\x06aaguid\x12\x1d\n" +
|
||||
"\n" +
|
||||
"sign_count\x18\x04 \x01(\rR\tsignCount\x12\"\n" +
|
||||
"\fdiscoverable\x18\x05 \x01(\bR\fdiscoverable\x12\x1e\n" +
|
||||
"\n" +
|
||||
"transports\x18\x06 \x01(\tR\n" +
|
||||
"transports\x129\n" +
|
||||
"\n" +
|
||||
"created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
|
||||
"\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +
|
||||
"lastUsedAt\"e\n" +
|
||||
"\x1fListWebAuthnCredentialsResponse\x12B\n" +
|
||||
"\vcredentials\x18\x01 \x03(\v2 .mcias.v1.WebAuthnCredentialInfoR\vcredentials\"e\n" +
|
||||
"\x1fRemoveWebAuthnCredentialRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\x12#\n" +
|
||||
"\rcredential_id\x18\x02 \x01(\x03R\fcredentialId\"\"\n" +
|
||||
" RemoveWebAuthnCredentialResponse2\x8e\x05\n" +
|
||||
"\vAuthService\x128\n" +
|
||||
"\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\n" +
|
||||
"\x06Logout\x12\x17.mcias.v1.LogoutRequest\x1a\x18.mcias.v1.LogoutResponse\x12G\n" +
|
||||
@@ -611,7 +917,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
|
||||
"EnrollTOTP\x12\x1b.mcias.v1.EnrollTOTPRequest\x1a\x1c.mcias.v1.EnrollTOTPResponse\x12J\n" +
|
||||
"\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" +
|
||||
"\n" +
|
||||
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" +
|
||||
"\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" +
|
||||
"\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_mcias_v1_auth_proto_rawDescOnce sync.Once
|
||||
@@ -625,42 +933,54 @@ func file_mcias_v1_auth_proto_rawDescGZIP() []byte {
|
||||
return file_mcias_v1_auth_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
|
||||
var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
|
||||
var file_mcias_v1_auth_proto_goTypes = []any{
|
||||
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
|
||||
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
|
||||
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
|
||||
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
|
||||
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
|
||||
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
|
||||
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
|
||||
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
|
||||
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
|
||||
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
|
||||
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
|
||||
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
|
||||
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
|
||||
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
|
||||
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
|
||||
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
|
||||
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
|
||||
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
|
||||
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
|
||||
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
|
||||
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
|
||||
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
|
||||
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
|
||||
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
|
||||
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
|
||||
(*ListWebAuthnCredentialsRequest)(nil), // 12: mcias.v1.ListWebAuthnCredentialsRequest
|
||||
(*WebAuthnCredentialInfo)(nil), // 13: mcias.v1.WebAuthnCredentialInfo
|
||||
(*ListWebAuthnCredentialsResponse)(nil), // 14: mcias.v1.ListWebAuthnCredentialsResponse
|
||||
(*RemoveWebAuthnCredentialRequest)(nil), // 15: mcias.v1.RemoveWebAuthnCredentialRequest
|
||||
(*RemoveWebAuthnCredentialResponse)(nil), // 16: mcias.v1.RemoveWebAuthnCredentialResponse
|
||||
(*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp
|
||||
}
|
||||
var file_mcias_v1_auth_proto_depIdxs = []int32{
|
||||
12, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||
12, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||
0, // 2: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
|
||||
2, // 3: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
|
||||
4, // 4: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
|
||||
6, // 5: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
|
||||
8, // 6: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
|
||||
10, // 7: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
|
||||
1, // 8: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
|
||||
3, // 9: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
|
||||
5, // 10: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
|
||||
7, // 11: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
|
||||
9, // 12: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
|
||||
11, // 13: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
|
||||
8, // [8:14] is the sub-list for method output_type
|
||||
2, // [2:8] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
2, // [2:2] is the sub-list for extension extendee
|
||||
0, // [0:2] is the sub-list for field type_name
|
||||
17, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||
17, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||
17, // 2: mcias.v1.WebAuthnCredentialInfo.created_at:type_name -> google.protobuf.Timestamp
|
||||
17, // 3: mcias.v1.WebAuthnCredentialInfo.last_used_at:type_name -> google.protobuf.Timestamp
|
||||
13, // 4: mcias.v1.ListWebAuthnCredentialsResponse.credentials:type_name -> mcias.v1.WebAuthnCredentialInfo
|
||||
0, // 5: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
|
||||
2, // 6: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
|
||||
4, // 7: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
|
||||
6, // 8: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
|
||||
8, // 9: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
|
||||
10, // 10: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
|
||||
12, // 11: mcias.v1.AuthService.ListWebAuthnCredentials:input_type -> mcias.v1.ListWebAuthnCredentialsRequest
|
||||
15, // 12: mcias.v1.AuthService.RemoveWebAuthnCredential:input_type -> mcias.v1.RemoveWebAuthnCredentialRequest
|
||||
1, // 13: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
|
||||
3, // 14: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
|
||||
5, // 15: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
|
||||
7, // 16: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
|
||||
9, // 17: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
|
||||
11, // 18: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
|
||||
14, // 19: mcias.v1.AuthService.ListWebAuthnCredentials:output_type -> mcias.v1.ListWebAuthnCredentialsResponse
|
||||
16, // 20: mcias.v1.AuthService.RemoveWebAuthnCredential:output_type -> mcias.v1.RemoveWebAuthnCredentialResponse
|
||||
13, // [13:21] is the sub-list for method output_type
|
||||
5, // [5:13] is the sub-list for method input_type
|
||||
5, // [5:5] is the sub-list for extension type_name
|
||||
5, // [5:5] is the sub-list for extension extendee
|
||||
0, // [0:5] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_mcias_v1_auth_proto_init() }
|
||||
@@ -674,7 +994,7 @@ func file_mcias_v1_auth_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 12,
|
||||
NumMessages: 17,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -21,12 +21,14 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
|
||||
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
|
||||
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
|
||||
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
|
||||
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
|
||||
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
|
||||
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
|
||||
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
|
||||
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
|
||||
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
|
||||
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
|
||||
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
|
||||
AuthService_ListWebAuthnCredentials_FullMethodName = "/mcias.v1.AuthService/ListWebAuthnCredentials"
|
||||
AuthService_RemoveWebAuthnCredential_FullMethodName = "/mcias.v1.AuthService/RemoveWebAuthnCredential"
|
||||
)
|
||||
|
||||
// AuthServiceClient is the client API for AuthService service.
|
||||
@@ -53,6 +55,12 @@ type AuthServiceClient interface {
|
||||
// RemoveTOTP removes TOTP from an account (admin only).
|
||||
// Requires: admin JWT in metadata.
|
||||
RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error)
|
||||
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
// Requires: admin JWT in metadata.
|
||||
ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error)
|
||||
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
|
||||
// Requires: admin JWT in metadata.
|
||||
RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error)
|
||||
}
|
||||
|
||||
type authServiceClient struct {
|
||||
@@ -123,6 +131,26 @@ func (c *authServiceClient) RemoveTOTP(ctx context.Context, in *RemoveTOTPReques
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authServiceClient) ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListWebAuthnCredentialsResponse)
|
||||
err := c.cc.Invoke(ctx, AuthService_ListWebAuthnCredentials_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authServiceClient) RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RemoveWebAuthnCredentialResponse)
|
||||
err := c.cc.Invoke(ctx, AuthService_RemoveWebAuthnCredential_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AuthServiceServer is the server API for AuthService service.
|
||||
// All implementations must embed UnimplementedAuthServiceServer
|
||||
// for forward compatibility.
|
||||
@@ -147,6 +175,12 @@ type AuthServiceServer interface {
|
||||
// RemoveTOTP removes TOTP from an account (admin only).
|
||||
// Requires: admin JWT in metadata.
|
||||
RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error)
|
||||
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
// Requires: admin JWT in metadata.
|
||||
ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error)
|
||||
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
|
||||
// Requires: admin JWT in metadata.
|
||||
RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error)
|
||||
mustEmbedUnimplementedAuthServiceServer()
|
||||
}
|
||||
|
||||
@@ -175,6 +209,12 @@ func (UnimplementedAuthServiceServer) ConfirmTOTP(context.Context, *ConfirmTOTPR
|
||||
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented")
|
||||
}
|
||||
func (UnimplementedAuthServiceServer) ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListWebAuthnCredentials not implemented")
|
||||
}
|
||||
func (UnimplementedAuthServiceServer) RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RemoveWebAuthnCredential not implemented")
|
||||
}
|
||||
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
|
||||
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -304,6 +344,42 @@ func _AuthService_RemoveTOTP_Handler(srv interface{}, ctx context.Context, dec f
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AuthService_ListWebAuthnCredentials_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListWebAuthnCredentialsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AuthService_ListWebAuthnCredentials_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, req.(*ListWebAuthnCredentialsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AuthService_RemoveWebAuthnCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RemoveWebAuthnCredentialRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AuthService_RemoveWebAuthnCredential_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, req.(*RemoveWebAuthnCredentialRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -335,6 +411,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "RemoveTOTP",
|
||||
Handler: _AuthService_RemoveTOTP_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListWebAuthnCredentials",
|
||||
Handler: _AuthService_ListWebAuthnCredentials_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RemoveWebAuthnCredential",
|
||||
Handler: _AuthService_RemoveWebAuthnCredential_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "mcias/v1/auth.proto",
|
||||
|
||||
17
go.mod
17
go.mod
@@ -7,8 +7,8 @@ require (
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/term v0.37.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/term v0.41.0
|
||||
google.golang.org/grpc v1.74.2
|
||||
google.golang.org/protobuf v1.36.7
|
||||
modernc.org/sqlite v1.46.1
|
||||
@@ -16,13 +16,20 @@ require (
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/webauthn v0.16.1 // indirect
|
||||
github.com/go-webauthn/x v0.2.2 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
48
go.sum
48
go.sum
@@ -2,10 +2,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
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.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
|
||||
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
|
||||
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
|
||||
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
@@ -14,6 +22,8 @@ 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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -32,8 +42,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
@@ -46,25 +58,25 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
|
||||
@@ -8,18 +8,29 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// Config is the top-level configuration structure parsed from the TOML file.
|
||||
type Config struct {
|
||||
type Config struct { //nolint:govet // fieldalignment: TOML section order is more readable
|
||||
Server ServerConfig `toml:"server"`
|
||||
MasterKey MasterKeyConfig `toml:"master_key"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Tokens TokensConfig `toml:"tokens"`
|
||||
Argon2 Argon2Config `toml:"argon2"`
|
||||
WebAuthn WebAuthnConfig `toml:"webauthn"`
|
||||
}
|
||||
|
||||
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
|
||||
// section disables WebAuthn support. If any field is set, RPID and RPOrigin are
|
||||
// required and RPOrigin must use the HTTPS scheme.
|
||||
type WebAuthnConfig struct {
|
||||
RPID string `toml:"rp_id"`
|
||||
RPOrigin string `toml:"rp_origin"`
|
||||
DisplayName string `toml:"display_name"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP listener and TLS settings.
|
||||
@@ -222,6 +233,19 @@ func (c *Config) validate() error {
|
||||
errs = append(errs, errors.New("master_key: only one of passphrase_env or keyfile may be set"))
|
||||
}
|
||||
|
||||
// WebAuthn — if any field is set, RPID and RPOrigin are required.
|
||||
hasWebAuthn := c.WebAuthn.RPID != "" || c.WebAuthn.RPOrigin != "" || c.WebAuthn.DisplayName != ""
|
||||
if hasWebAuthn {
|
||||
if c.WebAuthn.RPID == "" {
|
||||
errs = append(errs, errors.New("webauthn.rp_id is required when webauthn is configured"))
|
||||
}
|
||||
if c.WebAuthn.RPOrigin == "" {
|
||||
errs = append(errs, errors.New("webauthn.rp_origin is required when webauthn is configured"))
|
||||
} else if !strings.HasPrefix(c.WebAuthn.RPOrigin, "https://") {
|
||||
errs = append(errs, fmt.Errorf("webauthn.rp_origin must use the https:// scheme (got %q)", c.WebAuthn.RPOrigin))
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -233,3 +257,8 @@ func (c *Config) AdminExpiry() time.Duration { return c.Tokens.AdminExpiry.Durat
|
||||
|
||||
// ServiceExpiry returns the configured service token expiry duration.
|
||||
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration }
|
||||
|
||||
// WebAuthnEnabled reports whether WebAuthn/passkey support is configured.
|
||||
func (c *Config) WebAuthnEnabled() bool {
|
||||
return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != ""
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ var migrationsFS embed.FS
|
||||
// LatestSchemaVersion is the highest migration version defined in the
|
||||
// migrations/ directory. Update this constant whenever a new migration file
|
||||
// is added.
|
||||
const LatestSchemaVersion = 7
|
||||
const LatestSchemaVersion = 9
|
||||
|
||||
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
||||
// files. It opens a dedicated *sql.DB using the same DSN as the main
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS webauthn_credentials;
|
||||
18
internal/db/migrations/000009_webauthn_credentials.up.sql
Normal file
18
internal/db/migrations/000009_webauthn_credentials.up.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credential_id_enc BLOB NOT NULL,
|
||||
credential_id_nonce BLOB NOT NULL,
|
||||
public_key_enc BLOB NOT NULL,
|
||||
public_key_nonce BLOB NOT NULL,
|
||||
aaguid TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
discoverable INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webauthn_credentials_account_id ON webauthn_credentials(account_id);
|
||||
208
internal/db/webauthn.go
Normal file
208
internal/db/webauthn.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// CreateWebAuthnCredential inserts a new WebAuthn credential record.
|
||||
// All encrypted fields (credential_id, public_key) must be encrypted by the caller.
|
||||
func (db *DB) CreateWebAuthnCredential(cred *model.WebAuthnCredential) (int64, error) {
|
||||
n := now()
|
||||
result, err := db.sql.Exec(`
|
||||
INSERT INTO webauthn_credentials
|
||||
(account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
cred.AccountID, cred.Name, cred.CredentialIDEnc, cred.CredentialIDNonce,
|
||||
cred.PublicKeyEnc, cred.PublicKeyNonce, cred.AAGUID, cred.SignCount,
|
||||
boolToInt(cred.Discoverable), cred.Transports, n, n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: create webauthn credential: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: webauthn credential last insert id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetWebAuthnCredentials returns all WebAuthn credentials for an account.
|
||||
func (db *DB) GetWebAuthnCredentials(accountID int64) ([]*model.WebAuthnCredential, error) {
|
||||
rows, err := db.sql.Query(`
|
||||
SELECT id, account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at, last_used_at
|
||||
FROM webauthn_credentials WHERE account_id = ? ORDER BY created_at ASC`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list webauthn credentials: %w", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck // rows.Close error is non-fatal
|
||||
return scanWebAuthnCredentials(rows)
|
||||
}
|
||||
|
||||
// GetWebAuthnCredentialByID returns a single WebAuthn credential by its DB row ID.
|
||||
// Returns ErrNotFound if the credential does not exist.
|
||||
func (db *DB) GetWebAuthnCredentialByID(id int64) (*model.WebAuthnCredential, error) {
|
||||
row := db.sql.QueryRow(`
|
||||
SELECT id, account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at, last_used_at
|
||||
FROM webauthn_credentials WHERE id = ?`, id)
|
||||
return scanWebAuthnCredential(row)
|
||||
}
|
||||
|
||||
// DeleteWebAuthnCredential deletes a WebAuthn credential by ID, verifying ownership.
|
||||
// Returns ErrNotFound if the credential does not exist or does not belong to the account.
|
||||
func (db *DB) DeleteWebAuthnCredential(id, accountID int64) error {
|
||||
result, err := db.sql.Exec(
|
||||
`DELETE FROM webauthn_credentials WHERE id = ? AND account_id = ?`, id, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: delete webauthn credential: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: webauthn delete rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebAuthnCredentialAdmin deletes a WebAuthn credential by ID without ownership check.
|
||||
func (db *DB) DeleteWebAuthnCredentialAdmin(id int64) error {
|
||||
result, err := db.sql.Exec(`DELETE FROM webauthn_credentials WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: admin delete webauthn credential: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: webauthn admin delete rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllWebAuthnCredentials removes all WebAuthn credentials for an account.
|
||||
func (db *DB) DeleteAllWebAuthnCredentials(accountID int64) (int64, error) {
|
||||
result, err := db.sql.Exec(
|
||||
`DELETE FROM webauthn_credentials WHERE account_id = ?`, accountID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: delete all webauthn credentials: %w", err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// UpdateWebAuthnSignCount updates the sign counter for a credential.
|
||||
func (db *DB) UpdateWebAuthnSignCount(id int64, signCount uint32) error {
|
||||
_, err := db.sql.Exec(
|
||||
`UPDATE webauthn_credentials SET sign_count = ?, updated_at = ? WHERE id = ?`,
|
||||
signCount, now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update webauthn sign count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateWebAuthnLastUsed sets the last_used_at timestamp for a credential.
|
||||
func (db *DB) UpdateWebAuthnLastUsed(id int64) error {
|
||||
_, err := db.sql.Exec(
|
||||
`UPDATE webauthn_credentials SET last_used_at = ?, updated_at = ? WHERE id = ?`,
|
||||
now(), now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update webauthn last used: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasWebAuthnCredentials reports whether the account has any WebAuthn credentials.
|
||||
func (db *DB) HasWebAuthnCredentials(accountID int64) (bool, error) {
|
||||
var count int
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM webauthn_credentials WHERE account_id = ?`, accountID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: count webauthn credentials: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CountWebAuthnCredentials returns the number of WebAuthn credentials for an account.
|
||||
func (db *DB) CountWebAuthnCredentials(accountID int64) (int, error) {
|
||||
var count int
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM webauthn_credentials WHERE account_id = ?`, accountID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: count webauthn credentials: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// boolToInt converts a bool to 0/1 for SQLite storage.
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func scanWebAuthnCredentials(rows *sql.Rows) ([]*model.WebAuthnCredential, error) {
|
||||
var creds []*model.WebAuthnCredential
|
||||
for rows.Next() {
|
||||
cred, err := scanWebAuthnRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds = append(creds, cred)
|
||||
}
|
||||
return creds, rows.Err()
|
||||
}
|
||||
|
||||
// scannable is implemented by both *sql.Row and *sql.Rows.
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanWebAuthnRow(s scannable) (*model.WebAuthnCredential, error) {
|
||||
var cred model.WebAuthnCredential
|
||||
var createdAt, updatedAt string
|
||||
var lastUsedAt *string
|
||||
var discoverable int
|
||||
err := s.Scan(
|
||||
&cred.ID, &cred.AccountID, &cred.Name,
|
||||
&cred.CredentialIDEnc, &cred.CredentialIDNonce,
|
||||
&cred.PublicKeyEnc, &cred.PublicKeyNonce,
|
||||
&cred.AAGUID, &cred.SignCount,
|
||||
&discoverable, &cred.Transports,
|
||||
&createdAt, &updatedAt, &lastUsedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("db: scan webauthn credential: %w", err)
|
||||
}
|
||||
cred.Discoverable = discoverable != 0
|
||||
cred.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.UpdatedAt, err = parseTime(updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.LastUsedAt, err = nullableTime(lastUsedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func scanWebAuthnCredential(row *sql.Row) (*model.WebAuthnCredential, error) {
|
||||
return scanWebAuthnRow(row)
|
||||
}
|
||||
251
internal/db/webauthn_test.go
Normal file
251
internal/db/webauthn_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
func TestWebAuthnCRUD(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct, err := database.CreateAccount("webauthnuser", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
// Empty state.
|
||||
has, err := database.HasWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("has credentials: %v", err)
|
||||
}
|
||||
if has {
|
||||
t.Error("expected no credentials")
|
||||
}
|
||||
|
||||
count, err := database.CountWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("count credentials: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 credentials, got %d", count)
|
||||
}
|
||||
|
||||
creds, err := database.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get credentials (empty): %v", err)
|
||||
}
|
||||
if len(creds) != 0 {
|
||||
t.Errorf("expected 0 credentials, got %d", len(creds))
|
||||
}
|
||||
|
||||
// Create credential.
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct.ID,
|
||||
Name: "Test Key",
|
||||
CredentialIDEnc: []byte("enc-cred-id"),
|
||||
CredentialIDNonce: []byte("nonce-cred-id"),
|
||||
PublicKeyEnc: []byte("enc-pubkey"),
|
||||
PublicKeyNonce: []byte("nonce-pubkey"),
|
||||
AAGUID: "2fc0579f811347eab116bb5a8db9202a",
|
||||
SignCount: 0,
|
||||
Discoverable: true,
|
||||
Transports: "usb,nfc",
|
||||
}
|
||||
id, err := database.CreateWebAuthnCredential(cred)
|
||||
if err != nil {
|
||||
t.Fatalf("create credential: %v", err)
|
||||
}
|
||||
if id == 0 {
|
||||
t.Error("expected non-zero credential ID")
|
||||
}
|
||||
|
||||
// Now has credentials.
|
||||
has, err = database.HasWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("has credentials after create: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Error("expected credentials to exist")
|
||||
}
|
||||
|
||||
count, err = database.CountWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("count after create: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 credential, got %d", count)
|
||||
}
|
||||
|
||||
// Get by ID.
|
||||
got, err := database.GetWebAuthnCredentialByID(id)
|
||||
if err != nil {
|
||||
t.Fatalf("get by ID: %v", err)
|
||||
}
|
||||
if got.Name != "Test Key" {
|
||||
t.Errorf("Name = %q, want %q", got.Name, "Test Key")
|
||||
}
|
||||
if !got.Discoverable {
|
||||
t.Error("expected discoverable=true")
|
||||
}
|
||||
if got.Transports != "usb,nfc" {
|
||||
t.Errorf("Transports = %q, want %q", got.Transports, "usb,nfc")
|
||||
}
|
||||
if got.AccountID != acct.ID {
|
||||
t.Errorf("AccountID = %d, want %d", got.AccountID, acct.ID)
|
||||
}
|
||||
|
||||
// Get list.
|
||||
creds, err = database.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get credentials: %v", err)
|
||||
}
|
||||
if len(creds) != 1 {
|
||||
t.Fatalf("expected 1 credential, got %d", len(creds))
|
||||
}
|
||||
if creds[0].ID != id {
|
||||
t.Errorf("credential ID = %d, want %d", creds[0].ID, id)
|
||||
}
|
||||
|
||||
// Update sign count.
|
||||
if err := database.UpdateWebAuthnSignCount(id, 5); err != nil {
|
||||
t.Fatalf("update sign count: %v", err)
|
||||
}
|
||||
got, _ = database.GetWebAuthnCredentialByID(id)
|
||||
if got.SignCount != 5 {
|
||||
t.Errorf("SignCount = %d, want 5", got.SignCount)
|
||||
}
|
||||
|
||||
// Update last used.
|
||||
if err := database.UpdateWebAuthnLastUsed(id); err != nil {
|
||||
t.Fatalf("update last used: %v", err)
|
||||
}
|
||||
got, _ = database.GetWebAuthnCredentialByID(id)
|
||||
if got.LastUsedAt == nil {
|
||||
t.Error("expected LastUsedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnDeleteOwnership(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct1, _ := database.CreateAccount("wa1", model.AccountTypeHuman, "hash")
|
||||
acct2, _ := database.CreateAccount("wa2", model.AccountTypeHuman, "hash")
|
||||
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct1.ID,
|
||||
Name: "Key",
|
||||
CredentialIDEnc: []byte("enc"),
|
||||
CredentialIDNonce: []byte("nonce"),
|
||||
PublicKeyEnc: []byte("enc"),
|
||||
PublicKeyNonce: []byte("nonce"),
|
||||
}
|
||||
id, _ := database.CreateWebAuthnCredential(cred)
|
||||
|
||||
// Delete with wrong owner should fail.
|
||||
err := database.DeleteWebAuthnCredential(id, acct2.ID)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound for wrong owner, got %v", err)
|
||||
}
|
||||
|
||||
// Delete with correct owner succeeds.
|
||||
if err := database.DeleteWebAuthnCredential(id, acct1.ID); err != nil {
|
||||
t.Fatalf("delete with correct owner: %v", err)
|
||||
}
|
||||
|
||||
// Verify gone.
|
||||
_, err = database.GetWebAuthnCredentialByID(id)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnDeleteAdmin(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct, _ := database.CreateAccount("waadmin", model.AccountTypeHuman, "hash")
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct.ID,
|
||||
Name: "Key",
|
||||
CredentialIDEnc: []byte("enc"),
|
||||
CredentialIDNonce: []byte("nonce"),
|
||||
PublicKeyEnc: []byte("enc"),
|
||||
PublicKeyNonce: []byte("nonce"),
|
||||
}
|
||||
id, _ := database.CreateWebAuthnCredential(cred)
|
||||
|
||||
// Admin delete (no ownership check).
|
||||
if err := database.DeleteWebAuthnCredentialAdmin(id); err != nil {
|
||||
t.Fatalf("admin delete: %v", err)
|
||||
}
|
||||
|
||||
// Non-existent should return ErrNotFound.
|
||||
if err := database.DeleteWebAuthnCredentialAdmin(id); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound for non-existent, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnDeleteAll(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct, _ := database.CreateAccount("wada", model.AccountTypeHuman, "hash")
|
||||
|
||||
for i := range 3 {
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct.ID,
|
||||
Name: "Key",
|
||||
CredentialIDEnc: []byte{byte(i)},
|
||||
CredentialIDNonce: []byte("n"),
|
||||
PublicKeyEnc: []byte{byte(i)},
|
||||
PublicKeyNonce: []byte("n"),
|
||||
}
|
||||
if _, err := database.CreateWebAuthnCredential(cred); err != nil {
|
||||
t.Fatalf("create %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
deleted, err := database.DeleteAllWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("delete all: %v", err)
|
||||
}
|
||||
if deleted != 3 {
|
||||
t.Errorf("expected 3 deleted, got %d", deleted)
|
||||
}
|
||||
|
||||
count, _ := database.CountWebAuthnCredentials(acct.ID)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 after delete all, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnCascadeDelete(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct, _ := database.CreateAccount("wacascade", model.AccountTypeHuman, "hash")
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct.ID,
|
||||
Name: "Key",
|
||||
CredentialIDEnc: []byte("enc"),
|
||||
CredentialIDNonce: []byte("nonce"),
|
||||
PublicKeyEnc: []byte("enc"),
|
||||
PublicKeyNonce: []byte("nonce"),
|
||||
}
|
||||
id, _ := database.CreateWebAuthnCredential(cred)
|
||||
|
||||
// Delete the account — credentials should cascade.
|
||||
if err := database.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
|
||||
// The credential should still be retrievable (soft delete on account doesn't cascade).
|
||||
// But if we hard-delete via SQL, the FK cascade should clean up.
|
||||
// For now just verify the credential still exists after a status change.
|
||||
got, err := database.GetWebAuthnCredentialByID(id)
|
||||
if err != nil {
|
||||
t.Fatalf("get after account status change: %v", err)
|
||||
}
|
||||
if got.ID != id {
|
||||
t.Errorf("credential ID = %d, want %d", got.ID, id)
|
||||
}
|
||||
}
|
||||
92
internal/grpcserver/webauthn.go
Normal file
92
internal/grpcserver/webauthn.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// WebAuthn gRPC handlers for listing and removing WebAuthn credentials.
|
||||
// These are admin-only operations that mirror the REST handlers in
|
||||
// internal/server/handlers_webauthn.go.
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
// Requires: admin JWT in metadata.
|
||||
//
|
||||
// Security: credential material (IDs, public keys) is never included in the
|
||||
// response — only metadata (name, sign count, timestamps, etc.).
|
||||
func (a *authServiceServer) ListWebAuthnCredentials(ctx context.Context, req *mciasv1.ListWebAuthnCredentialsRequest) (*mciasv1.ListWebAuthnCredentialsResponse, error) {
|
||||
if err := a.s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AccountId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "account_id is required")
|
||||
}
|
||||
|
||||
acct, err := a.s.db.GetAccountByUUID(req.AccountId)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, "account not found")
|
||||
}
|
||||
|
||||
creds, err := a.s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
a.s.logger.Error("list webauthn credentials", "error", err, "account_id", acct.ID)
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
resp := &mciasv1.ListWebAuthnCredentialsResponse{
|
||||
Credentials: make([]*mciasv1.WebAuthnCredentialInfo, 0, len(creds)),
|
||||
}
|
||||
for _, c := range creds {
|
||||
info := &mciasv1.WebAuthnCredentialInfo{
|
||||
Id: c.ID,
|
||||
Name: c.Name,
|
||||
Aaguid: c.AAGUID,
|
||||
SignCount: c.SignCount,
|
||||
Discoverable: c.Discoverable,
|
||||
Transports: c.Transports,
|
||||
CreatedAt: timestamppb.New(c.CreatedAt),
|
||||
}
|
||||
if c.LastUsedAt != nil {
|
||||
info.LastUsedAt = timestamppb.New(*c.LastUsedAt)
|
||||
}
|
||||
resp.Credentials = append(resp.Credentials, info)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
|
||||
// Requires: admin JWT in metadata.
|
||||
func (a *authServiceServer) RemoveWebAuthnCredential(ctx context.Context, req *mciasv1.RemoveWebAuthnCredentialRequest) (*mciasv1.RemoveWebAuthnCredentialResponse, error) {
|
||||
if err := a.s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AccountId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "account_id is required")
|
||||
}
|
||||
if req.CredentialId == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "credential_id is required")
|
||||
}
|
||||
|
||||
acct, err := a.s.db.GetAccountByUUID(req.AccountId)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, "account not found")
|
||||
}
|
||||
|
||||
// DeleteWebAuthnCredentialAdmin bypasses ownership checks (admin operation).
|
||||
if err := a.s.db.DeleteWebAuthnCredentialAdmin(req.CredentialId); err != nil {
|
||||
a.s.logger.Error("delete webauthn credential", "error", err, "credential_id", req.CredentialId)
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
a.s.db.WriteAuditEvent(model.EventWebAuthnRemoved, nil, &acct.ID, peerIP(ctx), //nolint:errcheck
|
||||
fmt.Sprintf(`{"credential_id":%d}`, req.CredentialId))
|
||||
|
||||
return &mciasv1.RemoveWebAuthnCredentialResponse{}, nil
|
||||
}
|
||||
@@ -213,6 +213,11 @@ const (
|
||||
|
||||
EventTokenDelegateGranted = "token_delegate_granted"
|
||||
EventTokenDelegateRevoked = "token_delegate_revoked"
|
||||
|
||||
EventWebAuthnEnrolled = "webauthn_enrolled"
|
||||
EventWebAuthnRemoved = "webauthn_removed"
|
||||
EventWebAuthnLoginOK = "webauthn_login_ok"
|
||||
EventWebAuthnLoginFail = "webauthn_login_fail"
|
||||
)
|
||||
|
||||
// ServiceAccountDelegate records that a specific account has been granted
|
||||
@@ -229,6 +234,26 @@ type ServiceAccountDelegate struct {
|
||||
GranteeID int64 `json:"-"`
|
||||
}
|
||||
|
||||
// WebAuthnCredential holds a stored WebAuthn/passkey credential.
|
||||
// Credential IDs and public keys are encrypted at rest with AES-256-GCM;
|
||||
// decrypted values must never be logged or included in API responses.
|
||||
type WebAuthnCredential struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
Name string `json:"name"`
|
||||
AAGUID string `json:"aaguid"`
|
||||
Transports string `json:"transports,omitempty"`
|
||||
CredentialIDEnc []byte `json:"-"`
|
||||
CredentialIDNonce []byte `json:"-"`
|
||||
PublicKeyEnc []byte `json:"-"`
|
||||
PublicKeyNonce []byte `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"-"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
}
|
||||
|
||||
// PolicyRuleRecord is the database representation of a policy rule.
|
||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||
// The ID, Priority, and Description are stored as dedicated columns.
|
||||
|
||||
@@ -81,6 +81,16 @@ var defaultRules = []Rule{
|
||||
OwnerMatchesSubject: true,
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// Self-service WebAuthn enrollment: any authenticated human account may
|
||||
// register and manage their own passkeys/security keys. The handler
|
||||
// verifies the subject matches before writing. Mirrors TOTP rule -3.
|
||||
ID: -8,
|
||||
Description: "Self-service: any principal may enroll their own WebAuthn credentials",
|
||||
Priority: 0,
|
||||
Actions: []Action{ActionEnrollWebAuthn},
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// Public endpoints: token validation and login do not require
|
||||
// authentication. The middleware exempts them from RequireAuth entirely;
|
||||
|
||||
@@ -48,6 +48,9 @@ const (
|
||||
|
||||
ActionListRules Action = "policy:list"
|
||||
ActionManageRules Action = "policy:manage"
|
||||
|
||||
ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
|
||||
ActionRemoveWebAuthn Action = "webauthn:remove" // admin
|
||||
)
|
||||
|
||||
// ResourceType identifies what kind of object a request targets.
|
||||
@@ -60,6 +63,7 @@ const (
|
||||
ResourceAuditLog ResourceType = "audit_log"
|
||||
ResourceTOTP ResourceType = "totp"
|
||||
ResourcePolicy ResourceType = "policy"
|
||||
ResourceWebAuthn ResourceType = "webauthn"
|
||||
)
|
||||
|
||||
// Effect is the outcome of policy evaluation.
|
||||
|
||||
741
internal/server/handlers_webauthn.go
Normal file
741
internal/server/handlers_webauthn.go
Normal file
@@ -0,0 +1,741 @@
|
||||
// Package server: WebAuthn/passkey REST API handlers.
|
||||
//
|
||||
// Security design:
|
||||
// - Registration requires re-authentication (current password) to prevent a
|
||||
// stolen session token from enrolling attacker-controlled credentials.
|
||||
// - Challenge sessions are stored in a sync.Map with a 120-second TTL and are
|
||||
// single-use (deleted on consumption) to prevent replay attacks.
|
||||
// - All credential material (IDs, public keys) is encrypted at rest with
|
||||
// AES-256-GCM via the vault master key.
|
||||
// - Sign counter validation detects cloned authenticators.
|
||||
// - Login endpoints return generic errors to prevent credential enumeration.
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
|
||||
)
|
||||
|
||||
const (
|
||||
webauthnCeremonyTTL = 120 * time.Second
|
||||
webauthnCleanupPeriod = 5 * time.Minute
|
||||
webauthnCeremonyNonce = 16 // 128 bits of entropy
|
||||
)
|
||||
|
||||
// webauthnCeremony holds a pending registration or login ceremony.
|
||||
type webauthnCeremony struct {
|
||||
expiresAt time.Time
|
||||
session *libwebauthn.SessionData
|
||||
accountID int64 // 0 for discoverable login
|
||||
}
|
||||
|
||||
// pendingWebAuthnCeremonies is the package-level ceremony store.
|
||||
// Stored on the Server struct would require adding fields; using a
|
||||
// package-level map is consistent with the TOTP/token pattern from the UI.
|
||||
var pendingWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
|
||||
|
||||
func init() {
|
||||
go cleanupWebAuthnCeremonies()
|
||||
}
|
||||
|
||||
func cleanupWebAuthnCeremonies() {
|
||||
ticker := time.NewTicker(webauthnCleanupPeriod)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
pendingWebAuthnCeremonies.Range(func(key, value any) bool {
|
||||
c, ok := value.(*webauthnCeremony)
|
||||
if !ok || now.After(c.expiresAt) {
|
||||
pendingWebAuthnCeremonies.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func storeWebAuthnCeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
|
||||
raw, err := crypto.RandomBytes(webauthnCeremonyNonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
|
||||
}
|
||||
nonce := fmt.Sprintf("%x", raw)
|
||||
pendingWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
|
||||
session: session,
|
||||
accountID: accountID,
|
||||
expiresAt: time.Now().Add(webauthnCeremonyTTL),
|
||||
})
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
func consumeWebAuthnCeremony(nonce string) (*webauthnCeremony, bool) {
|
||||
v, ok := pendingWebAuthnCeremonies.LoadAndDelete(nonce)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
c, ok2 := v.(*webauthnCeremony)
|
||||
if !ok2 || time.Now().After(c.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// ---- Registration ----
|
||||
|
||||
type webauthnRegisterBeginRequest struct {
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type webauthnRegisterBeginResponse struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
}
|
||||
|
||||
// handleWebAuthnRegisterBegin starts a WebAuthn credential registration ceremony.
|
||||
//
|
||||
// Security (SEC-01): the current password is required to prevent a stolen
|
||||
// session from enrolling attacker-controlled credentials.
|
||||
func (s *Server) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.WebAuthnEnabled() {
|
||||
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req webauthnRegisterBeginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "password is required", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout before password verification.
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (WebAuthn register)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify current password with constant-time Argon2id.
|
||||
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "password is incorrect", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := s.vault.MasterKey()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
|
||||
// Load existing credentials to exclude them from registration.
|
||||
dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("load webauthn credentials", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
s.logger.Error("decrypt webauthn credentials", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
s.logger.Error("create webauthn instance", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
creation, session, err := wa.BeginRegistration(user,
|
||||
libwebauthn.WithExclusions(libwebauthn.Credentials(libCreds).CredentialDescriptors()),
|
||||
libwebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("begin webauthn registration", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
nonce, err := storeWebAuthnCeremony(session, acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
optionsJSON, err := json.Marshal(creation)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, webauthnRegisterBeginResponse{
|
||||
Options: optionsJSON,
|
||||
Nonce: nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnRegisterFinish completes WebAuthn credential registration.
|
||||
func (s *Server) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.WebAuthnEnabled() {
|
||||
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Read the raw body so we can extract the nonce and also pass
|
||||
// the credential response to the library via a reconstructed request.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
|
||||
bodyBytes, err := readAllBody(r)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract nonce and name from the wrapper.
|
||||
var wrapper struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce)
|
||||
if !ok {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "ceremony expired or invalid", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
if ceremony.accountID != acct.ID {
|
||||
middleware.WriteError(w, http.StatusForbidden, "ceremony mismatch", "forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := s.vault.MasterKey()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
|
||||
dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
|
||||
// Build a fake http.Request from the credential JSON for the library.
|
||||
fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
fakeReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("finish webauthn registration", "error", err)
|
||||
middleware.WriteError(w, http.StatusBadRequest, "registration failed", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if the credential is discoverable based on the flags.
|
||||
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
|
||||
|
||||
name := wrapper.Name
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
|
||||
// Encrypt and store the credential.
|
||||
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
modelCred.AccountID = acct.ID
|
||||
|
||||
credID, err := s.db.CreateWebAuthnCredential(modelCred)
|
||||
if err != nil {
|
||||
s.logger.Error("store webauthn credential", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
|
||||
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": credID,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Login ----
|
||||
|
||||
type webauthnLoginBeginRequest struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
type webauthnLoginBeginResponse struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
}
|
||||
|
||||
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony.
|
||||
// If username is provided, loads that account's credentials (non-discoverable flow).
|
||||
// If empty, starts a discoverable login.
|
||||
func (s *Server) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.WebAuthnEnabled() {
|
||||
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
var req webauthnLoginBeginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
s.logger.Error("create webauthn instance", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
assertion *protocol.CredentialAssertion
|
||||
session *libwebauthn.SessionData
|
||||
accountID int64
|
||||
)
|
||||
|
||||
if req.Username != "" {
|
||||
// Non-discoverable flow: load account credentials.
|
||||
acct, lookupErr := s.db.GetAccountByUsername(req.Username)
|
||||
if lookupErr != nil || acct.Status != model.AccountStatusActive {
|
||||
// Security: return a valid-looking response even for unknown users
|
||||
// to prevent username enumeration. Use discoverable login as a dummy.
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Check lockout.
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (WebAuthn login)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
// Return discoverable login as dummy to avoid enumeration.
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
masterKey, mkErr := s.vault.MasterKey()
|
||||
if mkErr != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
dbCreds, dbErr := s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if dbErr != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
if len(dbCreds) == 0 {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "no WebAuthn credentials registered", "no_credentials")
|
||||
return
|
||||
}
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if decErr != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
assertion, session, err = wa.BeginLogin(user)
|
||||
if err != nil {
|
||||
s.logger.Error("begin webauthn login", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
accountID = acct.ID
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Discoverable login (passkey).
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
s.logger.Error("begin discoverable webauthn login", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
nonce, err := storeWebAuthnCeremony(session, accountID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
optionsJSON, err := json.Marshal(assertion)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, webauthnLoginBeginResponse{
|
||||
Options: optionsJSON,
|
||||
Nonce: nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnLoginFinish completes a WebAuthn login ceremony and issues a JWT.
|
||||
func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.WebAuthnEnabled() {
|
||||
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
|
||||
bodyBytes, err := readAllBody(r)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce)
|
||||
if !ok {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := s.vault.MasterKey()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
|
||||
fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
fakeReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var (
|
||||
acct *model.Account
|
||||
cred *libwebauthn.Credential
|
||||
dbCreds []*model.WebAuthnCredential
|
||||
)
|
||||
|
||||
if ceremony.accountID != 0 {
|
||||
// Non-discoverable: we know the account.
|
||||
acct, err = s.db.GetAccountByID(ceremony.accountID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
dbCreds, err = s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if decErr != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Discoverable login: the library resolves the user from the credential.
|
||||
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
|
||||
// userHandle is the WebAuthnID we set (account UUID as bytes).
|
||||
acctUUID := string(userHandle)
|
||||
foundAcct, lookupErr := s.db.GetAccountByUUID(acctUUID)
|
||||
if lookupErr != nil {
|
||||
return nil, fmt.Errorf("account not found")
|
||||
}
|
||||
if foundAcct.Status != model.AccountStatusActive {
|
||||
return nil, fmt.Errorf("account inactive")
|
||||
}
|
||||
acct = foundAcct
|
||||
|
||||
foundDBCreds, credErr := s.db.GetWebAuthnCredentials(foundAcct.ID)
|
||||
if credErr != nil {
|
||||
return nil, fmt.Errorf("load credentials: %w", credErr)
|
||||
}
|
||||
dbCreds = foundDBCreds
|
||||
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, foundDBCreds)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("decrypt credentials: %w", decErr)
|
||||
}
|
||||
return mciaswebauthn.NewAccountUser(userHandle, foundAcct.Username, libCreds), nil
|
||||
}
|
||||
|
||||
cred, err = wa.FinishDiscoverableLogin(handler, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
s.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if acct == nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check account status and lockout.
|
||||
if acct.Status != model.AccountStatusActive {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (WebAuthn login finish)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: validate sign counter to detect cloned authenticators.
|
||||
// Find the matching DB credential to update.
|
||||
var matchedDBCred *model.WebAuthnCredential
|
||||
for _, dc := range dbCreds {
|
||||
decrypted, decErr := mciaswebauthn.DecryptCredential(masterKey, dc)
|
||||
if decErr != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(decrypted.ID, cred.ID) {
|
||||
matchedDBCred = dc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedDBCred != nil {
|
||||
// Security: reject sign counter rollback (cloned authenticator detection).
|
||||
// If both are 0, the authenticator doesn't support counters — allow it.
|
||||
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
|
||||
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
|
||||
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
|
||||
audit.JSON("reason", "counter_rollback",
|
||||
"expected_gt", fmt.Sprintf("%d", matchedDBCred.SignCount),
|
||||
"got", fmt.Sprintf("%d", cred.Authenticator.SignCount)))
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update sign count and last used.
|
||||
_ = s.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
|
||||
_ = s.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
|
||||
}
|
||||
|
||||
// Login succeeded: clear lockout counter.
|
||||
_ = s.db.ClearLoginFailures(acct.ID)
|
||||
|
||||
// Issue JWT.
|
||||
roles, err := s.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
expiry := s.cfg.DefaultExpiry()
|
||||
for _, role := range roles {
|
||||
if role == "admin" {
|
||||
expiry = s.cfg.AdminExpiry()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
privKey, err := s.vault.PrivKey()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
tokenStr, tokenClaims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
|
||||
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", tokenClaims.JTI, "via", "webauthn"))
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: tokenStr,
|
||||
ExpiresAt: tokenClaims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Credential management ----
|
||||
|
||||
type webauthnCredentialView struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
Name string `json:"name"`
|
||||
AAGUID string `json:"aaguid"`
|
||||
Transports string `json:"transports,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
}
|
||||
|
||||
// handleListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
func (s *Server) handleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
creds, err := s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
views := make([]webauthnCredentialView, 0, len(creds))
|
||||
for _, c := range creds {
|
||||
v := webauthnCredentialView{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
AAGUID: c.AAGUID,
|
||||
SignCount: c.SignCount,
|
||||
Discoverable: c.Discoverable,
|
||||
Transports: c.Transports,
|
||||
CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if c.LastUsedAt != nil {
|
||||
v.LastUsedAt = c.LastUsedAt.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, views)
|
||||
}
|
||||
|
||||
// handleDeleteWebAuthnCredential removes a specific WebAuthn credential.
|
||||
func (s *Server) handleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
credIDStr := r.PathValue("credentialId")
|
||||
credID, err := strconv.ParseInt(credIDStr, 10, 64)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid credential ID", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
|
||||
middleware.WriteError(w, http.StatusNotFound, "credential not found", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventWebAuthnRemoved, nil, &acct.ID,
|
||||
audit.JSON("credential_id", credIDStr))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// readAllBody reads the entire request body and returns it as a byte slice.
|
||||
func readAllBody(r *http.Request) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
_, err := buf.ReadFrom(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -308,6 +308,13 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
|
||||
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
|
||||
|
||||
// WebAuthn registration endpoints (require valid token; self-service).
|
||||
mux.Handle("POST /v1/auth/webauthn/register/begin", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterBegin)))
|
||||
mux.Handle("POST /v1/auth/webauthn/register/finish", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterFinish)))
|
||||
// WebAuthn login endpoints (public, rate-limited).
|
||||
mux.Handle("POST /v1/auth/webauthn/login/begin", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginBegin)))
|
||||
mux.Handle("POST /v1/auth/webauthn/login/finish", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginFinish)))
|
||||
|
||||
// Policy-gated endpoints (formerly admin-only; now controlled by the engine).
|
||||
mux.Handle("DELETE /v1/auth/totp",
|
||||
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
|
||||
@@ -338,6 +345,11 @@ func (s *Server) Handler() http.Handler {
|
||||
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
|
||||
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
|
||||
// WebAuthn credential management (policy-gated).
|
||||
mux.Handle("GET /v1/accounts/{id}/webauthn",
|
||||
requirePolicy(policy.ActionReadAccount, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleListWebAuthnCredentials)))
|
||||
mux.Handle("DELETE /v1/accounts/{id}/webauthn/{credentialId}",
|
||||
requirePolicy(policy.ActionRemoveWebAuthn, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleDeleteWebAuthnCredential)))
|
||||
mux.Handle("GET /v1/audit",
|
||||
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
|
||||
mux.Handle("GET /v1/accounts/{id}/tags",
|
||||
|
||||
@@ -197,6 +197,15 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load WebAuthn credentials for the account detail page.
|
||||
var webAuthnCreds []*model.WebAuthnCredential
|
||||
if u.cfg.WebAuthnEnabled() {
|
||||
webAuthnCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("load webauthn credentials", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
u.render(w, "account_detail", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||
Account: acct,
|
||||
@@ -211,6 +220,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
TokenDelegates: tokenDelegates,
|
||||
DelegatableAccounts: delegatableAccounts,
|
||||
CanIssueToken: true, // account_detail is admin-only, so admin can always issue
|
||||
WebAuthnCreds: webAuthnCreds,
|
||||
DeletePrefix: "/accounts/" + acct.UUID + "/webauthn",
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
|
||||
// handleLoginPage renders the login form.
|
||||
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, "login", LoginData{})
|
||||
u.render(w, "login", LoginData{
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
|
||||
@@ -290,13 +292,30 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target
|
||||
// handleProfilePage renders the profile page for the currently logged-in user.
|
||||
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "profile", ProfileData{
|
||||
claims := claimsFromContext(r.Context())
|
||||
|
||||
data := ProfileData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
ActorName: u.actorName(r),
|
||||
IsAdmin: isAdmin(r),
|
||||
},
|
||||
})
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
DeletePrefix: "/profile/webauthn",
|
||||
}
|
||||
|
||||
// Load WebAuthn credentials for the profile page.
|
||||
if u.cfg.WebAuthnEnabled() && claims != nil {
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err == nil {
|
||||
creds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err == nil {
|
||||
data.WebAuthnCreds = creds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.render(w, "profile", data)
|
||||
}
|
||||
|
||||
// handleSelfChangePassword allows an authenticated human user to change their
|
||||
|
||||
696
internal/ui/handlers_webauthn.go
Normal file
696
internal/ui/handlers_webauthn.go
Normal file
@@ -0,0 +1,696 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
|
||||
)
|
||||
|
||||
const (
|
||||
webauthnCeremonyTTL = 120 * time.Second
|
||||
webauthnCleanupPeriod = 5 * time.Minute
|
||||
webauthnNonceBytes = 16
|
||||
)
|
||||
|
||||
// webauthnCeremony holds a pending WebAuthn ceremony.
|
||||
type webauthnCeremony struct {
|
||||
expiresAt time.Time
|
||||
session *libwebauthn.SessionData
|
||||
accountID int64
|
||||
}
|
||||
|
||||
// pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI.
|
||||
var pendingUIWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
|
||||
|
||||
func init() {
|
||||
go cleanupUIWebAuthnCeremonies()
|
||||
}
|
||||
|
||||
func cleanupUIWebAuthnCeremonies() {
|
||||
ticker := time.NewTicker(webauthnCleanupPeriod)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
pendingUIWebAuthnCeremonies.Range(func(key, value any) bool {
|
||||
c, ok := value.(*webauthnCeremony)
|
||||
if !ok || now.After(c.expiresAt) {
|
||||
pendingUIWebAuthnCeremonies.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
|
||||
raw, err := crypto.RandomBytes(webauthnNonceBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
|
||||
}
|
||||
nonce := fmt.Sprintf("%x", raw)
|
||||
pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
|
||||
session: session,
|
||||
accountID: accountID,
|
||||
expiresAt: time.Now().Add(webauthnCeremonyTTL),
|
||||
})
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
func consumeUICeremony(nonce string) (*webauthnCeremony, bool) {
|
||||
v, ok := pendingUIWebAuthnCeremonies.LoadAndDelete(nonce)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
c, ok2 := v.(*webauthnCeremony)
|
||||
if !ok2 || time.Now().After(c.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// ---- Profile: registration ----
|
||||
|
||||
// handleWebAuthnBegin starts a WebAuthn credential registration ceremony.
|
||||
func (u *UIServer) handleWebAuthnBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.cfg.WebAuthnEnabled() {
|
||||
u.renderError(w, r, http.StatusNotFound, "WebAuthn not configured")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "password is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout.
|
||||
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
u.logger.Error("lockout check (WebAuthn enroll)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
writeJSONError(w, http.StatusTooManyRequests, "account temporarily locked")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify current password.
|
||||
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = u.db.RecordLoginFailure(acct.ID)
|
||||
writeJSONError(w, http.StatusUnauthorized, "password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
|
||||
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
u.logger.Error("create webauthn instance", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
creation, session, err := wa.BeginRegistration(user,
|
||||
libwebauthn.WithExclusions(libwebauthn.Credentials(libCreds).CredentialDescriptors()),
|
||||
libwebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||||
)
|
||||
if err != nil {
|
||||
u.logger.Error("begin webauthn registration", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
nonce, err := storeUICeremony(session, acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
optionsJSON, _ := json.Marshal(creation)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"options": json.RawMessage(optionsJSON),
|
||||
"nonce": nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnFinish completes WebAuthn credential registration.
|
||||
func (u *UIServer) handleWebAuthnFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.cfg.WebAuthnEnabled() {
|
||||
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(r.Body); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
ceremony, ok := consumeUICeremony(wrapper.Nonce)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusBadRequest, "ceremony expired or invalid")
|
||||
return
|
||||
}
|
||||
if ceremony.accountID != acct.ID {
|
||||
writeJSONError(w, http.StatusForbidden, "ceremony mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
|
||||
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||
fakeReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
u.logger.Error("finish webauthn registration", "error", err)
|
||||
writeJSONError(w, http.StatusBadRequest, "registration failed")
|
||||
return
|
||||
}
|
||||
|
||||
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
|
||||
name := wrapper.Name
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
|
||||
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
modelCred.AccountID = acct.ID
|
||||
|
||||
credID, err := u.db.CreateWebAuthnCredential(modelCred)
|
||||
if err != nil {
|
||||
u.logger.Error("store webauthn credential", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
|
||||
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": credID,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnDelete removes a WebAuthn credential from the profile page.
|
||||
func (u *UIServer) handleWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
credIDStr := r.PathValue("id")
|
||||
credID, err := strconv.ParseInt(credIDStr, 10, 64)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.DeleteWebAuthnCredential(credID, acct.ID); err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "credential not found")
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventWebAuthnRemoved, &acct.ID, &acct.ID,
|
||||
audit.JSON("credential_id", credIDStr))
|
||||
|
||||
// Return updated credentials list fragment.
|
||||
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "webauthn_credentials", ProfileData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
ActorName: u.actorName(r),
|
||||
IsAdmin: isAdmin(r),
|
||||
},
|
||||
WebAuthnCreds: creds,
|
||||
DeletePrefix: "/profile/webauthn",
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Login: WebAuthn ----
|
||||
|
||||
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony from the UI.
|
||||
func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.cfg.WebAuthnEnabled() {
|
||||
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
assertion *protocol.CredentialAssertion
|
||||
session *libwebauthn.SessionData
|
||||
accountID int64
|
||||
)
|
||||
|
||||
if req.Username != "" {
|
||||
acct, lookupErr := u.db.GetAccountByUsername(req.Username)
|
||||
if lookupErr != nil || acct.Status != model.AccountStatusActive {
|
||||
// Security: return discoverable login as dummy for unknown users.
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
} else {
|
||||
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
u.logger.Error("lockout check (WebAuthn UI login)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
} else {
|
||||
masterKey, mkErr := u.vault.MasterKey()
|
||||
if mkErr != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
dbCreds, dbErr := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if dbErr != nil || len(dbCreds) == 0 {
|
||||
writeJSONError(w, http.StatusBadRequest, "no passkeys registered")
|
||||
return
|
||||
}
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if decErr != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
assertion, session, err = wa.BeginLogin(user)
|
||||
accountID = acct.ID
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
u.logger.Error("begin webauthn login", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
nonce, err := storeUICeremony(session, accountID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
optionsJSON, _ := json.Marshal(assertion)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"options": json.RawMessage(optionsJSON),
|
||||
"nonce": nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnLoginFinish completes a WebAuthn login from the UI.
|
||||
func (u *UIServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.cfg.WebAuthnEnabled() {
|
||||
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(r.Body); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
ceremony, ok := consumeUICeremony(wrapper.Nonce)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
|
||||
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||
fakeReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var (
|
||||
acct *model.Account
|
||||
cred *libwebauthn.Credential
|
||||
dbCreds []*model.WebAuthnCredential
|
||||
)
|
||||
|
||||
if ceremony.accountID != 0 {
|
||||
acct, err = u.db.GetAccountByID(ceremony.accountID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
dbCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if decErr != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
|
||||
_ = u.db.RecordLoginFailure(acct.ID)
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
|
||||
acctUUID := string(userHandle)
|
||||
foundAcct, lookupErr := u.db.GetAccountByUUID(acctUUID)
|
||||
if lookupErr != nil {
|
||||
return nil, fmt.Errorf("account not found")
|
||||
}
|
||||
if foundAcct.Status != model.AccountStatusActive {
|
||||
return nil, fmt.Errorf("account inactive")
|
||||
}
|
||||
acct = foundAcct
|
||||
|
||||
foundDBCreds, credErr := u.db.GetWebAuthnCredentials(foundAcct.ID)
|
||||
if credErr != nil {
|
||||
return nil, fmt.Errorf("load credentials: %w", credErr)
|
||||
}
|
||||
dbCreds = foundDBCreds
|
||||
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, foundDBCreds)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("decrypt credentials: %w", decErr)
|
||||
}
|
||||
return mciaswebauthn.NewAccountUser(userHandle, foundAcct.Username, libCreds), nil
|
||||
}
|
||||
|
||||
cred, err = wa.FinishDiscoverableLogin(handler, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
u.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if acct == nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
if acct.Status != model.AccountStatusActive {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
u.logger.Error("lockout check (WebAuthn UI login finish)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate sign counter.
|
||||
var matchedDBCred *model.WebAuthnCredential
|
||||
for _, dc := range dbCreds {
|
||||
decrypted, decErr := mciaswebauthn.DecryptCredential(masterKey, dc)
|
||||
if decErr != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(decrypted.ID, cred.ID) {
|
||||
matchedDBCred = dc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedDBCred != nil {
|
||||
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
|
||||
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
|
||||
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
|
||||
audit.JSON("reason", "counter_rollback"))
|
||||
_ = u.db.RecordLoginFailure(acct.ID)
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = u.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
|
||||
_ = u.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
|
||||
}
|
||||
|
||||
_ = u.db.ClearLoginFailures(acct.ID)
|
||||
|
||||
// Issue JWT and set session cookie.
|
||||
expiry := u.cfg.DefaultExpiry()
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if rol == "admin" {
|
||||
expiry = u.cfg.AdminExpiry()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
privKey, err := u.vault.PrivKey()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
tokenStr, tokenClaims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: tokenStr,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: tokenClaims.ExpiresAt,
|
||||
})
|
||||
|
||||
if _, err := u.setCSRFCookies(w); err != nil {
|
||||
u.logger.Error("set CSRF cookie", "error", err)
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
|
||||
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
||||
audit.JSON("jti", tokenClaims.JTI, "via", "webauthn_ui"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"redirect": "/dashboard"})
|
||||
}
|
||||
|
||||
// ---- Admin: WebAuthn credential management ----
|
||||
|
||||
// handleAdminWebAuthnDelete removes a WebAuthn credential from the admin account detail page.
|
||||
func (u *UIServer) handleAdminWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
|
||||
accountUUID := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(accountUUID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
credIDStr := r.PathValue("credentialId")
|
||||
credID, err := strconv.ParseInt(credIDStr, 10, 64)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "credential not found")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventWebAuthnRemoved, actorID, &acct.ID,
|
||||
audit.JSON("credential_id", credIDStr, "admin", "true"))
|
||||
|
||||
// Return updated credentials list.
|
||||
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "webauthn_credentials", struct { //nolint:govet // fieldalignment: anonymous struct
|
||||
PageData
|
||||
WebAuthnCreds []*model.WebAuthnCredential
|
||||
DeletePrefix string
|
||||
WebAuthnEnabled bool
|
||||
}{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
ActorName: u.actorName(r),
|
||||
IsAdmin: isAdmin(r),
|
||||
},
|
||||
WebAuthnCreds: creds,
|
||||
DeletePrefix: "/accounts/" + accountUUID + "/webauthn",
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSONError writes a JSON error response.
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
@@ -177,6 +177,13 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
|
||||
}
|
||||
return *actorID == *cred.OwnerID
|
||||
},
|
||||
// derefTime dereferences a *time.Time, returning the zero time for nil.
|
||||
"derefTime": func(p *time.Time) time.Time {
|
||||
if p == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *p
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"gt": func(a, b int) bool { return a > b },
|
||||
@@ -213,6 +220,8 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
|
||||
"templates/fragments/password_reset_form.html",
|
||||
"templates/fragments/password_change_form.html",
|
||||
"templates/fragments/token_delegates.html",
|
||||
"templates/fragments/webauthn_credentials.html",
|
||||
"templates/fragments/webauthn_enroll.html",
|
||||
}
|
||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||
if err != nil {
|
||||
@@ -378,6 +387,9 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
||||
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
|
||||
uiMux.HandleFunc("POST /logout", u.handleLogout)
|
||||
// WebAuthn login routes (public, rate-limited).
|
||||
uiMux.Handle("POST /login/webauthn/begin", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginBegin)))
|
||||
uiMux.Handle("POST /login/webauthn/finish", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginFinish)))
|
||||
|
||||
// Protected routes.
|
||||
//
|
||||
@@ -432,6 +444,12 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
// Profile routes — accessible to any authenticated user (not admin-only).
|
||||
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
|
||||
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
|
||||
// WebAuthn profile routes (enrollment and management).
|
||||
uiMux.Handle("POST /profile/webauthn/begin", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnBegin))))
|
||||
uiMux.Handle("POST /profile/webauthn/finish", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnFinish))))
|
||||
uiMux.Handle("DELETE /profile/webauthn/{id}", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnDelete))))
|
||||
// Admin WebAuthn management.
|
||||
uiMux.Handle("DELETE /accounts/{id}/webauthn/{credentialId}", admin(u.handleAdminWebAuthnDelete))
|
||||
|
||||
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
||||
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
||||
@@ -729,6 +747,8 @@ type LoginData struct {
|
||||
// a short-lived server-side nonce is issued after successful password
|
||||
// verification, and only the nonce is embedded in the TOTP step form.
|
||||
Nonce string // single-use server-side nonce replacing the password hidden field
|
||||
// WebAuthnEnabled indicates whether the passkey login button should appear.
|
||||
WebAuthnEnabled bool
|
||||
}
|
||||
|
||||
// DashboardData is the view model for the dashboard page.
|
||||
@@ -746,7 +766,7 @@ type AccountsData struct {
|
||||
}
|
||||
|
||||
// AccountDetailData is the view model for the account detail page.
|
||||
type AccountDetailData struct {
|
||||
type AccountDetailData struct { //nolint:govet // fieldalignment: readability over alignment for view model
|
||||
Account *model.Account
|
||||
// PGCred is nil if none stored or the account is not a system account.
|
||||
PGCred *model.PGCredential
|
||||
@@ -772,10 +792,15 @@ type AccountDetailData struct {
|
||||
AllRoles []string
|
||||
Tags []string
|
||||
Tokens []*model.TokenRecord
|
||||
// WebAuthnCreds lists the WebAuthn credentials for this account (metadata only).
|
||||
WebAuthnCreds []*model.WebAuthnCredential
|
||||
// DeletePrefix is the URL prefix for WebAuthn credential delete buttons.
|
||||
DeletePrefix string
|
||||
// CanIssueToken is true when the viewing actor may issue tokens for this
|
||||
// system account (admin role or explicit delegate grant).
|
||||
// Placed last to minimise GC scan area.
|
||||
CanIssueToken bool
|
||||
CanIssueToken bool
|
||||
WebAuthnEnabled bool
|
||||
}
|
||||
|
||||
// ServiceAccountsData is the view model for the /service-accounts page.
|
||||
@@ -832,8 +857,11 @@ type PoliciesData struct {
|
||||
}
|
||||
|
||||
// ProfileData is the view model for the profile/settings page.
|
||||
type ProfileData struct {
|
||||
type ProfileData struct { //nolint:govet // fieldalignment: readability over alignment for view model
|
||||
PageData
|
||||
WebAuthnCreds []*model.WebAuthnCredential
|
||||
DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn")
|
||||
WebAuthnEnabled bool
|
||||
}
|
||||
|
||||
// PGCredsData is the view model for the "My PG Credentials" list page.
|
||||
|
||||
28
internal/webauthn/adapter.go
Normal file
28
internal/webauthn/adapter.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Package webauthn provides the adapter between the go-webauthn library and
|
||||
// MCIAS internal types. It handles WebAuthn instance configuration and
|
||||
// encryption/decryption of credential material stored in the database.
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
)
|
||||
|
||||
// NewWebAuthn creates a configured go-webauthn instance from MCIAS config.
|
||||
func NewWebAuthn(cfg *config.WebAuthnConfig) (*webauthn.WebAuthn, error) {
|
||||
if cfg.RPID == "" || cfg.RPOrigin == "" {
|
||||
return nil, fmt.Errorf("webauthn: RPID and RPOrigin are required")
|
||||
}
|
||||
displayName := cfg.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = "MCIAS"
|
||||
}
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPID: cfg.RPID,
|
||||
RPDisplayName: displayName,
|
||||
RPOrigins: []string{cfg.RPOrigin},
|
||||
})
|
||||
}
|
||||
75
internal/webauthn/adapter_test.go
Normal file
75
internal/webauthn/adapter_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
)
|
||||
|
||||
func TestNewWebAuthn(t *testing.T) {
|
||||
cfg := &config.WebAuthnConfig{
|
||||
RPID: "example.com",
|
||||
RPOrigin: "https://example.com",
|
||||
DisplayName: "Test App",
|
||||
}
|
||||
wa, err := NewWebAuthn(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWebAuthn: %v", err)
|
||||
}
|
||||
if wa == nil {
|
||||
t.Fatal("expected non-nil WebAuthn instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWebAuthnMissingFields(t *testing.T) {
|
||||
_, err := NewWebAuthn(&config.WebAuthnConfig{})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty config")
|
||||
}
|
||||
|
||||
_, err = NewWebAuthn(&config.WebAuthnConfig{RPID: "example.com"})
|
||||
if err == nil {
|
||||
t.Error("expected error for missing RPOrigin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWebAuthnDefaultDisplayName(t *testing.T) {
|
||||
cfg := &config.WebAuthnConfig{
|
||||
RPID: "example.com",
|
||||
RPOrigin: "https://example.com",
|
||||
}
|
||||
wa, err := NewWebAuthn(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWebAuthn: %v", err)
|
||||
}
|
||||
if wa == nil {
|
||||
t.Fatal("expected non-nil WebAuthn instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountUserInterface(t *testing.T) {
|
||||
uuidBytes := []byte("12345678-1234-1234-1234-123456789abc")
|
||||
creds := []libwebauthn.Credential{
|
||||
{ID: []byte("cred1")},
|
||||
{ID: []byte("cred2")},
|
||||
}
|
||||
user := NewAccountUser(uuidBytes, "alice", creds)
|
||||
|
||||
// Verify interface compliance.
|
||||
var _ libwebauthn.User = user
|
||||
|
||||
if string(user.WebAuthnID()) != string(uuidBytes) {
|
||||
t.Error("WebAuthnID mismatch")
|
||||
}
|
||||
if user.WebAuthnName() != "alice" {
|
||||
t.Errorf("WebAuthnName = %q, want %q", user.WebAuthnName(), "alice")
|
||||
}
|
||||
if user.WebAuthnDisplayName() != "alice" {
|
||||
t.Errorf("WebAuthnDisplayName = %q, want %q", user.WebAuthnDisplayName(), "alice")
|
||||
}
|
||||
if len(user.WebAuthnCredentials()) != 2 {
|
||||
t.Errorf("WebAuthnCredentials len = %d, want 2", len(user.WebAuthnCredentials()))
|
||||
}
|
||||
}
|
||||
99
internal/webauthn/convert.go
Normal file
99
internal/webauthn/convert.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// DecryptCredential decrypts a stored WebAuthn credential's ID and public key
|
||||
// and returns a webauthn.Credential suitable for the go-webauthn library.
|
||||
func DecryptCredential(masterKey []byte, cred *model.WebAuthnCredential) (*webauthn.Credential, error) {
|
||||
credID, err := crypto.OpenAESGCM(masterKey, cred.CredentialIDNonce, cred.CredentialIDEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: decrypt credential ID: %w", err)
|
||||
}
|
||||
pubKey, err := crypto.OpenAESGCM(masterKey, cred.PublicKeyNonce, cred.PublicKeyEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: decrypt public key: %w", err)
|
||||
}
|
||||
|
||||
// Parse transports from comma-separated string.
|
||||
var transports []protocol.AuthenticatorTransport
|
||||
if cred.Transports != "" {
|
||||
for _, t := range strings.Split(cred.Transports, ",") {
|
||||
transports = append(transports, protocol.AuthenticatorTransport(strings.TrimSpace(t)))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse AAGUID from hex string.
|
||||
var aaguid []byte
|
||||
if cred.AAGUID != "" {
|
||||
aaguid, _ = hex.DecodeString(cred.AAGUID)
|
||||
}
|
||||
|
||||
return &webauthn.Credential{
|
||||
ID: credID,
|
||||
PublicKey: pubKey,
|
||||
Transport: transports,
|
||||
Flags: webauthn.CredentialFlags{
|
||||
UserPresent: true,
|
||||
UserVerified: true,
|
||||
BackupEligible: cred.Discoverable,
|
||||
},
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: aaguid,
|
||||
SignCount: cred.SignCount,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecryptCredentials decrypts all stored credentials for use with the library.
|
||||
func DecryptCredentials(masterKey []byte, dbCreds []*model.WebAuthnCredential) ([]webauthn.Credential, error) {
|
||||
result := make([]webauthn.Credential, 0, len(dbCreds))
|
||||
for _, c := range dbCreds {
|
||||
decrypted, err := DecryptCredential(masterKey, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *decrypted)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EncryptCredential encrypts a library credential for database storage.
|
||||
// Returns a model.WebAuthnCredential with encrypted fields populated.
|
||||
func EncryptCredential(masterKey []byte, cred *webauthn.Credential, name string, discoverable bool) (*model.WebAuthnCredential, error) {
|
||||
credIDEnc, credIDNonce, err := crypto.SealAESGCM(masterKey, cred.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: encrypt credential ID: %w", err)
|
||||
}
|
||||
pubKeyEnc, pubKeyNonce, err := crypto.SealAESGCM(masterKey, cred.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: encrypt public key: %w", err)
|
||||
}
|
||||
|
||||
// Serialize transports as comma-separated string.
|
||||
var transportStrs []string
|
||||
for _, t := range cred.Transport {
|
||||
transportStrs = append(transportStrs, string(t))
|
||||
}
|
||||
|
||||
return &model.WebAuthnCredential{
|
||||
Name: name,
|
||||
CredentialIDEnc: credIDEnc,
|
||||
CredentialIDNonce: credIDNonce,
|
||||
PublicKeyEnc: pubKeyEnc,
|
||||
PublicKeyNonce: pubKeyNonce,
|
||||
AAGUID: hex.EncodeToString(cred.Authenticator.AAGUID),
|
||||
SignCount: cred.Authenticator.SignCount,
|
||||
Discoverable: discoverable,
|
||||
Transports: strings.Join(transportStrs, ","),
|
||||
}, nil
|
||||
}
|
||||
148
internal/webauthn/convert_test.go
Normal file
148
internal/webauthn/convert_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
func testMasterKey(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
masterKey := testMasterKey(t)
|
||||
|
||||
original := &libwebauthn.Credential{
|
||||
ID: []byte("credential-id-12345"),
|
||||
PublicKey: []byte("public-key-bytes-here"),
|
||||
Transport: []protocol.AuthenticatorTransport{
|
||||
protocol.USB,
|
||||
protocol.NFC,
|
||||
},
|
||||
Flags: libwebauthn.CredentialFlags{
|
||||
UserPresent: true,
|
||||
UserVerified: true,
|
||||
BackupEligible: true,
|
||||
},
|
||||
Authenticator: libwebauthn.Authenticator{
|
||||
AAGUID: []byte{0x2f, 0xc0, 0x57, 0x9f, 0x81, 0x13, 0x47, 0xea, 0xb1, 0x16, 0xbb, 0x5a, 0x8d, 0xb9, 0x20, 0x2a},
|
||||
SignCount: 42,
|
||||
},
|
||||
}
|
||||
|
||||
// Encrypt.
|
||||
encrypted, err := EncryptCredential(masterKey, original, "YubiKey 5", true)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
if encrypted.Name != "YubiKey 5" {
|
||||
t.Errorf("Name = %q, want %q", encrypted.Name, "YubiKey 5")
|
||||
}
|
||||
if !encrypted.Discoverable {
|
||||
t.Error("expected discoverable=true")
|
||||
}
|
||||
if encrypted.SignCount != 42 {
|
||||
t.Errorf("SignCount = %d, want 42", encrypted.SignCount)
|
||||
}
|
||||
if encrypted.Transports != "usb,nfc" {
|
||||
t.Errorf("Transports = %q, want %q", encrypted.Transports, "usb,nfc")
|
||||
}
|
||||
|
||||
// Encrypted fields should not be plaintext.
|
||||
if bytes.Equal(encrypted.CredentialIDEnc, original.ID) {
|
||||
t.Error("credential ID should be encrypted")
|
||||
}
|
||||
if bytes.Equal(encrypted.PublicKeyEnc, original.PublicKey) {
|
||||
t.Error("public key should be encrypted")
|
||||
}
|
||||
|
||||
// Decrypt.
|
||||
decrypted, err := DecryptCredential(masterKey, encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted.ID, original.ID) {
|
||||
t.Errorf("credential ID mismatch after roundtrip")
|
||||
}
|
||||
if !bytes.Equal(decrypted.PublicKey, original.PublicKey) {
|
||||
t.Errorf("public key mismatch after roundtrip")
|
||||
}
|
||||
if decrypted.Authenticator.SignCount != 42 {
|
||||
t.Errorf("SignCount = %d, want 42", decrypted.Authenticator.SignCount)
|
||||
}
|
||||
if len(decrypted.Transport) != 2 {
|
||||
t.Errorf("expected 2 transports, got %d", len(decrypted.Transport))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptCredentials(t *testing.T) {
|
||||
masterKey := testMasterKey(t)
|
||||
|
||||
// Create two encrypted credentials.
|
||||
var dbCreds []*model.WebAuthnCredential
|
||||
for i := range 3 {
|
||||
cred := &libwebauthn.Credential{
|
||||
ID: []byte{byte(i), 1, 2, 3},
|
||||
PublicKey: []byte{byte(i), 4, 5, 6},
|
||||
Authenticator: libwebauthn.Authenticator{
|
||||
SignCount: uint32(i),
|
||||
},
|
||||
}
|
||||
enc, err := EncryptCredential(masterKey, cred, "key", false)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt %d: %v", i, err)
|
||||
}
|
||||
dbCreds = append(dbCreds, enc)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt all: %v", err)
|
||||
}
|
||||
if len(decrypted) != 3 {
|
||||
t.Fatalf("expected 3 decrypted, got %d", len(decrypted))
|
||||
}
|
||||
for i, d := range decrypted {
|
||||
if d.ID[0] != byte(i) {
|
||||
t.Errorf("cred %d: ID[0] = %d, want %d", i, d.ID[0], byte(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithWrongKey(t *testing.T) {
|
||||
masterKey := testMasterKey(t)
|
||||
wrongKey := make([]byte, 32)
|
||||
for i := range wrongKey {
|
||||
wrongKey[i] = byte(i + 100)
|
||||
}
|
||||
|
||||
// Encrypt with correct key.
|
||||
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
|
||||
dbCred := &model.WebAuthnCredential{
|
||||
CredentialIDEnc: enc,
|
||||
CredentialIDNonce: nonce,
|
||||
PublicKeyEnc: enc,
|
||||
PublicKeyNonce: nonce,
|
||||
}
|
||||
|
||||
// Decrypt with wrong key should fail.
|
||||
_, err = DecryptCredential(wrongKey, dbCred)
|
||||
if err == nil {
|
||||
t.Error("expected error decrypting with wrong key")
|
||||
}
|
||||
}
|
||||
37
internal/webauthn/user.go
Normal file
37
internal/webauthn/user.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// AccountUser implements the webauthn.User interface for an MCIAS account.
|
||||
// The WebAuthnCredentials field must be populated with decrypted credentials
|
||||
// before passing to the library.
|
||||
type AccountUser struct {
|
||||
id []byte // UUID as bytes
|
||||
name string
|
||||
displayName string
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
// NewAccountUser creates a new AccountUser from account details and decrypted credentials.
|
||||
func NewAccountUser(uuidBytes []byte, username string, creds []webauthn.Credential) *AccountUser {
|
||||
return &AccountUser{
|
||||
id: uuidBytes,
|
||||
name: username,
|
||||
displayName: username,
|
||||
credentials: creds,
|
||||
}
|
||||
}
|
||||
|
||||
// WebAuthnID returns the user's unique ID as bytes.
|
||||
func (u *AccountUser) WebAuthnID() []byte { return u.id }
|
||||
|
||||
// WebAuthnName returns the user's login name.
|
||||
func (u *AccountUser) WebAuthnName() string { return u.name }
|
||||
|
||||
// WebAuthnDisplayName returns the user's display name.
|
||||
func (u *AccountUser) WebAuthnDisplayName() string { return u.displayName }
|
||||
|
||||
// WebAuthnCredentials returns the user's registered credentials.
|
||||
func (u *AccountUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||
255
openapi.yaml
255
openapi.yaml
@@ -86,6 +86,54 @@ components:
|
||||
type: boolean
|
||||
description: Whether TOTP is enrolled and required for this account.
|
||||
example: false
|
||||
webauthn_enabled:
|
||||
type: boolean
|
||||
description: Whether at least one WebAuthn credential is registered.
|
||||
example: false
|
||||
webauthn_count:
|
||||
type: integer
|
||||
description: Number of registered WebAuthn credentials.
|
||||
example: 0
|
||||
|
||||
WebAuthnCredentialInfo:
|
||||
type: object
|
||||
required: [id, name, sign_count, discoverable, created_at]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Database row ID.
|
||||
example: 1
|
||||
name:
|
||||
type: string
|
||||
description: User-supplied label for the credential.
|
||||
example: "YubiKey 5"
|
||||
aaguid:
|
||||
type: string
|
||||
description: Authenticator Attestation GUID.
|
||||
example: "2fc0579f-8113-47ea-b116-bb5a8db9202a"
|
||||
sign_count:
|
||||
type: integer
|
||||
format: uint32
|
||||
description: Signature counter (used to detect cloned authenticators).
|
||||
example: 42
|
||||
discoverable:
|
||||
type: boolean
|
||||
description: Whether this is a discoverable (passkey/resident) credential.
|
||||
example: true
|
||||
transports:
|
||||
type: string
|
||||
description: Comma-separated transport hints (usb, nfc, ble, internal).
|
||||
example: "usb,nfc"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-03-11T09:00:00Z"
|
||||
last_used_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
example: "2026-03-15T14:30:00Z"
|
||||
|
||||
AuditEvent:
|
||||
type: object
|
||||
@@ -847,6 +895,213 @@ paths:
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
# ── WebAuthn ──────────────────────────────────────────────────────────────
|
||||
|
||||
/v1/auth/webauthn/register/begin:
|
||||
post:
|
||||
summary: Begin WebAuthn registration
|
||||
description: |
|
||||
Start a WebAuthn credential registration ceremony. Requires the current
|
||||
password for re-authentication (same security model as TOTP enrollment).
|
||||
Returns PublicKeyCredentialCreationOptions for the browser WebAuthn API.
|
||||
operationId: beginWebAuthnRegister
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [password]
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
description: Current password for re-authentication.
|
||||
name:
|
||||
type: string
|
||||
description: Optional label for the credential (e.g. "YubiKey 5").
|
||||
example: "YubiKey 5"
|
||||
responses:
|
||||
"200":
|
||||
description: Registration options for navigator.credentials.create().
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: PublicKeyCredentialCreationOptions (WebAuthn spec).
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
description: Account temporarily locked.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/auth/webauthn/register/finish:
|
||||
post:
|
||||
summary: Finish WebAuthn registration
|
||||
description: |
|
||||
Complete the WebAuthn credential registration ceremony. The request body
|
||||
contains the authenticator's response from navigator.credentials.create().
|
||||
The credential is encrypted at rest with AES-256-GCM.
|
||||
operationId: finishWebAuthnRegister
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: AuthenticatorAttestationResponse (WebAuthn spec).
|
||||
responses:
|
||||
"200":
|
||||
description: Credential registered.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
/v1/auth/webauthn/login/begin:
|
||||
post:
|
||||
summary: Begin WebAuthn login
|
||||
description: |
|
||||
Start a WebAuthn authentication ceremony. Public RPC — no auth required.
|
||||
With a username: returns allowCredentials for the account's registered
|
||||
credentials. Without a username: starts a discoverable (passkey) flow.
|
||||
operationId: beginWebAuthnLogin
|
||||
tags: [Public]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: Optional. If omitted, starts a discoverable (passkey) flow.
|
||||
example: alice
|
||||
responses:
|
||||
"200":
|
||||
description: Assertion options for navigator.credentials.get().
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: PublicKeyCredentialRequestOptions (WebAuthn spec).
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
|
||||
/v1/auth/webauthn/login/finish:
|
||||
post:
|
||||
summary: Finish WebAuthn login
|
||||
description: |
|
||||
Complete the WebAuthn authentication ceremony. Validates the assertion,
|
||||
checks the sign counter, and issues a JWT. Public RPC — no auth required.
|
||||
operationId: finishWebAuthnLogin
|
||||
tags: [Public]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: AuthenticatorAssertionResponse (WebAuthn spec).
|
||||
responses:
|
||||
"200":
|
||||
description: Login successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TokenResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
|
||||
/v1/accounts/{id}/webauthn:
|
||||
get:
|
||||
summary: List WebAuthn credentials (admin)
|
||||
description: |
|
||||
Returns metadata for all WebAuthn credentials registered to an account.
|
||||
Credential material (IDs, public keys) is never included.
|
||||
operationId: listWebAuthnCredentials
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Account UUID.
|
||||
responses:
|
||||
"200":
|
||||
description: Credential metadata list.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
credentials:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WebAuthnCredentialInfo"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/accounts/{id}/webauthn/{credentialId}:
|
||||
delete:
|
||||
summary: Remove WebAuthn credential (admin)
|
||||
description: |
|
||||
Remove a specific WebAuthn credential from an account. Admin only.
|
||||
operationId: deleteWebAuthnCredential
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Account UUID.
|
||||
- name: credentialId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Credential database row ID.
|
||||
responses:
|
||||
"204":
|
||||
description: Credential removed.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/token/issue:
|
||||
post:
|
||||
summary: Issue service account token (admin)
|
||||
|
||||
@@ -75,6 +75,40 @@ message RemoveTOTPRequest {
|
||||
// RemoveTOTPResponse confirms removal.
|
||||
message RemoveTOTPResponse {}
|
||||
|
||||
// --- WebAuthn ---
|
||||
|
||||
// ListWebAuthnCredentialsRequest lists metadata for an account's WebAuthn credentials.
|
||||
message ListWebAuthnCredentialsRequest {
|
||||
string account_id = 1; // UUID
|
||||
}
|
||||
|
||||
// WebAuthnCredentialInfo holds metadata about a stored WebAuthn credential.
|
||||
// Credential material (IDs, public keys) is never included.
|
||||
message WebAuthnCredentialInfo {
|
||||
int64 id = 1;
|
||||
string name = 2;
|
||||
string aaguid = 3;
|
||||
uint32 sign_count = 4;
|
||||
bool discoverable = 5;
|
||||
string transports = 6;
|
||||
google.protobuf.Timestamp created_at = 7;
|
||||
google.protobuf.Timestamp last_used_at = 8;
|
||||
}
|
||||
|
||||
// ListWebAuthnCredentialsResponse returns credential metadata.
|
||||
message ListWebAuthnCredentialsResponse {
|
||||
repeated WebAuthnCredentialInfo credentials = 1;
|
||||
}
|
||||
|
||||
// RemoveWebAuthnCredentialRequest removes a specific WebAuthn credential (admin).
|
||||
message RemoveWebAuthnCredentialRequest {
|
||||
string account_id = 1; // UUID
|
||||
int64 credential_id = 2;
|
||||
}
|
||||
|
||||
// RemoveWebAuthnCredentialResponse confirms removal.
|
||||
message RemoveWebAuthnCredentialResponse {}
|
||||
|
||||
// AuthService handles all authentication flows.
|
||||
service AuthService {
|
||||
// Login authenticates with username+password (+optional TOTP) and returns a JWT.
|
||||
@@ -100,4 +134,12 @@ service AuthService {
|
||||
// RemoveTOTP removes TOTP from an account (admin only).
|
||||
// Requires: admin JWT in metadata.
|
||||
rpc RemoveTOTP(RemoveTOTPRequest) returns (RemoveTOTPResponse);
|
||||
|
||||
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
// Requires: admin JWT in metadata.
|
||||
rpc ListWebAuthnCredentials(ListWebAuthnCredentialsRequest) returns (ListWebAuthnCredentialsResponse);
|
||||
|
||||
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
|
||||
// Requires: admin JWT in metadata.
|
||||
rpc RemoveWebAuthnCredential(RemoveWebAuthnCredentialRequest) returns (RemoveWebAuthnCredentialResponse);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,54 @@ components:
|
||||
type: boolean
|
||||
description: Whether TOTP is enrolled and required for this account.
|
||||
example: false
|
||||
webauthn_enabled:
|
||||
type: boolean
|
||||
description: Whether at least one WebAuthn credential is registered.
|
||||
example: false
|
||||
webauthn_count:
|
||||
type: integer
|
||||
description: Number of registered WebAuthn credentials.
|
||||
example: 0
|
||||
|
||||
WebAuthnCredentialInfo:
|
||||
type: object
|
||||
required: [id, name, sign_count, discoverable, created_at]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Database row ID.
|
||||
example: 1
|
||||
name:
|
||||
type: string
|
||||
description: User-supplied label for the credential.
|
||||
example: "YubiKey 5"
|
||||
aaguid:
|
||||
type: string
|
||||
description: Authenticator Attestation GUID.
|
||||
example: "2fc0579f-8113-47ea-b116-bb5a8db9202a"
|
||||
sign_count:
|
||||
type: integer
|
||||
format: uint32
|
||||
description: Signature counter (used to detect cloned authenticators).
|
||||
example: 42
|
||||
discoverable:
|
||||
type: boolean
|
||||
description: Whether this is a discoverable (passkey/resident) credential.
|
||||
example: true
|
||||
transports:
|
||||
type: string
|
||||
description: Comma-separated transport hints (usb, nfc, ble, internal).
|
||||
example: "usb,nfc"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-03-11T09:00:00Z"
|
||||
last_used_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
example: "2026-03-15T14:30:00Z"
|
||||
|
||||
AuditEvent:
|
||||
type: object
|
||||
@@ -847,6 +895,213 @@ paths:
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
# ── WebAuthn ──────────────────────────────────────────────────────────────
|
||||
|
||||
/v1/auth/webauthn/register/begin:
|
||||
post:
|
||||
summary: Begin WebAuthn registration
|
||||
description: |
|
||||
Start a WebAuthn credential registration ceremony. Requires the current
|
||||
password for re-authentication (same security model as TOTP enrollment).
|
||||
Returns PublicKeyCredentialCreationOptions for the browser WebAuthn API.
|
||||
operationId: beginWebAuthnRegister
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [password]
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
description: Current password for re-authentication.
|
||||
name:
|
||||
type: string
|
||||
description: Optional label for the credential (e.g. "YubiKey 5").
|
||||
example: "YubiKey 5"
|
||||
responses:
|
||||
"200":
|
||||
description: Registration options for navigator.credentials.create().
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: PublicKeyCredentialCreationOptions (WebAuthn spec).
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
description: Account temporarily locked.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/auth/webauthn/register/finish:
|
||||
post:
|
||||
summary: Finish WebAuthn registration
|
||||
description: |
|
||||
Complete the WebAuthn credential registration ceremony. The request body
|
||||
contains the authenticator's response from navigator.credentials.create().
|
||||
The credential is encrypted at rest with AES-256-GCM.
|
||||
operationId: finishWebAuthnRegister
|
||||
tags: [Auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: AuthenticatorAttestationResponse (WebAuthn spec).
|
||||
responses:
|
||||
"200":
|
||||
description: Credential registered.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
/v1/auth/webauthn/login/begin:
|
||||
post:
|
||||
summary: Begin WebAuthn login
|
||||
description: |
|
||||
Start a WebAuthn authentication ceremony. Public RPC — no auth required.
|
||||
With a username: returns allowCredentials for the account's registered
|
||||
credentials. Without a username: starts a discoverable (passkey) flow.
|
||||
operationId: beginWebAuthnLogin
|
||||
tags: [Public]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: Optional. If omitted, starts a discoverable (passkey) flow.
|
||||
example: alice
|
||||
responses:
|
||||
"200":
|
||||
description: Assertion options for navigator.credentials.get().
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: PublicKeyCredentialRequestOptions (WebAuthn spec).
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
|
||||
/v1/auth/webauthn/login/finish:
|
||||
post:
|
||||
summary: Finish WebAuthn login
|
||||
description: |
|
||||
Complete the WebAuthn authentication ceremony. Validates the assertion,
|
||||
checks the sign counter, and issues a JWT. Public RPC — no auth required.
|
||||
operationId: finishWebAuthnLogin
|
||||
tags: [Public]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: AuthenticatorAssertionResponse (WebAuthn spec).
|
||||
responses:
|
||||
"200":
|
||||
description: Login successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TokenResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
|
||||
/v1/accounts/{id}/webauthn:
|
||||
get:
|
||||
summary: List WebAuthn credentials (admin)
|
||||
description: |
|
||||
Returns metadata for all WebAuthn credentials registered to an account.
|
||||
Credential material (IDs, public keys) is never included.
|
||||
operationId: listWebAuthnCredentials
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Account UUID.
|
||||
responses:
|
||||
"200":
|
||||
description: Credential metadata list.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
credentials:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WebAuthnCredentialInfo"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/accounts/{id}/webauthn/{credentialId}:
|
||||
delete:
|
||||
summary: Remove WebAuthn credential (admin)
|
||||
description: |
|
||||
Remove a specific WebAuthn credential from an account. Admin only.
|
||||
operationId: deleteWebAuthnCredential
|
||||
tags: [Admin — Accounts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Account UUID.
|
||||
- name: credentialId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Credential database row ID.
|
||||
responses:
|
||||
"204":
|
||||
description: Credential removed.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/v1/token/issue:
|
||||
post:
|
||||
summary: Issue service account token (admin)
|
||||
|
||||
215
web/static/webauthn.js
Normal file
215
web/static/webauthn.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// webauthn.js — WebAuthn/passkey helpers for the MCIAS web UI.
|
||||
// CSP-compliant: loaded as an external script, no inline code.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Base64URL encode/decode helpers for WebAuthn ArrayBuffer <-> JSON transport.
|
||||
function base64urlEncode(buffer) {
|
||||
var bytes = new Uint8Array(buffer);
|
||||
var str = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
str += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64urlDecode(str) {
|
||||
str = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) { str += '='; }
|
||||
var binary = atob(str);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// Get the CSRF token from the cookie for mutating requests.
|
||||
function getCSRFToken() {
|
||||
var match = document.cookie.match(/(?:^|;\s*)mcias_csrf=([^;]+)/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function showError(id, msg) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) { el.textContent = msg; el.style.display = ''; }
|
||||
}
|
||||
|
||||
function hideError(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) { el.style.display = 'none'; el.textContent = ''; }
|
||||
}
|
||||
|
||||
// mciasWebAuthnRegister initiates a passkey/security-key registration.
|
||||
window.mciasWebAuthnRegister = function (password, name, onSuccess, onError) {
|
||||
if (!window.PublicKeyCredential) {
|
||||
onError('WebAuthn is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
var csrf = getCSRFToken();
|
||||
var savedNonce = '';
|
||||
|
||||
fetch('/profile/webauthn/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ password: password, name: name })
|
||||
})
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Registration failed'); });
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
savedNonce = data.nonce;
|
||||
var opts = data.options;
|
||||
opts.publicKey.challenge = base64urlDecode(opts.publicKey.challenge);
|
||||
if (opts.publicKey.user && opts.publicKey.user.id) {
|
||||
opts.publicKey.user.id = base64urlDecode(opts.publicKey.user.id);
|
||||
}
|
||||
if (opts.publicKey.excludeCredentials) {
|
||||
for (var i = 0; i < opts.publicKey.excludeCredentials.length; i++) {
|
||||
opts.publicKey.excludeCredentials[i].id = base64urlDecode(opts.publicKey.excludeCredentials[i].id);
|
||||
}
|
||||
}
|
||||
return navigator.credentials.create(opts);
|
||||
})
|
||||
.then(function (credential) {
|
||||
if (!credential) throw new Error('Registration cancelled');
|
||||
var credJSON = {
|
||||
id: credential.id,
|
||||
rawId: base64urlEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: base64urlEncode(credential.response.attestationObject),
|
||||
clientDataJSON: base64urlEncode(credential.response.clientDataJSON)
|
||||
}
|
||||
};
|
||||
if (credential.response.getTransports) {
|
||||
credJSON.response.transports = credential.response.getTransports();
|
||||
}
|
||||
return fetch('/profile/webauthn/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ nonce: savedNonce, name: name, credential: credJSON })
|
||||
});
|
||||
})
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Registration failed'); });
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (result) { onSuccess(result); })
|
||||
.catch(function (err) { onError(err.message || 'Registration failed'); });
|
||||
};
|
||||
|
||||
// mciasWebAuthnLogin initiates a passkey login.
|
||||
window.mciasWebAuthnLogin = function (username, onSuccess, onError) {
|
||||
if (!window.PublicKeyCredential) {
|
||||
onError('WebAuthn is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
var savedNonce = '';
|
||||
|
||||
fetch('/login/webauthn/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username || '' })
|
||||
})
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); });
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
savedNonce = data.nonce;
|
||||
var opts = data.options;
|
||||
opts.publicKey.challenge = base64urlDecode(opts.publicKey.challenge);
|
||||
if (opts.publicKey.allowCredentials) {
|
||||
for (var i = 0; i < opts.publicKey.allowCredentials.length; i++) {
|
||||
opts.publicKey.allowCredentials[i].id = base64urlDecode(opts.publicKey.allowCredentials[i].id);
|
||||
}
|
||||
}
|
||||
return navigator.credentials.get(opts);
|
||||
})
|
||||
.then(function (assertion) {
|
||||
if (!assertion) throw new Error('Login cancelled');
|
||||
var credJSON = {
|
||||
id: assertion.id,
|
||||
rawId: base64urlEncode(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
authenticatorData: base64urlEncode(assertion.response.authenticatorData),
|
||||
clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
|
||||
signature: base64urlEncode(assertion.response.signature)
|
||||
}
|
||||
};
|
||||
if (assertion.response.userHandle) {
|
||||
credJSON.response.userHandle = base64urlEncode(assertion.response.userHandle);
|
||||
}
|
||||
return fetch('/login/webauthn/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nonce: savedNonce, credential: credJSON })
|
||||
});
|
||||
})
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); });
|
||||
return resp.json();
|
||||
})
|
||||
.then(function () { onSuccess(); })
|
||||
.catch(function (err) { onError(err.message || 'Login failed'); });
|
||||
};
|
||||
|
||||
// Auto-wire the profile page enrollment button.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var enrollBtn = document.getElementById('webauthn-enroll-btn');
|
||||
if (enrollBtn) {
|
||||
enrollBtn.addEventListener('click', function () {
|
||||
var pw = document.getElementById('webauthn-password').value;
|
||||
var name = document.getElementById('webauthn-name').value || 'Passkey';
|
||||
hideError('webauthn-enroll-error');
|
||||
hideError('webauthn-enroll-success');
|
||||
|
||||
if (!pw) {
|
||||
showError('webauthn-enroll-error', 'Password is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
enrollBtn.disabled = true;
|
||||
enrollBtn.textContent = 'Waiting for authenticator...';
|
||||
|
||||
window.mciasWebAuthnRegister(pw, name, function () {
|
||||
enrollBtn.disabled = false;
|
||||
enrollBtn.textContent = 'Add Passkey';
|
||||
document.getElementById('webauthn-password').value = '';
|
||||
var msg = document.getElementById('webauthn-enroll-success');
|
||||
if (msg) { msg.textContent = 'Passkey registered successfully.'; msg.style.display = ''; }
|
||||
// Reload the credentials list.
|
||||
window.location.reload();
|
||||
}, function (err) {
|
||||
enrollBtn.disabled = false;
|
||||
enrollBtn.textContent = 'Add Passkey';
|
||||
showError('webauthn-enroll-error', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-wire the login page passkey button.
|
||||
var loginBtn = document.getElementById('webauthn-login-btn');
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener('click', function () {
|
||||
hideError('webauthn-login-error');
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Waiting for authenticator...';
|
||||
|
||||
window.mciasWebAuthnLogin('', function () {
|
||||
window.location.href = '/dashboard';
|
||||
}, function (err) {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Sign in with passkey';
|
||||
showError('webauthn-login-error', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -15,6 +15,7 @@
|
||||
<dt class="text-muted">Status</dt>
|
||||
<dd id="status-cell">{{template "account_status" .}}</dd>
|
||||
<dt class="text-muted">TOTP</dt><dd>{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}</dd>
|
||||
{{if .WebAuthnEnabled}}<dt class="text-muted">Passkeys</dt><dd>{{len .WebAuthnCreds}} registered</dd>{{end}}
|
||||
<dt class="text-muted">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd>
|
||||
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd>
|
||||
</dl>
|
||||
@@ -44,6 +45,12 @@
|
||||
<div id="token-delegates-section">{{template "token_delegates" .}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .WebAuthnEnabled}}
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
|
||||
{{template "webauthn_credentials" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
|
||||
<div id="tags-editor">{{template "tags_editor" .}}</div>
|
||||
|
||||
30
web/templates/fragments/webauthn_credentials.html
Normal file
30
web/templates/fragments/webauthn_credentials.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{{define "webauthn_credentials"}}
|
||||
<div id="webauthn-credentials-section">
|
||||
{{if .WebAuthnCreds}}
|
||||
<table class="table" style="font-size:.85rem;margin-bottom:1rem">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Created</th><th>Last Used</th><th>Sign Count</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .WebAuthnCreds}}
|
||||
<tr>
|
||||
<td>{{.Name}}{{if .Discoverable}} <span class="badge" title="Passkey (discoverable)">passkey</span>{{end}}</td>
|
||||
<td class="text-small">{{formatTime .CreatedAt}}</td>
|
||||
<td class="text-small">{{if .LastUsedAt}}{{formatTime (derefTime .LastUsedAt)}}{{else}}Never{{end}}</td>
|
||||
<td>{{.SignCount}}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="{{$.DeletePrefix}}/{{.ID}}"
|
||||
hx-target="#webauthn-credentials-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove this passkey?">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="text-muted text-small">No passkeys registered.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
19
web/templates/fragments/webauthn_enroll.html
Normal file
19
web/templates/fragments/webauthn_enroll.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{{define "webauthn_enroll"}}
|
||||
<div id="webauthn-enroll-section">
|
||||
<div id="webauthn-enroll-error" class="alert alert-error" style="display:none" role="alert"></div>
|
||||
<div id="webauthn-enroll-success" class="alert alert-success" style="display:none" role="alert"></div>
|
||||
<div class="form-group">
|
||||
<label for="webauthn-name">Passkey Name</label>
|
||||
<input class="form-control" type="text" id="webauthn-name" placeholder="e.g. YubiKey 5" value="Passkey">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="webauthn-password">Current Password</label>
|
||||
<input class="form-control" type="password" id="webauthn-password" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="button" id="webauthn-enroll-btn">
|
||||
Add Passkey
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -29,10 +29,24 @@
|
||||
<button class="btn btn-primary" type="submit" style="width:100%">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if .WebAuthnEnabled}}
|
||||
<div style="margin-top:1rem;text-align:center">
|
||||
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem">
|
||||
<hr style="flex:1;border:0;border-top:1px solid #ddd">
|
||||
<span class="text-muted text-small">or</span>
|
||||
<hr style="flex:1;border:0;border-top:1px solid #ddd">
|
||||
</div>
|
||||
<div id="webauthn-login-error" class="alert alert-error" style="display:none" role="alert"></div>
|
||||
<button class="btn btn-secondary" type="button" id="webauthn-login-btn" style="width:100%">
|
||||
Sign in with passkey
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
{{if .WebAuthnEnabled}}<script src="/static/webauthn.js"></script>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
<div class="page-header">
|
||||
<h1>Profile</h1>
|
||||
</div>
|
||||
{{if .WebAuthnEnabled}}
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
|
||||
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
||||
Passkeys let you sign in without a password using your device's biometrics or a security key.
|
||||
</p>
|
||||
{{template "webauthn_credentials" .}}
|
||||
<h3 style="font-size:.9rem;font-weight:600;margin:1rem 0 .5rem">Add a Passkey</h3>
|
||||
{{template "webauthn_enroll" .}}
|
||||
</div>
|
||||
<script src="/static/webauthn.js"></script>
|
||||
{{end}}
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2>
|
||||
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
||||
|
||||
Reference in New Issue
Block a user