Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37afc68287 | |||
| 25417b24f4 | |||
|
|
19fa0c9a8e | ||
| 7db560dae4 | |||
| 124d0cdcd1 | |||
| cf1f4f94be | |||
| 52cc979814 | |||
| 8bf5c9033f | |||
| cb661bb8f5 | |||
| 9657f18784 | |||
| d4e8ef90ee | |||
| d6cc82755d | |||
| 0d38bbae00 | |||
| 23a27be57e |
@@ -0,0 +1,8 @@
|
|||||||
|
[2026-03-15 19:17] - Updated by Junie
|
||||||
|
{
|
||||||
|
"TYPE": "negative",
|
||||||
|
"CATEGORY": "Service reliability",
|
||||||
|
"EXPECTATION": "The Swagger docs endpoint should remain accessible and stable at all times.",
|
||||||
|
"NEW INSTRUCTION": "WHEN swagger/docs endpoint is down or errors THEN Diagnose cause, apply fix, and restore availability immediately"
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
[{"lang":"en","usageCount":1}]
|
[{"lang":"en","usageCount":7}]
|
||||||
287
ARCHITECTURE.md
287
ARCHITECTURE.md
@@ -15,7 +15,7 @@ parties that delegate authentication decisions to it.
|
|||||||
### Components
|
### Components
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ MCIAS Server (mciassrv) │
|
│ MCIAS Server (mciassrv) │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
||||||
│ │ Auth │ │ Token │ │ Account / Role │ │
|
│ │ Auth │ │ Token │ │ Account / Role │ │
|
||||||
@@ -32,7 +32,7 @@ parties that delegate authentication decisions to it.
|
|||||||
│ │ (net/http) │ │ (google.golang.org/ │ │
|
│ │ (net/http) │ │ (google.golang.org/ │ │
|
||||||
│ │ :8443 │ │ grpc) :9443 │ │
|
│ │ :8443 │ │ grpc) :9443 │ │
|
||||||
│ └──────────────────┘ └──────────────────────┘ │
|
│ └──────────────────┘ └──────────────────────┘ │
|
||||||
└──────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
▲ ▲ ▲ ▲
|
▲ ▲ ▲ ▲
|
||||||
│ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
|
│ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
@@ -128,7 +128,8 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
|
|||||||
**Human accounts** — interactive users. Can authenticate via:
|
**Human accounts** — interactive users. Can authenticate via:
|
||||||
- Username + password (Argon2id hash stored in DB)
|
- Username + password (Argon2id hash stored in DB)
|
||||||
- Optional TOTP (RFC 6238); if enrolled, required on every login
|
- 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:
|
**System accounts** — non-interactive service identities. Have:
|
||||||
- A single active bearer token at a time (rotating the token revokes the old one)
|
- A single active bearer token at a time (rotating the token revokes the old one)
|
||||||
@@ -367,7 +368,25 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| POST | `/v1/token/issue` | admin JWT | Issue service account token |
|
| POST | `/v1/token/issue` | admin JWT | Issue service account token |
|
||||||
| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI |
|
| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI |
|
||||||
|
|
||||||
### Account Endpoints (admin only)
|
### Token Download Endpoint
|
||||||
|
|
||||||
|
| Method | Path | Auth required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/token/download/{nonce}` | bearer JWT | Download a previously issued token via one-time nonce (5-min TTL, single-use) |
|
||||||
|
|
||||||
|
The token download flow issues a short-lived nonce when a service token is created
|
||||||
|
via `POST /accounts/{id}/token`. The bearer must be authenticated; the nonce is
|
||||||
|
deleted on first download to prevent replay. This avoids exposing the raw token
|
||||||
|
value in an HTMX fragment or flash message.
|
||||||
|
|
||||||
|
### Token Delegation Endpoints (admin only)
|
||||||
|
|
||||||
|
| Method | Path | Auth required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/accounts/{id}/token/delegates` | admin JWT | Grant a human account permission to issue tokens for a system account |
|
||||||
|
| DELETE | `/accounts/{id}/token/delegates/{grantee}` | admin JWT | Revoke token-issue delegation |
|
||||||
|
|
||||||
|
### Account Endpoints
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -376,6 +395,7 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| GET | `/v1/accounts/{id}` | admin JWT | Get account details |
|
| GET | `/v1/accounts/{id}` | admin JWT | Get account details |
|
||||||
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
||||||
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
|
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
|
||||||
|
| POST | `/v1/accounts/{id}/token` | bearer JWT (admin or delegate) | Issue/rotate service account token |
|
||||||
|
|
||||||
### Password Endpoints
|
### Password Endpoints
|
||||||
|
|
||||||
@@ -401,11 +421,23 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
|
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
|
||||||
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) |
|
| 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
|
### Postgres Credential Endpoints
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials |
|
| GET | `/v1/pgcreds` | bearer JWT | List all credentials accessible to the caller (owned + explicitly granted) |
|
||||||
|
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials for a specific account |
|
||||||
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
|
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
|
||||||
|
|
||||||
### Tag Endpoints (admin only)
|
### Tag Endpoints (admin only)
|
||||||
@@ -479,6 +511,7 @@ cookie pattern (`mcias_csrf`).
|
|||||||
| `/policies` | Policy rules management — create, enable/disable, delete |
|
| `/policies` | Policy rules management — create, enable/disable, delete |
|
||||||
| `/audit` | Audit log viewer |
|
| `/audit` | Audit log viewer |
|
||||||
| `/profile` | User profile — self-service password change (any authenticated user) |
|
| `/profile` | User profile — self-service password change (any authenticated user) |
|
||||||
|
| `/service-accounts` | Delegated service account list for non-admin users; issue/rotate token with one-time download |
|
||||||
|
|
||||||
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
||||||
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
||||||
@@ -658,6 +691,43 @@ CREATE TABLE policy_rules (
|
|||||||
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
|
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
|
||||||
expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339)
|
expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Token issuance delegation: tracks which human accounts may issue tokens for
|
||||||
|
-- a given system account without holding the global admin role. Admins manage
|
||||||
|
-- delegates; delegates can issue/rotate tokens for the specific system account
|
||||||
|
-- only and cannot modify any other account settings.
|
||||||
|
CREATE TABLE service_account_delegates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- target system account
|
||||||
|
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- human account granted access
|
||||||
|
granted_by INTEGER REFERENCES accounts(id), -- admin who granted access
|
||||||
|
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
UNIQUE (account_id, grantee_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
### Schema Notes
|
||||||
@@ -665,9 +735,10 @@ CREATE TABLE policy_rules (
|
|||||||
- Passwords are stored as PHC-format Argon2id strings (e.g.,
|
- Passwords are stored as PHC-format Argon2id strings (e.g.,
|
||||||
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
|
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
|
||||||
parameters. Future parameter upgrades are transparent.
|
parameters. Future parameter upgrades are transparent.
|
||||||
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a
|
- TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
|
||||||
master key held only in server memory (derived at startup from a passphrase
|
encrypted with AES-256-GCM using a master key held only in server memory
|
||||||
or keyfile). The nonce is stored adjacent to the ciphertext.
|
(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
|
- 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.
|
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
|
- The signing key encryption is layered: the Ed25519 private key is wrapped
|
||||||
@@ -735,30 +806,45 @@ mcias/
|
|||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ ├── mciasctl/ # REST admin CLI
|
│ ├── mciasctl/ # REST admin CLI
|
||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ ├── mciasdb/ # direct SQLite maintenance tool (Phase 6)
|
│ ├── mciasdb/ # direct SQLite maintenance tool
|
||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ └── mciasgrpcctl/ # gRPC admin CLI companion (Phase 7)
|
│ └── mciasgrpcctl/ # gRPC admin CLI companion
|
||||||
│ └── main.go
|
│ └── main.go
|
||||||
├── internal/
|
├── internal/
|
||||||
|
│ ├── audit/ # audit log event detail marshaling
|
||||||
│ ├── auth/ # login flow, TOTP verification, account lockout
|
│ ├── auth/ # login flow, TOTP verification, account lockout
|
||||||
│ ├── config/ # config file parsing and validation
|
│ ├── config/ # config file parsing and validation
|
||||||
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
||||||
│ ├── db/ # SQLite access layer (schema, migrations, queries)
|
│ ├── db/ # SQLite access layer (schema, migrations, queries)
|
||||||
│ ├── grpcserver/ # gRPC handler implementations (Phase 7)
|
│ │ └── migrations/ # numbered SQL migrations (currently 9)
|
||||||
|
│ ├── grpcserver/ # gRPC handler implementations
|
||||||
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
|
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
|
||||||
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
|
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
|
||||||
│ ├── policy/ # in-process authorization policy engine (§20)
|
│ ├── policy/ # in-process authorization policy engine (§20)
|
||||||
│ ├── server/ # HTTP handlers, router setup
|
│ ├── server/ # HTTP handlers, router setup
|
||||||
│ ├── token/ # JWT issuance, validation, revocation
|
│ ├── token/ # JWT issuance, validation, revocation
|
||||||
│ ├── ui/ # web UI context, CSRF, session, template handlers
|
│ ├── ui/ # web UI context, CSRF, session, template handlers
|
||||||
│ └── validate/ # input validation helpers (username, password strength)
|
│ ├── validate/ # input validation helpers (username, password strength)
|
||||||
|
│ ├── vault/ # master key lifecycle: seal/unseal state, key derivation
|
||||||
|
│ └── webauthn/ # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface)
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── static/ # CSS and static assets
|
│ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
|
||||||
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
│ ├── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||||
|
│ └── embed.go # fs.FS embedding of static files and templates
|
||||||
├── proto/
|
├── proto/
|
||||||
│ └── mcias/v1/ # Protobuf service definitions (Phase 7)
|
│ └── mcias/v1/ # Protobuf service definitions
|
||||||
├── gen/
|
├── gen/
|
||||||
│ └── mcias/v1/ # Generated Go stubs from protoc (committed; Phase 7)
|
│ └── mcias/v1/ # Generated Go stubs from protoc (committed)
|
||||||
|
├── clients/
|
||||||
|
│ ├── go/ # Go client library
|
||||||
|
│ ├── python/ # Python client library
|
||||||
|
│ ├── rust/ # Rust client library
|
||||||
|
│ └── lisp/ # Common Lisp client library
|
||||||
|
├── test/
|
||||||
|
│ ├── e2e/ # end-to-end test suite
|
||||||
|
│ └── mock/ # Go mock server for client integration tests
|
||||||
|
├── dist/ # operational artifacts: systemd unit, install script, config templates
|
||||||
|
├── man/man1/ # man pages (mciassrv.1, mciasctl.1, mciasdb.1, mciasgrpcctl.1)
|
||||||
└── go.mod
|
└── go.mod
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -810,6 +896,8 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
|
|||||||
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
|
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
|
||||||
| `policy_rule_deleted` | Policy rule deleted |
|
| `policy_rule_deleted` | Policy rule deleted |
|
||||||
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
|
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
|
||||||
|
| `token_delegate_granted` | Admin granted a human account permission to issue tokens for a system account |
|
||||||
|
| `token_delegate_revoked` | Admin revoked a human account's token-issue delegation |
|
||||||
| `vault_unsealed` | Vault unsealed via REST API or web UI; details include `source` (api\|ui) and `ip` |
|
| `vault_unsealed` | Vault unsealed via REST API or web UI; details include `source` (api\|ui) and `ip` |
|
||||||
| `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` |
|
| `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` |
|
||||||
|
|
||||||
@@ -970,7 +1058,8 @@ proto/
|
|||||||
└── v1/
|
└── v1/
|
||||||
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
|
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
|
||||||
├── token.proto # Validate, Issue, Revoke
|
├── token.proto # Validate, Issue, Revoke
|
||||||
├── account.proto # CRUD for accounts and roles
|
├── account.proto # CRUD for accounts, roles, and credentials
|
||||||
|
├── policy.proto # Policy rule CRUD (PolicyService)
|
||||||
├── admin.proto # Health, public-key retrieval
|
├── admin.proto # Health, public-key retrieval
|
||||||
└── common.proto # Shared message types (Error, Timestamp wrappers)
|
└── common.proto # Shared message types (Error, Timestamp wrappers)
|
||||||
|
|
||||||
@@ -991,6 +1080,7 @@ in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.
|
|||||||
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
|
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
|
||||||
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
|
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
|
||||||
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
|
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
|
||||||
|
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
|
||||||
| `AdminService` | `Health`, `GetPublicKey` |
|
| `AdminService` | `Health`, `GetPublicKey` |
|
||||||
|
|
||||||
All request/response messages follow the same credential-exclusion rules as
|
All request/response messages follow the same credential-exclusion rules as
|
||||||
@@ -1203,8 +1293,9 @@ The Makefile `docker` target automates the build step with the version tag.
|
|||||||
| `generate` | `go generate ./...` (re-generates proto stubs) |
|
| `generate` | `go generate ./...` (re-generates proto stubs) |
|
||||||
| `man` | Build man pages; compress to `.gz` in `man/` |
|
| `man` | Build man pages; compress to `.gz` in `man/` |
|
||||||
| `install` | Run `dist/install.sh` |
|
| `install` | Run `dist/install.sh` |
|
||||||
| `docker` | `docker build -t mcias:$(VERSION) .` |
|
| `docker` | `docker build -t mcias:$(VERSION) -t mcias:latest .` |
|
||||||
| `clean` | Remove `bin/` and compressed man pages |
|
| `docker-clean` | Remove local `mcias:$(VERSION)` and `mcias:latest` images; prune dangling images with the mcias label |
|
||||||
|
| `clean` | Remove `bin/`, compressed man pages, and local Docker images |
|
||||||
| `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |
|
| `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |
|
||||||
|
|
||||||
### Upgrade Path
|
### Upgrade Path
|
||||||
@@ -1373,6 +1464,8 @@ needed:
|
|||||||
|
|
||||||
- A human account should be able to access credentials for one specific service
|
- A human account should be able to access credentials for one specific service
|
||||||
without being a full admin.
|
without being a full admin.
|
||||||
|
- A human account should be able to issue/rotate tokens for one specific service
|
||||||
|
account without holding the global `admin` role (see token delegation, §21).
|
||||||
- A system account (`deploy-agent`) should only operate on hosts tagged
|
- A system account (`deploy-agent`) should only operate on hosts tagged
|
||||||
`env:staging`, not `env:production`.
|
`env:staging`, not `env:production`.
|
||||||
- A "secrets reader" role should read pgcreds for any service but change nothing.
|
- A "secrets reader" role should read pgcreds for any service but change nothing.
|
||||||
@@ -1465,7 +1558,7 @@ type Resource struct {
|
|||||||
// Rule is a single policy statement. All populated fields are ANDed.
|
// Rule is a single policy statement. All populated fields are ANDed.
|
||||||
// A zero/empty field is a wildcard (matches anything).
|
// A zero/empty field is a wildcard (matches anything).
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
ID int64 // database primary key; 0 for built-in rules
|
ID int64 // database primary key; negative for built-in rules (-1 … -7)
|
||||||
Description string
|
Description string
|
||||||
|
|
||||||
// Principal match conditions
|
// Principal match conditions
|
||||||
@@ -1681,3 +1774,157 @@ introduced.
|
|||||||
| `policy_rule_deleted` | Rule deleted |
|
| `policy_rule_deleted` | Rule deleted |
|
||||||
| `tag_added` | Tag added to an account |
|
| `tag_added` | Tag added to an account |
|
||||||
| `tag_removed` | Tag removed from an account |
|
| `tag_removed` | Tag removed from an account |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Token Issuance Delegation
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
The initial design required the `admin` role to issue a service account token.
|
||||||
|
This blocks a common workflow: a developer who owns one personal app (e.g.
|
||||||
|
`payments-api`) wants to rotate its service token without granting another
|
||||||
|
person full admin access to all of MCIAS.
|
||||||
|
|
||||||
|
Token issuance delegation solves this by allowing admins to grant specific
|
||||||
|
human accounts the right to issue/rotate tokens for specific system accounts —
|
||||||
|
and nothing else.
|
||||||
|
|
||||||
|
### Model
|
||||||
|
|
||||||
|
The `service_account_delegates` table stores the delegation relationship:
|
||||||
|
|
||||||
|
```
|
||||||
|
service_account_delegates(account_id, grantee_id, granted_by, granted_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `account_id` — the **system account** whose token the delegate may issue
|
||||||
|
- `grantee_id` — the **human account** granted the right
|
||||||
|
- `granted_by` — the admin who created the grant (for audit purposes)
|
||||||
|
|
||||||
|
A human account is a delegate if a row exists with their ID as `grantee_id`.
|
||||||
|
Delegates may:
|
||||||
|
|
||||||
|
- Issue/rotate the token for the specific system account
|
||||||
|
- Download the newly issued token via the one-time nonce endpoint
|
||||||
|
- View the system account on their `/service-accounts` page
|
||||||
|
|
||||||
|
Delegates may **not**:
|
||||||
|
|
||||||
|
- Modify roles, tags, or status on the system account
|
||||||
|
- Read or modify pgcreds for the system account
|
||||||
|
- List other accounts or perform any other admin operation
|
||||||
|
|
||||||
|
### Token Download Flow
|
||||||
|
|
||||||
|
Issuing a service token via `POST /accounts/{id}/token` (admin or delegate)
|
||||||
|
stores the raw token string in an in-memory `sync.Map` under a random nonce
|
||||||
|
with a 5-minute TTL. The handler returns the nonce in the HTMX fragment.
|
||||||
|
|
||||||
|
The caller redeems the nonce via `GET /token/download/{nonce}`, which:
|
||||||
|
|
||||||
|
1. Looks up the nonce in the map (missing → 404).
|
||||||
|
2. Deletes the nonce immediately (prevents replay).
|
||||||
|
3. Returns the token as `Content-Disposition: attachment; filename=token.txt`.
|
||||||
|
|
||||||
|
The nonce is not stored in the database and is lost on server restart. This
|
||||||
|
is intentional: if the download window is missed, the operator simply issues
|
||||||
|
a new token.
|
||||||
|
|
||||||
|
### Authorization Check
|
||||||
|
|
||||||
|
`POST /accounts/{id}/token` is authenticated (bearer JWT + CSRF) but not
|
||||||
|
admin-only. The handler performs an explicit check:
|
||||||
|
|
||||||
|
```
|
||||||
|
if claims.HasRole("admin") OR db.HasTokenIssueAccess(targetID, callerID):
|
||||||
|
proceed
|
||||||
|
else:
|
||||||
|
403 Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
|
This check is done in the handler rather than middleware because the
|
||||||
|
delegation relationship requires a DB lookup that depends on the caller's
|
||||||
|
identity and the specific target account.
|
||||||
|
|
||||||
|
### Admin Management
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|---|---|
|
||||||
|
| `POST /accounts/{id}/token/delegates` | Grant delegation (admin only) |
|
||||||
|
| `DELETE /accounts/{id}/token/delegates/{grantee}` | Revoke delegation (admin only) |
|
||||||
|
|
||||||
|
Both operations produce audit events (`token_delegate_granted`,
|
||||||
|
`token_delegate_revoked`) and are visible in the account detail UI under
|
||||||
|
the "Token Issue Access" section.
|
||||||
|
|
||||||
|
### Audit Events
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `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.
|
||||||
|
|||||||
11
Makefile
11
Makefile
@@ -10,6 +10,7 @@
|
|||||||
# make clean — remove bin/ and generated artifacts
|
# make clean — remove bin/ and generated artifacts
|
||||||
# make dist — build release tarballs for linux/amd64 and linux/arm64
|
# make dist — build release tarballs for linux/amd64 and linux/arm64
|
||||||
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
|
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
|
||||||
|
# make docker-clean — remove local mcias Docker images
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Variables
|
# Variables
|
||||||
@@ -98,6 +99,7 @@ install: build
|
|||||||
clean:
|
clean:
|
||||||
rm -rf $(BIN_DIR)
|
rm -rf $(BIN_DIR)
|
||||||
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
|
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
|
||||||
|
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# dist — cross-compiled release tarballs for linux/amd64 and linux/arm64
|
# dist — cross-compiled release tarballs for linux/amd64 and linux/arm64
|
||||||
@@ -134,6 +136,14 @@ dist: man
|
|||||||
docker:
|
docker:
|
||||||
docker build -t mcias:$(VERSION) -t mcias:latest .
|
docker build -t mcias:$(VERSION) -t mcias:latest .
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# docker-clean — remove local mcias Docker images
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
.PHONY: docker-clean
|
||||||
|
docker-clean:
|
||||||
|
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
|
||||||
|
-docker image prune -f --filter label=org.opencontainers.image.title=mcias 2>/dev/null || true
|
||||||
|
|
||||||
.PHONY: install-local
|
.PHONY: install-local
|
||||||
install-local: build
|
install-local: build
|
||||||
cp bin/* $(HOME)/.local/bin/
|
cp bin/* $(HOME)/.local/bin/
|
||||||
@@ -153,3 +163,4 @@ help:
|
|||||||
@echo " clean Remove build artifacts"
|
@echo " clean Remove build artifacts"
|
||||||
@echo " dist Build release tarballs for Linux amd64/arm64"
|
@echo " dist Build release tarballs for Linux amd64/arm64"
|
||||||
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest"
|
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest"
|
||||||
|
@echo " docker-clean Remove local mcias Docker images"
|
||||||
|
|||||||
514
POLICY.md
Normal file
514
POLICY.md
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
# MCIAS Policy Engine
|
||||||
|
|
||||||
|
Reference guide for the MCIAS attribute-based access control (ABAC) policy
|
||||||
|
engine. Covers concepts, rule authoring, the full action/resource catalogue,
|
||||||
|
built-in defaults, time-scoped rules, and worked examples.
|
||||||
|
|
||||||
|
For the authoritative design rationale and middleware integration details see
|
||||||
|
[ARCHITECTURE.md §20](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Concepts
|
||||||
|
|
||||||
|
### Evaluation model
|
||||||
|
|
||||||
|
The policy engine is a **pure function**: given a `PolicyInput` (assembled from
|
||||||
|
JWT claims and database lookups) and a slice of `Rule` values, it returns an
|
||||||
|
`Effect` (`allow` or `deny`) and a pointer to the matching rule.
|
||||||
|
|
||||||
|
Evaluation proceeds in three steps:
|
||||||
|
|
||||||
|
1. **Sort** all rules (built-in defaults + operator rules) by `Priority`
|
||||||
|
ascending. Lower number = evaluated first. Stable sort preserves insertion
|
||||||
|
order within the same priority.
|
||||||
|
2. **Deny-wins**: the first matching `deny` rule terminates evaluation
|
||||||
|
immediately and returns `Deny`.
|
||||||
|
3. **First-allow**: if no `deny` matched, the first matching `allow` rule
|
||||||
|
returns `Allow`.
|
||||||
|
4. **Default-deny**: if no rule matched at all, the request is denied.
|
||||||
|
|
||||||
|
The engine never touches the database. The caller (middleware) is responsible
|
||||||
|
for assembling `PolicyInput` from JWT claims and DB lookups before calling
|
||||||
|
`engine.Evaluate`.
|
||||||
|
|
||||||
|
### Rule matching
|
||||||
|
|
||||||
|
A rule matches a request when **every populated field** satisfies its
|
||||||
|
condition. An empty/zero field is a wildcard (matches anything).
|
||||||
|
|
||||||
|
| Rule field | Match condition |
|
||||||
|
|---|---|
|
||||||
|
| `roles` | Principal holds **at least one** of the listed roles |
|
||||||
|
| `account_types` | Principal's account type is in the list (`"human"`, `"system"`) |
|
||||||
|
| `subject_uuid` | Principal UUID equals this value exactly |
|
||||||
|
| `actions` | Request action is in the list |
|
||||||
|
| `resource_type` | Target resource type equals this value |
|
||||||
|
| `owner_matches_subject` | (if `true`) resource owner UUID equals the principal UUID |
|
||||||
|
| `service_names` | Target service account username is in the list |
|
||||||
|
| `required_tags` | Target account carries **all** of the listed tags |
|
||||||
|
|
||||||
|
All conditions are AND-ed. To express OR across principals or resources, create
|
||||||
|
multiple rules.
|
||||||
|
|
||||||
|
### Priority
|
||||||
|
|
||||||
|
| Range | Intended use |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Built-in defaults (compiled in; cannot be overridden via API) |
|
||||||
|
| 1–49 | High-precedence operator deny rules (explicit blocks) |
|
||||||
|
| 50–99 | Normal operator allow rules |
|
||||||
|
| 100 | Default for new rules created via API or CLI |
|
||||||
|
| 101+ | Low-precedence fallback rules |
|
||||||
|
|
||||||
|
Because deny-wins applies within the matched set (not just within a priority
|
||||||
|
band), a `deny` rule at priority 100 still overrides an `allow` at priority 50
|
||||||
|
if both match. Use explicit deny rules at low priority numbers (e.g. 10) when
|
||||||
|
you want them to fire before any allow can be considered.
|
||||||
|
|
||||||
|
### Built-in default rules
|
||||||
|
|
||||||
|
These rules are compiled into the binary (`internal/policy/defaults.go`). They
|
||||||
|
have IDs -1 through -7, priority 0, and **cannot be disabled or deleted via
|
||||||
|
the API**. They reproduce the previous binary admin/non-admin behavior exactly.
|
||||||
|
|
||||||
|
| ID | Description | Conditions | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| -1 | Admin wildcard | `roles=[admin]` | allow |
|
||||||
|
| -2 | Self-service logout / token renewal | `actions=[auth:logout, tokens:renew]` | allow |
|
||||||
|
| -3 | Self-service TOTP enrollment | `actions=[totp:enroll]` | allow |
|
||||||
|
| -7 | Self-service password change | `account_types=[human]`, `actions=[auth:change_password]` | allow |
|
||||||
|
| -4 | System account reads own pgcreds | `account_types=[system]`, `actions=[pgcreds:read]`, `resource_type=pgcreds`, `owner_matches_subject=true` | allow |
|
||||||
|
| -5 | System account issues/renews own token | `account_types=[system]`, `actions=[tokens:issue, tokens:renew]`, `resource_type=token`, `owner_matches_subject=true` | allow |
|
||||||
|
| -6 | Public endpoints | `actions=[tokens:validate, auth:login]` | allow |
|
||||||
|
|
||||||
|
Custom operator rules extend this baseline; they do not replace it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Actions and Resource Types
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
Actions follow the `resource:verb` convention. Use the exact string values
|
||||||
|
shown below when authoring rules.
|
||||||
|
|
||||||
|
| Action string | Description | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `accounts:list` | List all accounts | admin |
|
||||||
|
| `accounts:create` | Create an account | admin |
|
||||||
|
| `accounts:read` | Read account details | admin |
|
||||||
|
| `accounts:update` | Update account (status, etc.) | admin |
|
||||||
|
| `accounts:delete` | Soft-delete an account | admin |
|
||||||
|
| `roles:read` | Read role assignments | admin |
|
||||||
|
| `roles:write` | Grant or revoke roles | admin |
|
||||||
|
| `tags:read` | Read account tags | admin |
|
||||||
|
| `tags:write` | Set account tags | admin |
|
||||||
|
| `tokens:issue` | Issue or rotate a service token | admin or delegate |
|
||||||
|
| `tokens:revoke` | Revoke a token | admin |
|
||||||
|
| `tokens:validate` | Validate a token | public |
|
||||||
|
| `tokens:renew` | Renew own token | self-service |
|
||||||
|
| `pgcreds:read` | Read Postgres credentials | admin or delegated |
|
||||||
|
| `pgcreds:write` | Set Postgres credentials | admin |
|
||||||
|
| `audit:read` | Read audit log | admin |
|
||||||
|
| `totp:enroll` | Enroll TOTP | self-service |
|
||||||
|
| `totp:remove` | Remove TOTP from an account | admin |
|
||||||
|
| `auth:login` | Authenticate (username + password) | public |
|
||||||
|
| `auth:logout` | Invalidate own session token | self-service |
|
||||||
|
| `auth:change_password` | Change own password | self-service |
|
||||||
|
| `policy:list` | List policy rules | admin |
|
||||||
|
| `policy:manage` | Create, update, or delete policy rules | admin |
|
||||||
|
|
||||||
|
### Resource types
|
||||||
|
|
||||||
|
| Resource type string | Description |
|
||||||
|
|---|---|
|
||||||
|
| `account` | A human or system account record |
|
||||||
|
| `token` | A JWT or service bearer token |
|
||||||
|
| `pgcreds` | A Postgres credential record |
|
||||||
|
| `audit_log` | The audit event log |
|
||||||
|
| `totp` | A TOTP enrollment record |
|
||||||
|
| `policy` | A policy rule record |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Rule Schema
|
||||||
|
|
||||||
|
Rules are stored in the `policy_rules` table. The `rule_json` column holds a
|
||||||
|
JSON-encoded `RuleBody`. All other fields are dedicated columns.
|
||||||
|
|
||||||
|
### Database columns
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER PK | Auto-assigned |
|
||||||
|
| `priority` | INTEGER | Default 100; lower = evaluated first |
|
||||||
|
| `description` | TEXT | Human-readable label (required) |
|
||||||
|
| `enabled` | BOOLEAN | Disabled rules are excluded from the cache |
|
||||||
|
| `not_before` | DATETIME (nullable) | Rule inactive before this UTC timestamp |
|
||||||
|
| `expires_at` | DATETIME (nullable) | Rule inactive at and after this UTC timestamp |
|
||||||
|
| `rule_json` | TEXT | JSON-encoded `RuleBody` (see below) |
|
||||||
|
|
||||||
|
### RuleBody JSON fields
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effect": "allow" | "deny",
|
||||||
|
"roles": ["role1", "role2"],
|
||||||
|
"account_types": ["human"] | ["system"] | ["human", "system"],
|
||||||
|
"subject_uuid": "<UUID string>",
|
||||||
|
"actions": ["action:verb", ...],
|
||||||
|
"resource_type": "<resource type string>",
|
||||||
|
"owner_matches_subject": true | false,
|
||||||
|
"service_names": ["svc-username", ...],
|
||||||
|
"required_tags": ["tag:value", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All fields are optional except `effect`. Omitted fields are wildcards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Managing Rules
|
||||||
|
|
||||||
|
### Via mciasctl
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# List all rules
|
||||||
|
mciasctl policy list
|
||||||
|
|
||||||
|
# Create a rule from a JSON file
|
||||||
|
mciasctl policy create -description "My rule" -json rule.json
|
||||||
|
|
||||||
|
# Create a time-scoped rule
|
||||||
|
mciasctl policy create \
|
||||||
|
-description "Temp production access" \
|
||||||
|
-json rule.json \
|
||||||
|
-not-before 2026-04-01T00:00:00Z \
|
||||||
|
-expires-at 2026-04-01T04:00:00Z
|
||||||
|
|
||||||
|
# Enable or disable a rule
|
||||||
|
mciasctl policy update -id 7 -enabled=false
|
||||||
|
|
||||||
|
# Delete a rule
|
||||||
|
mciasctl policy delete -id 7
|
||||||
|
```
|
||||||
|
|
||||||
|
### Via REST API (admin JWT required)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/v1/policy/rules` | List all rules |
|
||||||
|
| POST | `/v1/policy/rules` | Create a rule |
|
||||||
|
| GET | `/v1/policy/rules/{id}` | Get a single rule |
|
||||||
|
| PATCH | `/v1/policy/rules/{id}` | Update priority, enabled, or description |
|
||||||
|
| DELETE | `/v1/policy/rules/{id}` | Delete a rule |
|
||||||
|
|
||||||
|
### Via Web UI
|
||||||
|
|
||||||
|
The `/policies` page lists all rules with enable/disable toggles and a create
|
||||||
|
form. Mutating operations use HTMX partial-page updates.
|
||||||
|
|
||||||
|
### Cache reload
|
||||||
|
|
||||||
|
The `Engine` caches the active rule set in memory. It reloads automatically
|
||||||
|
after any `policy_rule_*` admin event. To force a reload without a rule change,
|
||||||
|
send `SIGHUP` to `mciassrv`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Account Tags
|
||||||
|
|
||||||
|
Tags are key:value strings attached to accounts (human or system) and used as
|
||||||
|
resource match conditions in rules. They are stored in the `account_tags` table.
|
||||||
|
|
||||||
|
### Recommended tag conventions
|
||||||
|
|
||||||
|
| Tag | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `env:production` | Account belongs to the production environment |
|
||||||
|
| `env:staging` | Account belongs to the staging environment |
|
||||||
|
| `env:dev` | Account belongs to the development environment |
|
||||||
|
| `svc:payments-api` | Account is associated with the payments-api service |
|
||||||
|
| `machine:db-west-01` | Account is associated with a specific host |
|
||||||
|
| `team:platform` | Account is owned by the platform team |
|
||||||
|
|
||||||
|
Tag names are not enforced by the schema; the conventions above are
|
||||||
|
recommendations only.
|
||||||
|
|
||||||
|
### Managing tags
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Set tags on an account (replaces the full tag set atomically)
|
||||||
|
mciasctl accounts update -id <uuid> -tags "env:staging,svc:payments-api"
|
||||||
|
|
||||||
|
# Via REST (admin JWT)
|
||||||
|
PUT /v1/accounts/{id}/tags
|
||||||
|
Content-Type: application/json
|
||||||
|
["env:staging", "svc:payments-api"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Worked Examples
|
||||||
|
|
||||||
|
### Example A — Named service delegation
|
||||||
|
|
||||||
|
**Goal:** Alice needs to read Postgres credentials for `payments-api` only.
|
||||||
|
|
||||||
|
1. Grant Alice the role `svc:payments-api`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl accounts roles grant -id <alice-uuid> -role svc:payments-api
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create the allow rule (`rule.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effect": "allow",
|
||||||
|
"roles": ["svc:payments-api"],
|
||||||
|
"actions": ["pgcreds:read"],
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"service_names": ["payments-api"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl policy create -description "Alice: read payments-api pgcreds" \
|
||||||
|
-json rule.json -priority 50
|
||||||
|
```
|
||||||
|
|
||||||
|
When Alice calls `GET /v1/accounts/{payments-api-uuid}/pgcreds`, the middleware
|
||||||
|
sets `resource.ServiceName = "payments-api"`. The rule matches and access is
|
||||||
|
granted. A call against any other service account sets a different
|
||||||
|
`ServiceName`; no rule matches and default-deny applies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example B — Machine-tag gating (staging only)
|
||||||
|
|
||||||
|
**Goal:** `deploy-agent` may read pgcreds for staging accounts but must be
|
||||||
|
explicitly blocked from production.
|
||||||
|
|
||||||
|
1. Tag all staging system accounts:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl accounts update -id <svc-uuid> -tags "env:staging"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Explicit deny for production (low priority number = evaluated first):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effect": "deny",
|
||||||
|
"subject_uuid": "<deploy-agent-uuid>",
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"required_tags": ["env:production"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl policy create -description "deploy-agent: deny production pgcreds" \
|
||||||
|
-json deny.json -priority 10
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Allow for staging:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effect": "allow",
|
||||||
|
"subject_uuid": "<deploy-agent-uuid>",
|
||||||
|
"actions": ["pgcreds:read"],
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"required_tags": ["env:staging"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl policy create -description "deploy-agent: allow staging pgcreds" \
|
||||||
|
-json allow.json -priority 50
|
||||||
|
```
|
||||||
|
|
||||||
|
The deny rule (priority 10) fires before the allow rule (priority 50) for any
|
||||||
|
production-tagged resource. For staging resources the deny does not match and
|
||||||
|
the allow rule permits access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example C — Blanket "secrets reader" role
|
||||||
|
|
||||||
|
**Goal:** Any account holding the `secrets-reader` role may read pgcreds for
|
||||||
|
any service.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effect": "allow",
|
||||||
|
"roles": ["secrets-reader"],
|
||||||
|
"actions": ["pgcreds:read"],
|
||||||
|
"resource_type": "pgcreds"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl policy create -description "secrets-reader: read any pgcreds" \
|
||||||
|
-json rule.json -priority 50
|
||||||
|
```
|
||||||
|
|
||||||
|
No `service_names` or `required_tags` means the rule matches any target
|
||||||
|
account. Grant the role to any account that needs broad read access:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl accounts roles grant -id <uuid> -role secrets-reader
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example D — Time-scoped emergency access
|
||||||
|
|
||||||
|
**Goal:** `deploy-agent` needs temporary access to production pgcreds for a
|
||||||
|
4-hour maintenance window on 2026-04-01.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effect": "allow",
|
||||||
|
"subject_uuid": "<deploy-agent-uuid>",
|
||||||
|
"actions": ["pgcreds:read"],
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"required_tags": ["env:production"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl policy create \
|
||||||
|
-description "deploy-agent: temp production access (maintenance window)" \
|
||||||
|
-json rule.json \
|
||||||
|
-priority 50 \
|
||||||
|
-not-before 2026-04-01T02:00:00Z \
|
||||||
|
-expires-at 2026-04-01T06:00:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine excludes this rule from the cache before `not_before` and after
|
||||||
|
`expires_at`. No manual cleanup is required; the rule becomes inert
|
||||||
|
automatically. Both fields are nullable — omitting either means no constraint
|
||||||
|
on that end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example E — Per-account subject rule
|
||||||
|
|
||||||
|
**Goal:** Bob (a contractor) may issue/rotate the token for `worker-bot` only,
|
||||||
|
without any admin role.
|
||||||
|
|
||||||
|
1. Grant delegation via the delegation API (preferred for token issuance; see
|
||||||
|
ARCHITECTURE.md §21):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl accounts token delegates grant \
|
||||||
|
-id <worker-bot-uuid> -grantee <bob-uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, equivalently, via a policy rule:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effect": "allow",
|
||||||
|
"subject_uuid": "<bob-uuid>",
|
||||||
|
"actions": ["tokens:issue", "tokens:renew"],
|
||||||
|
"resource_type": "token",
|
||||||
|
"service_names": ["worker-bot"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl policy create -description "Bob: issue worker-bot token" \
|
||||||
|
-json rule.json -priority 50
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Bob uses the `/service-accounts` UI page or `mciasctl` to rotate the token
|
||||||
|
and download it via the one-time nonce endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example F — Deny a specific account from all access
|
||||||
|
|
||||||
|
**Goal:** Temporarily block `mallory` (UUID known) from all operations without
|
||||||
|
deleting the account.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effect": "deny",
|
||||||
|
"subject_uuid": "<mallory-uuid>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl policy create -description "Block mallory (incident response)" \
|
||||||
|
-json rule.json -priority 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Priority 1 ensures this deny fires before any allow rule. Because deny-wins
|
||||||
|
applies globally (not just within a priority band), this blocks mallory even
|
||||||
|
though the admin wildcard (priority 0, allow) would otherwise match. Note: the
|
||||||
|
admin wildcard is an `allow` rule; a `deny` at any priority overrides it for
|
||||||
|
the matched principal.
|
||||||
|
|
||||||
|
To lift the block, delete or disable the rule:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mciasctl policy update -id <rule-id> -enabled=false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security Recommendations
|
||||||
|
|
||||||
|
1. **Prefer explicit deny rules for sensitive resources.** Use `required_tags`
|
||||||
|
or `service_names` to scope allow rules narrowly, and add a corresponding
|
||||||
|
deny rule at a lower priority number for the resources that must never be
|
||||||
|
accessible.
|
||||||
|
|
||||||
|
2. **Use time-scoped rules for temporary access.** Set `expires_at` instead of
|
||||||
|
creating a rule and relying on manual deletion. The engine enforces expiry
|
||||||
|
automatically at cache-load time.
|
||||||
|
|
||||||
|
3. **Avoid wildcard allow rules without resource scoping.** A rule with only
|
||||||
|
`roles` and `actions` but no `resource_type`, `service_names`, or
|
||||||
|
`required_tags` matches every resource of every type. Scope rules as
|
||||||
|
narrowly as the use case allows.
|
||||||
|
|
||||||
|
4. **Audit deny events.** Every explicit deny produces a `policy_deny` audit
|
||||||
|
event. Review the audit log (`GET /v1/audit` or the `/audit` UI page)
|
||||||
|
regularly to detect unexpected access patterns.
|
||||||
|
|
||||||
|
5. **Do not rely on priority alone for security boundaries.** Priority controls
|
||||||
|
evaluation order, not security strength. A deny rule at priority 100 still
|
||||||
|
overrides an allow at priority 50 if both match. Use deny rules explicitly
|
||||||
|
rather than assuming a lower-priority allow will be shadowed.
|
||||||
|
|
||||||
|
6. **Keep the built-in defaults intact.** The compiled-in rules reproduce the
|
||||||
|
baseline admin/self-service behavior. Custom rules extend this baseline;
|
||||||
|
they cannot disable the defaults. Do not attempt to work around them by
|
||||||
|
creating conflicting operator rules — the deny-wins semantics mean an
|
||||||
|
operator deny at priority 1 will block even the admin wildcard for the
|
||||||
|
matched principal.
|
||||||
|
|
||||||
|
7. **Reload after bulk changes.** After importing many rules via the REST API,
|
||||||
|
send `SIGHUP` to `mciassrv` to force an immediate cache reload rather than
|
||||||
|
waiting for the next individual rule event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Audit Events
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `policy_deny` | Engine denied a request; payload: `{action, resource_type, service_name, required_tags, matched_rule_id}` — never contains credential material |
|
||||||
|
| `policy_rule_created` | New operator rule created |
|
||||||
|
| `policy_rule_updated` | Rule priority, enabled flag, or description changed |
|
||||||
|
| `policy_rule_deleted` | Rule deleted |
|
||||||
|
| `tag_added` | Tag added to an account |
|
||||||
|
| `tag_removed` | Tag removed from an account |
|
||||||
|
|
||||||
|
All events are written to the `audit_events` table and are visible via
|
||||||
|
`GET /v1/audit` (admin JWT required) or the `/audit` web UI page.
|
||||||
169
PROGRESS.md
169
PROGRESS.md
@@ -2,7 +2,174 @@
|
|||||||
|
|
||||||
Source of truth for current development state.
|
Source of truth for current development state.
|
||||||
---
|
---
|
||||||
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
Phases 0–14 complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||||
|
|
||||||
|
### 2026-03-16 — TOTP enrollment via web UI
|
||||||
|
|
||||||
|
**Task:** Add TOTP enrollment and management to the web UI profile page.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- **Dependency:** `github.com/skip2/go-qrcode` for server-side QR code generation
|
||||||
|
- **Profile page:** TOTP section showing enabled status or enrollment form
|
||||||
|
- **Enrollment flow:** Password re-auth → generate secret → show QR code + manual entry → confirm with 6-digit code
|
||||||
|
- **QR code:** Generated server-side as `data:image/png;base64,...` URI (CSP-compliant)
|
||||||
|
- **Account detail:** Admin "Remove TOTP" button with HTMX delete + confirm
|
||||||
|
- **Enrollment nonces:** `pendingTOTPEnrolls sync.Map` with 5-minute TTL, single-use
|
||||||
|
- **Template fragments:** `totp_section.html`, `totp_enroll_qr.html`
|
||||||
|
- **Handler:** `internal/ui/handlers_totp.go` with `handleTOTPEnrollStart`, `handleTOTPConfirm`, `handleAdminTOTPRemove`
|
||||||
|
- **Security:** Password re-auth (SEC-01), lockout check, CSRF, single-use nonces, TOTP counter replay prevention (CRIT-01)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
**Task:** Full documentation audit to sync ARCHITECTURE.md and PROJECT_PLAN.md with v1.0.0 implementation.
|
||||||
|
|
||||||
|
**ARCHITECTURE.md changes:**
|
||||||
|
- §8 Postgres Credential Endpoints: added missing `GET /v1/pgcreds`
|
||||||
|
- §12 Directory/Package Structure: added `internal/audit/`, `internal/vault/`, `web/embed.go`; added `clients/`, `test/`, `dist/`, `man/` top-level dirs; removed stale "(Phase N)" labels
|
||||||
|
- §17 Proto Package Layout: added `policy.proto`
|
||||||
|
- §17 Service Definitions: added `PolicyService` row
|
||||||
|
- §18 Makefile Targets: added `docker-clean`; corrected `docker` and `clean` descriptions
|
||||||
|
|
||||||
|
**PROJECT_PLAN.md changes:**
|
||||||
|
- All phases 0–9 marked `[COMPLETE]`
|
||||||
|
- Added status summary at top (v1.0.0, 2026-03-15)
|
||||||
|
- Phase 4.1: added `mciasctl pgcreds list` subcommand (implemented, was missing from plan)
|
||||||
|
- Phase 7.1: added `policy.proto` to proto file list
|
||||||
|
- Phase 8.5: added `docker-clean` target; corrected `docker` and `clean` target descriptions
|
||||||
|
- Added Phase 10: Web UI (HTMX)
|
||||||
|
- Added Phase 11: Authorization Policy Engine
|
||||||
|
- Added Phase 12: Vault Seal/Unseal Lifecycle
|
||||||
|
- Added Phase 13: Token Delegation and pgcred Access Grants
|
||||||
|
- Updated implementation order to include phases 10–13
|
||||||
|
|
||||||
|
**No code changes.** Documentation only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-03-15 — Makefile: docker image cleanup
|
||||||
|
|
||||||
|
**Task:** Ensure `make clean` removes Docker build images; add dedicated `docker-clean` target.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- `clean` target now runs `docker rmi mcias:$(VERSION) mcias:latest` (errors suppressed so clean works without Docker).
|
||||||
|
- New `docker-clean` target removes the versioned and `latest` tags and prunes dangling images with the mcias label.
|
||||||
|
- Header comment and `help` target updated to document `docker-clean`.
|
||||||
|
|
||||||
|
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-03-15 — Fix Swagger server URLs
|
||||||
|
|
||||||
|
**Task:** Update Swagger `servers` section to use correct auth server URLs.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- `openapi.yaml` and `web/static/openapi.yaml`: replaced `https://auth.example.com:8443` with `https://mcias.metacircular.net:8443` (Production) and `https://localhost:8443` (Local test server).
|
||||||
|
|
||||||
|
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-03-15 — Fix /docs Swagger UI (bundle assets locally)
|
||||||
|
|
||||||
|
**Problem:** `/docs` was broken because `docs.html` loaded `swagger-ui-bundle.js` and `swagger-ui.css` from `unpkg.com` CDN, which is blocked by the server's `Content-Security-Policy: default-src 'self'` header.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Downloaded `swagger-ui-dist@5.32.0` via npm and copied `swagger-ui-bundle.js` and `swagger-ui.css` into `web/static/` (embedded at build time).
|
||||||
|
- Updated `docs.html` to reference `/static/swagger-ui-bundle.js` and `/static/swagger-ui.css`.
|
||||||
|
- Added `GET /static/swagger-ui-bundle.js` and `GET /static/swagger-ui.css` handlers in `server.go` serving the embedded bytes with correct `Content-Type` headers.
|
||||||
|
- No CSP changes required; strict `default-src 'self'` is preserved.
|
||||||
|
|
||||||
|
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-03-15 — Checkpoint: lint fixes
|
||||||
|
|
||||||
|
**Task:** Checkpoint — lint clean, tests pass, commit.
|
||||||
|
|
||||||
|
**Lint fixes (13 issues resolved):**
|
||||||
|
- `errorlint`: `internal/vault/vault_test.go` — replaced `err != ErrSealed` with `errors.Is(err, ErrSealed)`.
|
||||||
|
- `gofmt`: `internal/config/config.go`, `internal/config/config_test.go`, `internal/middleware/middleware_test.go` — reformatted with `goimports`.
|
||||||
|
- `govet/fieldalignment`: `internal/vault/vault.go`, `internal/ui/csrf.go`, `internal/audit/detail_test.go`, `internal/middleware/middleware_test.go` — reordered struct fields for optimal alignment.
|
||||||
|
- `unused`: `internal/ui/csrf.go` — removed unused `newCSRFManager` function (superseded by `newCSRFManagerFromVault`).
|
||||||
|
- `revive/early-return`: `cmd/mciassrv/main.go` — inverted condition to eliminate else-after-return.
|
||||||
|
|
||||||
|
**Verification:** `golangci-lint run ./...` → 0 issues; `go test ./...` → all packages pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-03-15 — Documentation: ARCHITECTURE.md update + POLICY.md
|
||||||
|
|
||||||
|
**Task:** Ensure ARCHITECTURE.md is accurate; add POLICY.md describing the policy engine.
|
||||||
|
|
||||||
|
**ARCHITECTURE.md fix:**
|
||||||
|
- Corrected `Rule.ID` comment: built-in default rules use negative IDs (-1 … -7), not 0 (§20 Core Types code block).
|
||||||
|
|
||||||
|
**New file: POLICY.md**
|
||||||
|
- Operator reference guide for the ABAC policy engine.
|
||||||
|
- Covers: evaluation model (deny-wins, default-deny, stable priority sort), rule matching semantics, priority conventions, all built-in default rules (IDs -1 … -7) with conditions, full action and resource-type catalogue, rule schema (DB columns + RuleBody JSON), rule management via `mciasctl` / REST API / Web UI, account tag conventions, cache reload, six worked examples (named service delegation, machine-tag gating, blanket role, time-scoped access, per-account subject rule, incident-response deny), security recommendations, and audit events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-03-15 — Service account token delegation and download
|
||||||
|
|
||||||
|
**Problem:** Only admins could issue tokens for service accounts, and the only way to retrieve the token was a flash message (copy-paste). There was no delegation mechanism for non-admin users.
|
||||||
|
|
||||||
|
**Solution:** Added token-issue delegation and a one-time secure file download flow.
|
||||||
|
|
||||||
|
**DB (`internal/db/`):**
|
||||||
|
- Migration `000008`: new `service_account_delegates` table — tracks which human accounts may issue tokens for a given system account
|
||||||
|
- `GrantTokenIssueAccess`, `RevokeTokenIssueAccess`, `ListTokenIssueDelegates`, `HasTokenIssueAccess`, `ListDelegatedServiceAccounts` functions
|
||||||
|
|
||||||
|
**Model (`internal/model/`):**
|
||||||
|
- New `ServiceAccountDelegate` type
|
||||||
|
- New audit event constants: `EventTokenDelegateGranted`, `EventTokenDelegateRevoked`
|
||||||
|
|
||||||
|
**UI (`internal/ui/`):**
|
||||||
|
- `handleIssueSystemToken`: now allows admins and delegates (not just admins); after issuance stores token in a short-lived (5 min) single-use download nonce; returns download link in the HTMX fragment
|
||||||
|
- `handleDownloadToken`: serves the token as `Content-Disposition: attachment` via the one-time nonce; nonce deleted on first use to prevent replay
|
||||||
|
- `handleGrantTokenDelegate` / `handleRevokeTokenDelegate`: admin-only endpoints to manage delegate access for a system account
|
||||||
|
- `handleServiceAccountsPage`: new `/service-accounts` page for non-admin delegates to see their assigned service accounts and issue tokens
|
||||||
|
- New `tokenDownloads sync.Map` in `UIServer` with background cleanup goroutine
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
- `POST /accounts/{id}/token` — changed from admin-only to authed+CSRF, authorization checked in handler
|
||||||
|
- `GET /token/download/{nonce}` — new, authed
|
||||||
|
- `POST /accounts/{id}/token/delegates` — new, admin-only
|
||||||
|
- `DELETE /accounts/{id}/token/delegates/{grantee}` — new, admin-only
|
||||||
|
- `GET /service-accounts` — new, authed (delegates' token management page)
|
||||||
|
|
||||||
|
**Templates:**
|
||||||
|
- `token_list.html`: shows download link after issuance
|
||||||
|
- `token_delegates.html`: new fragment for admin delegate management
|
||||||
|
- `account_detail.html`: added "Token Issue Access" section for system accounts
|
||||||
|
- `service_accounts.html`: new page listing delegated service accounts with issue button
|
||||||
|
- `base.html`: non-admin nav now shows "Service Accounts" link
|
||||||
|
|
||||||
### 2026-03-14 — Vault seal/unseal lifecycle
|
### 2026-03-14 — Vault seal/unseal lifecycle
|
||||||
|
|
||||||
|
|||||||
258
PROJECT_PLAN.md
258
PROJECT_PLAN.md
@@ -5,7 +5,19 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 0 — Repository Bootstrap
|
## Status
|
||||||
|
|
||||||
|
**v1.0.0 tagged (2026-03-15). All phases complete.**
|
||||||
|
|
||||||
|
All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||||
|
See PROGRESS.md for the detailed development log.
|
||||||
|
|
||||||
|
Phases 0–9 match the original plan. Phases 10–13 document significant
|
||||||
|
features implemented beyond the original plan scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Repository Bootstrap **[COMPLETE]**
|
||||||
|
|
||||||
### Step 0.1: Go module and dependency setup
|
### Step 0.1: Go module and dependency setup
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -23,7 +35,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1 — Foundational Packages
|
## Phase 1 — Foundational Packages **[COMPLETE]**
|
||||||
|
|
||||||
### Step 1.1: `internal/model` — shared data types
|
### Step 1.1: `internal/model` — shared data types
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -69,7 +81,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — Authentication Core
|
## Phase 2 — Authentication Core **[COMPLETE]**
|
||||||
|
|
||||||
### Step 2.1: `internal/token` — JWT issuance and validation
|
### Step 2.1: `internal/token` — JWT issuance and validation
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -107,7 +119,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3 — HTTP Server
|
## Phase 3 — HTTP Server **[COMPLETE]**
|
||||||
|
|
||||||
### Step 3.1: `internal/middleware` — HTTP middleware
|
### Step 3.1: `internal/middleware` — HTTP middleware
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -143,6 +155,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
|
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
|
||||||
- `DELETE /v1/auth/totp` — admin; removes TOTP from account
|
- `DELETE /v1/auth/totp` — admin; removes TOTP from account
|
||||||
- `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials
|
- `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials
|
||||||
|
- `GET /v1/pgcreds` — list all accessible credentials (owned + granted)
|
||||||
- Credential fields (password hash, TOTP secret, Postgres password) are
|
- Credential fields (password hash, TOTP secret, Postgres password) are
|
||||||
**never** included in any API response
|
**never** included in any API response
|
||||||
- Tests: each endpoint happy path; auth middleware applied correctly; invalid
|
- Tests: each endpoint happy path; auth middleware applied correctly; invalid
|
||||||
@@ -160,7 +173,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4 — Admin CLI
|
## Phase 4 — Admin CLI **[COMPLETE]**
|
||||||
|
|
||||||
### Step 4.1: `cmd/mciasctl` — admin CLI
|
### Step 4.1: `cmd/mciasctl` — admin CLI
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -177,6 +190,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
- `mciasctl role revoke -id UUID -role ROLE`
|
- `mciasctl role revoke -id UUID -role ROLE`
|
||||||
- `mciasctl token issue -id UUID` (system accounts)
|
- `mciasctl token issue -id UUID` (system accounts)
|
||||||
- `mciasctl token revoke -jti JTI`
|
- `mciasctl token revoke -jti JTI`
|
||||||
|
- `mciasctl pgcreds list`
|
||||||
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
|
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
|
||||||
- `mciasctl pgcreds get -id UUID`
|
- `mciasctl pgcreds get -id UUID`
|
||||||
- `mciasctl auth login`
|
- `mciasctl auth login`
|
||||||
@@ -191,7 +205,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5 — End-to-End Tests and Hardening
|
## Phase 5 — End-to-End Tests and Hardening **[COMPLETE]**
|
||||||
|
|
||||||
### Step 5.1: End-to-end test suite
|
### Step 5.1: End-to-end test suite
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -228,7 +242,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6 — mciasdb: Database Maintenance Tool
|
## Phase 6 — mciasdb: Database Maintenance Tool **[COMPLETE]**
|
||||||
|
|
||||||
See ARCHITECTURE.md §16 for full design rationale, trust model, and command
|
See ARCHITECTURE.md §16 for full design rationale, trust model, and command
|
||||||
surface.
|
surface.
|
||||||
@@ -314,9 +328,7 @@ surface.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
## Phase 7 — gRPC Interface **[COMPLETE]**
|
||||||
|
|
||||||
## Phase 7 — gRPC Interface
|
|
||||||
|
|
||||||
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
|
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
|
||||||
transport security requirements.
|
transport security requirements.
|
||||||
@@ -324,7 +336,8 @@ transport security requirements.
|
|||||||
### Step 7.1: Protobuf definitions and generated code
|
### Step 7.1: Protobuf definitions and generated code
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
- `proto/mcias/v1/` directory contains `.proto` files for all service groups:
|
- `proto/mcias/v1/` directory contains `.proto` files for all service groups:
|
||||||
`auth.proto`, `token.proto`, `account.proto`, `admin.proto`
|
`auth.proto`, `token.proto`, `account.proto`, `policy.proto`, `admin.proto`,
|
||||||
|
`common.proto`
|
||||||
- All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17)
|
- All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17)
|
||||||
- `proto/generate.go` contains a `//go:generate protoc ...` directive that
|
- `proto/generate.go` contains a `//go:generate protoc ...` directive that
|
||||||
produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and
|
produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and
|
||||||
@@ -357,10 +370,11 @@ transport security requirements.
|
|||||||
- gRPC server uses the same TLS certificate and key as the REST server (loaded
|
- gRPC server uses the same TLS certificate and key as the REST server (loaded
|
||||||
from config); minimum TLS 1.2 enforced via `tls.Config`
|
from config); minimum TLS 1.2 enforced via `tls.Config`
|
||||||
- Unary server interceptor chain:
|
- Unary server interceptor chain:
|
||||||
1. Request logger (method name, peer IP, status, duration)
|
1. Sealed interceptor (blocks all RPCs when vault sealed, except Health)
|
||||||
2. Auth interceptor (extracts Bearer token, validates, injects claims into
|
2. Request logger (method name, peer IP, status, duration)
|
||||||
|
3. Auth interceptor (extracts Bearer token, validates, injects claims into
|
||||||
`context.Context`)
|
`context.Context`)
|
||||||
3. Rate-limit interceptor (per-IP token bucket, same parameters as REST)
|
4. Rate-limit interceptor (per-IP token bucket, same parameters as REST)
|
||||||
- No credential material logged by any interceptor
|
- No credential material logged by any interceptor
|
||||||
- Tests: interceptor chain applied correctly; rate-limit triggers after burst
|
- Tests: interceptor chain applied correctly; rate-limit triggers after burst
|
||||||
|
|
||||||
@@ -396,7 +410,7 @@ transport security requirements.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 8 — Operational Artifacts
|
## Phase 8 — Operational Artifacts **[COMPLETE]**
|
||||||
|
|
||||||
See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
||||||
|
|
||||||
@@ -461,7 +475,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
|||||||
- `generate` — `go generate ./...` (proto stubs from Phase 7)
|
- `generate` — `go generate ./...` (proto stubs from Phase 7)
|
||||||
- `man` — build compressed man pages
|
- `man` — build compressed man pages
|
||||||
- `install` — run `dist/install.sh`
|
- `install` — run `dist/install.sh`
|
||||||
- `clean` — remove `bin/` and generated artifacts
|
- `docker` — `docker build -t mcias:$(VERSION) -t mcias:latest .`
|
||||||
|
- `docker-clean` — remove local `mcias:$(VERSION)` and `mcias:latest` images;
|
||||||
|
prune dangling images with the mcias label
|
||||||
|
- `clean` — remove `bin/`, compressed man pages, and local Docker images
|
||||||
- `dist` — build release tarballs for linux/amd64 and linux/arm64 (using
|
- `dist` — build release tarballs for linux/amd64 and linux/arm64 (using
|
||||||
`GOOS`/`GOARCH` cross-compilation)
|
`GOOS`/`GOARCH` cross-compilation)
|
||||||
- `make build` works from a clean checkout after `go mod download`
|
- `make build` works from a clean checkout after `go mod download`
|
||||||
@@ -483,13 +500,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
|||||||
- `dist/mcias.conf.docker.example` — config template suitable for container
|
- `dist/mcias.conf.docker.example` — config template suitable for container
|
||||||
deployment: `listen_addr = "0.0.0.0:8443"`, `grpc_addr = "0.0.0.0:9443"`,
|
deployment: `listen_addr = "0.0.0.0:8443"`, `grpc_addr = "0.0.0.0:9443"`,
|
||||||
`db_path = "/data/mcias.db"`, TLS cert/key paths under `/etc/mcias/`
|
`db_path = "/data/mcias.db"`, TLS cert/key paths under `/etc/mcias/`
|
||||||
- `Makefile` gains a `docker` target: `docker build -t mcias:$(VERSION) .`
|
|
||||||
where `VERSION` defaults to the output of `git describe --tags --always`
|
|
||||||
- Tests:
|
- Tests:
|
||||||
- `docker build .` completes without error (run in CI if Docker available;
|
- `docker build .` completes without error (run in CI if Docker available;
|
||||||
skip gracefully if not)
|
skip gracefully if not)
|
||||||
- `docker run --rm mcias:latest mciassrv --help` exits 0
|
- `docker run --rm mcias:latest mciassrv --help` exits 0
|
||||||
- Image size documented in PROGRESS.md (target: under 50 MB)
|
|
||||||
|
|
||||||
### Step 8.7: Documentation
|
### Step 8.7: Documentation
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -501,7 +515,7 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 9 — Client Libraries
|
## Phase 9 — Client Libraries **[COMPLETE]**
|
||||||
|
|
||||||
See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language
|
See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language
|
||||||
implementation notes.
|
implementation notes.
|
||||||
@@ -606,6 +620,203 @@ implementation notes.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 10 — Web UI (HTMX) **[COMPLETE]**
|
||||||
|
|
||||||
|
Not in the original plan. Implemented alongside and after Phase 3.
|
||||||
|
|
||||||
|
See ARCHITECTURE.md §8 (Web Management UI) for design details.
|
||||||
|
|
||||||
|
### Step 10.1: `internal/ui` — HTMX web interface
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Go `html/template` pages embedded at compile time via `web/embed.go`
|
||||||
|
- CSRF protection: HMAC-signed double-submit cookie (`mcias_csrf`)
|
||||||
|
- Session: JWT stored as `HttpOnly; Secure; SameSite=Strict` cookie
|
||||||
|
- Security headers: `Content-Security-Policy: default-src 'self'`,
|
||||||
|
`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin`
|
||||||
|
- Pages: login, dashboard, account list/detail, role editor, tag editor,
|
||||||
|
pgcreds, audit log viewer, policy rules, user profile, service-accounts
|
||||||
|
- HTMX partial-page updates for mutations (role updates, tag edits, policy
|
||||||
|
toggles, access grants)
|
||||||
|
- Empty-state handling on all list pages (zero records case tested)
|
||||||
|
|
||||||
|
### Step 10.2: Swagger UI at `/docs`
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `GET /docs` serves Swagger UI for `openapi.yaml`
|
||||||
|
- swagger-ui-bundle.js and swagger-ui.css bundled locally in `web/static/`
|
||||||
|
(CDN blocked by CSP `default-src 'self'`)
|
||||||
|
- `GET /docs/openapi.yaml` serves the OpenAPI spec
|
||||||
|
- `openapi.yaml` kept in sync with REST API surface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11 — Authorization Policy Engine **[COMPLETE]**
|
||||||
|
|
||||||
|
Not in the original plan (CLI subcommands for policy were planned in Phase 4,
|
||||||
|
but the engine itself was not a discrete plan phase).
|
||||||
|
|
||||||
|
See ARCHITECTURE.md §20 for full design, evaluation algorithm, and built-in
|
||||||
|
default rules.
|
||||||
|
|
||||||
|
### Step 11.1: `internal/policy` — in-process ABAC engine
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Pure evaluation: `Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule)`
|
||||||
|
- Deny-wins: any explicit deny overrides all allows
|
||||||
|
- Default-deny: no matching rule → deny
|
||||||
|
- Built-in default rules (IDs -1 … -7) compiled in; reproduce previous
|
||||||
|
binary admin/non-admin behavior exactly; cannot be disabled via API
|
||||||
|
- Match fields: roles, account types, subject UUID, actions, resource type,
|
||||||
|
owner-matches-subject, service names, required tags (all ANDed; zero value
|
||||||
|
= wildcard)
|
||||||
|
- Temporal constraints on DB-backed rules: `not_before`, `expires_at`
|
||||||
|
- `Engine` wrapper: caches rule set in memory; reloads on policy mutations
|
||||||
|
- Tests: all built-in rules; deny-wins over allow; default-deny fallback;
|
||||||
|
temporal filtering; concurrent access
|
||||||
|
|
||||||
|
### Step 11.2: Middleware and REST integration
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `RequirePolicy(engine, action, resourceType)` middleware replaces
|
||||||
|
`RequireRole("admin")` where policy-gated
|
||||||
|
- Every explicit deny produces a `policy_deny` audit event
|
||||||
|
- REST endpoints: `GET|POST /v1/policy/rules`, `GET|PATCH|DELETE /v1/policy/rules/{id}`
|
||||||
|
- DB schema: `policy_rules` and `account_tags` tables (migrations 000004,
|
||||||
|
000006)
|
||||||
|
- `PATCH /v1/policy/rules/{id}` supports updating `priority`, `enabled`,
|
||||||
|
`not_before`, `expires_at`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 12 — Vault Seal/Unseal Lifecycle **[COMPLETE]**
|
||||||
|
|
||||||
|
Not in the original plan.
|
||||||
|
|
||||||
|
See ARCHITECTURE.md §8 (Vault Endpoints) for the API surface.
|
||||||
|
|
||||||
|
### Step 12.1: `internal/vault` — master key lifecycle
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Thread-safe `Vault` struct with `sync.RWMutex`-protected state
|
||||||
|
- Methods: `IsSealed()`, `Unseal(passphrase)`, `Seal()`, `MasterKey()`,
|
||||||
|
`PrivKey()`, `PubKey()`
|
||||||
|
- `Seal()` zeroes all key material before nilling (memguard-style cleanup)
|
||||||
|
- `DeriveFromPassphrase()` and `DecryptSigningKey()` extracted to `derive.go`
|
||||||
|
for reuse by unseal handlers
|
||||||
|
- Tests: state transitions; key zeroing verified; concurrent read/write safety
|
||||||
|
|
||||||
|
### Step 12.2: REST and UI integration
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `POST /v1/vault/unseal` — rate-limited (3/s burst 5); derives key, unseals
|
||||||
|
- `GET /v1/vault/status` — always accessible; returns `{"sealed": bool}`
|
||||||
|
- `POST /v1/vault/seal` — admin only; zeroes key material
|
||||||
|
- `GET /v1/health` returns `{"status":"sealed"}` when sealed
|
||||||
|
- All other `/v1/*` endpoints return 503 `vault_sealed` when sealed
|
||||||
|
- UI redirects all paths to `/unseal` when sealed (except `/static/`)
|
||||||
|
- gRPC: `sealedInterceptor` first in chain; blocks all RPCs except Health
|
||||||
|
- Startup: server may start in sealed state if passphrase env var is absent
|
||||||
|
- Audit events: `vault_sealed`, `vault_unsealed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 13 — Token Delegation and pgcred Access Grants **[COMPLETE]**
|
||||||
|
|
||||||
|
Not in the original plan.
|
||||||
|
|
||||||
|
See ARCHITECTURE.md §21 (Token Issuance Delegation) for design details.
|
||||||
|
|
||||||
|
### Step 13.1: Service account token delegation
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- DB migration 000008: `service_account_delegates` table
|
||||||
|
- `POST /accounts/{id}/token/delegates` — admin grants delegation
|
||||||
|
- `DELETE /accounts/{id}/token/delegates/{grantee}` — admin revokes delegation
|
||||||
|
- `POST /accounts/{id}/token` — accepts admin or delegate (not admin-only)
|
||||||
|
- One-time token download: nonce stored in `sync.Map` with 5-minute TTL;
|
||||||
|
`GET /token/download/{nonce}` serves token as attachment, deletes nonce
|
||||||
|
- `/service-accounts` page for non-admin delegates
|
||||||
|
- Audit events: `token_delegate_granted`, `token_delegate_revoked`
|
||||||
|
|
||||||
|
### Step 13.2: pgcred fine-grained access grants
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- DB migration 000005: `pgcred_access_grants` table
|
||||||
|
- `POST /accounts/{id}/pgcreds/access` — owner grants read access to grantee
|
||||||
|
- `DELETE /accounts/{id}/pgcreds/access/{grantee}` — owner revokes access
|
||||||
|
- `GET /v1/pgcreds` — lists all credentials accessible to caller (owned +
|
||||||
|
granted); includes credential ID for reference
|
||||||
|
- Grantees may view connection metadata; password is never decrypted for them
|
||||||
|
- Audit events: `pgcred_access_granted`, `pgcred_access_revoked`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
## Implementation Order
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -618,6 +829,13 @@ Phase 0 → Phase 1 (1.1, 1.2, 1.3, 1.4 in parallel or sequence)
|
|||||||
→ Phase 7 (7.1 → 7.2 → 7.3 → 7.4 → 7.5 → 7.6)
|
→ Phase 7 (7.1 → 7.2 → 7.3 → 7.4 → 7.5 → 7.6)
|
||||||
→ Phase 8 (8.1 → 8.2 → 8.3 → 8.4 → 8.5 → 8.6)
|
→ Phase 8 (8.1 → 8.2 → 8.3 → 8.4 → 8.5 → 8.6)
|
||||||
→ Phase 9 (9.1 → 9.2 → 9.3 → 9.4 → 9.5 → 9.6)
|
→ Phase 9 (9.1 → 9.2 → 9.3 → 9.4 → 9.5 → 9.6)
|
||||||
|
→ Phase 10 (interleaved with Phase 3 and later phases)
|
||||||
|
→ 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.
|
Each step must have passing tests before the next step begins.
|
||||||
|
Phases 0–13 complete as of v1.0.0 (2026-03-15).
|
||||||
|
Phase 14 complete as of 2026-03-16.
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ type PublicKey struct {
|
|||||||
type TokenClaims struct {
|
type TokenClaims struct {
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
Sub string `json:"sub,omitempty"`
|
Sub string `json:"sub,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
Roles []string `json:"roles,omitempty"`
|
Roles []string `json:"roles,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
func (t *tool) runAccount(args []string) {
|
func (t *tool) runAccount(args []string) {
|
||||||
if len(args) == 0 {
|
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] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
@@ -28,6 +28,8 @@ func (t *tool) runAccount(args []string) {
|
|||||||
t.accountSetStatus(args[1:])
|
t.accountSetStatus(args[1:])
|
||||||
case "reset-totp":
|
case "reset-totp":
|
||||||
t.accountResetTOTP(args[1:])
|
t.accountResetTOTP(args[1:])
|
||||||
|
case "reset-webauthn":
|
||||||
|
t.webauthnReset(args[1:])
|
||||||
default:
|
default:
|
||||||
fatalf("unknown account subcommand %q", args[0])
|
fatalf("unknown account subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
//
|
//
|
||||||
// pgcreds get --id UUID
|
// pgcreds get --id UUID
|
||||||
// pgcreds set --id UUID --host H --port P --db D --user U
|
// pgcreds set --id UUID --host H --port P --db D --user U
|
||||||
|
//
|
||||||
|
// rekey
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -107,6 +109,10 @@ func main() {
|
|||||||
tool.runAudit(subArgs)
|
tool.runAudit(subArgs)
|
||||||
case "pgcreds":
|
case "pgcreds":
|
||||||
tool.runPGCreds(subArgs)
|
tool.runPGCreds(subArgs)
|
||||||
|
case "webauthn":
|
||||||
|
tool.runWebAuthn(subArgs)
|
||||||
|
case "rekey":
|
||||||
|
tool.runRekey(subArgs)
|
||||||
default:
|
default:
|
||||||
fatalf("unknown command %q; run with no args for usage", command)
|
fatalf("unknown command %q; run with no args for usage", command)
|
||||||
}
|
}
|
||||||
@@ -241,6 +247,11 @@ Commands:
|
|||||||
account set-password --id UUID (prompts interactively)
|
account set-password --id UUID (prompts interactively)
|
||||||
account set-status --id UUID --status active|inactive|deleted
|
account set-status --id UUID --status active|inactive|deleted
|
||||||
account reset-totp --id UUID
|
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 list --id UUID
|
||||||
role grant --id UUID --role ROLE
|
role grant --id UUID --role ROLE
|
||||||
@@ -259,6 +270,9 @@ Commands:
|
|||||||
pgcreds set --id UUID --host H [--port P] --db D --user U
|
pgcreds set --id UUID --host H [--port P] --db D --user U
|
||||||
(password is prompted interactively)
|
(password is prompted interactively)
|
||||||
|
|
||||||
|
rekey Re-encrypt all secrets under a new master passphrase
|
||||||
|
(prompts interactively; requires server to be stopped)
|
||||||
|
|
||||||
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
|
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
|
||||||
file. Use it only when the server is unavailable or for break-glass recovery.
|
file. Use it only when the server is unavailable or for break-glass recovery.
|
||||||
All write operations are recorded in the audit log.
|
All write operations are recorded in the audit log.
|
||||||
|
|||||||
@@ -438,3 +438,141 @@ func TestPGCredsGetNotFound(t *testing.T) {
|
|||||||
t.Fatal("expected ErrNotFound, got nil")
|
t.Fatal("expected ErrNotFound, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- rekey command tests ----
|
||||||
|
|
||||||
|
// TestRekeyCommandRoundTrip exercises runRekey end-to-end with real AES-256-GCM
|
||||||
|
// encryption and actual Argon2id key derivation. It verifies that all secrets
|
||||||
|
// (signing key, TOTP, pg password) remain accessible after rekey and that the
|
||||||
|
// old master key no longer decrypts the re-encrypted values.
|
||||||
|
//
|
||||||
|
// Note: Argon2id derivation (time=3, memory=128 MiB) makes this test slow (~2 s).
|
||||||
|
func TestRekeyCommandRoundTrip(t *testing.T) {
|
||||||
|
tool := newTestTool(t)
|
||||||
|
|
||||||
|
// ── Setup: signing key encrypted under old master key ──
|
||||||
|
_, privKey, err := crypto.GenerateEd25519KeyPair()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key pair: %v", err)
|
||||||
|
}
|
||||||
|
sigKeyPEM, err := crypto.MarshalPrivateKeyPEM(privKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal key: %v", err)
|
||||||
|
}
|
||||||
|
sigEnc, sigNonce, err := crypto.SealAESGCM(tool.masterKey, sigKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal signing key: %v", err)
|
||||||
|
}
|
||||||
|
if err := tool.db.WriteServerConfig(sigEnc, sigNonce); err != nil {
|
||||||
|
t.Fatalf("write server config: %v", err)
|
||||||
|
}
|
||||||
|
// WriteMasterKeySalt so ReadServerConfig has a valid salt row.
|
||||||
|
oldSalt, err := crypto.NewSalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gen salt: %v", err)
|
||||||
|
}
|
||||||
|
if err := tool.db.WriteMasterKeySalt(oldSalt); err != nil {
|
||||||
|
t.Fatalf("write salt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup: account with TOTP ──
|
||||||
|
a, err := tool.db.CreateAccount("rekeyuser", "human", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
totpSecret := []byte("JBSWY3DPEHPK3PXP")
|
||||||
|
totpEnc, totpNonce, err := crypto.SealAESGCM(tool.masterKey, totpSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal totp: %v", err)
|
||||||
|
}
|
||||||
|
if err := tool.db.SetTOTP(a.ID, totpEnc, totpNonce); err != nil {
|
||||||
|
t.Fatalf("set totp: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup: pg credentials ──
|
||||||
|
pgPass := []byte("pgpassword123")
|
||||||
|
pgEnc, pgNonce, err := crypto.SealAESGCM(tool.masterKey, pgPass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal pg pass: %v", err)
|
||||||
|
}
|
||||||
|
if err := tool.db.WritePGCredentials(a.ID, "localhost", 5432, "mydb", "myuser", pgEnc, pgNonce); err != nil {
|
||||||
|
t.Fatalf("write pg creds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pipe new passphrase twice into stdin ──
|
||||||
|
const newPassphrase = "new-master-passphrase-for-test"
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create stdin pipe: %v", err)
|
||||||
|
}
|
||||||
|
origStdin := os.Stdin
|
||||||
|
os.Stdin = r
|
||||||
|
t.Cleanup(func() { os.Stdin = origStdin })
|
||||||
|
if _, err := fmt.Fprintf(w, "%s\n%s\n", newPassphrase, newPassphrase); err != nil {
|
||||||
|
t.Fatalf("write stdin: %v", err)
|
||||||
|
}
|
||||||
|
_ = w.Close()
|
||||||
|
|
||||||
|
// ── Execute rekey ──
|
||||||
|
tool.runRekey(nil)
|
||||||
|
|
||||||
|
// ── Derive new key from stored salt + new passphrase ──
|
||||||
|
newSalt, err := tool.db.ReadMasterKeySalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read new salt: %v", err)
|
||||||
|
}
|
||||||
|
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive new key: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for i := range newKey {
|
||||||
|
newKey[i] = 0
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Signing key must decrypt with new key.
|
||||||
|
newSigEnc, newSigNonce, err := tool.db.ReadServerConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read server config after rekey: %v", err)
|
||||||
|
}
|
||||||
|
decPEM, err := crypto.OpenAESGCM(newKey, newSigNonce, newSigEnc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt signing key with new key: %v", err)
|
||||||
|
}
|
||||||
|
if string(decPEM) != string(sigKeyPEM) {
|
||||||
|
t.Error("signing key PEM mismatch after rekey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old key must NOT decrypt the re-encrypted signing key.
|
||||||
|
// Security: adversarial check that old key is invalidated.
|
||||||
|
if _, err := crypto.OpenAESGCM(tool.masterKey, newSigNonce, newSigEnc); err == nil {
|
||||||
|
t.Error("old key still decrypts signing key after rekey — ciphertext was not replaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTP must decrypt with new key.
|
||||||
|
updatedAcct, err := tool.db.GetAccountByUUID(a.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get account after rekey: %v", err)
|
||||||
|
}
|
||||||
|
decTOTP, err := crypto.OpenAESGCM(newKey, updatedAcct.TOTPSecretNonce, updatedAcct.TOTPSecretEnc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt TOTP with new key: %v", err)
|
||||||
|
}
|
||||||
|
if string(decTOTP) != string(totpSecret) {
|
||||||
|
t.Errorf("TOTP mismatch: got %q, want %q", decTOTP, totpSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg password must decrypt with new key.
|
||||||
|
updatedCred, err := tool.db.ReadPGCredentials(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read pg creds after rekey: %v", err)
|
||||||
|
}
|
||||||
|
decPG, err := crypto.OpenAESGCM(newKey, updatedCred.PGPasswordNonce, updatedCred.PGPasswordEnc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt pg password with new key: %v", err)
|
||||||
|
}
|
||||||
|
if string(decPG) != string(pgPass) {
|
||||||
|
t.Errorf("pg password mismatch: got %q, want %q", decPG, pgPass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
154
cmd/mciasdb/rekey.go
Normal file
154
cmd/mciasdb/rekey.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runRekey re-encrypts all secrets under a new passphrase-derived master key.
|
||||||
|
//
|
||||||
|
// The current master key (already loaded in tool.masterKey by openDB) is used
|
||||||
|
// to decrypt every encrypted secret: the Ed25519 signing key, all TOTP secrets,
|
||||||
|
// and all Postgres credential passwords. The operator is then prompted for a
|
||||||
|
// new passphrase (confirmed), a fresh Argon2id salt is generated, a new 256-bit
|
||||||
|
// master key is derived, and all secrets are re-encrypted and written back in a
|
||||||
|
// single atomic SQLite transaction.
|
||||||
|
//
|
||||||
|
// Security: The entire re-encryption happens in memory first; the database is
|
||||||
|
// only updated once all ciphertext has been produced successfully. The new
|
||||||
|
// salt replaces the old salt atomically within the same transaction so the
|
||||||
|
// database is never left in a mixed state. Both the old and new master keys
|
||||||
|
// are zeroed in deferred cleanup. No secret material is logged or printed.
|
||||||
|
func (t *tool) runRekey(_ []string) {
|
||||||
|
// ── 1. Decrypt signing key under old master key ──────────────────────
|
||||||
|
sigKeyEnc, sigKeyNonce, err := t.db.ReadServerConfig()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read server config: %v", err)
|
||||||
|
}
|
||||||
|
sigKeyPEM, err := crypto.OpenAESGCM(t.masterKey, sigKeyNonce, sigKeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("decrypt signing key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Decrypt all TOTP secrets under old master key ─────────────────
|
||||||
|
totpAccounts, err := t.db.ListAccountsWithTOTP()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("list accounts with TOTP: %v", err)
|
||||||
|
}
|
||||||
|
type totpPlain struct {
|
||||||
|
secret []byte
|
||||||
|
accountID int64
|
||||||
|
}
|
||||||
|
totpPlaintexts := make([]totpPlain, 0, len(totpAccounts))
|
||||||
|
for _, a := range totpAccounts {
|
||||||
|
pt, err := crypto.OpenAESGCM(t.masterKey, a.TOTPSecretNonce, a.TOTPSecretEnc)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("decrypt TOTP secret for account %s: %v", a.Username, err)
|
||||||
|
}
|
||||||
|
totpPlaintexts = append(totpPlaintexts, totpPlain{accountID: a.ID, secret: pt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Decrypt all pg_credentials passwords under old master key ──────
|
||||||
|
pgCreds, err := t.db.ListAllPGCredentials()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("list pg credentials: %v", err)
|
||||||
|
}
|
||||||
|
type pgPlain struct {
|
||||||
|
password []byte
|
||||||
|
credID int64
|
||||||
|
}
|
||||||
|
pgPlaintexts := make([]pgPlain, 0, len(pgCreds))
|
||||||
|
for _, c := range pgCreds {
|
||||||
|
pt, err := crypto.OpenAESGCM(t.masterKey, c.PGPasswordNonce, c.PGPasswordEnc)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("decrypt pg password for credential %d: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
pgPlaintexts = append(pgPlaintexts, pgPlain{credID: c.ID, password: pt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Prompt for new passphrase (confirmed) ──────────────────────────
|
||||||
|
fmt.Fprintln(os.Stderr, "Enter new master passphrase (will not echo):")
|
||||||
|
newPassphrase, err := readPassword("New passphrase: ")
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read passphrase: %v", err)
|
||||||
|
}
|
||||||
|
if newPassphrase == "" {
|
||||||
|
fatalf("passphrase must not be empty")
|
||||||
|
}
|
||||||
|
confirm, err := readPassword("Confirm passphrase: ")
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read passphrase confirmation: %v", err)
|
||||||
|
}
|
||||||
|
if newPassphrase != confirm {
|
||||||
|
fatalf("passphrases do not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Derive new master key ──────────────────────────────────────────
|
||||||
|
// Security: a fresh random salt is generated for every rekey so that the
|
||||||
|
// new key is independent of the old key even if the same passphrase is
|
||||||
|
// reused. The new salt is stored atomically with the re-encrypted secrets.
|
||||||
|
newSalt, err := crypto.NewSalt()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("generate new salt: %v", err)
|
||||||
|
}
|
||||||
|
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("derive new master key: %v", err)
|
||||||
|
}
|
||||||
|
// Zero both keys when done, regardless of outcome.
|
||||||
|
defer func() {
|
||||||
|
for i := range newKey {
|
||||||
|
newKey[i] = 0
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ── 6. Re-encrypt signing key ─────────────────────────────────────────
|
||||||
|
newSigKeyEnc, newSigKeyNonce, err := crypto.SealAESGCM(newKey, sigKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("re-encrypt signing key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Re-encrypt TOTP secrets ────────────────────────────────────────
|
||||||
|
totpRows := make([]db.TOTPRekeyRow, 0, len(totpPlaintexts))
|
||||||
|
for _, tp := range totpPlaintexts {
|
||||||
|
enc, nonce, err := crypto.SealAESGCM(newKey, tp.secret)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("re-encrypt TOTP secret for account %d: %v", tp.accountID, err)
|
||||||
|
}
|
||||||
|
totpRows = append(totpRows, db.TOTPRekeyRow{
|
||||||
|
AccountID: tp.accountID,
|
||||||
|
Enc: enc,
|
||||||
|
Nonce: nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Re-encrypt pg_credentials passwords ────────────────────────────
|
||||||
|
pgRows := make([]db.PGRekeyRow, 0, len(pgPlaintexts))
|
||||||
|
for _, pp := range pgPlaintexts {
|
||||||
|
enc, nonce, err := crypto.SealAESGCM(newKey, pp.password)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("re-encrypt pg password for credential %d: %v", pp.credID, err)
|
||||||
|
}
|
||||||
|
pgRows = append(pgRows, db.PGRekeyRow{
|
||||||
|
CredentialID: pp.credID,
|
||||||
|
Enc: enc,
|
||||||
|
Nonce: nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. Atomic commit ──────────────────────────────────────────────────
|
||||||
|
if err := t.db.Rekey(newSalt, newSigKeyEnc, newSigKeyNonce, totpRows, pgRows); err != nil {
|
||||||
|
fatalf("rekey database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.db.WriteAuditEvent("master_key_rekeyed", nil, nil, "", `{"actor":"mciasdb"}`); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Rekey complete: %d TOTP secrets and %d pg credentials re-encrypted.\n",
|
||||||
|
len(totpRows), len(pgRows))
|
||||||
|
fmt.Fprintln(os.Stderr, "Update your mcias.toml or passphrase environment variable to use the new passphrase.")
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
@@ -87,7 +87,9 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
masterKey, mkErr := loadMasterKey(cfg, database)
|
masterKey, mkErr := loadMasterKey(cfg, database)
|
||||||
if mkErr != nil {
|
if mkErr != nil {
|
||||||
// Check if we can start sealed (passphrase mode, empty env var).
|
// Check if we can start sealed (passphrase mode, empty env var).
|
||||||
if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" {
|
if cfg.MasterKey.KeyFile != "" || os.Getenv(cfg.MasterKey.PassphraseEnv) != "" {
|
||||||
|
return fmt.Errorf("load master key: %w", mkErr)
|
||||||
|
}
|
||||||
// Verify that this is not a first run — the signing key must already exist.
|
// Verify that this is not a first run — the signing key must already exist.
|
||||||
enc, nonce, scErr := database.ReadServerConfig()
|
enc, nonce, scErr := database.ReadServerConfig()
|
||||||
if scErr != nil || enc == nil || nonce == nil {
|
if scErr != nil || enc == nil || nonce == nil {
|
||||||
@@ -95,9 +97,6 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
v = vault.NewSealed()
|
v = vault.NewSealed()
|
||||||
logger.Info("vault starting in sealed state")
|
logger.Info("vault starting in sealed state")
|
||||||
} else {
|
|
||||||
return fmt.Errorf("load master key: %w", mkErr)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Load or generate the Ed25519 signing key.
|
// Load or generate the Ed25519 signing key.
|
||||||
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v6.33.4
|
// protoc v3.20.3
|
||||||
// source: mcias/v1/account.proto
|
// source: mcias/v1/account.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc v6.33.4
|
// - protoc v3.20.3
|
||||||
// source: mcias/v1/account.proto
|
// source: mcias/v1/account.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
@@ -569,6 +569,288 @@ func (*RemoveTOTPResponse) Descriptor() ([]byte, []int) {
|
|||||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{11}
|
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
|
var File_mcias_v1_auth_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_mcias_v1_auth_proto_rawDesc = "" +
|
const file_mcias_v1_auth_proto_rawDesc = "" +
|
||||||
@@ -601,7 +883,31 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
|
|||||||
"\x11RemoveTOTPRequest\x12\x1d\n" +
|
"\x11RemoveTOTPRequest\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"account_id\x18\x01 \x01(\tR\taccountId\"\x14\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" +
|
"\vAuthService\x128\n" +
|
||||||
"\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\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" +
|
"\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" +
|
"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" +
|
"\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" +
|
||||||
"\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 (
|
var (
|
||||||
file_mcias_v1_auth_proto_rawDescOnce sync.Once
|
file_mcias_v1_auth_proto_rawDescOnce sync.Once
|
||||||
@@ -625,7 +933,7 @@ func file_mcias_v1_auth_proto_rawDescGZIP() []byte {
|
|||||||
return file_mcias_v1_auth_proto_rawDescData
|
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{
|
var file_mcias_v1_auth_proto_goTypes = []any{
|
||||||
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
|
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
|
||||||
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
|
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
|
||||||
@@ -639,28 +947,40 @@ var file_mcias_v1_auth_proto_goTypes = []any{
|
|||||||
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
|
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
|
||||||
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
|
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
|
||||||
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
|
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
|
||||||
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
|
(*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{
|
var file_mcias_v1_auth_proto_depIdxs = []int32{
|
||||||
12, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
|
17, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||||
12, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
|
17, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||||
0, // 2: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
|
17, // 2: mcias.v1.WebAuthnCredentialInfo.created_at:type_name -> google.protobuf.Timestamp
|
||||||
2, // 3: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
|
17, // 3: mcias.v1.WebAuthnCredentialInfo.last_used_at:type_name -> google.protobuf.Timestamp
|
||||||
4, // 4: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
|
13, // 4: mcias.v1.ListWebAuthnCredentialsResponse.credentials:type_name -> mcias.v1.WebAuthnCredentialInfo
|
||||||
6, // 5: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
|
0, // 5: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
|
||||||
8, // 6: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
|
2, // 6: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
|
||||||
10, // 7: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
|
4, // 7: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
|
||||||
1, // 8: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
|
6, // 8: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
|
||||||
3, // 9: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
|
8, // 9: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
|
||||||
5, // 10: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
|
10, // 10: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
|
||||||
7, // 11: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
|
12, // 11: mcias.v1.AuthService.ListWebAuthnCredentials:input_type -> mcias.v1.ListWebAuthnCredentialsRequest
|
||||||
9, // 12: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
|
15, // 12: mcias.v1.AuthService.RemoveWebAuthnCredential:input_type -> mcias.v1.RemoveWebAuthnCredentialRequest
|
||||||
11, // 13: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
|
1, // 13: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
|
||||||
8, // [8:14] is the sub-list for method output_type
|
3, // 14: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
|
||||||
2, // [2:8] is the sub-list for method input_type
|
5, // 15: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
|
||||||
2, // [2:2] is the sub-list for extension type_name
|
7, // 16: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
|
||||||
2, // [2:2] is the sub-list for extension extendee
|
9, // 17: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
|
||||||
0, // [0:2] is the sub-list for field type_name
|
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() }
|
func init() { file_mcias_v1_auth_proto_init() }
|
||||||
@@ -674,7 +994,7 @@ func file_mcias_v1_auth_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 12,
|
NumMessages: 17,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const (
|
|||||||
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
|
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
|
||||||
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
|
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
|
||||||
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
|
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.
|
// AuthServiceClient is the client API for AuthService service.
|
||||||
@@ -53,6 +55,12 @@ type AuthServiceClient interface {
|
|||||||
// RemoveTOTP removes TOTP from an account (admin only).
|
// RemoveTOTP removes TOTP from an account (admin only).
|
||||||
// Requires: admin JWT in metadata.
|
// Requires: admin JWT in metadata.
|
||||||
RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error)
|
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 {
|
type authServiceClient struct {
|
||||||
@@ -123,6 +131,26 @@ func (c *authServiceClient) RemoveTOTP(ctx context.Context, in *RemoveTOTPReques
|
|||||||
return out, nil
|
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.
|
// AuthServiceServer is the server API for AuthService service.
|
||||||
// All implementations must embed UnimplementedAuthServiceServer
|
// All implementations must embed UnimplementedAuthServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -147,6 +175,12 @@ type AuthServiceServer interface {
|
|||||||
// RemoveTOTP removes TOTP from an account (admin only).
|
// RemoveTOTP removes TOTP from an account (admin only).
|
||||||
// Requires: admin JWT in metadata.
|
// Requires: admin JWT in metadata.
|
||||||
RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error)
|
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()
|
mustEmbedUnimplementedAuthServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +209,12 @@ func (UnimplementedAuthServiceServer) ConfirmTOTP(context.Context, *ConfirmTOTPR
|
|||||||
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
|
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented")
|
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) mustEmbedUnimplementedAuthServiceServer() {}
|
||||||
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -304,6 +344,42 @@ func _AuthService_RemoveTOTP_Handler(srv interface{}, ctx context.Context, dec f
|
|||||||
return interceptor(ctx, in, info, handler)
|
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.
|
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -335,6 +411,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "RemoveTOTP",
|
MethodName: "RemoveTOTP",
|
||||||
Handler: _AuthService_RemoveTOTP_Handler,
|
Handler: _AuthService_RemoveTOTP_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListWebAuthnCredentials",
|
||||||
|
Handler: _AuthService_ListWebAuthnCredentials_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveWebAuthnCredential",
|
||||||
|
Handler: _AuthService_RemoveWebAuthnCredential_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "mcias/v1/auth.proto",
|
Metadata: "mcias/v1/auth.proto",
|
||||||
|
|||||||
18
go.mod
18
go.mod
@@ -7,8 +7,8 @@ require (
|
|||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/term v0.37.0
|
golang.org/x/term v0.41.0
|
||||||
google.golang.org/grpc v1.74.2
|
google.golang.org/grpc v1.74.2
|
||||||
google.golang.org/protobuf v1.36.7
|
google.golang.org/protobuf v1.36.7
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
@@ -16,13 +16,21 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // 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/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
50
go.sum
50
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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=
|
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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -32,8 +42,12 @@ 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
@@ -46,25 +60,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/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 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
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 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/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
|
||||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
|
|
||||||
func TestJSON(t *testing.T) {
|
func TestJSON(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
verify func(t *testing.T, result string)
|
||||||
name string
|
name string
|
||||||
pairs []string
|
pairs []string
|
||||||
verify func(t *testing.T, result string)
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "single pair",
|
name: "single pair",
|
||||||
@@ -109,9 +109,9 @@ func TestJSON(t *testing.T) {
|
|||||||
|
|
||||||
func TestJSONWithRoles(t *testing.T) {
|
func TestJSONWithRoles(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
verify func(t *testing.T, result string)
|
||||||
name string
|
name string
|
||||||
roles []string
|
roles []string
|
||||||
verify func(t *testing.T, result string)
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "multiple roles",
|
name: "multiple roles",
|
||||||
|
|||||||
@@ -8,18 +8,29 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the top-level configuration structure parsed from the TOML file.
|
// 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"`
|
Server ServerConfig `toml:"server"`
|
||||||
MasterKey MasterKeyConfig `toml:"master_key"`
|
MasterKey MasterKeyConfig `toml:"master_key"`
|
||||||
Database DatabaseConfig `toml:"database"`
|
Database DatabaseConfig `toml:"database"`
|
||||||
Tokens TokensConfig `toml:"tokens"`
|
Tokens TokensConfig `toml:"tokens"`
|
||||||
Argon2 Argon2Config `toml:"argon2"`
|
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.
|
// 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"))
|
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...)
|
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.
|
// ServiceExpiry returns the configured service token expiry duration.
|
||||||
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.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 != ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -1245,3 +1245,268 @@ func (db *DB) ClearLoginFailures(accountID int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAccountsWithTOTP returns all accounts (including deleted) that have a
|
||||||
|
// non-null TOTP secret stored, so that rekey can re-encrypt every secret even
|
||||||
|
// for inactive or deleted accounts.
|
||||||
|
func (db *DB) ListAccountsWithTOTP() ([]*model.Account, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT id, uuid, username, account_type, COALESCE(password_hash,''),
|
||||||
|
status, totp_required,
|
||||||
|
totp_secret_enc, totp_secret_nonce,
|
||||||
|
created_at, updated_at, deleted_at
|
||||||
|
FROM accounts
|
||||||
|
WHERE totp_secret_enc IS NOT NULL
|
||||||
|
ORDER BY id ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list accounts with TOTP: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var accounts []*model.Account
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := db.scanAccountRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts = append(accounts, a)
|
||||||
|
}
|
||||||
|
return accounts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllPGCredentials returns every row in pg_credentials. Used by rekey
|
||||||
|
// to re-encrypt all stored passwords under a new master key.
|
||||||
|
func (db *DB) ListAllPGCredentials() ([]*model.PGCredential, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username,
|
||||||
|
pg_password_enc, pg_password_nonce, created_at, updated_at, owner_id
|
||||||
|
FROM pg_credentials
|
||||||
|
ORDER BY id ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list all pg credentials: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var creds []*model.PGCredential
|
||||||
|
for rows.Next() {
|
||||||
|
var cred model.PGCredential
|
||||||
|
var createdAtStr, updatedAtStr string
|
||||||
|
var ownerID sql.NullInt64
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
|
||||||
|
&cred.PGDatabase, &cred.PGUsername,
|
||||||
|
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
|
||||||
|
&createdAtStr, &updatedAtStr, &ownerID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan pg credential: %w", err)
|
||||||
|
}
|
||||||
|
var parseErr error
|
||||||
|
cred.CreatedAt, parseErr = parseTime(createdAtStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, parseErr
|
||||||
|
}
|
||||||
|
cred.UpdatedAt, parseErr = parseTime(updatedAtStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, parseErr
|
||||||
|
}
|
||||||
|
if ownerID.Valid {
|
||||||
|
v := ownerID.Int64
|
||||||
|
cred.OwnerID = &v
|
||||||
|
}
|
||||||
|
creds = append(creds, &cred)
|
||||||
|
}
|
||||||
|
return creds, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTPRekeyRow carries a re-encrypted TOTP secret for a single account.
|
||||||
|
type TOTPRekeyRow struct {
|
||||||
|
Enc []byte
|
||||||
|
Nonce []byte
|
||||||
|
AccountID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGRekeyRow carries a re-encrypted Postgres password for a single credential row.
|
||||||
|
type PGRekeyRow struct {
|
||||||
|
Enc []byte
|
||||||
|
Nonce []byte
|
||||||
|
CredentialID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rekey atomically replaces the master-key salt and all secrets encrypted
|
||||||
|
// under the old master key with values encrypted under the new master key.
|
||||||
|
//
|
||||||
|
// Security: The entire replacement is performed inside a single SQLite
|
||||||
|
// transaction so that a crash mid-way leaves the database either fully on the
|
||||||
|
// old key or fully on the new key — never in a mixed state. The caller is
|
||||||
|
// responsible for zeroing the old and new master keys after this call returns.
|
||||||
|
func (db *DB) Rekey(newSalt, newSigningKeyEnc, newSigningKeyNonce []byte, totpRows []TOTPRekeyRow, pgRows []PGRekeyRow) error {
|
||||||
|
tx, err := db.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: rekey begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
n := now()
|
||||||
|
|
||||||
|
// Replace master key salt and signing key atomically.
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE server_config
|
||||||
|
SET master_key_salt = ?,
|
||||||
|
signing_key_enc = ?,
|
||||||
|
signing_key_nonce = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = 1
|
||||||
|
`, newSalt, newSigningKeyEnc, newSigningKeyNonce, n)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: rekey update server_config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encrypt each TOTP secret.
|
||||||
|
for _, row := range totpRows {
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET totp_secret_enc = ?,
|
||||||
|
totp_secret_nonce = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, row.Enc, row.Nonce, n, row.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: rekey update TOTP for account %d: %w", row.AccountID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encrypt each pg_credentials password.
|
||||||
|
for _, row := range pgRows {
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE pg_credentials
|
||||||
|
SET pg_password_enc = ?,
|
||||||
|
pg_password_nonce = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, row.Enc, row.Nonce, n, row.CredentialID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: rekey update pg credential %d: %w", row.CredentialID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("db: rekey commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantTokenIssueAccess records that granteeID may issue tokens for the system
|
||||||
|
// account identified by accountID. Idempotent: a second call for the same
|
||||||
|
// (account, grantee) pair is silently ignored via INSERT OR IGNORE.
|
||||||
|
func (db *DB) GrantTokenIssueAccess(accountID, granteeID int64, grantedBy *int64) error {
|
||||||
|
_, err := db.sql.Exec(`
|
||||||
|
INSERT OR IGNORE INTO service_account_delegates
|
||||||
|
(account_id, grantee_id, granted_by, granted_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, accountID, granteeID, grantedBy, now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: grant token issue access: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeTokenIssueAccess removes the delegate grant for granteeID on accountID.
|
||||||
|
// Returns ErrNotFound if no such grant exists.
|
||||||
|
func (db *DB) RevokeTokenIssueAccess(accountID, granteeID int64) error {
|
||||||
|
result, err := db.sql.Exec(`
|
||||||
|
DELETE FROM service_account_delegates
|
||||||
|
WHERE account_id = ? AND grantee_id = ?
|
||||||
|
`, accountID, granteeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: revoke token issue access: %w", err)
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: revoke token issue access rows: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokenIssueDelegates returns all delegate grants for the given system account.
|
||||||
|
func (db *DB) ListTokenIssueDelegates(accountID int64) ([]*model.ServiceAccountDelegate, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT d.id, d.account_id, d.grantee_id, d.granted_by, d.granted_at,
|
||||||
|
a.uuid, a.username
|
||||||
|
FROM service_account_delegates d
|
||||||
|
JOIN accounts a ON a.id = d.grantee_id
|
||||||
|
WHERE d.account_id = ?
|
||||||
|
ORDER BY d.granted_at ASC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list token issue delegates: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []*model.ServiceAccountDelegate
|
||||||
|
for rows.Next() {
|
||||||
|
var d model.ServiceAccountDelegate
|
||||||
|
var grantedAt string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&d.ID, &d.AccountID, &d.GranteeID, &d.GrantedBy, &grantedAt,
|
||||||
|
&d.GranteeUUID, &d.GranteeName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan token issue delegate: %w", err)
|
||||||
|
}
|
||||||
|
t, err := parseTime(grantedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.GrantedAt = t
|
||||||
|
out = append(out, &d)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasTokenIssueAccess reports whether actorID has been granted permission to
|
||||||
|
// issue tokens for the system account identified by accountID.
|
||||||
|
func (db *DB) HasTokenIssueAccess(accountID, actorID int64) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := db.sql.QueryRow(`
|
||||||
|
SELECT COUNT(1) FROM service_account_delegates
|
||||||
|
WHERE account_id = ? AND grantee_id = ?
|
||||||
|
`, accountID, actorID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("db: has token issue access: %w", err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDelegatedServiceAccounts returns system accounts for which actorID has
|
||||||
|
// been granted token-issue access.
|
||||||
|
func (db *DB) ListDelegatedServiceAccounts(actorID int64) ([]*model.Account, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT a.id, a.uuid, a.username, a.account_type, COALESCE(a.password_hash,''),
|
||||||
|
a.status, a.totp_required,
|
||||||
|
a.totp_secret_enc, a.totp_secret_nonce,
|
||||||
|
a.created_at, a.updated_at, a.deleted_at
|
||||||
|
FROM service_account_delegates d
|
||||||
|
JOIN accounts a ON a.id = d.account_id
|
||||||
|
WHERE d.grantee_id = ? AND a.status != 'deleted'
|
||||||
|
ORDER BY a.username ASC
|
||||||
|
`, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list delegated service accounts: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []*model.Account
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := db.scanAccountRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,3 +194,210 @@ func TestListAuditEventsCombinedFilters(t *testing.T) {
|
|||||||
t.Fatalf("expected 0 events, got %d", len(events))
|
t.Fatalf("expected 0 events, got %d", len(events))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- rekey helper tests ----
|
||||||
|
|
||||||
|
func TestListAccountsWithTOTP(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// No accounts with TOTP yet.
|
||||||
|
accounts, err := database.ListAccountsWithTOTP()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAccountsWithTOTP (empty): %v", err)
|
||||||
|
}
|
||||||
|
if len(accounts) != 0 {
|
||||||
|
t.Fatalf("expected 0 accounts, got %d", len(accounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an account and store a TOTP secret.
|
||||||
|
a, err := database.CreateAccount("totpuser", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.SetTOTP(a.ID, []byte("enc"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatalf("set TOTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create another account without TOTP.
|
||||||
|
if _, err := database.CreateAccount("nototp", model.AccountTypeHuman, "hash"); err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err = database.ListAccountsWithTOTP()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAccountsWithTOTP: %v", err)
|
||||||
|
}
|
||||||
|
if len(accounts) != 1 {
|
||||||
|
t.Fatalf("expected 1 account with TOTP, got %d", len(accounts))
|
||||||
|
}
|
||||||
|
if accounts[0].ID != a.ID {
|
||||||
|
t.Errorf("expected account ID %d, got %d", a.ID, accounts[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAllPGCredentials(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
creds, err := database.ListAllPGCredentials()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAllPGCredentials (empty): %v", err)
|
||||||
|
}
|
||||||
|
if len(creds) != 0 {
|
||||||
|
t.Fatalf("expected 0 creds, got %d", len(creds))
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := database.CreateAccount("pguser", model.AccountTypeSystem, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("enc"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatalf("write pg credentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err = database.ListAllPGCredentials()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAllPGCredentials: %v", err)
|
||||||
|
}
|
||||||
|
if len(creds) != 1 {
|
||||||
|
t.Fatalf("expected 1 credential, got %d", len(creds))
|
||||||
|
}
|
||||||
|
if creds[0].AccountID != a.ID {
|
||||||
|
t.Errorf("expected account ID %d, got %d", a.ID, creds[0].AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRekey(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// Set up: salt + signing key.
|
||||||
|
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
|
||||||
|
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
|
||||||
|
t.Fatalf("write salt: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.WriteServerConfig([]byte("oldenc"), []byte("oldnonce")); err != nil {
|
||||||
|
t.Fatalf("write server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up: account with TOTP.
|
||||||
|
a, err := database.CreateAccount("rekeyuser", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.SetTOTP(a.ID, []byte("totpenc"), []byte("totpnonce")); err != nil {
|
||||||
|
t.Fatalf("set TOTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up: pg credential.
|
||||||
|
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("pgenc"), []byte("pgnonce")); err != nil {
|
||||||
|
t.Fatalf("write pg creds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute Rekey.
|
||||||
|
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||||
|
totpRows := []TOTPRekeyRow{{AccountID: a.ID, Enc: []byte("newtotpenc"), Nonce: []byte("newtotpnonce")}}
|
||||||
|
pgCred, err := database.ReadPGCredentials(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read pg creds: %v", err)
|
||||||
|
}
|
||||||
|
pgRows := []PGRekeyRow{{CredentialID: pgCred.ID, Enc: []byte("newpgenc"), Nonce: []byte("newpgnonce")}}
|
||||||
|
|
||||||
|
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), totpRows, pgRows); err != nil {
|
||||||
|
t.Fatalf("Rekey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: salt replaced.
|
||||||
|
gotSalt, err := database.ReadMasterKeySalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read salt after rekey: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotSalt) != string(newSalt) {
|
||||||
|
t.Errorf("salt mismatch: got %q, want %q", gotSalt, newSalt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: signing key replaced.
|
||||||
|
gotEnc, gotNonce, err := database.ReadServerConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read server config after rekey: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotEnc) != "newenc" || string(gotNonce) != "newnonce" {
|
||||||
|
t.Errorf("signing key enc/nonce mismatch after rekey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: TOTP replaced.
|
||||||
|
updatedAcct, err := database.GetAccountByID(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get account after rekey: %v", err)
|
||||||
|
}
|
||||||
|
if string(updatedAcct.TOTPSecretEnc) != "newtotpenc" || string(updatedAcct.TOTPSecretNonce) != "newtotpnonce" {
|
||||||
|
t.Errorf("TOTP enc/nonce mismatch after rekey: enc=%q nonce=%q",
|
||||||
|
updatedAcct.TOTPSecretEnc, updatedAcct.TOTPSecretNonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: pg credential replaced.
|
||||||
|
updatedCred, err := database.ReadPGCredentials(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read pg creds after rekey: %v", err)
|
||||||
|
}
|
||||||
|
if string(updatedCred.PGPasswordEnc) != "newpgenc" || string(updatedCred.PGPasswordNonce) != "newpgnonce" {
|
||||||
|
t.Errorf("pg enc/nonce mismatch after rekey: enc=%q nonce=%q",
|
||||||
|
updatedCred.PGPasswordEnc, updatedCred.PGPasswordNonce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRekeyEmptyDatabase(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// Minimal setup: salt and signing key only; no TOTP, no pg creds.
|
||||||
|
salt := []byte("saltsaltsaltsaltsaltsaltsaltsalt") // 32 bytes
|
||||||
|
if err := database.WriteMasterKeySalt(salt); err != nil {
|
||||||
|
t.Fatalf("write salt: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatalf("write server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||||
|
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
|
||||||
|
t.Fatalf("Rekey (empty): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSalt, err := database.ReadMasterKeySalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read salt: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotSalt) != string(newSalt) {
|
||||||
|
t.Errorf("salt mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRekeyOldSaltUnchangedOnQueryError verifies the salt and encrypted data
|
||||||
|
// is only present under the new values after a successful Rekey — the old
|
||||||
|
// values must be gone. Uses the same approach as TestRekey but reads the
|
||||||
|
// stored salt before and confirms it changes.
|
||||||
|
func TestRekeyReplacesSalt(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
|
||||||
|
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
|
||||||
|
t.Fatalf("write salt: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatalf("write server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||||
|
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
|
||||||
|
t.Fatalf("Rekey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSalt, err := database.ReadMasterKeySalt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read salt: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotSalt) == string(oldSalt) {
|
||||||
|
t.Error("old salt still present after rekey")
|
||||||
|
}
|
||||||
|
if string(gotSalt) != string(newSalt) {
|
||||||
|
t.Errorf("expected new salt %q, got %q", newSalt, gotSalt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ var migrationsFS embed.FS
|
|||||||
// LatestSchemaVersion is the highest migration version defined in the
|
// LatestSchemaVersion is the highest migration version defined in the
|
||||||
// migrations/ directory. Update this constant whenever a new migration file
|
// migrations/ directory. Update this constant whenever a new migration file
|
||||||
// is added.
|
// is added.
|
||||||
const LatestSchemaVersion = 7
|
const LatestSchemaVersion = 9
|
||||||
|
|
||||||
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
// 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
|
// files. It opens a dedicated *sql.DB using the same DSN as the main
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- service_account_delegates tracks which human accounts are permitted to issue
|
||||||
|
-- tokens for a given system account without holding the global admin role.
|
||||||
|
-- Admins manage delegates; delegates can issue/rotate tokens for the specific
|
||||||
|
-- system account only and cannot modify any other account settings.
|
||||||
|
CREATE TABLE IF NOT EXISTS service_account_delegates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
granted_by INTEGER REFERENCES accounts(id),
|
||||||
|
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
UNIQUE (account_id, grantee_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sa_delegates_account ON service_account_delegates (account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sa_delegates_grantee ON service_account_delegates (grantee_id);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -361,8 +361,8 @@ func TestClientIP(t *testing.T) {
|
|||||||
remoteAddr string
|
remoteAddr string
|
||||||
xForwardedFor string
|
xForwardedFor string
|
||||||
xRealIP string
|
xRealIP string
|
||||||
trustedProxy net.IP
|
|
||||||
want string
|
want string
|
||||||
|
trustedProxy net.IP
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no proxy configured: uses RemoteAddr",
|
name: "no proxy configured: uses RemoteAddr",
|
||||||
|
|||||||
@@ -210,8 +210,50 @@ const (
|
|||||||
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
|
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
|
||||||
|
|
||||||
EventPasswordChanged = "password_changed"
|
EventPasswordChanged = "password_changed"
|
||||||
|
|
||||||
|
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
|
||||||
|
// permission to issue tokens for a given system account. Only admins can
|
||||||
|
// add or remove delegates; delegates can issue/rotate tokens for that specific
|
||||||
|
// system account and nothing else.
|
||||||
|
type ServiceAccountDelegate struct {
|
||||||
|
GrantedAt time.Time `json:"granted_at"`
|
||||||
|
GrantedBy *int64 `json:"-"`
|
||||||
|
GranteeUUID string `json:"grantee_id"`
|
||||||
|
GranteeName string `json:"grantee_username"`
|
||||||
|
ID int64 `json:"-"`
|
||||||
|
AccountID int64 `json:"-"`
|
||||||
|
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.
|
// PolicyRuleRecord is the database representation of a policy rule.
|
||||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||||
// The ID, Priority, and Description are stored as dedicated columns.
|
// The ID, Priority, and Description are stored as dedicated columns.
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ var defaultRules = []Rule{
|
|||||||
OwnerMatchesSubject: true,
|
OwnerMatchesSubject: true,
|
||||||
Effect: Allow,
|
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
|
// Public endpoints: token validation and login do not require
|
||||||
// authentication. The middleware exempts them from RequireAuth entirely;
|
// authentication. The middleware exempts them from RequireAuth entirely;
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const (
|
|||||||
|
|
||||||
ActionListRules Action = "policy:list"
|
ActionListRules Action = "policy:list"
|
||||||
ActionManageRules Action = "policy:manage"
|
ActionManageRules Action = "policy:manage"
|
||||||
|
|
||||||
|
ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
|
||||||
|
ActionRemoveWebAuthn Action = "webauthn:remove" // admin
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResourceType identifies what kind of object a request targets.
|
// ResourceType identifies what kind of object a request targets.
|
||||||
@@ -60,6 +63,7 @@ const (
|
|||||||
ResourceAuditLog ResourceType = "audit_log"
|
ResourceAuditLog ResourceType = "audit_log"
|
||||||
ResourceTOTP ResourceType = "totp"
|
ResourceTOTP ResourceType = "totp"
|
||||||
ResourcePolicy ResourceType = "policy"
|
ResourcePolicy ResourceType = "policy"
|
||||||
|
ResourceWebAuthn ResourceType = "webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Effect is the outcome of policy evaluation.
|
// Effect is the outcome of policy evaluation.
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
|
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
|
||||||
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
// Reload the in-memory engine so the new rule takes effect immediately.
|
||||||
|
s.reloadPolicyEngine()
|
||||||
|
|
||||||
rv, err := policyRuleToResponse(rec)
|
rv, err := policyRuleToResponse(rec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
@@ -325,6 +328,9 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
||||||
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
||||||
|
|
||||||
|
// Reload the in-memory engine so rule changes take effect immediately.
|
||||||
|
s.reloadPolicyEngine()
|
||||||
|
|
||||||
updated, err := s.db.GetPolicyRule(rec.ID)
|
updated, err := s.db.GetPolicyRule(rec.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
@@ -358,6 +364,9 @@ func (s *Server) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request)
|
|||||||
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
||||||
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
// Reload the in-memory engine so the deleted rule is removed immediately.
|
||||||
|
s.reloadPolicyEngine()
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -27,6 +28,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||||
@@ -40,15 +42,154 @@ type Server struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
|
polEng *policy.Engine
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Server with the given dependencies.
|
// New creates a Server with the given dependencies.
|
||||||
|
// The policy engine is initialised and loaded from the database on construction.
|
||||||
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server {
|
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server {
|
||||||
|
eng := policy.NewEngine()
|
||||||
|
if err := loadEngineRules(eng, database); err != nil {
|
||||||
|
logger.Warn("policy engine initial load failed; built-in defaults will apply", "error", err)
|
||||||
|
}
|
||||||
return &Server{
|
return &Server{
|
||||||
db: database,
|
db: database,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
vault: v,
|
vault: v,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
polEng: eng,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEngineRules reads all policy rules from the database and loads them into eng.
|
||||||
|
// Enabled/disabled and validity-window filtering is handled by the engine itself.
|
||||||
|
func loadEngineRules(eng *policy.Engine, database *db.DB) error {
|
||||||
|
records, err := database.ListPolicyRules(false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list policy rules: %w", err)
|
||||||
|
}
|
||||||
|
prs := make([]policy.PolicyRecord, len(records))
|
||||||
|
for i, r := range records {
|
||||||
|
prs[i] = policy.PolicyRecord{
|
||||||
|
ID: r.ID,
|
||||||
|
Priority: r.Priority,
|
||||||
|
Description: r.Description,
|
||||||
|
RuleJSON: r.RuleJSON,
|
||||||
|
Enabled: r.Enabled,
|
||||||
|
NotBefore: r.NotBefore,
|
||||||
|
ExpiresAt: r.ExpiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eng.SetRules(prs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadPolicyEngine reloads operator rules from the database into the engine.
|
||||||
|
// Called after any create, update, or delete of a policy rule so that the
|
||||||
|
// in-memory cache stays consistent with the database.
|
||||||
|
func (s *Server) reloadPolicyEngine() {
|
||||||
|
if err := loadEngineRules(s.polEng, s.db); err != nil {
|
||||||
|
s.logger.Error("reload policy engine", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// accountTypeLookup returns an AccountTypeLookup closure that resolves the
|
||||||
|
// account type ("human" or "system") for the given subject UUID. Used by the
|
||||||
|
// RequirePolicy middleware to populate PolicyInput.AccountType.
|
||||||
|
func (s *Server) accountTypeLookup() middleware.AccountTypeLookup {
|
||||||
|
return func(subjectUUID string) string {
|
||||||
|
acct, err := s.db.GetAccountByUUID(subjectUUID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(acct.AccountType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// policyDenyLogger returns a PolicyDenyLogger that records policy denials in
|
||||||
|
// the audit log as EventPolicyDeny events.
|
||||||
|
func (s *Server) policyDenyLogger() middleware.PolicyDenyLogger {
|
||||||
|
return func(r *http.Request, claims *token.Claims, action policy.Action, res policy.Resource, matchedRuleID int64) {
|
||||||
|
s.writeAudit(r, model.EventPolicyDeny, nil, nil,
|
||||||
|
fmt.Sprintf(`{"subject":%q,"action":%q,"resource_type":%q,"rule_id":%d}`,
|
||||||
|
claims.Subject, action, res.Type, matchedRuleID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAccountResource assembles the policy.Resource for endpoints that
|
||||||
|
// target a specific account ({id} path parameter). Looks up the account's
|
||||||
|
// UUID, username (for ServiceName), and tags from the database.
|
||||||
|
// Returns an empty Resource on lookup failure; deny-by-default in the engine
|
||||||
|
// means this safely falls through to a denial for owner-scoped rules.
|
||||||
|
func (s *Server) buildAccountResource(r *http.Request, _ *token.Claims) policy.Resource {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
return policy.Resource{}
|
||||||
|
}
|
||||||
|
acct, err := s.db.GetAccountByUUID(id)
|
||||||
|
if err != nil {
|
||||||
|
return policy.Resource{}
|
||||||
|
}
|
||||||
|
tags, _ := s.db.GetAccountTags(acct.ID)
|
||||||
|
return policy.Resource{
|
||||||
|
OwnerUUID: acct.UUID,
|
||||||
|
ServiceName: acct.Username,
|
||||||
|
Tags: tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTokenResource assembles the policy.Resource for token-issue requests.
|
||||||
|
// The request body contains account_id (UUID); the resource owner is that account.
|
||||||
|
// Because this builder reads the body it must be called before the body is
|
||||||
|
// consumed by the handler — the middleware calls it before invoking next.
|
||||||
|
func (s *Server) buildTokenResource(r *http.Request, _ *token.Claims) policy.Resource {
|
||||||
|
// Peek at the account_id without consuming the body.
|
||||||
|
// We read the body into a small wrapper struct to get the target UUID.
|
||||||
|
// The actual handler re-reads the body via decodeJSON, so this is safe
|
||||||
|
// because http.MaxBytesReader is applied by the handler, not here.
|
||||||
|
var peek struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBytes))
|
||||||
|
if err != nil {
|
||||||
|
return policy.Resource{}
|
||||||
|
}
|
||||||
|
// Restore the body for the downstream handler.
|
||||||
|
r.Body = io.NopCloser(strings.NewReader(string(body)))
|
||||||
|
if err := json.Unmarshal(body, &peek); err != nil || peek.AccountID == "" {
|
||||||
|
return policy.Resource{}
|
||||||
|
}
|
||||||
|
acct, err := s.db.GetAccountByUUID(peek.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return policy.Resource{}
|
||||||
|
}
|
||||||
|
tags, _ := s.db.GetAccountTags(acct.ID)
|
||||||
|
return policy.Resource{
|
||||||
|
OwnerUUID: acct.UUID,
|
||||||
|
ServiceName: acct.Username,
|
||||||
|
Tags: tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildJTIResource assembles the policy.Resource for token-revoke requests.
|
||||||
|
// Looks up the token record by {jti} to identify the owning account.
|
||||||
|
func (s *Server) buildJTIResource(r *http.Request, _ *token.Claims) policy.Resource {
|
||||||
|
jti := r.PathValue("jti")
|
||||||
|
if jti == "" {
|
||||||
|
return policy.Resource{}
|
||||||
|
}
|
||||||
|
rec, err := s.db.GetTokenRecord(jti)
|
||||||
|
if err != nil {
|
||||||
|
return policy.Resource{}
|
||||||
|
}
|
||||||
|
acct, err := s.db.GetAccountByID(rec.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return policy.Resource{}
|
||||||
|
}
|
||||||
|
tags, _ := s.db.GetAccountTags(acct.ID)
|
||||||
|
return policy.Resource{
|
||||||
|
OwnerUUID: acct.UUID,
|
||||||
|
ServiceName: acct.Username,
|
||||||
|
Tags: tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +232,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
|
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
|
||||||
}
|
}
|
||||||
|
swaggerJS, err := fs.ReadFile(staticFS, "swagger-ui-bundle.js")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("server: read swagger-ui-bundle.js: %v", err))
|
||||||
|
}
|
||||||
|
swaggerCSS, err := fs.ReadFile(staticFS, "swagger-ui.css")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("server: read swagger-ui.css: %v", err))
|
||||||
|
}
|
||||||
// Security (DEF-09): apply defensive HTTP headers to the docs handlers.
|
// Security (DEF-09): apply defensive HTTP headers to the docs handlers.
|
||||||
// The Swagger UI page at /docs loads JavaScript from the same origin
|
// The Swagger UI page at /docs loads JavaScript from the same origin
|
||||||
// and renders untrusted content (API descriptions), so it benefits from
|
// and renders untrusted content (API descriptions), so it benefits from
|
||||||
@@ -105,6 +254,16 @@ func (s *Server) Handler() http.Handler {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(specYAML)
|
_, _ = w.Write(specYAML)
|
||||||
})))
|
})))
|
||||||
|
mux.Handle("GET /static/swagger-ui-bundle.js", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(swaggerJS)
|
||||||
|
})))
|
||||||
|
mux.Handle("GET /static/swagger-ui.css", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(swaggerCSS)
|
||||||
|
})))
|
||||||
|
|
||||||
// Vault endpoints (exempt from sealed middleware and auth).
|
// Vault endpoints (exempt from sealed middleware and auth).
|
||||||
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
|
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
|
||||||
@@ -114,44 +273,104 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
// Authenticated endpoints.
|
// Authenticated endpoints.
|
||||||
requireAuth := middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)
|
requireAuth := middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)
|
||||||
requireAdmin := func(h http.Handler) http.Handler {
|
|
||||||
return requireAuth(middleware.RequireRole("admin")(h))
|
// Policy middleware factory: chains requireAuth → RequirePolicy → next.
|
||||||
|
// All protected endpoints use this instead of the old requireAdmin wrapper
|
||||||
|
// so that operator-defined policy rules (not just the admin role) control
|
||||||
|
// access. The built-in admin wildcard rule (ID -1) preserves existing
|
||||||
|
// admin behaviour; additional operator rules can grant non-admin accounts
|
||||||
|
// access to specific actions.
|
||||||
|
//
|
||||||
|
// Security: deny-wins + default-deny in the engine mean that any
|
||||||
|
// misconfiguration results in 403, never silent permit.
|
||||||
|
acctTypeLookup := s.accountTypeLookup()
|
||||||
|
denyLogger := s.policyDenyLogger()
|
||||||
|
requirePolicy := func(
|
||||||
|
action policy.Action,
|
||||||
|
resType policy.ResourceType,
|
||||||
|
builder middleware.ResourceBuilder,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
|
pol := middleware.RequirePolicy(s.polEng, action, resType, builder, acctTypeLookup, denyLogger)
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return requireAuth(pol(next))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth endpoints (require valid token).
|
// Resource builders for endpoints that target a specific account or token.
|
||||||
|
buildAcct := middleware.ResourceBuilder(s.buildAccountResource)
|
||||||
|
buildToken := middleware.ResourceBuilder(s.buildTokenResource)
|
||||||
|
buildJTI := middleware.ResourceBuilder(s.buildJTIResource)
|
||||||
|
|
||||||
|
// Auth endpoints (require valid token; self-service rules in built-in defaults
|
||||||
|
// allow any authenticated principal to perform these operations).
|
||||||
mux.Handle("POST /v1/auth/logout", requireAuth(http.HandlerFunc(s.handleLogout)))
|
mux.Handle("POST /v1/auth/logout", requireAuth(http.HandlerFunc(s.handleLogout)))
|
||||||
mux.Handle("POST /v1/auth/renew", requireAuth(http.HandlerFunc(s.handleRenew)))
|
mux.Handle("POST /v1/auth/renew", requireAuth(http.HandlerFunc(s.handleRenew)))
|
||||||
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
|
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
|
||||||
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
|
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
|
||||||
|
|
||||||
// Admin-only endpoints.
|
// WebAuthn registration endpoints (require valid token; self-service).
|
||||||
mux.Handle("DELETE /v1/auth/totp", requireAdmin(http.HandlerFunc(s.handleTOTPRemove)))
|
mux.Handle("POST /v1/auth/webauthn/register/begin", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterBegin)))
|
||||||
mux.Handle("POST /v1/token/issue", requireAdmin(http.HandlerFunc(s.handleTokenIssue)))
|
mux.Handle("POST /v1/auth/webauthn/register/finish", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterFinish)))
|
||||||
mux.Handle("DELETE /v1/token/{jti}", requireAdmin(http.HandlerFunc(s.handleTokenRevoke)))
|
// WebAuthn login endpoints (public, rate-limited).
|
||||||
mux.Handle("GET /v1/accounts", requireAdmin(http.HandlerFunc(s.handleListAccounts)))
|
mux.Handle("POST /v1/auth/webauthn/login/begin", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginBegin)))
|
||||||
mux.Handle("POST /v1/accounts", requireAdmin(http.HandlerFunc(s.handleCreateAccount)))
|
mux.Handle("POST /v1/auth/webauthn/login/finish", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginFinish)))
|
||||||
mux.Handle("GET /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleGetAccount)))
|
|
||||||
mux.Handle("PATCH /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleUpdateAccount)))
|
// Policy-gated endpoints (formerly admin-only; now controlled by the engine).
|
||||||
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount)))
|
mux.Handle("DELETE /v1/auth/totp",
|
||||||
mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles)))
|
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
|
||||||
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles)))
|
mux.Handle("POST /v1/token/issue",
|
||||||
mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole)))
|
requirePolicy(policy.ActionIssueToken, policy.ResourceToken, buildToken)(http.HandlerFunc(s.handleTokenIssue)))
|
||||||
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole)))
|
mux.Handle("DELETE /v1/token/{jti}",
|
||||||
|
requirePolicy(policy.ActionRevokeToken, policy.ResourceToken, buildJTI)(http.HandlerFunc(s.handleTokenRevoke)))
|
||||||
|
mux.Handle("GET /v1/accounts",
|
||||||
|
requirePolicy(policy.ActionListAccounts, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleListAccounts)))
|
||||||
|
mux.Handle("POST /v1/accounts",
|
||||||
|
requirePolicy(policy.ActionCreateAccount, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleCreateAccount)))
|
||||||
|
mux.Handle("GET /v1/accounts/{id}",
|
||||||
|
requirePolicy(policy.ActionReadAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetAccount)))
|
||||||
|
mux.Handle("PATCH /v1/accounts/{id}",
|
||||||
|
requirePolicy(policy.ActionUpdateAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleUpdateAccount)))
|
||||||
|
mux.Handle("DELETE /v1/accounts/{id}",
|
||||||
|
requirePolicy(policy.ActionDeleteAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleDeleteAccount)))
|
||||||
|
mux.Handle("GET /v1/accounts/{id}/roles",
|
||||||
|
requirePolicy(policy.ActionReadRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetRoles)))
|
||||||
|
mux.Handle("PUT /v1/accounts/{id}/roles",
|
||||||
|
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleSetRoles)))
|
||||||
|
mux.Handle("POST /v1/accounts/{id}/roles",
|
||||||
|
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGrantRole)))
|
||||||
|
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}",
|
||||||
|
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleRevokeRole)))
|
||||||
mux.Handle("GET /v1/pgcreds", requireAuth(http.HandlerFunc(s.handleListAccessiblePGCreds)))
|
mux.Handle("GET /v1/pgcreds", requireAuth(http.HandlerFunc(s.handleListAccessiblePGCreds)))
|
||||||
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
mux.Handle("GET /v1/accounts/{id}/pgcreds",
|
||||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
|
||||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
|
||||||
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
|
||||||
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
// WebAuthn credential management (policy-gated).
|
||||||
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
|
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",
|
||||||
|
requirePolicy(policy.ActionReadTags, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetTags)))
|
||||||
|
mux.Handle("PUT /v1/accounts/{id}/tags",
|
||||||
|
requirePolicy(policy.ActionWriteTags, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleSetTags)))
|
||||||
|
mux.Handle("PUT /v1/accounts/{id}/password",
|
||||||
|
requirePolicy(policy.ActionUpdateAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleAdminSetPassword)))
|
||||||
|
|
||||||
// Self-service password change (requires valid token; actor must match target account).
|
// Self-service password change (requires valid token; actor must match target account).
|
||||||
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
|
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
|
||||||
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
mux.Handle("GET /v1/policy/rules",
|
||||||
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleListPolicyRules)))
|
||||||
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
mux.Handle("POST /v1/policy/rules",
|
||||||
mux.Handle("PATCH /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleUpdatePolicyRule)))
|
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||||
mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule)))
|
mux.Handle("GET /v1/policy/rules/{id}",
|
||||||
|
requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleGetPolicyRule)))
|
||||||
|
mux.Handle("PATCH /v1/policy/rules/{id}",
|
||||||
|
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleUpdatePolicyRule)))
|
||||||
|
mux.Handle("DELETE /v1/policy/rules/{id}",
|
||||||
|
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule)))
|
||||||
|
|
||||||
// UI routes (HTMX-based management frontend).
|
// UI routes (HTMX-based management frontend).
|
||||||
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
|
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
|
||||||
@@ -451,6 +670,7 @@ type validateRequest struct {
|
|||||||
|
|
||||||
type validateResponse struct {
|
type validateResponse struct {
|
||||||
Subject string `json:"sub,omitempty"`
|
Subject string `json:"sub,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
Roles []string `json:"roles,omitempty"`
|
Roles []string `json:"roles,omitempty"`
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
@@ -490,12 +710,16 @@ func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, validateResponse{
|
resp := validateResponse{
|
||||||
Valid: true,
|
Valid: true,
|
||||||
Subject: claims.Subject,
|
Subject: claims.Subject,
|
||||||
Roles: claims.Roles,
|
Roles: claims.Roles,
|
||||||
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||||
})
|
}
|
||||||
|
if acct, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
resp.Username = acct.Username
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueTokenRequest struct {
|
type issueTokenRequest struct {
|
||||||
@@ -1315,13 +1539,13 @@ func (s *Server) handleListAccessiblePGCreds(w http.ResponseWriter, r *http.Requ
|
|||||||
type pgCredResponse struct {
|
type pgCredResponse struct {
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
ID int64 `json:"id"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
ServiceAccountID string `json:"service_account_id"`
|
ServiceAccountID string `json:"service_account_id"`
|
||||||
ServiceAccountName string `json:"service_account_name,omitempty"`
|
ServiceAccountName string `json:"service_account_name,omitempty"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Port int `json:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
response := make([]pgCredResponse, len(creds))
|
response := make([]pgCredResponse, len(creds))
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha1" //nolint:gosec // G505: SHA1 required by RFC 6238 TOTP (HMAC-SHA1)
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
@@ -972,3 +973,149 @@ func TestTOTPMissingDoesNotIncrementLockout(t *testing.T) {
|
|||||||
t.Error("account was locked after TOTP-missing login — lockout counter was incorrectly incremented (PEN-06)")
|
t.Error("account was locked after TOTP-missing login — lockout counter was incorrectly incremented (PEN-06)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// issueSystemToken creates a system account, issues a JWT with the given roles,
|
||||||
|
// tracks it in the database, and returns the token string and account.
|
||||||
|
func issueSystemToken(t *testing.T, srv *Server, priv ed25519.PrivateKey, username string, roles []string) (string, *model.Account) {
|
||||||
|
t.Helper()
|
||||||
|
acct, err := srv.db.CreateAccount(username, model.AccountTypeSystem, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create system account: %v", err)
|
||||||
|
}
|
||||||
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, roles, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue token: %v", err)
|
||||||
|
}
|
||||||
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
t.Fatalf("track token: %v", err)
|
||||||
|
}
|
||||||
|
return tokenStr, acct
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPolicyEnforcement verifies that the policy engine gates access:
|
||||||
|
// - Admin role is always allowed (built-in wildcard rule).
|
||||||
|
// - Unauthenticated requests are rejected.
|
||||||
|
// - Non-admin accounts are denied by default.
|
||||||
|
// - A non-admin account gains access once an operator policy rule is created.
|
||||||
|
// - Deleting the rule reverts to denial.
|
||||||
|
func TestPolicyEnforcement(t *testing.T) {
|
||||||
|
srv, _, priv, _ := newTestServer(t)
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin-pol")
|
||||||
|
|
||||||
|
// 1. Admin can list accounts (built-in wildcard rule -1).
|
||||||
|
rr := doRequest(t, handler, "GET", "/v1/accounts", nil, adminToken)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("admin list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Unauthenticated request is rejected.
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, "")
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("unauth list accounts status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. System account with no operator rules is denied by default.
|
||||||
|
svcToken, svcAcct := issueSystemToken(t, srv, priv, "metacrypt", []string{"user"})
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("system account (no policy) list accounts status = %d, want 403; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create an operator policy rule granting the system account accounts:list.
|
||||||
|
rule := createPolicyRuleRequest{
|
||||||
|
Description: "allow metacrypt to list accounts",
|
||||||
|
Priority: 50,
|
||||||
|
Rule: policy.RuleBody{
|
||||||
|
SubjectUUID: svcAcct.UUID,
|
||||||
|
AccountTypes: []string{"system"},
|
||||||
|
Actions: []policy.Action{policy.ActionListAccounts},
|
||||||
|
Effect: policy.Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr = doRequest(t, handler, "POST", "/v1/policy/rules", rule, adminToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create policy rule status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var created policyRuleResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("unmarshal created rule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. The same system account can now list accounts.
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("system account (with policy) list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. The system account is still denied other actions (accounts:read).
|
||||||
|
rr = doRequest(t, handler, "POST", "/v1/accounts", map[string]string{
|
||||||
|
"username": "newuser", "password": "newpassword123", "account_type": "human",
|
||||||
|
}, svcToken)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("system account (list-only policy) create account status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Delete the rule and verify the account is denied again.
|
||||||
|
rr = doRequest(t, handler, "DELETE", fmt.Sprintf("/v1/policy/rules/%d", created.ID), nil, adminToken)
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete policy rule status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("system account (rule deleted) list accounts status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPolicyDenyRule verifies that an explicit Deny rule blocks access even
|
||||||
|
// when an Allow rule would otherwise permit it.
|
||||||
|
func TestPolicyDenyRule(t *testing.T) {
|
||||||
|
srv, _, priv, _ := newTestServer(t)
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin-deny")
|
||||||
|
|
||||||
|
// Create an Allow rule for the system account.
|
||||||
|
svcToken, svcAcct := issueSystemToken(t, srv, priv, "svc-deny", []string{"user"})
|
||||||
|
allow := createPolicyRuleRequest{
|
||||||
|
Description: "allow svc-deny to list accounts",
|
||||||
|
Priority: 50,
|
||||||
|
Rule: policy.RuleBody{
|
||||||
|
SubjectUUID: svcAcct.UUID,
|
||||||
|
Actions: []policy.Action{policy.ActionListAccounts},
|
||||||
|
Effect: policy.Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/policy/rules", allow, adminToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create allow rule status = %d; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access is granted.
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("with allow rule, list accounts status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a higher-priority Deny rule for the same account.
|
||||||
|
deny := createPolicyRuleRequest{
|
||||||
|
Description: "deny svc-deny accounts:list",
|
||||||
|
Priority: 10, // lower number = higher precedence
|
||||||
|
Rule: policy.RuleBody{
|
||||||
|
SubjectUUID: svcAcct.UUID,
|
||||||
|
Actions: []policy.Action{policy.ActionListAccounts},
|
||||||
|
Effect: policy.Deny,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr = doRequest(t, handler, "POST", "/v1/policy/rules", deny, adminToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create deny rule status = %d; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny-wins: access must now be blocked despite the Allow rule.
|
||||||
|
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("deny-wins: list accounts status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,15 +29,9 @@ import (
|
|||||||
// on the next unseal. This is safe because sealed middleware prevents
|
// on the next unseal. This is safe because sealed middleware prevents
|
||||||
// reaching CSRF-protected routes.
|
// reaching CSRF-protected routes.
|
||||||
type CSRFManager struct {
|
type CSRFManager struct {
|
||||||
mu sync.Mutex
|
|
||||||
key []byte
|
|
||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
}
|
key []byte
|
||||||
|
mu sync.Mutex
|
||||||
// newCSRFManager creates a CSRFManager with a static key derived from masterKey.
|
|
||||||
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
|
|
||||||
func newCSRFManager(masterKey []byte) *CSRFManager {
|
|
||||||
return &CSRFManager{key: deriveCSRFKey(masterKey)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
||||||
|
|||||||
@@ -182,6 +182,30 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
tags = nil
|
tags = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For system accounts, load token issue delegates and the full account
|
||||||
|
// list so admins can add new ones.
|
||||||
|
var tokenDelegates []*model.ServiceAccountDelegate
|
||||||
|
var delegatableAccounts []*model.Account
|
||||||
|
if acct.AccountType == model.AccountTypeSystem && isAdmin(r) {
|
||||||
|
tokenDelegates, err = u.db.ListTokenIssueDelegates(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list token issue delegates", "error", err)
|
||||||
|
}
|
||||||
|
delegatableAccounts, err = u.db.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list accounts for delegate dropdown", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
u.render(w, "account_detail", AccountDetailData{
|
||||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
Account: acct,
|
Account: acct,
|
||||||
@@ -193,6 +217,12 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
GrantableAccounts: grantableAccounts,
|
GrantableAccounts: grantableAccounts,
|
||||||
ActorID: actorID,
|
ActorID: actorID,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
|
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,6 +1039,13 @@ func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleIssueSystemToken issues a long-lived service token for a system account.
|
// handleIssueSystemToken issues a long-lived service token for a system account.
|
||||||
|
// Accessible to admins and to accounts that have been granted delegate access
|
||||||
|
// for this specific service account via service_account_delegates.
|
||||||
|
//
|
||||||
|
// Security: authorization is checked server-side against the JWT claims stored
|
||||||
|
// in the request context — it cannot be bypassed by client-side manipulation.
|
||||||
|
// After issuance the token string is stored in a short-lived single-use
|
||||||
|
// download nonce so the operator can retrieve it exactly once as a file.
|
||||||
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
acct, err := u.db.GetAccountByUUID(id)
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
@@ -1021,6 +1058,32 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: require admin role OR an explicit delegate grant for this account.
|
||||||
|
actorClaims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if !isAdmin(r) {
|
||||||
|
if actorClaims == nil {
|
||||||
|
u.renderError(w, r, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actorID = &actor.ID
|
||||||
|
hasAccess, err := u.db.HasTokenIssueAccess(acct.ID, actor.ID)
|
||||||
|
if err != nil || !hasAccess {
|
||||||
|
u.renderError(w, r, http.StatusForbidden, "not authorized to issue tokens for this service account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if actorClaims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
roles, err := u.db.GetRoles(acct.ID)
|
roles, err := u.db.GetRoles(acct.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
||||||
@@ -1054,17 +1117,18 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
|
|||||||
u.logger.Warn("set system token record", "error", err)
|
u.logger.Warn("set system token record", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
actorClaims := claimsFromContext(r.Context())
|
|
||||||
var actorID *int64
|
|
||||||
if actorClaims != nil {
|
|
||||||
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
|
||||||
if err == nil {
|
|
||||||
actorID = &actor.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
|
u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
|
||||||
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
|
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
|
||||||
|
|
||||||
|
// Store the raw token in the short-lived download cache so the operator
|
||||||
|
// can retrieve it exactly once via the download endpoint.
|
||||||
|
downloadNonce, err := u.storeTokenDownload(tokenStr, acct.UUID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("store token download nonce", "error", err)
|
||||||
|
// Non-fatal: fall back to showing the token in the flash message.
|
||||||
|
downloadNonce = ""
|
||||||
|
}
|
||||||
|
|
||||||
// Re-fetch token list including the new token.
|
// Re-fetch token list including the new token.
|
||||||
tokens, err := u.db.ListTokensForAccount(acct.ID)
|
tokens, err := u.db.ListTokensForAccount(acct.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1077,13 +1141,209 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
|
|||||||
csrfToken = ""
|
csrfToken = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash the raw token once at the top so the operator can copy it.
|
var flash string
|
||||||
|
if downloadNonce == "" {
|
||||||
|
// Fallback: show token in flash when download nonce could not be stored.
|
||||||
|
flash = fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr)
|
||||||
|
} else {
|
||||||
|
flash = "Token issued. Download it now — it will not be available again."
|
||||||
|
}
|
||||||
|
|
||||||
u.render(w, "token_list", AccountDetailData{
|
u.render(w, "token_list", AccountDetailData{
|
||||||
PageData: PageData{
|
PageData: PageData{CSRFToken: csrfToken, Flash: flash},
|
||||||
CSRFToken: csrfToken,
|
|
||||||
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr),
|
|
||||||
},
|
|
||||||
Account: acct,
|
Account: acct,
|
||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
|
DownloadNonce: downloadNonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDownloadToken serves the just-issued service token as a file
|
||||||
|
// attachment. The nonce is single-use and expires after tokenDownloadTTL.
|
||||||
|
//
|
||||||
|
// Security: the nonce was generated with crypto/rand (128 bits) at issuance
|
||||||
|
// time and is deleted from the in-memory store on first retrieval, preventing
|
||||||
|
// replay. The response sets Content-Disposition: attachment so the browser
|
||||||
|
// saves the file rather than rendering it, reducing the risk of an XSS vector
|
||||||
|
// if the token were displayed inline.
|
||||||
|
func (u *UIServer) handleDownloadToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nonce := r.PathValue("nonce")
|
||||||
|
if nonce == "" {
|
||||||
|
http.Error(w, "missing nonce", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr, accountID, ok := u.consumeTokenDownload(nonce)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "download link expired or already used", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := "service-account-" + accountID + ".token"
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
// Security: Content-Type is text/plain and Content-Disposition is attachment,
|
||||||
|
// so the browser will save the file rather than render it, mitigating XSS risk.
|
||||||
|
_, _ = fmt.Fprint(w, tokenStr) //nolint:gosec // G705: token served as attachment, not rendered by browser
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGrantTokenDelegate adds a delegate who may issue tokens for a system
|
||||||
|
// account. Only admins may call this endpoint.
|
||||||
|
//
|
||||||
|
// Security: the target system account and grantee are looked up by UUID so the
|
||||||
|
// URL/form fields cannot reference arbitrary row IDs. Audit event
|
||||||
|
// EventTokenDelegateGranted is recorded on success.
|
||||||
|
func (u *UIServer) handleGrantTokenDelegate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "service account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acct.AccountType != model.AccountTypeSystem {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "token issue delegates are only supported for system accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
granteeUUID := strings.TrimSpace(r.FormValue("grantee_uuid"))
|
||||||
|
if granteeUUID == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "grantee is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grantee, err := u.db.GetAccountByUUID(granteeUUID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "grantee account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actorClaims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if actorClaims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.GrantTokenIssueAccess(acct.ID, grantee.ID, actorID); err != nil {
|
||||||
|
u.logger.Error("grant token issue access", "error", err)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to grant access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventTokenDelegateGranted, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"grantee":%q}`, grantee.UUID))
|
||||||
|
|
||||||
|
delegates, err := u.db.ListTokenIssueDelegates(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list token issue delegates after grant", "error", err)
|
||||||
|
}
|
||||||
|
allAccounts, err := u.db.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list accounts for delegate grant", "error", err)
|
||||||
|
}
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
csrfToken = ""
|
||||||
|
}
|
||||||
|
u.render(w, "token_delegates", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
TokenDelegates: delegates,
|
||||||
|
DelegatableAccounts: allAccounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeTokenDelegate removes a delegate's permission to issue tokens for
|
||||||
|
// a system account. Only admins may call this endpoint.
|
||||||
|
//
|
||||||
|
// Security: grantee looked up by UUID from the URL path. Audit event
|
||||||
|
// EventTokenDelegateRevoked recorded on success.
|
||||||
|
func (u *UIServer) handleRevokeTokenDelegate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "service account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
granteeUUID := r.PathValue("grantee")
|
||||||
|
grantee, err := u.db.GetAccountByUUID(granteeUUID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "grantee not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.RevokeTokenIssueAccess(acct.ID, grantee.ID); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to revoke access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actorClaims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if actorClaims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventTokenDelegateRevoked, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"grantee":%q}`, grantee.UUID))
|
||||||
|
|
||||||
|
delegates, err := u.db.ListTokenIssueDelegates(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list token issue delegates after revoke", "error", err)
|
||||||
|
}
|
||||||
|
allAccounts, err := u.db.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list accounts for delegate dropdown", "error", err)
|
||||||
|
}
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
csrfToken = ""
|
||||||
|
}
|
||||||
|
u.render(w, "token_delegates", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
TokenDelegates: delegates,
|
||||||
|
DelegatableAccounts: allAccounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceAccountsPage renders the /service-accounts page showing all
|
||||||
|
// system accounts the current user has delegate access to, along with the
|
||||||
|
// ability to issue and download tokens for them.
|
||||||
|
func (u *UIServer) handleServiceAccountsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
if claims == nil {
|
||||||
|
u.redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "could not resolve actor")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := u.db.ListDelegatedServiceAccounts(actor.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load service accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "service_accounts", ServiceAccountsData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
|
Accounts: accounts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import (
|
|||||||
|
|
||||||
// handleLoginPage renders the login form.
|
// handleLoginPage renders the login form.
|
||||||
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
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).
|
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
|
||||||
@@ -290,13 +292,33 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target
|
|||||||
// handleProfilePage renders the profile page for the currently logged-in user.
|
// handleProfilePage renders the profile page for the currently logged-in user.
|
||||||
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
||||||
csrfToken, _ := u.setCSRFCookies(w)
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
u.render(w, "profile", ProfileData{
|
claims := claimsFromContext(r.Context())
|
||||||
|
|
||||||
|
data := ProfileData{
|
||||||
PageData: PageData{
|
PageData: PageData{
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
ActorName: u.actorName(r),
|
ActorName: u.actorName(r),
|
||||||
IsAdmin: isAdmin(r),
|
IsAdmin: isAdmin(r),
|
||||||
},
|
},
|
||||||
})
|
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||||
|
DeletePrefix: "/profile/webauthn",
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims != nil {
|
||||||
|
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
data.TOTPEnabled = acct.TOTPRequired
|
||||||
|
// Load WebAuthn credentials for the profile page.
|
||||||
|
if u.cfg.WebAuthnEnabled() {
|
||||||
|
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
|
// handleSelfChangePassword allows an authenticated human user to change their
|
||||||
|
|||||||
@@ -129,6 +129,27 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
|||||||
priority = p
|
priority = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ruleJSON []byte
|
||||||
|
|
||||||
|
if rawJSON := strings.TrimSpace(r.FormValue("rule_json")); rawJSON != "" {
|
||||||
|
// JSON mode: parse and re-marshal to normalise and validate the input.
|
||||||
|
var body policy.RuleBody
|
||||||
|
if err := json.Unmarshal([]byte(rawJSON), &body); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, fmt.Sprintf("invalid rule JSON: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Effect != policy.Allow && body.Effect != policy.Deny {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "rule JSON must include effect 'allow' or 'deny'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
ruleJSON, err = json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Form mode: build RuleBody from individual fields.
|
||||||
effectStr := r.FormValue("effect")
|
effectStr := r.FormValue("effect")
|
||||||
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
|
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
|
||||||
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
|
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
|
||||||
@@ -165,11 +186,13 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
|||||||
body.RequiredTags = splitCommas(tags)
|
body.RequiredTags = splitCommas(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
ruleJSON, err := json.Marshal(body)
|
var err error
|
||||||
|
ruleJSON, err = json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse optional time-scoped validity window from datetime-local inputs.
|
// Parse optional time-scoped validity window from datetime-local inputs.
|
||||||
var notBefore, expiresAt *time.Time
|
var notBefore, expiresAt *time.Time
|
||||||
|
|||||||
287
internal/ui/handlers_totp.go
Normal file
287
internal/ui/handlers_totp.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleTOTPEnrollStart processes the password re-auth step and generates
|
||||||
|
// the TOTP secret + QR code for the user to scan.
|
||||||
|
//
|
||||||
|
// Security (SEC-01): the current password is required to prevent a stolen
|
||||||
|
// session from enrolling attacker-controlled TOTP. Lockout is checked and
|
||||||
|
// failures are recorded to prevent brute-force use as a password oracle.
|
||||||
|
func (u *UIServer) handleTOTPEnrollStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "invalid form submission"})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already enrolled — show enabled status.
|
||||||
|
if acct.TOTPRequired {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPEnabled: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if password == "" {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "password is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: check lockout before verifying password.
|
||||||
|
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||||
|
if lockErr != nil {
|
||||||
|
u.logger.Error("lockout check (UI TOTP enroll)", "error", lockErr)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "account temporarily locked, please try again later"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: verify current password with constant-time Argon2id path.
|
||||||
|
ok, verifyErr := auth.VerifyPassword(password, acct.PasswordHash)
|
||||||
|
if verifyErr != nil || !ok {
|
||||||
|
_ = u.db.RecordLoginFailure(acct.ID)
|
||||||
|
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "password is incorrect"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate TOTP secret.
|
||||||
|
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("generate TOTP secret", "error", err)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt and store as pending (totp_required stays 0 until confirmed).
|
||||||
|
masterKey, err := u.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret)
|
||||||
|
if err != nil {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
|
||||||
|
// remains 0 until the user proves possession via ConfirmTOTP.
|
||||||
|
if err := u.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
|
||||||
|
u.logger.Error("store pending TOTP", "error", err)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
|
||||||
|
|
||||||
|
// Generate QR code PNG.
|
||||||
|
png, err := qrcode.Encode(otpURI, qrcode.Medium, 200)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("generate QR code", "error", err)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
|
||||||
|
|
||||||
|
// Issue enrollment nonce for the confirm step.
|
||||||
|
nonce, err := u.issueTOTPEnrollNonce(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("issue TOTP enroll nonce", "error", err)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
|
u.render(w, "totp_enroll_qr", ProfileData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
TOTPSecret: b32Secret,
|
||||||
|
TOTPQR: qrDataURI,
|
||||||
|
TOTPEnrollNonce: nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTOTPConfirm validates the TOTP code and activates enrollment.
|
||||||
|
//
|
||||||
|
// Security (CRIT-01): the counter is recorded to prevent replay of the same
|
||||||
|
// code within its validity window.
|
||||||
|
func (u *UIServer) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "invalid form submission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
if claims == nil {
|
||||||
|
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := r.FormValue("totp_enroll_nonce")
|
||||||
|
totpCode := r.FormValue("totp_code")
|
||||||
|
|
||||||
|
// Security: consume the nonce (single-use); reject if unknown or expired.
|
||||||
|
accountID, ok := u.consumeTOTPEnrollNonce(nonce)
|
||||||
|
if !ok {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "session expired, please start enrollment again"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acct, err := u.db.GetAccountByID(accountID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("get account for TOTP confirm", "error", err, "account_id", accountID)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: verify nonce accountID matches session claims.
|
||||||
|
if acct.UUID != claims.Subject {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "session mismatch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if acct.TOTPSecretEnc == nil {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "enrollment not started"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt and validate TOTP code.
|
||||||
|
masterKey, err := u.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("decrypt TOTP secret for confirm", "error", err, "account_id", acct.ID)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, totpCounter, err := auth.ValidateTOTP(secret, totpCode)
|
||||||
|
if err != nil || !valid {
|
||||||
|
// Re-issue a fresh nonce so the user can retry without restarting.
|
||||||
|
u.reissueTOTPEnrollQR(w, r, acct, secret, "invalid TOTP code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security (CRIT-01): reject replay of a code already used.
|
||||||
|
if err := u.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
|
||||||
|
u.reissueTOTPEnrollQR(w, r, acct, secret, "invalid TOTP code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate TOTP (sets totp_required=1).
|
||||||
|
if err := u.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
||||||
|
u.logger.Error("set TOTP", "error", err, "account_id", acct.ID)
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, nil, "")
|
||||||
|
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{
|
||||||
|
TOTPEnabled: true,
|
||||||
|
TOTPSuccess: "Two-factor authentication enabled successfully.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// reissueTOTPEnrollQR re-renders the QR code page with a fresh nonce after
|
||||||
|
// a failed code confirmation, so the user can retry without restarting.
|
||||||
|
func (u *UIServer) reissueTOTPEnrollQR(w http.ResponseWriter, r *http.Request, acct *model.Account, secret []byte, errMsg string) {
|
||||||
|
b32Secret := base32.StdEncoding.EncodeToString(secret)
|
||||||
|
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
|
||||||
|
|
||||||
|
png, err := qrcode.Encode(otpURI, qrcode.Medium, 200)
|
||||||
|
if err != nil {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
|
||||||
|
|
||||||
|
newNonce, nonceErr := u.issueTOTPEnrollNonce(acct.ID)
|
||||||
|
if nonceErr != nil {
|
||||||
|
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
|
u.render(w, "totp_enroll_qr", ProfileData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
TOTPSecret: b32Secret,
|
||||||
|
TOTPQR: qrDataURI,
|
||||||
|
TOTPEnrollNonce: newNonce,
|
||||||
|
TOTPError: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminTOTPRemove removes TOTP from an account (admin only).
|
||||||
|
func (u *UIServer) handleAdminTOTPRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountUUID := r.PathValue("id")
|
||||||
|
if accountUUID == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "missing account ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acct, err := u.db.GetAccountByUUID(accountUUID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.ClearTOTP(acct.ID); err != nil {
|
||||||
|
u.logger.Error("clear TOTP (admin)", "error", err, "account_id", acct.ID)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
|
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.EventTOTPRemoved, actorID, &acct.ID,
|
||||||
|
audit.JSON("admin", "true"))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = fmt.Fprint(w, `Disabled <span class="text-muted text-small">(removed)</span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTOTPSection is a helper to render the totp_section fragment with
|
||||||
|
// common page data fields populated.
|
||||||
|
func (u *UIServer) renderTOTPSection(w http.ResponseWriter, r *http.Request, data ProfileData) {
|
||||||
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
|
data.CSRFToken = csrfToken
|
||||||
|
data.ActorName = u.actorName(r)
|
||||||
|
data.IsAdmin = isAdmin(r)
|
||||||
|
u.render(w, "totp_section", data)
|
||||||
|
}
|
||||||
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})
|
||||||
|
}
|
||||||
@@ -54,15 +54,32 @@ type pendingLogin struct {
|
|||||||
accountID int64
|
accountID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokenDownload is a short-lived record that holds a just-issued service token
|
||||||
|
// string so the operator can download it as a file. It is single-use and
|
||||||
|
// expires after tokenDownloadTTL.
|
||||||
|
//
|
||||||
|
// Security: the token string is stored only for tokenDownloadTTL after
|
||||||
|
// issuance. The nonce is random (128 bits) and single-use: it is deleted from
|
||||||
|
// the map on first retrieval so it cannot be replayed.
|
||||||
|
type tokenDownload struct {
|
||||||
|
expiresAt time.Time
|
||||||
|
token string
|
||||||
|
accountID string // service account UUID (for the filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenDownloadTTL = 5 * time.Minute
|
||||||
|
|
||||||
// UIServer serves the HTMX-based management UI.
|
// UIServer serves the HTMX-based management UI.
|
||||||
type UIServer struct {
|
type UIServer struct {
|
||||||
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
|
||||||
tmpls map[string]*template.Template // page name → template set
|
tmpls map[string]*template.Template // page name → template set
|
||||||
db *db.DB
|
db *db.DB
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
csrf *CSRFManager
|
csrf *CSRFManager
|
||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
|
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
||||||
|
tokenDownloads sync.Map // nonce (string) → *tokenDownload
|
||||||
|
pendingTOTPEnrolls sync.Map // nonce (string) → *pendingTOTPEnroll
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
||||||
@@ -97,6 +114,48 @@ func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
|
|||||||
return pl.accountID, true
|
return pl.accountID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pendingTOTPEnroll stores the account ID for a TOTP enrollment ceremony
|
||||||
|
// that has passed password re-auth and generated a secret, awaiting code
|
||||||
|
// confirmation.
|
||||||
|
type pendingTOTPEnroll struct {
|
||||||
|
expiresAt time.Time
|
||||||
|
accountID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
const totpEnrollTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
// issueTOTPEnrollNonce creates a random single-use nonce for the TOTP
|
||||||
|
// enrollment confirmation step.
|
||||||
|
func (u *UIServer) issueTOTPEnrollNonce(accountID int64) (string, error) {
|
||||||
|
raw := make([]byte, totpNonceBytes)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", fmt.Errorf("ui: generate TOTP enroll nonce: %w", err)
|
||||||
|
}
|
||||||
|
nonce := hex.EncodeToString(raw)
|
||||||
|
u.pendingTOTPEnrolls.Store(nonce, &pendingTOTPEnroll{
|
||||||
|
accountID: accountID,
|
||||||
|
expiresAt: time.Now().Add(totpEnrollTTL),
|
||||||
|
})
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeTOTPEnrollNonce looks up and deletes the enrollment nonce,
|
||||||
|
// returning the associated account ID. Returns (0, false) if unknown or expired.
|
||||||
|
func (u *UIServer) consumeTOTPEnrollNonce(nonce string) (int64, bool) {
|
||||||
|
v, ok := u.pendingTOTPEnrolls.LoadAndDelete(nonce)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
pe, ok2 := v.(*pendingTOTPEnroll)
|
||||||
|
if !ok2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if time.Now().After(pe.expiresAt) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return pe.accountID, true
|
||||||
|
}
|
||||||
|
|
||||||
// dummyHash returns the pre-computed Argon2id PHC hash for constant-time dummy
|
// dummyHash returns the pre-computed Argon2id PHC hash for constant-time dummy
|
||||||
// verification when an account is unknown or inactive (F-07).
|
// verification when an account is unknown or inactive (F-07).
|
||||||
// Delegates to auth.DummyHash() which uses sync.Once for one-time computation.
|
// Delegates to auth.DummyHash() which uses sync.Once for one-time computation.
|
||||||
@@ -161,6 +220,13 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
|
|||||||
}
|
}
|
||||||
return *actorID == *cred.OwnerID
|
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 },
|
"add": func(a, b int) int { return a + b },
|
||||||
"sub": 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 },
|
"gt": func(a, b int) bool { return a > b },
|
||||||
@@ -196,6 +262,11 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
|
|||||||
"templates/fragments/policy_form.html",
|
"templates/fragments/policy_form.html",
|
||||||
"templates/fragments/password_reset_form.html",
|
"templates/fragments/password_reset_form.html",
|
||||||
"templates/fragments/password_change_form.html",
|
"templates/fragments/password_change_form.html",
|
||||||
|
"templates/fragments/token_delegates.html",
|
||||||
|
"templates/fragments/webauthn_credentials.html",
|
||||||
|
"templates/fragments/webauthn_enroll.html",
|
||||||
|
"templates/fragments/totp_section.html",
|
||||||
|
"templates/fragments/totp_enroll_qr.html",
|
||||||
}
|
}
|
||||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,6 +286,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
|
|||||||
"pgcreds": "templates/pgcreds.html",
|
"pgcreds": "templates/pgcreds.html",
|
||||||
"profile": "templates/profile.html",
|
"profile": "templates/profile.html",
|
||||||
"unseal": "templates/unseal.html",
|
"unseal": "templates/unseal.html",
|
||||||
|
"service_accounts": "templates/service_accounts.html",
|
||||||
}
|
}
|
||||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||||
for name, file := range pageFiles {
|
for name, file := range pageFiles {
|
||||||
@@ -242,6 +314,8 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
|
|||||||
// entries abandoned by users who never complete step 2 would otherwise
|
// entries abandoned by users who never complete step 2 would otherwise
|
||||||
// accumulate indefinitely, enabling a memory-exhaustion attack.
|
// accumulate indefinitely, enabling a memory-exhaustion attack.
|
||||||
go srv.cleanupPendingLogins()
|
go srv.cleanupPendingLogins()
|
||||||
|
go srv.cleanupTokenDownloads()
|
||||||
|
go srv.cleanupPendingTOTPEnrolls()
|
||||||
|
|
||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
@@ -264,6 +338,72 @@ func (u *UIServer) cleanupPendingLogins() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storeTokenDownload saves a just-issued token string in the short-lived
|
||||||
|
// download store and returns a random single-use nonce the caller can include
|
||||||
|
// in the response. The download nonce expires after tokenDownloadTTL.
|
||||||
|
func (u *UIServer) storeTokenDownload(tokenStr, accountID string) (string, error) {
|
||||||
|
raw := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", fmt.Errorf("ui: generate download nonce: %w", err)
|
||||||
|
}
|
||||||
|
nonce := hex.EncodeToString(raw)
|
||||||
|
u.tokenDownloads.Store(nonce, &tokenDownload{
|
||||||
|
token: tokenStr,
|
||||||
|
accountID: accountID,
|
||||||
|
expiresAt: time.Now().Add(tokenDownloadTTL),
|
||||||
|
})
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeTokenDownload looks up, validates, and deletes the download nonce.
|
||||||
|
// Returns the token string and account UUID, or ("", "", false) if the nonce
|
||||||
|
// is unknown or expired.
|
||||||
|
//
|
||||||
|
// Security: single-use deletion prevents replay; expiry bounds the window.
|
||||||
|
func (u *UIServer) consumeTokenDownload(nonce string) (tokenStr, accountID string, ok bool) {
|
||||||
|
v, loaded := u.tokenDownloads.LoadAndDelete(nonce)
|
||||||
|
if !loaded {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
td, valid := v.(*tokenDownload)
|
||||||
|
if !valid || time.Now().After(td.expiresAt) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return td.token, td.accountID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTokenDownloads periodically evicts expired entries from tokenDownloads.
|
||||||
|
func (u *UIServer) cleanupTokenDownloads() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
u.tokenDownloads.Range(func(key, value any) bool {
|
||||||
|
td, ok := value.(*tokenDownload)
|
||||||
|
if !ok || now.After(td.expiresAt) {
|
||||||
|
u.tokenDownloads.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupPendingTOTPEnrolls periodically evicts expired TOTP enrollment nonces.
|
||||||
|
func (u *UIServer) cleanupPendingTOTPEnrolls() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
u.pendingTOTPEnrolls.Range(func(key, value any) bool {
|
||||||
|
pe, ok := value.(*pendingTOTPEnroll)
|
||||||
|
if !ok || now.After(pe.expiresAt) {
|
||||||
|
u.pendingTOTPEnrolls.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Register attaches all UI routes to mux, wrapped with security headers.
|
// Register attaches all UI routes to mux, wrapped with security headers.
|
||||||
// All UI responses (pages, fragments, redirects, static assets) carry the
|
// All UI responses (pages, fragments, redirects, static assets) carry the
|
||||||
// headers added by securityHeaders.
|
// headers added by securityHeaders.
|
||||||
@@ -309,6 +449,9 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
||||||
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
|
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
|
||||||
uiMux.HandleFunc("POST /logout", u.handleLogout)
|
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.
|
// Protected routes.
|
||||||
//
|
//
|
||||||
@@ -333,7 +476,14 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
|
uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
|
||||||
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
|
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
|
||||||
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
|
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
|
||||||
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
|
// Token issuance is accessible to both admins and delegates; the handler
|
||||||
|
// enforces the admin-or-delegate check internally.
|
||||||
|
uiMux.Handle("POST /accounts/{id}/token", authed(u.requireCSRF(http.HandlerFunc(u.handleIssueSystemToken))))
|
||||||
|
// Token download uses a one-time nonce issued at token-issuance time.
|
||||||
|
uiMux.Handle("GET /token/download/{nonce}", authed(http.HandlerFunc(u.handleDownloadToken)))
|
||||||
|
// Token issue delegate management — admin only.
|
||||||
|
uiMux.Handle("POST /accounts/{id}/token/delegates", admin(u.handleGrantTokenDelegate))
|
||||||
|
uiMux.Handle("DELETE /accounts/{id}/token/delegates/{grantee}", admin(u.handleRevokeTokenDelegate))
|
||||||
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
|
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
|
||||||
uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
|
uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
|
||||||
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
|
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
|
||||||
@@ -349,9 +499,24 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
||||||
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
|
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
|
||||||
|
|
||||||
|
// Service accounts page — accessible to any authenticated user; shows only
|
||||||
|
// the service accounts for which the current user is a token-issue delegate.
|
||||||
|
uiMux.Handle("GET /service-accounts", authed(http.HandlerFunc(u.handleServiceAccountsPage)))
|
||||||
|
|
||||||
// Profile routes — accessible to any authenticated user (not admin-only).
|
// Profile routes — accessible to any authenticated user (not admin-only).
|
||||||
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
|
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
|
||||||
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
|
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))))
|
||||||
|
// TOTP profile routes (enrollment).
|
||||||
|
uiMux.Handle("POST /profile/totp/enroll", authed(u.requireCSRF(http.HandlerFunc(u.handleTOTPEnrollStart))))
|
||||||
|
uiMux.Handle("POST /profile/totp/confirm", authed(u.requireCSRF(http.HandlerFunc(u.handleTOTPConfirm))))
|
||||||
|
// Admin WebAuthn management.
|
||||||
|
uiMux.Handle("DELETE /accounts/{id}/webauthn/{credentialId}", admin(u.handleAdminWebAuthnDelete))
|
||||||
|
// Admin TOTP removal.
|
||||||
|
uiMux.Handle("DELETE /accounts/{id}/totp", admin(u.handleAdminTOTPRemove))
|
||||||
|
|
||||||
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
// 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
|
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
||||||
@@ -649,6 +814,8 @@ type LoginData struct {
|
|||||||
// a short-lived server-side nonce is issued after successful password
|
// a short-lived server-side nonce is issued after successful password
|
||||||
// verification, and only the nonce is embedded in the TOTP step form.
|
// verification, and only the nonce is embedded in the TOTP step form.
|
||||||
Nonce string // single-use server-side nonce replacing the password hidden field
|
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.
|
// DashboardData is the view model for the dashboard page.
|
||||||
@@ -666,7 +833,7 @@ type AccountsData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AccountDetailData is the view model for the account detail page.
|
// 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
|
Account *model.Account
|
||||||
// PGCred is nil if none stored or the account is not a system account.
|
// PGCred is nil if none stored or the account is not a system account.
|
||||||
PGCred *model.PGCredential
|
PGCred *model.PGCredential
|
||||||
@@ -678,11 +845,43 @@ type AccountDetailData struct {
|
|||||||
// ActorID is the DB id of the currently logged-in user; used in templates
|
// ActorID is the DB id of the currently logged-in user; used in templates
|
||||||
// to decide whether to show the owner-only management controls.
|
// to decide whether to show the owner-only management controls.
|
||||||
ActorID *int64
|
ActorID *int64
|
||||||
|
// TokenDelegates lists accounts that may issue tokens for this service account.
|
||||||
|
// Only populated for system accounts when viewed by an admin.
|
||||||
|
TokenDelegates []*model.ServiceAccountDelegate
|
||||||
|
// DelegatableAccounts is the list of human accounts available for the
|
||||||
|
// "add delegate" dropdown. Only populated for admins.
|
||||||
|
DelegatableAccounts []*model.Account
|
||||||
|
// DownloadNonce is a one-time nonce for downloading the just-issued token.
|
||||||
|
// Populated by handleIssueSystemToken; empty otherwise.
|
||||||
|
DownloadNonce string
|
||||||
PageData
|
PageData
|
||||||
Roles []string
|
Roles []string
|
||||||
AllRoles []string
|
AllRoles []string
|
||||||
Tags []string
|
Tags []string
|
||||||
Tokens []*model.TokenRecord
|
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
|
||||||
|
WebAuthnEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceAccountsData is the view model for the /service-accounts page.
|
||||||
|
// It shows the system accounts for which the current user has delegate access,
|
||||||
|
// plus the just-issued token download nonce (if a token was just issued).
|
||||||
|
type ServiceAccountsData struct {
|
||||||
|
// Accounts is the list of system accounts the actor may issue tokens for.
|
||||||
|
Accounts []*model.Account
|
||||||
|
// DownloadNonce is a one-time nonce for downloading the just-issued token.
|
||||||
|
// Non-empty immediately after a successful token issuance.
|
||||||
|
DownloadNonce string
|
||||||
|
// IssuedFor is the UUID of the account whose token was just issued.
|
||||||
|
IssuedFor string
|
||||||
|
PageData
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditData is the view model for the audit log page.
|
// AuditData is the view model for the audit log page.
|
||||||
@@ -725,8 +924,18 @@ type PoliciesData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProfileData is the view model for the profile/settings page.
|
// 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
|
PageData
|
||||||
|
WebAuthnCreds []*model.WebAuthnCredential
|
||||||
|
DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn")
|
||||||
|
WebAuthnEnabled bool
|
||||||
|
// TOTP enrollment fields (populated only during enrollment flow).
|
||||||
|
TOTPEnabled bool
|
||||||
|
TOTPSecret string // base32-encoded; shown once during enrollment
|
||||||
|
TOTPQR string // data:image/png;base64,... QR code
|
||||||
|
TOTPEnrollNonce string // single-use nonce for confirm step
|
||||||
|
TOTPError string // enrollment-specific error message
|
||||||
|
TOTPSuccess string // success flash after confirmation
|
||||||
}
|
}
|
||||||
|
|
||||||
// PGCredsData is the view model for the "My PG Credentials" list page.
|
// PGCredsData is the view model for the "My PG Credentials" list page.
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ var ErrSealed = errors.New("vault is sealed")
|
|||||||
// Vault holds the server's cryptographic key material behind a mutex.
|
// Vault holds the server's cryptographic key material behind a mutex.
|
||||||
// All three servers (REST, UI, gRPC) share a single Vault by pointer.
|
// All three servers (REST, UI, gRPC) share a single Vault by pointer.
|
||||||
type Vault struct {
|
type Vault struct {
|
||||||
mu sync.RWMutex
|
|
||||||
masterKey []byte
|
masterKey []byte
|
||||||
privKey ed25519.PrivateKey
|
privKey ed25519.PrivateKey
|
||||||
pubKey ed25519.PublicKey
|
pubKey ed25519.PublicKey
|
||||||
|
mu sync.RWMutex
|
||||||
sealed bool
|
sealed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -25,13 +26,13 @@ func TestNewSealed(t *testing.T) {
|
|||||||
if !v.IsSealed() {
|
if !v.IsSealed() {
|
||||||
t.Fatal("NewSealed() should be sealed")
|
t.Fatal("NewSealed() should be sealed")
|
||||||
}
|
}
|
||||||
if _, err := v.MasterKey(); err != ErrSealed {
|
if _, err := v.MasterKey(); !errors.Is(err, ErrSealed) {
|
||||||
t.Fatalf("MasterKey() error = %v, want ErrSealed", err)
|
t.Fatalf("MasterKey() error = %v, want ErrSealed", err)
|
||||||
}
|
}
|
||||||
if _, err := v.PrivKey(); err != ErrSealed {
|
if _, err := v.PrivKey(); !errors.Is(err, ErrSealed) {
|
||||||
t.Fatalf("PrivKey() error = %v, want ErrSealed", err)
|
t.Fatalf("PrivKey() error = %v, want ErrSealed", err)
|
||||||
}
|
}
|
||||||
if _, err := v.PubKey(); err != ErrSealed {
|
if _, err := v.PubKey(); !errors.Is(err, ErrSealed) {
|
||||||
t.Fatalf("PubKey() error = %v, want ErrSealed", err)
|
t.Fatalf("PubKey() error = %v, want ErrSealed", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 }
|
||||||
333
openapi.yaml
333
openapi.yaml
@@ -14,8 +14,10 @@ info:
|
|||||||
10 requests per second per IP, burst of 10.
|
10 requests per second per IP, burst of 10.
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: https://auth.example.com:8443
|
- url: https://mcias.metacircular.net:8443
|
||||||
description: Production
|
description: Production
|
||||||
|
- url: https://localhost:8443
|
||||||
|
description: Local test server
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
@@ -84,6 +86,54 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: Whether TOTP is enrolled and required for this account.
|
description: Whether TOTP is enrolled and required for this account.
|
||||||
example: false
|
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:
|
AuditEvent:
|
||||||
type: object
|
type: object
|
||||||
@@ -221,8 +271,8 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
description: |
|
description: |
|
||||||
Time after which the rule is no longer active. NULL means no
|
Time after which the rule is no longer active. NULL means no
|
||||||
constraint (never expires). Rules where `expires_at <= now()` are
|
constraint (never expires). Rules where expires_at is in the past
|
||||||
skipped during evaluation.
|
are skipped during evaluation.
|
||||||
example: "2026-06-01T00:00:00Z"
|
example: "2026-06-01T00:00:00Z"
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
@@ -606,6 +656,10 @@ paths:
|
|||||||
format: uuid
|
format: uuid
|
||||||
description: Subject (account UUID). Present when valid=true.
|
description: Subject (account UUID). Present when valid=true.
|
||||||
example: 550e8400-e29b-41d4-a716-446655440000
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Account username. Present when valid=true and the account exists.
|
||||||
|
example: alice
|
||||||
roles:
|
roles:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -619,7 +673,7 @@ paths:
|
|||||||
example: "2026-04-10T12:34:56Z"
|
example: "2026-04-10T12:34:56Z"
|
||||||
examples:
|
examples:
|
||||||
valid:
|
valid:
|
||||||
value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
|
value: {valid: true, sub: "550e8400-...", username: alice, roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
|
||||||
invalid:
|
invalid:
|
||||||
value: {valid: false}
|
value: {valid: false}
|
||||||
"429":
|
"429":
|
||||||
@@ -841,6 +895,213 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$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:
|
/v1/token/issue:
|
||||||
post:
|
post:
|
||||||
summary: Issue service account token (admin)
|
summary: Issue service account token (admin)
|
||||||
@@ -1267,6 +1528,70 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/v1/pgcreds:
|
||||||
|
get:
|
||||||
|
summary: List accessible Postgres credentials
|
||||||
|
description: |
|
||||||
|
Return all Postgres credentials accessible to the authenticated account:
|
||||||
|
credentials owned by the account plus any explicitly granted by an admin.
|
||||||
|
|
||||||
|
The `id` field is the credential record ID; use it together with the
|
||||||
|
`service_account_id` to fetch full details via
|
||||||
|
`GET /v1/accounts/{id}/pgcreds`. Passwords are **not** returned by this
|
||||||
|
endpoint.
|
||||||
|
operationId: listAccessiblePGCreds
|
||||||
|
tags: [Admin — Credentials]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Array of accessible Postgres credential summaries.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [id, service_account_id, host, port, database, username, created_at, updated_at]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
description: Credential record ID.
|
||||||
|
example: 7
|
||||||
|
service_account_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: UUID of the system account that owns these credentials.
|
||||||
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
service_account_name:
|
||||||
|
type: string
|
||||||
|
description: Username of the owning system account (omitted if unavailable).
|
||||||
|
example: payments-api
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
example: db.example.com
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
example: 5432
|
||||||
|
database:
|
||||||
|
type: string
|
||||||
|
example: mydb
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
example: myuser
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: "2026-03-11T09:00:00Z"
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: "2026-03-11T09:00:00Z"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/VaultSealed"
|
||||||
|
|
||||||
/v1/audit:
|
/v1/audit:
|
||||||
get:
|
get:
|
||||||
summary: Query audit log (admin)
|
summary: Query audit log (admin)
|
||||||
|
|||||||
@@ -75,6 +75,40 @@ message RemoveTOTPRequest {
|
|||||||
// RemoveTOTPResponse confirms removal.
|
// RemoveTOTPResponse confirms removal.
|
||||||
message RemoveTOTPResponse {}
|
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.
|
// AuthService handles all authentication flows.
|
||||||
service AuthService {
|
service AuthService {
|
||||||
// Login authenticates with username+password (+optional TOTP) and returns a JWT.
|
// 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).
|
// RemoveTOTP removes TOTP from an account (admin only).
|
||||||
// Requires: admin JWT in metadata.
|
// Requires: admin JWT in metadata.
|
||||||
rpc RemoveTOTP(RemoveTOTPRequest) returns (RemoveTOTPResponse);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>MCIAS API Reference</title>
|
<title>MCIAS API Reference</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
<link rel="stylesheet" href="/static/swagger-ui.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="swagger-ui"></div>
|
<div id="swagger-ui"></div>
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
<script src="/static/swagger-ui-bundle.js"></script>
|
||||||
<script>
|
<script>
|
||||||
SwaggerUIBundle({
|
SwaggerUIBundle({
|
||||||
url: "/docs/openapi.yaml",
|
url: "/docs/openapi.yaml",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2
web/static/swagger-ui-bundle.js
Normal file
2
web/static/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
web/static/swagger-ui.css
Normal file
3
web/static/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -14,7 +14,19 @@
|
|||||||
<dt class="text-muted">Type</dt><dd>{{.Account.AccountType}}</dd>
|
<dt class="text-muted">Type</dt><dd>{{.Account.AccountType}}</dd>
|
||||||
<dt class="text-muted">Status</dt>
|
<dt class="text-muted">Status</dt>
|
||||||
<dd id="status-cell">{{template "account_status" .}}</dd>
|
<dd id="status-cell">{{template "account_status" .}}</dd>
|
||||||
<dt class="text-muted">TOTP</dt><dd>{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}</dd>
|
<dt class="text-muted">TOTP</dt>
|
||||||
|
<dd id="totp-admin-status">
|
||||||
|
{{if .Account.TOTPRequired}}
|
||||||
|
Enabled
|
||||||
|
<button class="btn btn-sm btn-danger" style="margin-left:.5rem"
|
||||||
|
hx-delete="/accounts/{{.Account.UUID}}/totp"
|
||||||
|
hx-target="#totp-admin-status"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Remove TOTP for this account?"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>Remove</button>
|
||||||
|
{{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">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd>
|
||||||
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd>
|
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -26,7 +38,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="d-flex align-center justify-between" style="margin-bottom:1rem">
|
<div class="d-flex align-center justify-between" style="margin-bottom:1rem">
|
||||||
<h2 style="font-size:1rem;font-weight:600">Tokens</h2>
|
<h2 style="font-size:1rem;font-weight:600">Tokens</h2>
|
||||||
{{if eq (string .Account.AccountType) "system"}}
|
{{if and (eq (string .Account.AccountType) "system") .CanIssueToken}}
|
||||||
<button class="btn btn-sm btn-secondary"
|
<button class="btn btn-sm btn-secondary"
|
||||||
hx-post="/accounts/{{.Account.UUID}}/token"
|
hx-post="/accounts/{{.Account.UUID}}/token"
|
||||||
hx-target="#token-list" hx-swap="outerHTML">Issue Token</button>
|
hx-target="#token-list" hx-swap="outerHTML">Issue Token</button>
|
||||||
@@ -39,6 +51,16 @@
|
|||||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Postgres Credentials</h2>
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Postgres Credentials</h2>
|
||||||
{{template "pgcreds_form" .}}
|
{{template "pgcreds_form" .}}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Token Issue Access</h2>
|
||||||
|
<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}}
|
{{end}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
|
{{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
|
||||||
<li><a href="/audit">Audit</a></li>
|
<li><a href="/audit">Audit</a></li>
|
||||||
<li><a href="/policies">Policies</a></li>
|
<li><a href="/policies">Policies</a></li>
|
||||||
<li><a href="/pgcreds">PG Creds</a></li>{{end}}
|
<li><a href="/pgcreds">PG Creds</a></li>{{else}}<li><a href="/service-accounts">Service Accounts</a></li>{{end}}
|
||||||
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
|
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
|
||||||
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
{{define "policy_form"}}
|
{{define "policy_form"}}
|
||||||
|
<div style="margin-bottom:.75rem;border-bottom:1px solid var(--border-color);padding-bottom:.5rem;display:flex;gap:.5rem">
|
||||||
|
<button type="button" id="tab-form" class="btn btn-sm btn-secondary"
|
||||||
|
onclick="showTab('form')" style="font-size:.8rem">Form</button>
|
||||||
|
<button type="button" id="tab-json" class="btn btn-sm"
|
||||||
|
onclick="showTab('json')" style="font-size:.8rem;opacity:.6">JSON</button>
|
||||||
|
</div>
|
||||||
<form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin">
|
<form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin">
|
||||||
|
<div id="pf-form-mode">
|
||||||
<div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem">
|
<div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem">
|
||||||
<input class="form-control" type="text" name="description"
|
<input class="form-control" type="text" name="description"
|
||||||
placeholder="Description" required>
|
placeholder="Description" required>
|
||||||
@@ -13,10 +20,13 @@
|
|||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-small text-muted">Roles (select multiple)</label>
|
<label class="text-small text-muted">Roles (select multiple)</label>
|
||||||
<select class="form-control" name="roles" multiple size="4" style="font-size:.85rem">
|
<select class="form-control" name="roles" multiple size="6" style="font-size:.85rem">
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
<option value="user">user</option>
|
<option value="user">user</option>
|
||||||
<option value="service">service</option>
|
<option value="guest">guest</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="commenter">commenter</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -82,6 +92,49 @@
|
|||||||
<input class="form-control" type="datetime-local" name="expires_at" style="font-size:.85rem">
|
<input class="form-control" type="datetime-local" name="expires_at" style="font-size:.85rem">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pf-json-mode" style="display:none">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 80px;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<input class="form-control" type="text" name="description"
|
||||||
|
placeholder="Description" id="pf-json-desc">
|
||||||
|
<input class="form-control" type="number" name="priority"
|
||||||
|
placeholder="100" value="100" min="0" max="9999" id="pf-json-priority">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:.5rem">
|
||||||
|
<label class="text-small text-muted">Rule JSON (<code>effect</code> required; other fields optional)</label>
|
||||||
|
<textarea class="form-control" name="rule_json" rows="12"
|
||||||
|
style="font-family:monospace;font-size:.8rem;white-space:pre"
|
||||||
|
placeholder='{"effect":"allow","roles":["admin"],"actions":["accounts:list"]}'></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Not before (UTC, optional)</label>
|
||||||
|
<input class="form-control" type="datetime-local" name="not_before" id="pf-json-nb" style="font-size:.85rem">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Expires at (UTC, optional)</label>
|
||||||
|
<input class="form-control" type="datetime-local" name="expires_at" id="pf-json-ea" style="font-size:.85rem">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
|
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
|
||||||
</form>
|
</form>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var active = 'form';
|
||||||
|
window.showTab = function(tab) {
|
||||||
|
active = tab;
|
||||||
|
document.getElementById('pf-form-mode').style.display = tab === 'form' ? '' : 'none';
|
||||||
|
document.getElementById('pf-json-mode').style.display = tab === 'json' ? '' : 'none';
|
||||||
|
document.getElementById('tab-form').style.opacity = tab === 'form' ? '1' : '.6';
|
||||||
|
document.getElementById('tab-json').style.opacity = tab === 'json' ? '1' : '.6';
|
||||||
|
var formBtn = document.getElementById('tab-form');
|
||||||
|
var jsonBtn = document.getElementById('tab-json');
|
||||||
|
formBtn.className = tab === 'form' ? 'btn btn-sm btn-secondary' : 'btn btn-sm';
|
||||||
|
jsonBtn.className = tab === 'json' ? 'btn btn-sm btn-secondary' : 'btn btn-sm';
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
47
web/templates/fragments/token_delegates.html
Normal file
47
web/templates/fragments/token_delegates.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{{define "token_delegates"}}
|
||||||
|
<div id="token-delegates-section">
|
||||||
|
<h3 style="font-size:.9rem;font-weight:600;margin-bottom:.5rem">Token Issue Delegates</h3>
|
||||||
|
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
||||||
|
Delegates can issue and rotate tokens for this service account without holding the admin role.
|
||||||
|
</p>
|
||||||
|
{{if .TokenDelegates}}
|
||||||
|
<table class="table table-sm" style="font-size:.85rem;margin-bottom:.75rem">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Account</th><th>Granted</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .TokenDelegates}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.GranteeName}}</td>
|
||||||
|
<td class="text-small text-muted">{{formatTime .GrantedAt}}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
hx-delete="/accounts/{{$.Account.UUID}}/token/delegates/{{.GranteeUUID}}"
|
||||||
|
hx-target="#token-delegates-section" hx-swap="outerHTML"
|
||||||
|
hx-confirm="Remove delegate access for {{.GranteeName}}?">Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted text-small" style="margin-bottom:.75rem">No delegates.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .DelegatableAccounts}}
|
||||||
|
<form hx-post="/accounts/{{.Account.UUID}}/token/delegates"
|
||||||
|
hx-target="#token-delegates-section" hx-swap="outerHTML"
|
||||||
|
style="display:flex;gap:.5rem;align-items:center">
|
||||||
|
<select class="form-control" name="grantee_uuid" required style="flex:1">
|
||||||
|
<option value="">— select account to add as delegate —</option>
|
||||||
|
{{range .DelegatableAccounts}}
|
||||||
|
{{if eq (string .AccountType) "human"}}
|
||||||
|
<option value="{{.UUID}}">{{.Username}}</option>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-secondary" type="submit">Add Delegate</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
{{define "token_list"}}
|
{{define "token_list"}}
|
||||||
<div id="token-list">
|
<div id="token-list">
|
||||||
|
{{if .Flash}}
|
||||||
|
<div class="alert alert-success" role="alert" style="margin-bottom:1rem">
|
||||||
|
{{.Flash}}
|
||||||
|
{{if .DownloadNonce}}
|
||||||
|
<div style="margin-top:.5rem">
|
||||||
|
<a class="btn btn-sm btn-secondary"
|
||||||
|
href="/token/download/{{.DownloadNonce}}">Download token as file</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{if .Tokens}}
|
{{if .Tokens}}
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
35
web/templates/fragments/totp_enroll_qr.html
Normal file
35
web/templates/fragments/totp_enroll_qr.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "totp_enroll_qr"}}
|
||||||
|
<div id="totp-section">
|
||||||
|
{{if .TOTPError}}<div class="alert alert-error" role="alert">{{.TOTPError}}</div>{{end}}
|
||||||
|
<p class="text-small" style="margin-bottom:.75rem">
|
||||||
|
Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.
|
||||||
|
</p>
|
||||||
|
<div style="text-align:center;margin:1rem 0">
|
||||||
|
<img src="{{.TOTPQR}}" alt="TOTP QR Code" width="200" height="200"
|
||||||
|
style="image-rendering:pixelated">
|
||||||
|
</div>
|
||||||
|
<details style="margin-bottom:1rem">
|
||||||
|
<summary class="text-small text-muted" style="cursor:pointer">Manual entry</summary>
|
||||||
|
<code style="font-size:.8rem;word-break:break-all;display:block;margin-top:.5rem;
|
||||||
|
padding:.5rem;background:var(--color-bg-alt,#f5f5f5);border-radius:4px">
|
||||||
|
{{.TOTPSecret}}
|
||||||
|
</code>
|
||||||
|
</details>
|
||||||
|
<form hx-post="/profile/totp/confirm"
|
||||||
|
hx-target="#totp-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
|
||||||
|
<input type="hidden" name="totp_enroll_nonce" value="{{.TOTPEnrollNonce}}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp-confirm-code">Authenticator Code</label>
|
||||||
|
<input type="text" id="totp-confirm-code" name="totp_code"
|
||||||
|
class="form-control" autocomplete="one-time-code"
|
||||||
|
inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
|
||||||
|
required autofocus placeholder="6-digit code">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.5rem">
|
||||||
|
Verify & Enable
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
29
web/templates/fragments/totp_section.html
Normal file
29
web/templates/fragments/totp_section.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{{define "totp_section"}}
|
||||||
|
<div id="totp-section">
|
||||||
|
{{if .TOTPSuccess}}<div class="alert alert-success" role="alert">{{.TOTPSuccess}}</div>{{end}}
|
||||||
|
{{if .TOTPEnabled}}
|
||||||
|
<p class="text-small" style="margin-bottom:.5rem">
|
||||||
|
<span style="color:var(--color-success,#27ae60);font-weight:600">✓ Enabled</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted text-small">To remove TOTP, contact an administrator.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
||||||
|
Add a time-based one-time password for two-factor authentication.
|
||||||
|
</p>
|
||||||
|
{{if .TOTPError}}<div class="alert alert-error" role="alert">{{.TOTPError}}</div>{{end}}
|
||||||
|
<form hx-post="/profile/totp/enroll"
|
||||||
|
hx-target="#totp-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp-enroll-password">Current Password</label>
|
||||||
|
<input type="password" id="totp-enroll-password" name="password"
|
||||||
|
class="form-control" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.5rem">
|
||||||
|
Set Up Authenticator
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
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}}
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<div class="brand-heading">MCIAS</div>
|
<div class="brand-heading">MCIAS</div>
|
||||||
<div class="brand-subtitle">Metacircular Identity & Access System</div>
|
<div class="brand-subtitle">Metacircular Identity & Access System</div>
|
||||||
<div class="card">
|
<div class="card" id="login-card">
|
||||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||||
<form id="login-form" method="POST" action="/login"
|
<form id="login-form" method="POST" action="/login"
|
||||||
hx-post="/login" hx-target="#login-form" hx-swap="outerHTML">
|
hx-post="/login" hx-target="#login-card" hx-swap="outerHTML" hx-select="#login-card">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input class="form-control" type="text" id="username" name="username"
|
<input class="form-control" type="text" id="username" name="username"
|
||||||
@@ -29,10 +29,24 @@
|
|||||||
<button class="btn btn-primary" type="submit" style="width:100%">Sign in</button>
|
<button class="btn btn-primary" type="submit" style="width:100%">Sign in</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
{{if .WebAuthnEnabled}}<script src="/static/webauthn.js"></script>{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -4,6 +4,22 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Two-Factor Authentication (TOTP)</h2>
|
||||||
|
{{template "totp_section" .}}
|
||||||
|
</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">
|
<div class="card">
|
||||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2>
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2>
|
||||||
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
<p class="text-muted text-small" style="margin-bottom:.75rem">
|
||||||
|
|||||||
47
web/templates/service_accounts.html
Normal file
47
web/templates/service_accounts.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{{define "service_accounts"}}{{template "base" .}}{{end}}
|
||||||
|
{{define "title"}}Service Accounts — MCIAS{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Service Accounts</h1>
|
||||||
|
<p class="text-muted text-small">Service accounts for which you have been granted token-issue access.</p>
|
||||||
|
</div>
|
||||||
|
{{if .DownloadNonce}}
|
||||||
|
<div class="alert alert-success" role="alert" style="margin-bottom:1rem">
|
||||||
|
Token issued.
|
||||||
|
<a class="btn btn-sm btn-secondary" style="margin-left:.5rem"
|
||||||
|
href="/token/download/{{.DownloadNonce}}">Download token as file</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Accounts}}
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Status</th><th>Action</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Accounts}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td><span class="badge badge-{{string .Status}}">{{string .Status}}</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
hx-post="/accounts/{{.UUID}}/token"
|
||||||
|
hx-target="#issue-result-{{.UUID}}"
|
||||||
|
hx-swap="outerHTML">Issue Token</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3">
|
||||||
|
<div id="issue-result-{{.UUID}}"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-muted text-small">You have not been granted access to any service accounts.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user