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:
2026-03-11 18:02:53 -07:00
parent 0c441f5c4f
commit a80242ae3e
21 changed files with 1425 additions and 20 deletions

3
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) §23 for the full security model. See [ARCHITECTURE.md](ARCHITECTURE.md) §23 for the full security model.

View File

@@ -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
View 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
View 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
}

View 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,
})
}

View 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
}

View 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)
}
}

View 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
View 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
View 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
}

View 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}}

View 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}}

View 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}}&mdash;{{end}}</td>
<td class="text-small text-muted">{{if .TargetUsername}}{{.TargetUsername}}{{else}}&mdash;{{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}}

View File

@@ -0,0 +1,3 @@
{{define "error_fragment"}}
<div class="alert alert-error" role="alert">{{.Error}}</div>
{{end}}

View 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}}

View 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}}

View 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}}