From a80242ae3e4d1dace25150cc62262d023a8fd623 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 11 Mar 2026 18:02:53 -0700 Subject: [PATCH] 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. --- .gitignore | 3 + ARCHITECTURE.md | 38 +- PROGRESS.md | 10 +- PROJECT.md | 6 +- README.md | 6 +- internal/server/server.go | 8 + internal/ui/context.go | 30 ++ internal/ui/csrf.go | 65 ++++ internal/ui/handlers_accounts.go | 400 ++++++++++++++++++++ internal/ui/handlers_audit.go | 100 +++++ internal/ui/handlers_auth.go | 183 +++++++++ internal/ui/handlers_dashboard.go | 45 +++ internal/ui/session.go | 20 + internal/ui/ui.go | 365 ++++++++++++++++++ web/templates/fragments/account_row.html | 39 ++ web/templates/fragments/account_status.html | 21 + web/templates/fragments/audit_rows.html | 14 + web/templates/fragments/error.html | 3 + web/templates/fragments/roles_editor.html | 27 ++ web/templates/fragments/token_list.html | 44 +++ web/templates/fragments/totp_step.html | 18 + 21 files changed, 1425 insertions(+), 20 deletions(-) create mode 100644 internal/ui/context.go create mode 100644 internal/ui/csrf.go create mode 100644 internal/ui/handlers_accounts.go create mode 100644 internal/ui/handlers_audit.go create mode 100644 internal/ui/handlers_auth.go create mode 100644 internal/ui/handlers_dashboard.go create mode 100644 internal/ui/session.go create mode 100644 internal/ui/ui.go create mode 100644 web/templates/fragments/account_row.html create mode 100644 web/templates/fragments/account_status.html create mode 100644 web/templates/fragments/audit_rows.html create mode 100644 web/templates/fragments/error.html create mode 100644 web/templates/fragments/roles_editor.html create mode 100644 web/templates/fragments/token_list.html create mode 100644 web/templates/fragments/totp_step.html diff --git a/.gitignore b/.gitignore index f93719b..28f77b8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ clients/python/tests/__pycache__/ clients/python/.pytest_cache/ clients/python/*.egg-info/ clients/lisp/**/*.fasl + +# manual testing +/run/ \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d9f63a8..04ebc9c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -88,7 +88,7 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest. | 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 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. | | @@ -278,8 +278,8 @@ All endpoints use JSON request/response bodies. All responses include a | Method | Path | Auth required | Description | |---|---|---|---| | 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 | -| DELETE | `/v1/token/{jti}` | admin JWT or role-scoped JWT | Revoke token by JTI | +| POST | `/v1/token/issue` | admin JWT | Issue service account token | +| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI | ### Account Endpoints (admin only) @@ -310,9 +310,15 @@ All endpoints use JSON request/response bodies. All responses include a | 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 | +### Audit Endpoints (admin only) + +| Method | Path | Auth required | Description | +|---|---|---|---| +| GET | `/v1/audit` | admin JWT | List audit log events | + ### Admin / Server Endpoints | Method | Path | Auth required | Description | @@ -335,8 +341,11 @@ CREATE TABLE server_config ( id INTEGER PRIMARY KEY CHECK (id = 1), -- Ed25519 private key, PEM PKCS#8, encrypted at rest with AES-256-GCM -- using a master key derived from the startup passphrase. - signing_key_enc BLOB NOT NULL, - signing_key_nonce BLOB NOT NULL, + signing_key_enc BLOB, + 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')), 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 master key held only in server memory (derived at startup from a passphrase or keyfile). The nonce is stored adjacent to the ciphertext. +- The master key salt is stored in `server_config.master_key_salt` so the + Argon2id KDF produces the same key on every restart. Generated on first run. - The signing key encryption is layered: the Ed25519 private key is wrapped with AES-256-GCM using the startup master key. Operators must supply the passphrase/keyfile on each server restart. @@ -472,6 +483,7 @@ or a keyfile path — never inline in the config file. ```toml [server] 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_key = "/etc/mcias/server.key" @@ -518,7 +530,11 @@ mcias/ │ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit) │ ├── model/ # shared data types (Account, Token, Role, etc.) │ ├── 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/ │ └── mcias/v1/ # Protobuf service definitions (Phase 7) ├── gen/ @@ -798,7 +814,7 @@ mciassrv starts both listeners in the same process: │ ┌────────────────┐ ┌────────────────────┐ │ │ │ REST listener │ │ gRPC listener │ │ │ │ (net/http) │ │ (google.golang. │ │ -│ │ :8443 │ │ org/grpc) :8444 │ │ +│ │ :8443 │ │ org/grpc) :9443 │ │ │ └───────┬─────────┘ └──────────┬─────────┘ │ │ └──────────────┬─────────┘ │ │ ▼ │ @@ -818,7 +834,7 @@ configured window. ```toml [server] 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_key = "/etc/mcias/server.key" ``` @@ -916,7 +932,7 @@ FROM debian:bookworm-slim 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 - TLS termination happens inside the container (same cert/key as bare-metal 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/` | | `install` | Run `dist/install.sh` | | `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 | ### Upgrade Path diff --git a/PROGRESS.md b/PROGRESS.md index 6ed561d..d6a3ba7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ 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 conditions (go test -race ./...). - [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}` - Token state guarded by `sync.RWMutex` for concurrent safety - 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 - Crate: `mcias-client`; tokio async, reqwest + rustls-tls (no OpenSSL dep) - `MciasError` enum via `thiserror`; `Arc>>` 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 - ASDF system `mcias-client`; HTTP via dexador, JSON via yason - CLOS class `mcias-client`; plain functions for all operations - Conditions: `mcias-error` base + 6 typed subclasses - 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 to `t`/`nil` before returning @@ -50,7 +50,7 @@ conditions (go test -race ./...). - Package `mcias_client` (setuptools, pyproject.toml); dep: `httpx >= 0.27` - `Client` context manager; `py.typed` marker; all symbols fully annotated - 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 - `Server` struct with `sync.RWMutex`; used by Go client integration test diff --git a/PROJECT.md b/PROJECT.md index 3f928f8..ab22e05 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -55,10 +55,14 @@ Performance is secondary, and can be tuned later. critical for this. + We will also need to build client libraries in several languages 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. + mciasctl is the tool for admins to create and manage accounts, issue 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 diff --git a/README.md b/README.md index 0359421..03a7ba8 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ make docker # build Docker image mcias: ```sh TOKEN=$(curl -sk https://localhost:8443/v1/auth/login \ -d '{"username":"admin","password":"..."}' | jq -r .token) -export MCIAS_TOKEN=$token +export MCIAS_TOKEN=$TOKEN mciasctl -server https://localhost:8443 account list mciasctl account create -username alice -password s3cr3t @@ -192,7 +192,7 @@ See `man mciasctl` for the full reference. ```sh export MCIAS_MASTER_PASSPHRASE=your-passphrase -CONF<�--config /etc/mcias/mcias.conf +CONF="--config /etc/mcias/mcias.conf" mciasdb $CONF schema verify mciasdb $CONF account list @@ -288,4 +288,4 @@ man mciasgrpcctl # gRPC admin CLI - Credential fields never appear in any API response. - 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. diff --git a/internal/server/server.go b/internal/server/server.go index 3d40eca..005f0c8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,6 +24,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/token" + "git.wntrmute.dev/kyle/mcias/internal/ui" ) // 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("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. var root http.Handler = mux root = middleware.RequestLogger(s.logger)(root) diff --git a/internal/ui/context.go b/internal/ui/context.go new file mode 100644 index 0000000..6511650 --- /dev/null +++ b/internal/ui/context.go @@ -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 +} diff --git a/internal/ui/csrf.go b/internal/ui/csrf.go new file mode 100644 index 0000000..ce39b04 --- /dev/null +++ b/internal/ui/csrf.go @@ -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 +} diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go new file mode 100644 index 0000000..5cd52ea --- /dev/null +++ b/internal/ui/handlers_accounts.go @@ -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 ; 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, + }) +} diff --git a/internal/ui/handlers_audit.go b/internal/ui/handlers_audit.go new file mode 100644 index 0000000..ff13c1a --- /dev/null +++ b/internal/ui/handlers_audit.go @@ -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 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 +} diff --git a/internal/ui/handlers_auth.go b/internal/ui/handlers_auth.go new file mode 100644 index 0000000..54f513e --- /dev/null +++ b/internal/ui/handlers_auth.go @@ -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) + } +} diff --git a/internal/ui/handlers_dashboard.go b/internal/ui/handlers_dashboard.go new file mode 100644 index 0000000..d694fc0 --- /dev/null +++ b/internal/ui/handlers_dashboard.go @@ -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, + }) +} diff --git a/internal/ui/session.go b/internal/ui/session.go new file mode 100644 index 0000000..56bbefc --- /dev/null +++ b/internal/ui/session.go @@ -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) +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..884673c --- /dev/null +++ b/internal/ui/ui.go @@ -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, ``, 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 +} diff --git a/web/templates/fragments/account_row.html b/web/templates/fragments/account_row.html new file mode 100644 index 0000000..f77992e --- /dev/null +++ b/web/templates/fragments/account_row.html @@ -0,0 +1,39 @@ +{{define "account_row"}} + + {{.Username}} + {{.AccountType}} + + + {{.Status}} + + + {{if .TOTPRequired}}Yes{{else}}No{{end}} + {{formatTime .CreatedAt}} + +
+ View + {{if eq (string .Status) "active"}} + + {{else if eq (string .Status) "inactive"}} + + {{end}} + {{if ne (string .Status) "deleted"}} + + {{end}} +
+ + +{{end}} diff --git a/web/templates/fragments/account_status.html b/web/templates/fragments/account_status.html new file mode 100644 index 0000000..960c0f6 --- /dev/null +++ b/web/templates/fragments/account_status.html @@ -0,0 +1,21 @@ +{{define "account_status"}} + + {{.Account.Status}} + +{{if ne (string .Account.Status) "deleted"}} +{{if eq (string .Account.Status) "active"}} + +{{else}} + +{{end}} +{{end}} +{{end}} diff --git a/web/templates/fragments/audit_rows.html b/web/templates/fragments/audit_rows.html new file mode 100644 index 0000000..f088982 --- /dev/null +++ b/web/templates/fragments/audit_rows.html @@ -0,0 +1,14 @@ +{{define "audit_rows"}} +{{range .Events}} + + {{formatTime .EventTime}} + {{.EventType}} + {{if .ActorUsername}}{{.ActorUsername}}{{else}}—{{end}} + {{if .TargetUsername}}{{.TargetUsername}}{{else}}—{{end}} + {{.IPAddress}} + {{.Details}} + +{{else}} +No events found. +{{end}} +{{end}} diff --git a/web/templates/fragments/error.html b/web/templates/fragments/error.html new file mode 100644 index 0000000..da9d79d --- /dev/null +++ b/web/templates/fragments/error.html @@ -0,0 +1,3 @@ +{{define "error_fragment"}} + +{{end}} diff --git a/web/templates/fragments/roles_editor.html b/web/templates/fragments/roles_editor.html new file mode 100644 index 0000000..588bc8f --- /dev/null +++ b/web/templates/fragments/roles_editor.html @@ -0,0 +1,27 @@ +{{define "roles_editor"}} +
+
+ +
+ {{range .AllRoles}} + + {{end}} +
+
+ +
+ + (optional) +
+
+ +
+
+{{end}} diff --git a/web/templates/fragments/token_list.html b/web/templates/fragments/token_list.html new file mode 100644 index 0000000..eb57cf7 --- /dev/null +++ b/web/templates/fragments/token_list.html @@ -0,0 +1,44 @@ +{{define "token_list"}} +
+ {{if .Tokens}} +
+ + + + + + + + {{range .Tokens}} + + + + + + + + {{end}} + +
JTIIssuedExpiresStatusAction
{{truncateJTI .JTI}}{{formatTime .IssuedAt}}{{formatTime .ExpiresAt}} + {{if .IsRevoked}} + revoked + {{else if .IsExpired}} + expired + {{else}} + active + {{end}} + + {{if not .IsRevoked}} + + {{end}} +
+
+ {{else}} +

No tokens issued.

+ {{end}} +
+{{end}} diff --git a/web/templates/fragments/totp_step.html b/web/templates/fragments/totp_step.html new file mode 100644 index 0000000..3cce119 --- /dev/null +++ b/web/templates/fragments/totp_step.html @@ -0,0 +1,18 @@ +{{define "totp_step"}} +
+ {{if .Error}}{{end}} + + + +
+ + +
+
+ +
+
+{{end}}