Add HTMX-based UI templates and handlers for account and audit management
- Introduced `web/templates/` for HTMX-fragmented pages (`dashboard`, `accounts`, `account_detail`, `error_fragment`, etc.). - Implemented UI routes for account CRUD, audit log display, and login/logout with CSRF protection. - Added `internal/ui/` package for handlers, CSRF manager, session validation, and token issuance. - Updated documentation to include new UI features and templates directory structure. - Security: Double-submit CSRF cookies, constant-time HMAC validation, login password/Argon2id re-verification at all steps to prevent bypass.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -32,3 +32,6 @@ clients/python/tests/__pycache__/
|
|||||||
clients/python/.pytest_cache/
|
clients/python/.pytest_cache/
|
||||||
clients/python/*.egg-info/
|
clients/python/*.egg-info/
|
||||||
clients/lisp/**/*.fasl
|
clients/lisp/**/*.fasl
|
||||||
|
|
||||||
|
# manual testing
|
||||||
|
/run/
|
||||||
@@ -88,7 +88,7 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
|
|||||||
|
|
||||||
| Purpose | Algorithm | Rationale |
|
| Purpose | Algorithm | Rationale |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Password hashing | Argon2id | OWASP-recommended; memory-hard; resists GPU/ASIC attacks. Parameters: time=3, memory=64MB, threads=4 (meets OWASP 2023 minimum of time=2, memory=64MB). |
|
| Password hashing | Argon2id | OWASP-recommended; memory-hard; resists GPU/ASIC attacks. Parameters: time=3, memory=64MB, threads=4 (meets OWASP 2023 minimum of time=2, memory=64MB). Master key derivation uses time=3, memory=128MB, threads=4 (higher cost acceptable at startup). |
|
||||||
| JWT signing | Ed25519 (EdDSA) | Fast, short signatures, no parameter malleability, immune to invalid-curve attacks. RFC 8037. |
|
| JWT signing | Ed25519 (EdDSA) | Fast, short signatures, no parameter malleability, immune to invalid-curve attacks. RFC 8037. |
|
||||||
| JWT key storage | Raw Ed25519 private key in PEM-encoded PKCS#8 file, chmod 0600. | |
|
| JWT key storage | Raw Ed25519 private key in PEM-encoded PKCS#8 file, chmod 0600. | |
|
||||||
| TOTP | HMAC-SHA1 per RFC 6238 (industry standard). Shared secret stored encrypted with AES-256-GCM using a server-side key. | |
|
| TOTP | HMAC-SHA1 per RFC 6238 (industry standard). Shared secret stored encrypted with AES-256-GCM using a server-side key. | |
|
||||||
@@ -278,8 +278,8 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | `/v1/token/validate` | none | Validate a JWT (passed as Bearer header) |
|
| POST | `/v1/token/validate` | none | Validate a JWT (passed as Bearer header) |
|
||||||
| POST | `/v1/token/issue` | admin JWT or role-scoped JWT | Issue service account token |
|
| POST | `/v1/token/issue` | admin JWT | Issue service account token |
|
||||||
| DELETE | `/v1/token/{jti}` | admin JWT or role-scoped JWT | Revoke token by JTI |
|
| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI |
|
||||||
|
|
||||||
### Account Endpoints (admin only)
|
### Account Endpoints (admin only)
|
||||||
|
|
||||||
@@ -310,9 +310,15 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT or role-scoped JWT | Retrieve Postgres credentials |
|
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials |
|
||||||
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
|
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
|
||||||
|
|
||||||
|
### Audit Endpoints (admin only)
|
||||||
|
|
||||||
|
| Method | Path | Auth required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/v1/audit` | admin JWT | List audit log events |
|
||||||
|
|
||||||
### Admin / Server Endpoints
|
### Admin / Server Endpoints
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
@@ -335,8 +341,11 @@ CREATE TABLE server_config (
|
|||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
-- Ed25519 private key, PEM PKCS#8, encrypted at rest with AES-256-GCM
|
-- Ed25519 private key, PEM PKCS#8, encrypted at rest with AES-256-GCM
|
||||||
-- using a master key derived from the startup passphrase.
|
-- using a master key derived from the startup passphrase.
|
||||||
signing_key_enc BLOB NOT NULL,
|
signing_key_enc BLOB,
|
||||||
signing_key_nonce BLOB NOT NULL,
|
signing_key_nonce BLOB,
|
||||||
|
-- Argon2id salt for master key derivation; stable across restarts so the
|
||||||
|
-- passphrase always yields the same key. Generated on first run.
|
||||||
|
master_key_salt BLOB,
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
);
|
);
|
||||||
@@ -444,6 +453,8 @@ CREATE INDEX idx_audit_event ON audit_log (event_type);
|
|||||||
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a
|
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a
|
||||||
master key held only in server memory (derived at startup from a passphrase
|
master key held only in server memory (derived at startup from a passphrase
|
||||||
or keyfile). The nonce is stored adjacent to the ciphertext.
|
or keyfile). The nonce is stored adjacent to the ciphertext.
|
||||||
|
- The master key salt is stored in `server_config.master_key_salt` so the
|
||||||
|
Argon2id KDF produces the same key on every restart. Generated on first run.
|
||||||
- The signing key encryption is layered: the Ed25519 private key is wrapped
|
- The signing key encryption is layered: the Ed25519 private key is wrapped
|
||||||
with AES-256-GCM using the startup master key. Operators must supply the
|
with AES-256-GCM using the startup master key. Operators must supply the
|
||||||
passphrase/keyfile on each server restart.
|
passphrase/keyfile on each server restart.
|
||||||
@@ -472,6 +483,7 @@ or a keyfile path — never inline in the config file.
|
|||||||
```toml
|
```toml
|
||||||
[server]
|
[server]
|
||||||
listen_addr = "0.0.0.0:8443"
|
listen_addr = "0.0.0.0:8443"
|
||||||
|
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/etc/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/etc/mcias/server.key"
|
||||||
|
|
||||||
@@ -518,7 +530,11 @@ mcias/
|
|||||||
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit)
|
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit)
|
||||||
│ ├── model/ # shared data types (Account, Token, Role, etc.)
|
│ ├── model/ # shared data types (Account, Token, Role, etc.)
|
||||||
│ ├── 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
|
||||||
|
├── web/
|
||||||
|
│ ├── static/ # CSS and static assets
|
||||||
|
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||||
├── proto/
|
├── proto/
|
||||||
│ └── mcias/v1/ # Protobuf service definitions (Phase 7)
|
│ └── mcias/v1/ # Protobuf service definitions (Phase 7)
|
||||||
├── gen/
|
├── gen/
|
||||||
@@ -798,7 +814,7 @@ mciassrv starts both listeners in the same process:
|
|||||||
│ ┌────────────────┐ ┌────────────────────┐ │
|
│ ┌────────────────┐ ┌────────────────────┐ │
|
||||||
│ │ REST listener │ │ gRPC listener │ │
|
│ │ REST listener │ │ gRPC listener │ │
|
||||||
│ │ (net/http) │ │ (google.golang. │ │
|
│ │ (net/http) │ │ (google.golang. │ │
|
||||||
│ │ :8443 │ │ org/grpc) :8444 │ │
|
│ │ :8443 │ │ org/grpc) :9443 │ │
|
||||||
│ └───────┬─────────┘ └──────────┬─────────┘ │
|
│ └───────┬─────────┘ └──────────┬─────────┘ │
|
||||||
│ └──────────────┬─────────┘ │
|
│ └──────────────┬─────────┘ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
@@ -818,7 +834,7 @@ configured window.
|
|||||||
```toml
|
```toml
|
||||||
[server]
|
[server]
|
||||||
listen_addr = "0.0.0.0:8443"
|
listen_addr = "0.0.0.0:8443"
|
||||||
grpc_addr = "0.0.0.0:8444" # optional; omit to disable gRPC
|
grpc_addr = "0.0.0.0:9443" # optional; omit to disable gRPC
|
||||||
tls_cert = "/etc/mcias/server.crt"
|
tls_cert = "/etc/mcias/server.crt"
|
||||||
tls_key = "/etc/mcias/server.key"
|
tls_key = "/etc/mcias/server.key"
|
||||||
```
|
```
|
||||||
@@ -916,7 +932,7 @@ FROM debian:bookworm-slim
|
|||||||
|
|
||||||
Security properties of the runtime image:
|
Security properties of the runtime image:
|
||||||
|
|
||||||
- No shell, no package manager, no Go toolchain — minimal attack surface
|
- No Go toolchain, no build cache, no source code — minimal attack surface
|
||||||
- Non-root user (`mcias`, uid 10001) — no escalation path
|
- Non-root user (`mcias`, uid 10001) — no escalation path
|
||||||
- TLS termination happens inside the container (same cert/key as bare-metal
|
- TLS termination happens inside the container (same cert/key as bare-metal
|
||||||
deployment); the operator mounts `/etc/mcias/` as a read-only volume
|
deployment); the operator mounts `/etc/mcias/` as a read-only volume
|
||||||
@@ -953,7 +969,7 @@ The Makefile `docker` target automates the build step with the version tag.
|
|||||||
| `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) .` |
|
||||||
| `clean` | Remove `bin/`, `gen/`, compressed man pages |
|
| `clean` | Remove `bin/` and compressed man pages |
|
||||||
| `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
|
||||||
|
|||||||
10
PROGRESS.md
10
PROGRESS.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Source of truth for current development state.
|
Source of truth for current development state.
|
||||||
---
|
---
|
||||||
All phases complete. 137 Go server tests + 25 Go client tests + 22 Rust client
|
All phases complete. 137 Go server tests + 25 Go client tests + 23 Rust client
|
||||||
tests + 37 Lisp client tests + 32 Python client tests pass. Zero race
|
tests + 37 Lisp client tests + 32 Python client tests pass. Zero race
|
||||||
conditions (go test -race ./...).
|
conditions (go test -race ./...).
|
||||||
- [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
|
- [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
|
||||||
@@ -30,19 +30,19 @@ conditions (go test -race ./...).
|
|||||||
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
|
- TLS 1.2+ enforced via `tls.Config{MinVersion: tls.VersionTLS12}`
|
||||||
- Token state guarded by `sync.RWMutex` for concurrent safety
|
- Token state guarded by `sync.RWMutex` for concurrent safety
|
||||||
- JSON decoded with `DisallowUnknownFields` on all responses
|
- JSON decoded with `DisallowUnknownFields` on all responses
|
||||||
- 20 tests in `client_test.go`; all pass with `go test -race`
|
- 25 tests in `client_test.go`; all pass with `go test -race`
|
||||||
|
|
||||||
**clients/rust/** — Rust async client library
|
**clients/rust/** — Rust async client library
|
||||||
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
|
- Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep)
|
||||||
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
|
- `MciasError` enum via `thiserror`; `Arc<RwLock<Option<String>>>` for token
|
||||||
- 22 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
|
- 23 integration tests using `wiremock`; `cargo clippy -- -D warnings` clean
|
||||||
|
|
||||||
**clients/lisp/** — Common Lisp client library
|
**clients/lisp/** — Common Lisp client library
|
||||||
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
|
- ASDF system `mcias-client`; HTTP via dexador, JSON via yason
|
||||||
- CLOS class `mcias-client`; plain functions for all operations
|
- CLOS class `mcias-client`; plain functions for all operations
|
||||||
- Conditions: `mcias-error` base + 6 typed subclasses
|
- Conditions: `mcias-error` base + 6 typed subclasses
|
||||||
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
|
- Mock server: Hunchentoot `mock-dispatcher` subclass (port 0, random per test)
|
||||||
- 33 fiveam checks; all pass on SBCL 2.6.1
|
- 37 fiveam checks; all pass on SBCL 2.6.1
|
||||||
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
|
- Fixed: yason decodes JSON `false` as `:false`; `validate-token` normalises
|
||||||
to `t`/`nil` before returning
|
to `t`/`nil` before returning
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ conditions (go test -race ./...).
|
|||||||
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
|
- Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27`
|
||||||
- `Client` context manager; `py.typed` marker; all symbols fully annotated
|
- `Client` context manager; `py.typed` marker; all symbols fully annotated
|
||||||
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
|
- Dataclasses: `Account`, `PublicKey`, `PGCreds`
|
||||||
- 33 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
|
- 32 pytest tests using `respx` mock transport; `mypy --strict` clean; `ruff` clean
|
||||||
|
|
||||||
**test/mock/mockserver.go** — Go in-memory mock server
|
**test/mock/mockserver.go** — Go in-memory mock server
|
||||||
- `Server` struct with `sync.RWMutex`; used by Go client integration test
|
- `Server` struct with `sync.RWMutex`; used by Go client integration test
|
||||||
|
|||||||
@@ -55,10 +55,14 @@ Performance is secondary, and can be tuned later.
|
|||||||
critical for this.
|
critical for this.
|
||||||
+ We will also need to build client libraries in several languages
|
+ We will also need to build client libraries in several languages
|
||||||
later on.
|
later on.
|
||||||
+ There should be two command line tools associated with MCIAS:
|
+ There are four command line tools associated with MCIAS:
|
||||||
+ mciassrv is the authentication server.
|
+ mciassrv is the authentication server.
|
||||||
+ mciasctl is the tool for admins to create and manage accounts, issue
|
+ mciasctl is the tool for admins to create and manage accounts, issue
|
||||||
or revoke tokens, and manage postgres database credentials.
|
or revoke tokens, and manage postgres database credentials.
|
||||||
|
+ mciasdb is the offline database maintenance tool for break-glass
|
||||||
|
recovery, bootstrap, and direct SQLite operations.
|
||||||
|
+ mciasgrpcctl is the gRPC admin CLI companion (mirrors mciasctl
|
||||||
|
over gRPC).
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ make docker # build Docker image mcias:<version>
|
|||||||
```sh
|
```sh
|
||||||
TOKEN=$(curl -sk https://localhost:8443/v1/auth/login \
|
TOKEN=$(curl -sk https://localhost:8443/v1/auth/login \
|
||||||
-d '{"username":"admin","password":"..."}' | jq -r .token)
|
-d '{"username":"admin","password":"..."}' | jq -r .token)
|
||||||
export MCIAS_TOKEN=$token
|
export MCIAS_TOKEN=$TOKEN
|
||||||
|
|
||||||
mciasctl -server https://localhost:8443 account list
|
mciasctl -server https://localhost:8443 account list
|
||||||
mciasctl account create -username alice -password s3cr3t
|
mciasctl account create -username alice -password s3cr3t
|
||||||
@@ -192,7 +192,7 @@ See `man mciasctl` for the full reference.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
export MCIAS_MASTER_PASSPHRASE=your-passphrase
|
||||||
CONF<<3C>--config /etc/mcias/mcias.conf
|
CONF="--config /etc/mcias/mcias.conf"
|
||||||
|
|
||||||
mciasdb $CONF schema verify
|
mciasdb $CONF schema verify
|
||||||
mciasdb $CONF account list
|
mciasdb $CONF account list
|
||||||
@@ -288,4 +288,4 @@ man mciasgrpcctl # gRPC admin CLI
|
|||||||
- Credential fields never appear in any API response.
|
- Credential fields never appear in any API response.
|
||||||
- TLS 1.2 minimum protocol version.
|
- TLS 1.2 minimum protocol version.
|
||||||
|
|
||||||
See [ARCHITECTURE.md](ARCHITECTURE.md) §2-³3 for the full security model.
|
See [ARCHITECTURE.md](ARCHITECTURE.md) §2–3 for the full security model.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"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/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server holds the dependencies injected into all handlers.
|
// Server holds the dependencies injected into all handlers.
|
||||||
@@ -85,6 +86,13 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
||||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||||
|
|
||||||
|
// UI routes (HTMX-based management frontend).
|
||||||
|
uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("ui: init failed: %v", err))
|
||||||
|
}
|
||||||
|
uiSrv.Register(mux)
|
||||||
|
|
||||||
// Apply global middleware: logging and login-path rate limiting.
|
// Apply global middleware: logging and login-path rate limiting.
|
||||||
var root http.Handler = mux
|
var root http.Handler = mux
|
||||||
root = middleware.RequestLogger(s.logger)(root)
|
root = middleware.RequestLogger(s.logger)(root)
|
||||||
|
|||||||
30
internal/ui/context.go
Normal file
30
internal/ui/context.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uiContextKey is the unexported type for UI context values, preventing
|
||||||
|
// collisions with keys from other packages.
|
||||||
|
type uiContextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
uiClaimsKey uiContextKey = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
// contextWithClaims stores validated JWT claims in the request context.
|
||||||
|
func contextWithClaims(ctx context.Context, claims *token.Claims) context.Context {
|
||||||
|
return context.WithValue(ctx, uiClaimsKey, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimsFromContext retrieves the JWT claims stored by requireCookieAuth.
|
||||||
|
// Returns nil if no claims are present (unauthenticated request).
|
||||||
|
func claimsFromContext(ctx context.Context) *token.Claims {
|
||||||
|
c, ok := ctx.Value(uiClaimsKey).(*token.Claims)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
65
internal/ui/csrf.go
Normal file
65
internal/ui/csrf.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Package ui provides the HTMX-based management web interface for MCIAS.
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
|
||||||
|
//
|
||||||
|
// Security design:
|
||||||
|
// - The CSRF key is derived from the server master key via SHA-256 with a
|
||||||
|
// domain-separation prefix, so it is unique to the UI CSRF function.
|
||||||
|
// - The cookie value is 32 bytes of cryptographic random (non-HttpOnly so
|
||||||
|
// HTMX can read it via JavaScript-free double-submit; SameSite=Strict
|
||||||
|
// provides the primary CSRF defence for browser-initiated requests).
|
||||||
|
// - The form/header value is HMAC-SHA256(key, cookieVal); this is what the
|
||||||
|
// server verifies. An attacker cannot forge the HMAC without the key.
|
||||||
|
// - Comparison uses crypto/subtle.ConstantTimeCompare to prevent timing attacks.
|
||||||
|
type CSRFManager struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCSRFManager creates a CSRFManager whose key is derived from masterKey.
|
||||||
|
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
|
||||||
|
func newCSRFManager(masterKey []byte) *CSRFManager {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte("mcias-ui-csrf-v1"))
|
||||||
|
h.Write(masterKey)
|
||||||
|
return &CSRFManager{key: h.Sum(nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewToken generates a fresh CSRF token pair.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - cookieVal: hex(32 random bytes) — stored in the mcias_csrf cookie
|
||||||
|
// - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header
|
||||||
|
func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(raw); err != nil {
|
||||||
|
return "", "", fmt.Errorf("csrf: generate random bytes: %w", err)
|
||||||
|
}
|
||||||
|
cookieVal = hex.EncodeToString(raw)
|
||||||
|
mac := hmac.New(sha256.New, c.key)
|
||||||
|
mac.Write([]byte(cookieVal))
|
||||||
|
headerVal = hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return cookieVal, headerVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate verifies that headerVal is the correct HMAC of cookieVal.
|
||||||
|
// Returns false on any mismatch or decoding error.
|
||||||
|
func (c *CSRFManager) Validate(cookieVal, headerVal string) bool {
|
||||||
|
if cookieVal == "" || headerVal == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, c.key)
|
||||||
|
mac.Write([]byte(cookieVal))
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
// Security: constant-time comparison prevents timing oracle attacks.
|
||||||
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(headerVal)) == 1
|
||||||
|
}
|
||||||
400
internal/ui/handlers_accounts.go
Normal file
400
internal/ui/handlers_accounts.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
|
||||||
|
var knownRoles = []string{"admin", "user", "service"}
|
||||||
|
|
||||||
|
// handleAccountsList renders the accounts list page.
|
||||||
|
func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := u.db.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "accounts", AccountsData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Accounts: accounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateAccount creates a new account and returns the account_row fragment.
|
||||||
|
func (u *UIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
accountTypeStr := r.FormValue("account_type")
|
||||||
|
|
||||||
|
if username == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "username is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountType := model.AccountTypeHuman
|
||||||
|
if accountTypeStr == string(model.AccountTypeSystem) {
|
||||||
|
accountType = model.AccountTypeSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash string
|
||||||
|
if password != "" {
|
||||||
|
argonCfg := auth.ArgonParams{
|
||||||
|
Time: u.cfg.Argon2.Time,
|
||||||
|
Memory: u.cfg.Argon2.Memory,
|
||||||
|
Threads: u.cfg.Argon2.Threads,
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
passwordHash, err = auth.HashPassword(password, argonCfg)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("hash password", "error", err)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if accountType == model.AccountTypeHuman {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "password is required for human accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &acct.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acct, err := u.db.CreateAccount(username, accountType, passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create account: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventAccountCreated, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"username":%q,"type":%q}`, username, accountType))
|
||||||
|
|
||||||
|
u.render(w, "account_row", acct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountDetail renders the account detail page.
|
||||||
|
func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := u.db.GetRoles(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := u.db.ListTokensForAccount(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list tokens for account", "error", err)
|
||||||
|
tokens = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "account_detail", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
Roles: roles,
|
||||||
|
AllRoles: knownRoles,
|
||||||
|
Tokens: tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateAccountStatus toggles an account between active and inactive.
|
||||||
|
func (u *UIServer) handleUpdateAccountStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statusStr := r.FormValue("status")
|
||||||
|
var newStatus model.AccountStatus
|
||||||
|
switch statusStr {
|
||||||
|
case string(model.AccountStatusActive):
|
||||||
|
newStatus = model.AccountStatusActive
|
||||||
|
case string(model.AccountStatusInactive):
|
||||||
|
newStatus = model.AccountStatusInactive
|
||||||
|
default:
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.UpdateAccountStatus(acct.ID, newStatus); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "update failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acct.Status = newStatus
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventAccountUpdated, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"status":%q}`, newStatus))
|
||||||
|
|
||||||
|
// Respond with the updated row (for HTMX outerHTML swap on accounts list)
|
||||||
|
// or updated status cell (for HTMX innerHTML swap on account detail).
|
||||||
|
// The hx-target in accounts.html targets the whole <tr>; in account_detail.html
|
||||||
|
// it targets #status-cell. We detect which by checking the request path context.
|
||||||
|
if strings.Contains(r.Header.Get("HX-Target"), "status-cell") || r.Header.Get("HX-Target") == "status-cell" {
|
||||||
|
data := AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: ""},
|
||||||
|
Account: acct,
|
||||||
|
}
|
||||||
|
u.render(w, "account_status", data)
|
||||||
|
} else {
|
||||||
|
u.render(w, "account_row", acct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteAccount soft-deletes an account and returns empty body (HTMX removes row).
|
||||||
|
func (u *UIServer) handleDeleteAccount(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, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke all active tokens for the deleted account.
|
||||||
|
if err := u.db.RevokeAllUserTokens(acct.ID, "account_deleted"); err != nil {
|
||||||
|
u.logger.Warn("revoke tokens for deleted account", "account_id", acct.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventAccountDeleted, actorID, &acct.ID, "")
|
||||||
|
|
||||||
|
// Return empty body; HTMX will remove the row via hx-swap="outerHTML".
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRolesEditForm returns the roles editor fragment (GET — no CSRF check needed).
|
||||||
|
func (u *UIServer) handleRolesEditForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := u.db.GetRoles(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "roles_editor", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
Roles: roles,
|
||||||
|
AllRoles: knownRoles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetRoles replaces the full role set for an account.
|
||||||
|
func (u *UIServer) handleSetRoles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect checked roles + optional custom role.
|
||||||
|
roles := r.Form["roles"] // multi-value from checkboxes
|
||||||
|
if custom := strings.TrimSpace(r.FormValue("custom_role")); custom != "" {
|
||||||
|
roles = append(roles, custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.SetRoles(acct.ID, roles, actorID); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to set roles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventRoleGranted, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"roles":%q}`, strings.Join(roles, ",")))
|
||||||
|
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
csrfToken = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "roles_editor", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
Roles: roles,
|
||||||
|
AllRoles: knownRoles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeToken revokes a specific token by JTI.
|
||||||
|
func (u *UIServer) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jti := r.PathValue("jti")
|
||||||
|
if jti == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "missing JTI")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.RevokeToken(jti, "ui_revoke"); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "revoke failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventTokenRevoked, actorID, nil,
|
||||||
|
fmt.Sprintf(`{"jti":%q,"reason":"ui_revoke"}`, jti))
|
||||||
|
|
||||||
|
// Return empty body; HTMX removes the row.
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIssueSystemToken issues a long-lived service token for a system account.
|
||||||
|
func (u *UIServer) handleIssueSystemToken(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, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acct.AccountType != model.AccountTypeSystem {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "only system accounts can have service tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := u.db.GetRoles(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry := u.cfg.ServiceExpiry()
|
||||||
|
tokenStr, claims, err := u.issueToken(acct.UUID, roles, expiry)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("issue system token", "error", err)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
u.logger.Error("track system token", "error", err)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to track token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store as system token for easy retrieval.
|
||||||
|
if err := u.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
|
||||||
|
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,
|
||||||
|
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
|
||||||
|
|
||||||
|
// Re-fetch token list including the new token.
|
||||||
|
tokens, err := u.db.ListTokensForAccount(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("list tokens after issue", "error", err)
|
||||||
|
tokens = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
csrfToken = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash the raw token once at the top so the operator can copy it.
|
||||||
|
u.render(w, "token_list", AccountDetailData{
|
||||||
|
PageData: PageData{
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr),
|
||||||
|
},
|
||||||
|
Account: acct,
|
||||||
|
Tokens: tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
100
internal/ui/handlers_audit.go
Normal file
100
internal/ui/handlers_audit.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const auditPageSize = 50
|
||||||
|
|
||||||
|
// auditEventTypes lists all known event types for the filter dropdown.
|
||||||
|
var auditEventTypes = []string{
|
||||||
|
model.EventLoginOK,
|
||||||
|
model.EventLoginFail,
|
||||||
|
model.EventLoginTOTPFail,
|
||||||
|
model.EventTokenIssued,
|
||||||
|
model.EventTokenRenewed,
|
||||||
|
model.EventTokenRevoked,
|
||||||
|
model.EventTokenExpired,
|
||||||
|
model.EventAccountCreated,
|
||||||
|
model.EventAccountUpdated,
|
||||||
|
model.EventAccountDeleted,
|
||||||
|
model.EventRoleGranted,
|
||||||
|
model.EventRoleRevoked,
|
||||||
|
model.EventTOTPEnrolled,
|
||||||
|
model.EventTOTPRemoved,
|
||||||
|
model.EventPGCredAccessed,
|
||||||
|
model.EventPGCredUpdated,
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuditPage renders the full audit log page with the first page embedded.
|
||||||
|
func (u *UIServer) handleAuditPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := u.buildAuditData(r, 1, csrfToken)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load audit log")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "audit", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuditRows returns only the <tbody> rows fragment for HTMX partial updates.
|
||||||
|
func (u *UIServer) handleAuditRows(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
page, _ := strconv.Atoi(pageStr)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := u.buildAuditData(r, page, "")
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load audit log")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "audit_rows", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAuditData fetches one page of audit events and builds AuditData.
|
||||||
|
func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) (AuditData, error) {
|
||||||
|
filterType := r.URL.Query().Get("event_type")
|
||||||
|
offset := (page - 1) * auditPageSize
|
||||||
|
|
||||||
|
params := db.AuditQueryParams{
|
||||||
|
Limit: auditPageSize,
|
||||||
|
Offset: offset,
|
||||||
|
EventType: filterType,
|
||||||
|
}
|
||||||
|
|
||||||
|
events, total, err := u.db.ListAuditEventsPaged(params)
|
||||||
|
if err != nil {
|
||||||
|
return AuditData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int(total) / auditPageSize
|
||||||
|
if int(total)%auditPageSize != 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuditData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Events: events,
|
||||||
|
EventTypes: auditEventTypes,
|
||||||
|
FilterType: filterType,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
183
internal/ui/handlers_auth.go
Normal file
183
internal/ui/handlers_auth.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleLoginPage renders the login form.
|
||||||
|
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u.render(w, "login", LoginData{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLoginPost processes username+password (and optional TOTP code).
|
||||||
|
//
|
||||||
|
// Security design:
|
||||||
|
// - Password is verified via Argon2id on every request, including the TOTP
|
||||||
|
// second step, to prevent credential-bypass by jumping to TOTP directly.
|
||||||
|
// - Timing is held constant regardless of whether the account exists, by
|
||||||
|
// always running a dummy Argon2 check for unknown accounts.
|
||||||
|
// - On TOTP required: returns the totp_step fragment (200) so HTMX swaps the
|
||||||
|
// form in place. The username and password are included as hidden fields;
|
||||||
|
// they are re-verified on the TOTP submission.
|
||||||
|
// - On success: issues a JWT, stores it as an HttpOnly session cookie, sets
|
||||||
|
// CSRF tokens, then redirects via HX-Redirect (HTMX) or 302 (browser).
|
||||||
|
func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.render(w, "totp_step", LoginData{Error: "invalid form submission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := r.FormValue("username")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
totpCode := r.FormValue("totp_code")
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
u.render(w, "login", LoginData{Error: "username and password are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load account by username.
|
||||||
|
acct, err := u.db.GetAccountByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
||||||
|
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||||
|
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||||
|
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
|
||||||
|
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: check account status before credential verification.
|
||||||
|
if acct.Status != model.AccountStatusActive {
|
||||||
|
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||||
|
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
|
||||||
|
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password. Always run even if TOTP step, to prevent bypass.
|
||||||
|
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
||||||
|
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTP check.
|
||||||
|
if acct.TOTPRequired {
|
||||||
|
if totpCode == "" {
|
||||||
|
// Return TOTP step fragment so HTMX swaps the form.
|
||||||
|
u.render(w, "totp_step", LoginData{
|
||||||
|
Username: username,
|
||||||
|
// Security: password is embedded as a hidden form field so the
|
||||||
|
// second submission can re-verify it. It is never logged.
|
||||||
|
Password: password,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Decrypt and validate TOTP secret.
|
||||||
|
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||||
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
valid, err := auth.ValidateTOTP(secret, totpCode)
|
||||||
|
if err != nil || !valid {
|
||||||
|
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||||
|
u.render(w, "totp_step", LoginData{
|
||||||
|
Error: "invalid TOTP code",
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine token expiry based on admin role.
|
||||||
|
expiry := u.cfg.DefaultExpiry()
|
||||||
|
roles, err := u.db.GetRoles(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, rol := range roles {
|
||||||
|
if rol == "admin" {
|
||||||
|
expiry = u.cfg.AdminExpiry()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("issue token", "error", err)
|
||||||
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
u.logger.Error("track token", "error", err)
|
||||||
|
u.render(w, "login", LoginData{Error: "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: set session cookie as HttpOnly, Secure, SameSite=Strict.
|
||||||
|
// Path=/ so it is sent on all UI routes (not just /ui/*).
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: tokenStr,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Expires: claims.ExpiresAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set CSRF tokens for subsequent requests.
|
||||||
|
if _, err := u.setCSRFCookies(w); err != nil {
|
||||||
|
u.logger.Error("set CSRF cookie", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
|
||||||
|
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
||||||
|
fmt.Sprintf(`{"jti":%q,"via":"ui"}`, claims.JTI))
|
||||||
|
|
||||||
|
// Redirect to dashboard.
|
||||||
|
if isHTMX(r) {
|
||||||
|
w.Header().Set("HX-Redirect", "/dashboard")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout revokes the session token and clears the cookie.
|
||||||
|
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err == nil && cookie.Value != "" {
|
||||||
|
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||||
|
if err == nil {
|
||||||
|
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
||||||
|
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventTokenRevoked, nil, nil,
|
||||||
|
fmt.Sprintf(`{"jti":%q,"reason":"ui_logout"}`, claims.JTI))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.clearSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAudit is a fire-and-forget audit log helper for the UI package.
|
||||||
|
func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
||||||
|
ip := clientIP(r)
|
||||||
|
if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
||||||
|
u.logger.Warn("write audit event", "type", eventType, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/ui/handlers_dashboard.go
Normal file
45
internal/ui/handlers_dashboard.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleDashboard renders the main dashboard page with account counts and recent events.
|
||||||
|
func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("set CSRF cookies", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := u.db.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var total, active int
|
||||||
|
for _, a := range accounts {
|
||||||
|
total++
|
||||||
|
if a.Status == model.AccountStatusActive {
|
||||||
|
active++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events, _, err := u.db.ListAuditEventsPaged(db.AuditQueryParams{Limit: 10, Offset: 0})
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("load recent audit events", "error", err)
|
||||||
|
events = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "dashboard", DashboardData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
TotalAccounts: total,
|
||||||
|
ActiveAccounts: active,
|
||||||
|
RecentEvents: events,
|
||||||
|
})
|
||||||
|
}
|
||||||
20
internal/ui/session.go
Normal file
20
internal/ui/session.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateSessionToken wraps token.ValidateToken for use by UI session middleware.
|
||||||
|
// Security: identical validation pipeline as the REST API — alg check, signature,
|
||||||
|
// expiry, issuer, revocation (revocation checked by caller).
|
||||||
|
func validateSessionToken(pubKey ed25519.PublicKey, tokenStr, issuer string) (*token.Claims, error) {
|
||||||
|
return token.ValidateToken(pubKey, tokenStr, issuer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueToken is a convenience method for issuing a signed JWT.
|
||||||
|
func (u *UIServer) issueToken(subject string, roles []string, expiry time.Duration) (string, *token.Claims, error) {
|
||||||
|
return token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, subject, roles, expiry)
|
||||||
|
}
|
||||||
365
internal/ui/ui.go
Normal file
365
internal/ui/ui.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
// Package ui provides the HTMX-based management web interface for MCIAS.
|
||||||
|
//
|
||||||
|
// Security design:
|
||||||
|
// - Session tokens are stored as HttpOnly, Secure, SameSite=Strict cookies.
|
||||||
|
// They are never accessible to JavaScript.
|
||||||
|
// - CSRF protection uses the HMAC-signed Double-Submit Cookie pattern.
|
||||||
|
// The mcias_csrf cookie is non-HttpOnly so HTMX can include it in the
|
||||||
|
// X-CSRF-Token header on every mutating request. SameSite=Strict is the
|
||||||
|
// primary browser-level CSRF defence.
|
||||||
|
// - UI handlers call internal Go functions directly — no internal HTTP round-trips.
|
||||||
|
// - All templates are parsed once at startup via embed.FS; no dynamic template
|
||||||
|
// loading from disk at request time.
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:../../web/templates
|
||||||
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
//go:embed all:../../web/static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieName = "mcias_session"
|
||||||
|
csrfCookieName = "mcias_csrf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UIServer serves the HTMX-based management UI.
|
||||||
|
type UIServer struct {
|
||||||
|
db *db.DB
|
||||||
|
cfg *config.Config
|
||||||
|
pubKey ed25519.PublicKey
|
||||||
|
privKey ed25519.PrivateKey
|
||||||
|
masterKey []byte
|
||||||
|
logger *slog.Logger
|
||||||
|
csrf *CSRFManager
|
||||||
|
tmpl *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a UIServer, parses all templates, and returns it.
|
||||||
|
// Returns an error if template parsing fails.
|
||||||
|
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
|
||||||
|
csrf := newCSRFManager(masterKey)
|
||||||
|
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"formatTime": func(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
},
|
||||||
|
"truncateJTI": func(jti string) string {
|
||||||
|
if len(jti) > 8 {
|
||||||
|
return jti[:8]
|
||||||
|
}
|
||||||
|
return jti
|
||||||
|
},
|
||||||
|
"string": func(v interface{}) string {
|
||||||
|
switch s := v.(type) {
|
||||||
|
case model.AccountStatus:
|
||||||
|
return string(s)
|
||||||
|
case model.AccountType:
|
||||||
|
return string(s)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hasRole": func(roles []string, role string) bool {
|
||||||
|
for _, r := range roles {
|
||||||
|
if r == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
"not": func(b bool) bool { return !b },
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
"sub": func(a, b int) int { return a - b },
|
||||||
|
"gt": func(a, b int) bool { return a > b },
|
||||||
|
"lt": func(a, b int) bool { return a < b },
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
|
||||||
|
"web/templates/base.html",
|
||||||
|
"web/templates/login.html",
|
||||||
|
"web/templates/dashboard.html",
|
||||||
|
"web/templates/accounts.html",
|
||||||
|
"web/templates/account_detail.html",
|
||||||
|
"web/templates/audit.html",
|
||||||
|
"web/templates/fragments/account_row.html",
|
||||||
|
"web/templates/fragments/account_status.html",
|
||||||
|
"web/templates/fragments/roles_editor.html",
|
||||||
|
"web/templates/fragments/token_list.html",
|
||||||
|
"web/templates/fragments/totp_step.html",
|
||||||
|
"web/templates/fragments/error.html",
|
||||||
|
"web/templates/fragments/audit_rows.html",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ui: parse templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UIServer{
|
||||||
|
db: database,
|
||||||
|
cfg: cfg,
|
||||||
|
pubKey: pub,
|
||||||
|
privKey: priv,
|
||||||
|
masterKey: masterKey,
|
||||||
|
logger: logger,
|
||||||
|
csrf: csrf,
|
||||||
|
tmpl: tmpl,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register attaches all UI routes to mux.
|
||||||
|
func (u *UIServer) Register(mux *http.ServeMux) {
|
||||||
|
// Static assets — serve from the web/static/ sub-directory of the embed.
|
||||||
|
staticSubFS, err := fs.Sub(staticFS, "web/static")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("ui: static sub-FS: %v", err))
|
||||||
|
}
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||||
|
|
||||||
|
// Redirect root to login.
|
||||||
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auth routes (no session required).
|
||||||
|
mux.HandleFunc("GET /login", u.handleLoginPage)
|
||||||
|
mux.HandleFunc("POST /login", u.handleLoginPost)
|
||||||
|
mux.HandleFunc("POST /logout", u.handleLogout)
|
||||||
|
|
||||||
|
// Protected routes.
|
||||||
|
auth := u.requireCookieAuth
|
||||||
|
admin := func(h http.HandlerFunc) http.Handler {
|
||||||
|
return auth(u.requireCSRF(http.HandlerFunc(h)))
|
||||||
|
}
|
||||||
|
adminGet := func(h http.HandlerFunc) http.Handler {
|
||||||
|
return auth(http.HandlerFunc(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.Handle("GET /dashboard", adminGet(u.handleDashboard))
|
||||||
|
mux.Handle("GET /accounts", adminGet(u.handleAccountsList))
|
||||||
|
mux.Handle("POST /accounts", admin(u.handleCreateAccount))
|
||||||
|
mux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
|
||||||
|
mux.Handle("PATCH /accounts/{id}/status", admin(u.handleUpdateAccountStatus))
|
||||||
|
mux.Handle("DELETE /accounts/{id}", admin(u.handleDeleteAccount))
|
||||||
|
mux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
|
||||||
|
mux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
|
||||||
|
mux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
|
||||||
|
mux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
|
||||||
|
mux.Handle("GET /audit", adminGet(u.handleAuditPage))
|
||||||
|
mux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Middleware ----
|
||||||
|
|
||||||
|
// requireCookieAuth validates the mcias_session cookie and injects claims.
|
||||||
|
// On failure, HTMX requests get HX-Redirect; browser requests get a redirect.
|
||||||
|
func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
u.redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||||
|
if err != nil {
|
||||||
|
u.clearSessionCookie(w)
|
||||||
|
u.redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check revocation.
|
||||||
|
rec, err := u.db.GetTokenRecord(claims.JTI)
|
||||||
|
if err != nil || rec.IsRevoked() {
|
||||||
|
u.clearSessionCookie(w)
|
||||||
|
u.redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := contextWithClaims(r.Context(), claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireCSRF validates the CSRF token on mutating requests (POST/PUT/PATCH/DELETE).
|
||||||
|
// The token is read from X-CSRF-Token header (HTMX) or _csrf form field (fallback).
|
||||||
|
func (u *UIServer) requireCSRF(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(csrfCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
http.Error(w, "CSRF cookie missing", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header takes precedence (HTMX sets it automatically via hx-headers on body).
|
||||||
|
formVal := r.Header.Get("X-CSRF-Token")
|
||||||
|
if formVal == "" {
|
||||||
|
// Fallback: parse form and read _csrf field.
|
||||||
|
if parseErr := r.ParseForm(); parseErr == nil {
|
||||||
|
formVal = r.FormValue("_csrf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !u.csrf.Validate(cookie.Value, formVal) {
|
||||||
|
http.Error(w, "CSRF token invalid", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
// isHTMX reports whether the request was initiated by HTMX.
|
||||||
|
func isHTMX(r *http.Request) bool {
|
||||||
|
return r.Header.Get("HX-Request") == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectToLogin redirects to the login page, using HX-Redirect for HTMX.
|
||||||
|
func (u *UIServer) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if isHTMX(r) {
|
||||||
|
w.Header().Set("HX-Redirect", "/login")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSessionCookie expires the session cookie.
|
||||||
|
func (u *UIServer) clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCSRFCookies sets the mcias_csrf cookie and returns the header value to
|
||||||
|
// embed in the page/form.
|
||||||
|
func (u *UIServer) setCSRFCookies(w http.ResponseWriter) (string, error) {
|
||||||
|
cookieVal, headerVal, err := u.csrf.NewToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: csrfCookieName,
|
||||||
|
Value: cookieVal,
|
||||||
|
Path: "/",
|
||||||
|
// Security: non-HttpOnly so that HTMX can embed it in hx-headers;
|
||||||
|
// SameSite=Strict is the primary CSRF defence for browser requests.
|
||||||
|
HttpOnly: false,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
return headerVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// render executes the named template, writing the result to w.
|
||||||
|
// Renders to a buffer first so partial template failures don't corrupt output.
|
||||||
|
func (u *UIServer) render(w http.ResponseWriter, name string, data interface{}) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := u.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
||||||
|
u.logger.Error("template render error", "template", name, "error", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderError returns an error response appropriate for the request type.
|
||||||
|
func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||||
|
if isHTMX(r) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = fmt.Fprintf(w, `<div class="alert alert-error" role="alert">%s</div>`, template.HTMLEscapeString(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, msg, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP extracts the client IP from RemoteAddr (best effort).
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
addr := r.RemoteAddr
|
||||||
|
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
||||||
|
return addr[:idx]
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Page data types ----
|
||||||
|
|
||||||
|
// PageData is embedded in all page-level view structs.
|
||||||
|
type PageData struct {
|
||||||
|
CSRFToken string
|
||||||
|
Flash string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginData is the view model for the login page.
|
||||||
|
type LoginData struct {
|
||||||
|
Error string
|
||||||
|
Username string // pre-filled on TOTP step
|
||||||
|
Password string // pre-filled on TOTP step (value attr, never logged)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardData is the view model for the dashboard page.
|
||||||
|
type DashboardData struct {
|
||||||
|
PageData
|
||||||
|
TotalAccounts int
|
||||||
|
ActiveAccounts int
|
||||||
|
RecentEvents []*db.AuditEventView
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountsData is the view model for the accounts list page.
|
||||||
|
type AccountsData struct {
|
||||||
|
PageData
|
||||||
|
Accounts []*model.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountDetailData is the view model for the account detail page.
|
||||||
|
type AccountDetailData struct {
|
||||||
|
PageData
|
||||||
|
Account *model.Account
|
||||||
|
Roles []string
|
||||||
|
AllRoles []string
|
||||||
|
Tokens []*model.TokenRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditData is the view model for the audit log page.
|
||||||
|
type AuditData struct {
|
||||||
|
PageData
|
||||||
|
Events []*db.AuditEventView
|
||||||
|
EventTypes []string
|
||||||
|
FilterType string
|
||||||
|
Total int64
|
||||||
|
Page int
|
||||||
|
TotalPages int
|
||||||
|
}
|
||||||
39
web/templates/fragments/account_row.html
Normal file
39
web/templates/fragments/account_row.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{{define "account_row"}}
|
||||||
|
<tr id="account-row-{{.UUID}}">
|
||||||
|
<td><a href="/accounts/{{.UUID}}">{{.Username}}</a></td>
|
||||||
|
<td class="text-small text-muted">{{.AccountType}}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{if eq (string .Status) "active"}}badge-active{{else if eq (string .Status) "inactive"}}badge-inactive{{else}}badge-deleted{{end}}">
|
||||||
|
{{.Status}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-small text-muted">{{if .TOTPRequired}}Yes{{else}}No{{end}}</td>
|
||||||
|
<td class="text-small text-muted">{{formatTime .CreatedAt}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<a class="btn btn-sm btn-secondary" href="/accounts/{{.UUID}}">View</a>
|
||||||
|
{{if eq (string .Status) "active"}}
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
hx-patch="/accounts/{{.UUID}}/status"
|
||||||
|
hx-vals='{"status":"inactive"}'
|
||||||
|
hx-target="#account-row-{{.UUID}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Deactivate this account?">Deactivate</button>
|
||||||
|
{{else if eq (string .Status) "inactive"}}
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
hx-patch="/accounts/{{.UUID}}/status"
|
||||||
|
hx-vals='{"status":"active"}'
|
||||||
|
hx-target="#account-row-{{.UUID}}"
|
||||||
|
hx-swap="outerHTML">Activate</button>
|
||||||
|
{{end}}
|
||||||
|
{{if ne (string .Status) "deleted"}}
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
hx-delete="/accounts/{{.UUID}}"
|
||||||
|
hx-target="#account-row-{{.UUID}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Permanently delete this account? This cannot be undone.">Delete</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
21
web/templates/fragments/account_status.html
Normal file
21
web/templates/fragments/account_status.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{{define "account_status"}}
|
||||||
|
<span class="badge {{if eq (string .Account.Status) "active"}}badge-active{{else if eq (string .Account.Status) "inactive"}}badge-inactive{{else}}badge-deleted{{end}}">
|
||||||
|
{{.Account.Status}}
|
||||||
|
</span>
|
||||||
|
{{if ne (string .Account.Status) "deleted"}}
|
||||||
|
{{if eq (string .Account.Status) "active"}}
|
||||||
|
<button class="btn btn-sm btn-secondary" style="margin-left:.5rem"
|
||||||
|
hx-patch="/accounts/{{.Account.UUID}}/status"
|
||||||
|
hx-vals='{"status":"inactive"}'
|
||||||
|
hx-target="#status-cell"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Deactivate this account?">Deactivate</button>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn btn-sm btn-secondary" style="margin-left:.5rem"
|
||||||
|
hx-patch="/accounts/{{.Account.UUID}}/status"
|
||||||
|
hx-vals='{"status":"active"}'
|
||||||
|
hx-target="#status-cell"
|
||||||
|
hx-swap="innerHTML">Activate</button>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
14
web/templates/fragments/audit_rows.html
Normal file
14
web/templates/fragments/audit_rows.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{{define "audit_rows"}}
|
||||||
|
{{range .Events}}
|
||||||
|
<tr>
|
||||||
|
<td class="text-small text-muted">{{formatTime .EventTime}}</td>
|
||||||
|
<td><code style="font-size:.8rem">{{.EventType}}</code></td>
|
||||||
|
<td class="text-small text-muted">{{if .ActorUsername}}{{.ActorUsername}}{{else}}—{{end}}</td>
|
||||||
|
<td class="text-small text-muted">{{if .TargetUsername}}{{.TargetUsername}}{{else}}—{{end}}</td>
|
||||||
|
<td class="text-small text-muted">{{.IPAddress}}</td>
|
||||||
|
<td class="text-small text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{.Details}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6" class="text-muted text-small" style="text-align:center;padding:2rem">No events found.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
3
web/templates/fragments/error.html
Normal file
3
web/templates/fragments/error.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{{define "error_fragment"}}
|
||||||
|
<div class="alert alert-error" role="alert">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
27
web/templates/fragments/roles_editor.html
Normal file
27
web/templates/fragments/roles_editor.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "roles_editor"}}
|
||||||
|
<div id="roles-editor">
|
||||||
|
<form hx-put="/accounts/{{.Account.UUID}}/roles"
|
||||||
|
hx-target="#roles-editor"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||||
|
<div class="d-flex gap-1 align-center" style="flex-wrap:wrap;margin-bottom:.75rem">
|
||||||
|
{{range .AllRoles}}
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;font-size:.875rem;cursor:pointer">
|
||||||
|
<input type="checkbox" name="roles" value="{{.}}"
|
||||||
|
{{if hasRole $.Roles .}}checked{{end}}>
|
||||||
|
{{.}}
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:.75rem">
|
||||||
|
<label style="font-size:.875rem;font-weight:600;display:block;margin-bottom:.25rem">Custom role</label>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<input class="form-control" type="text" name="custom_role" placeholder="e.g. editor"
|
||||||
|
style="max-width:200px;font-size:.875rem">
|
||||||
|
<span class="text-muted text-small" style="align-self:center">(optional)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">Save Roles</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
44
web/templates/fragments/token_list.html
Normal file
44
web/templates/fragments/token_list.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{{define "token_list"}}
|
||||||
|
<div id="token-list">
|
||||||
|
{{if .Tokens}}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>JTI</th><th>Issued</th><th>Expires</th><th>Status</th><th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Tokens}}
|
||||||
|
<tr id="token-row-{{truncateJTI .JTI}}">
|
||||||
|
<td><code style="font-size:.75rem">{{truncateJTI .JTI}}</code></td>
|
||||||
|
<td class="text-small text-muted">{{formatTime .IssuedAt}}</td>
|
||||||
|
<td class="text-small text-muted">{{formatTime .ExpiresAt}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .IsRevoked}}
|
||||||
|
<span class="badge badge-deleted">revoked</span>
|
||||||
|
{{else if .IsExpired}}
|
||||||
|
<span class="badge badge-inactive">expired</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-active">active</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if not .IsRevoked}}
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
hx-delete="/token/{{.JTI}}"
|
||||||
|
hx-target="#token-row-{{truncateJTI .JTI}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Revoke this token?">Revoke</button>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted text-small">No tokens issued.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
18
web/templates/fragments/totp_step.html
Normal file
18
web/templates/fragments/totp_step.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{{define "totp_step"}}
|
||||||
|
<form id="login-form" method="POST" action="/login"
|
||||||
|
hx-post="/login" hx-target="#login-form" hx-swap="outerHTML">
|
||||||
|
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||||
|
<input type="hidden" name="username" value="{{.Username}}">
|
||||||
|
<input type="hidden" name="password" value="{{.Password}}">
|
||||||
|
<input type="hidden" name="totp_step" value="1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp_code">Authenticator Code</label>
|
||||||
|
<input class="form-control" type="text" id="totp_code" name="totp_code"
|
||||||
|
autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]{6}"
|
||||||
|
maxlength="6" required autofocus placeholder="6-digit code">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" type="submit" style="width:100%">Verify</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user