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:
30
internal/ui/context.go
Normal file
30
internal/ui/context.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
)
|
||||
|
||||
// uiContextKey is the unexported type for UI context values, preventing
|
||||
// collisions with keys from other packages.
|
||||
type uiContextKey int
|
||||
|
||||
const (
|
||||
uiClaimsKey uiContextKey = iota
|
||||
)
|
||||
|
||||
// contextWithClaims stores validated JWT claims in the request context.
|
||||
func contextWithClaims(ctx context.Context, claims *token.Claims) context.Context {
|
||||
return context.WithValue(ctx, uiClaimsKey, claims)
|
||||
}
|
||||
|
||||
// claimsFromContext retrieves the JWT claims stored by requireCookieAuth.
|
||||
// Returns nil if no claims are present (unauthenticated request).
|
||||
func claimsFromContext(ctx context.Context) *token.Claims {
|
||||
c, ok := ctx.Value(uiClaimsKey).(*token.Claims)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
65
internal/ui/csrf.go
Normal file
65
internal/ui/csrf.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Package ui provides the HTMX-based management web interface for MCIAS.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
|
||||
//
|
||||
// Security design:
|
||||
// - The CSRF key is derived from the server master key via SHA-256 with a
|
||||
// domain-separation prefix, so it is unique to the UI CSRF function.
|
||||
// - The cookie value is 32 bytes of cryptographic random (non-HttpOnly so
|
||||
// HTMX can read it via JavaScript-free double-submit; SameSite=Strict
|
||||
// provides the primary CSRF defence for browser-initiated requests).
|
||||
// - The form/header value is HMAC-SHA256(key, cookieVal); this is what the
|
||||
// server verifies. An attacker cannot forge the HMAC without the key.
|
||||
// - Comparison uses crypto/subtle.ConstantTimeCompare to prevent timing attacks.
|
||||
type CSRFManager struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// newCSRFManager creates a CSRFManager whose key is derived from masterKey.
|
||||
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
|
||||
func newCSRFManager(masterKey []byte) *CSRFManager {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("mcias-ui-csrf-v1"))
|
||||
h.Write(masterKey)
|
||||
return &CSRFManager{key: h.Sum(nil)}
|
||||
}
|
||||
|
||||
// NewToken generates a fresh CSRF token pair.
|
||||
//
|
||||
// Returns:
|
||||
// - cookieVal: hex(32 random bytes) — stored in the mcias_csrf cookie
|
||||
// - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header
|
||||
func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err = rand.Read(raw); err != nil {
|
||||
return "", "", fmt.Errorf("csrf: generate random bytes: %w", err)
|
||||
}
|
||||
cookieVal = hex.EncodeToString(raw)
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
mac.Write([]byte(cookieVal))
|
||||
headerVal = hex.EncodeToString(mac.Sum(nil))
|
||||
return cookieVal, headerVal, nil
|
||||
}
|
||||
|
||||
// Validate verifies that headerVal is the correct HMAC of cookieVal.
|
||||
// Returns false on any mismatch or decoding error.
|
||||
func (c *CSRFManager) Validate(cookieVal, headerVal string) bool {
|
||||
if cookieVal == "" || headerVal == "" {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
mac.Write([]byte(cookieVal))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
// Security: constant-time comparison prevents timing oracle attacks.
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(headerVal)) == 1
|
||||
}
|
||||
400
internal/ui/handlers_accounts.go
Normal file
400
internal/ui/handlers_accounts.go
Normal file
@@ -0,0 +1,400 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
|
||||
var knownRoles = []string{"admin", "user", "service"}
|
||||
|
||||
// handleAccountsList renders the accounts list page.
|
||||
func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := u.db.ListAccounts()
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
|
||||
return
|
||||
}
|
||||
|
||||
u.render(w, "accounts", AccountsData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Accounts: accounts,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateAccount creates a new account and returns the account_row fragment.
|
||||
func (u *UIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
accountTypeStr := r.FormValue("account_type")
|
||||
|
||||
if username == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "username is required")
|
||||
return
|
||||
}
|
||||
|
||||
accountType := model.AccountTypeHuman
|
||||
if accountTypeStr == string(model.AccountTypeSystem) {
|
||||
accountType = model.AccountTypeSystem
|
||||
}
|
||||
|
||||
var passwordHash string
|
||||
if password != "" {
|
||||
argonCfg := auth.ArgonParams{
|
||||
Time: u.cfg.Argon2.Time,
|
||||
Memory: u.cfg.Argon2.Memory,
|
||||
Threads: u.cfg.Argon2.Threads,
|
||||
}
|
||||
var err error
|
||||
passwordHash, err = auth.HashPassword(password, argonCfg)
|
||||
if err != nil {
|
||||
u.logger.Error("hash password", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
} else if accountType == model.AccountTypeHuman {
|
||||
u.renderError(w, r, http.StatusBadRequest, "password is required for human accounts")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err == nil {
|
||||
actorID = &acct.ID
|
||||
}
|
||||
}
|
||||
|
||||
acct, err := u.db.CreateAccount(username, accountType, passwordHash)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create account: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventAccountCreated, actorID, &acct.ID,
|
||||
fmt.Sprintf(`{"username":%q,"type":%q}`, username, accountType))
|
||||
|
||||
u.render(w, "account_row", acct)
|
||||
}
|
||||
|
||||
// handleAccountDetail renders the account detail page.
|
||||
func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := u.db.ListTokensForAccount(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("list tokens for account", "error", err)
|
||||
tokens = nil
|
||||
}
|
||||
|
||||
u.render(w, "account_detail", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
Roles: roles,
|
||||
AllRoles: knownRoles,
|
||||
Tokens: tokens,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateAccountStatus toggles an account between active and inactive.
|
||||
func (u *UIServer) handleUpdateAccountStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
statusStr := r.FormValue("status")
|
||||
var newStatus model.AccountStatus
|
||||
switch statusStr {
|
||||
case string(model.AccountStatusActive):
|
||||
newStatus = model.AccountStatusActive
|
||||
case string(model.AccountStatusInactive):
|
||||
newStatus = model.AccountStatusInactive
|
||||
default:
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.UpdateAccountStatus(acct.ID, newStatus); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
acct.Status = newStatus
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventAccountUpdated, actorID, &acct.ID,
|
||||
fmt.Sprintf(`{"status":%q}`, newStatus))
|
||||
|
||||
// Respond with the updated row (for HTMX outerHTML swap on accounts list)
|
||||
// or updated status cell (for HTMX innerHTML swap on account detail).
|
||||
// The hx-target in accounts.html targets the whole <tr>; in account_detail.html
|
||||
// it targets #status-cell. We detect which by checking the request path context.
|
||||
if strings.Contains(r.Header.Get("HX-Target"), "status-cell") || r.Header.Get("HX-Target") == "status-cell" {
|
||||
data := AccountDetailData{
|
||||
PageData: PageData{CSRFToken: ""},
|
||||
Account: acct,
|
||||
}
|
||||
u.render(w, "account_status", data)
|
||||
} else {
|
||||
u.render(w, "account_row", acct)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteAccount soft-deletes an account and returns empty body (HTMX removes row).
|
||||
func (u *UIServer) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke all active tokens for the deleted account.
|
||||
if err := u.db.RevokeAllUserTokens(acct.ID, "account_deleted"); err != nil {
|
||||
u.logger.Warn("revoke tokens for deleted account", "account_id", acct.ID, "error", err)
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventAccountDeleted, actorID, &acct.ID, "")
|
||||
|
||||
// Return empty body; HTMX will remove the row via hx-swap="outerHTML".
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleRolesEditForm returns the roles editor fragment (GET — no CSRF check needed).
|
||||
func (u *UIServer) handleRolesEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
||||
return
|
||||
}
|
||||
|
||||
u.render(w, "roles_editor", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
Roles: roles,
|
||||
AllRoles: knownRoles,
|
||||
})
|
||||
}
|
||||
|
||||
// handleSetRoles replaces the full role set for an account.
|
||||
func (u *UIServer) handleSetRoles(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Collect checked roles + optional custom role.
|
||||
roles := r.Form["roles"] // multi-value from checkboxes
|
||||
if custom := strings.TrimSpace(r.FormValue("custom_role")); custom != "" {
|
||||
roles = append(roles, custom)
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
if err := u.db.SetRoles(acct.ID, roles, actorID); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to set roles")
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventRoleGranted, actorID, &acct.ID,
|
||||
fmt.Sprintf(`{"roles":%q}`, strings.Join(roles, ",")))
|
||||
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
csrfToken = ""
|
||||
}
|
||||
|
||||
u.render(w, "roles_editor", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
Roles: roles,
|
||||
AllRoles: knownRoles,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRevokeToken revokes a specific token by JTI.
|
||||
func (u *UIServer) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
jti := r.PathValue("jti")
|
||||
if jti == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "missing JTI")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.RevokeToken(jti, "ui_revoke"); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "revoke failed")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventTokenRevoked, actorID, nil,
|
||||
fmt.Sprintf(`{"jti":%q,"reason":"ui_revoke"}`, jti))
|
||||
|
||||
// Return empty body; HTMX removes the row.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleIssueSystemToken issues a long-lived service token for a system account.
|
||||
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeSystem {
|
||||
u.renderError(w, r, http.StatusBadRequest, "only system accounts can have service tokens")
|
||||
return
|
||||
}
|
||||
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
|
||||
return
|
||||
}
|
||||
|
||||
expiry := u.cfg.ServiceExpiry()
|
||||
tokenStr, claims, err := u.issueToken(acct.UUID, roles, expiry)
|
||||
if err != nil {
|
||||
u.logger.Error("issue system token", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to issue token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||
u.logger.Error("track system token", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to track token")
|
||||
return
|
||||
}
|
||||
|
||||
// Store as system token for easy retrieval.
|
||||
if err := u.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
|
||||
u.logger.Warn("set system token record", "error", err)
|
||||
}
|
||||
|
||||
actorClaims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if actorClaims != nil {
|
||||
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
|
||||
if err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
|
||||
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
|
||||
|
||||
// Re-fetch token list including the new token.
|
||||
tokens, err := u.db.ListTokensForAccount(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("list tokens after issue", "error", err)
|
||||
tokens = nil
|
||||
}
|
||||
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
csrfToken = ""
|
||||
}
|
||||
|
||||
// Flash the raw token once at the top so the operator can copy it.
|
||||
u.render(w, "token_list", AccountDetailData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr),
|
||||
},
|
||||
Account: acct,
|
||||
Tokens: tokens,
|
||||
})
|
||||
}
|
||||
100
internal/ui/handlers_audit.go
Normal file
100
internal/ui/handlers_audit.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
const auditPageSize = 50
|
||||
|
||||
// auditEventTypes lists all known event types for the filter dropdown.
|
||||
var auditEventTypes = []string{
|
||||
model.EventLoginOK,
|
||||
model.EventLoginFail,
|
||||
model.EventLoginTOTPFail,
|
||||
model.EventTokenIssued,
|
||||
model.EventTokenRenewed,
|
||||
model.EventTokenRevoked,
|
||||
model.EventTokenExpired,
|
||||
model.EventAccountCreated,
|
||||
model.EventAccountUpdated,
|
||||
model.EventAccountDeleted,
|
||||
model.EventRoleGranted,
|
||||
model.EventRoleRevoked,
|
||||
model.EventTOTPEnrolled,
|
||||
model.EventTOTPRemoved,
|
||||
model.EventPGCredAccessed,
|
||||
model.EventPGCredUpdated,
|
||||
}
|
||||
|
||||
// handleAuditPage renders the full audit log page with the first page embedded.
|
||||
func (u *UIServer) handleAuditPage(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := u.buildAuditData(r, 1, csrfToken)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load audit log")
|
||||
return
|
||||
}
|
||||
|
||||
u.render(w, "audit", data)
|
||||
}
|
||||
|
||||
// handleAuditRows returns only the <tbody> rows fragment for HTMX partial updates.
|
||||
func (u *UIServer) handleAuditRows(w http.ResponseWriter, r *http.Request) {
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
data, err := u.buildAuditData(r, page, "")
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load audit log")
|
||||
return
|
||||
}
|
||||
|
||||
u.render(w, "audit_rows", data)
|
||||
}
|
||||
|
||||
// buildAuditData fetches one page of audit events and builds AuditData.
|
||||
func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) (AuditData, error) {
|
||||
filterType := r.URL.Query().Get("event_type")
|
||||
offset := (page - 1) * auditPageSize
|
||||
|
||||
params := db.AuditQueryParams{
|
||||
Limit: auditPageSize,
|
||||
Offset: offset,
|
||||
EventType: filterType,
|
||||
}
|
||||
|
||||
events, total, err := u.db.ListAuditEventsPaged(params)
|
||||
if err != nil {
|
||||
return AuditData{}, err
|
||||
}
|
||||
|
||||
totalPages := int(total) / auditPageSize
|
||||
if int(total)%auditPageSize != 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return AuditData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Events: events,
|
||||
EventTypes: auditEventTypes,
|
||||
FilterType: filterType,
|
||||
Total: total,
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
183
internal/ui/handlers_auth.go
Normal file
183
internal/ui/handlers_auth.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
)
|
||||
|
||||
// handleLoginPage renders the login form.
|
||||
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, "login", LoginData{})
|
||||
}
|
||||
|
||||
// handleLoginPost processes username+password (and optional TOTP code).
|
||||
//
|
||||
// Security design:
|
||||
// - Password is verified via Argon2id on every request, including the TOTP
|
||||
// second step, to prevent credential-bypass by jumping to TOTP directly.
|
||||
// - Timing is held constant regardless of whether the account exists, by
|
||||
// always running a dummy Argon2 check for unknown accounts.
|
||||
// - On TOTP required: returns the totp_step fragment (200) so HTMX swaps the
|
||||
// form in place. The username and password are included as hidden fields;
|
||||
// they are re-verified on the TOTP submission.
|
||||
// - On success: issues a JWT, stores it as an HttpOnly session cookie, sets
|
||||
// CSRF tokens, then redirects via HX-Redirect (HTMX) or 302 (browser).
|
||||
func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.render(w, "totp_step", LoginData{Error: "invalid form submission"})
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
totpCode := r.FormValue("totp_code")
|
||||
|
||||
if username == "" || password == "" {
|
||||
u.render(w, "login", LoginData{Error: "username and password are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load account by username.
|
||||
acct, err := u.db.GetAccountByUsername(username)
|
||||
if err != nil {
|
||||
// Security: always run dummy Argon2 to prevent timing-based user enumeration.
|
||||
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
u.writeAudit(r, model.EventLoginFail, nil, nil,
|
||||
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, username))
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check account status before credential verification.
|
||||
if acct.Status != model.AccountStatusActive {
|
||||
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password. Always run even if TOTP step, to prevent bypass.
|
||||
ok, err := auth.VerifyPassword(password, acct.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// TOTP check.
|
||||
if acct.TOTPRequired {
|
||||
if totpCode == "" {
|
||||
// Return TOTP step fragment so HTMX swaps the form.
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Username: username,
|
||||
// Security: password is embedded as a hidden form field so the
|
||||
// second submission can re-verify it. It is never logged.
|
||||
Password: password,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Decrypt and validate TOTP secret.
|
||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
if err != nil {
|
||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
valid, err := auth.ValidateTOTP(secret, totpCode)
|
||||
if err != nil || !valid {
|
||||
u.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
||||
u.render(w, "totp_step", LoginData{
|
||||
Error: "invalid TOTP code",
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Determine token expiry based on admin role.
|
||||
expiry := u.cfg.DefaultExpiry()
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if rol == "admin" {
|
||||
expiry = u.cfg.AdminExpiry()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||
if err != nil {
|
||||
u.logger.Error("issue token", "error", err)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||
u.logger.Error("track token", "error", err)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: set session cookie as HttpOnly, Secure, SameSite=Strict.
|
||||
// Path=/ so it is sent on all UI routes (not just /ui/*).
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: tokenStr,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: claims.ExpiresAt,
|
||||
})
|
||||
|
||||
// Set CSRF tokens for subsequent requests.
|
||||
if _, err := u.setCSRFCookies(w); err != nil {
|
||||
u.logger.Error("set CSRF cookie", "error", err)
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
|
||||
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
||||
fmt.Sprintf(`{"jti":%q,"via":"ui"}`, claims.JTI))
|
||||
|
||||
// Redirect to dashboard.
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleLogout revokes the session token and clears the cookie.
|
||||
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err == nil && cookie.Value != "" {
|
||||
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
if err == nil {
|
||||
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
||||
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
|
||||
}
|
||||
u.writeAudit(r, model.EventTokenRevoked, nil, nil,
|
||||
fmt.Sprintf(`{"jti":%q,"reason":"ui_logout"}`, claims.JTI))
|
||||
}
|
||||
}
|
||||
u.clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// writeAudit is a fire-and-forget audit log helper for the UI package.
|
||||
func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
||||
ip := clientIP(r)
|
||||
if err := u.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
||||
u.logger.Warn("write audit event", "type", eventType, "error", err)
|
||||
}
|
||||
}
|
||||
45
internal/ui/handlers_dashboard.go
Normal file
45
internal/ui/handlers_dashboard.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// handleDashboard renders the main dashboard page with account counts and recent events.
|
||||
func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
u.logger.Error("set CSRF cookies", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := u.db.ListAccounts()
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
|
||||
return
|
||||
}
|
||||
|
||||
var total, active int
|
||||
for _, a := range accounts {
|
||||
total++
|
||||
if a.Status == model.AccountStatusActive {
|
||||
active++
|
||||
}
|
||||
}
|
||||
|
||||
events, _, err := u.db.ListAuditEventsPaged(db.AuditQueryParams{Limit: 10, Offset: 0})
|
||||
if err != nil {
|
||||
u.logger.Warn("load recent audit events", "error", err)
|
||||
events = nil
|
||||
}
|
||||
|
||||
u.render(w, "dashboard", DashboardData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
TotalAccounts: total,
|
||||
ActiveAccounts: active,
|
||||
RecentEvents: events,
|
||||
})
|
||||
}
|
||||
20
internal/ui/session.go
Normal file
20
internal/ui/session.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
)
|
||||
|
||||
// validateSessionToken wraps token.ValidateToken for use by UI session middleware.
|
||||
// Security: identical validation pipeline as the REST API — alg check, signature,
|
||||
// expiry, issuer, revocation (revocation checked by caller).
|
||||
func validateSessionToken(pubKey ed25519.PublicKey, tokenStr, issuer string) (*token.Claims, error) {
|
||||
return token.ValidateToken(pubKey, tokenStr, issuer)
|
||||
}
|
||||
|
||||
// issueToken is a convenience method for issuing a signed JWT.
|
||||
func (u *UIServer) issueToken(subject string, roles []string, expiry time.Duration) (string, *token.Claims, error) {
|
||||
return token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, subject, roles, expiry)
|
||||
}
|
||||
365
internal/ui/ui.go
Normal file
365
internal/ui/ui.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// Package ui provides the HTMX-based management web interface for MCIAS.
|
||||
//
|
||||
// Security design:
|
||||
// - Session tokens are stored as HttpOnly, Secure, SameSite=Strict cookies.
|
||||
// They are never accessible to JavaScript.
|
||||
// - CSRF protection uses the HMAC-signed Double-Submit Cookie pattern.
|
||||
// The mcias_csrf cookie is non-HttpOnly so HTMX can include it in the
|
||||
// X-CSRF-Token header on every mutating request. SameSite=Strict is the
|
||||
// primary browser-level CSRF defence.
|
||||
// - UI handlers call internal Go functions directly — no internal HTTP round-trips.
|
||||
// - All templates are parsed once at startup via embed.FS; no dynamic template
|
||||
// loading from disk at request time.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
//go:embed all:../../web/templates
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed all:../../web/static
|
||||
var staticFS embed.FS
|
||||
|
||||
const (
|
||||
sessionCookieName = "mcias_session"
|
||||
csrfCookieName = "mcias_csrf"
|
||||
)
|
||||
|
||||
// UIServer serves the HTMX-based management UI.
|
||||
type UIServer struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// New constructs a UIServer, parses all templates, and returns it.
|
||||
// Returns an error if template parsing fails.
|
||||
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
|
||||
csrf := newCSRFManager(masterKey)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"formatTime": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"truncateJTI": func(jti string) string {
|
||||
if len(jti) > 8 {
|
||||
return jti[:8]
|
||||
}
|
||||
return jti
|
||||
},
|
||||
"string": func(v interface{}) string {
|
||||
switch s := v.(type) {
|
||||
case model.AccountStatus:
|
||||
return string(s)
|
||||
case model.AccountType:
|
||||
return string(s)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
},
|
||||
"hasRole": func(roles []string, role string) bool {
|
||||
for _, r := range roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
"not": func(b bool) bool { return !b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"gt": func(a, b int) bool { return a > b },
|
||||
"lt": func(a, b int) bool { return a < b },
|
||||
}
|
||||
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/login.html",
|
||||
"web/templates/dashboard.html",
|
||||
"web/templates/accounts.html",
|
||||
"web/templates/account_detail.html",
|
||||
"web/templates/audit.html",
|
||||
"web/templates/fragments/account_row.html",
|
||||
"web/templates/fragments/account_status.html",
|
||||
"web/templates/fragments/roles_editor.html",
|
||||
"web/templates/fragments/token_list.html",
|
||||
"web/templates/fragments/totp_step.html",
|
||||
"web/templates/fragments/error.html",
|
||||
"web/templates/fragments/audit_rows.html",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ui: parse templates: %w", err)
|
||||
}
|
||||
|
||||
return &UIServer{
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
pubKey: pub,
|
||||
privKey: priv,
|
||||
masterKey: masterKey,
|
||||
logger: logger,
|
||||
csrf: csrf,
|
||||
tmpl: tmpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register attaches all UI routes to mux.
|
||||
func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
// Static assets — serve from the web/static/ sub-directory of the embed.
|
||||
staticSubFS, err := fs.Sub(staticFS, "web/static")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ui: static sub-FS: %v", err))
|
||||
}
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||
|
||||
// Redirect root to login.
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// Auth routes (no session required).
|
||||
mux.HandleFunc("GET /login", u.handleLoginPage)
|
||||
mux.HandleFunc("POST /login", u.handleLoginPost)
|
||||
mux.HandleFunc("POST /logout", u.handleLogout)
|
||||
|
||||
// Protected routes.
|
||||
auth := u.requireCookieAuth
|
||||
admin := func(h http.HandlerFunc) http.Handler {
|
||||
return auth(u.requireCSRF(http.HandlerFunc(h)))
|
||||
}
|
||||
adminGet := func(h http.HandlerFunc) http.Handler {
|
||||
return auth(http.HandlerFunc(h))
|
||||
}
|
||||
|
||||
mux.Handle("GET /dashboard", adminGet(u.handleDashboard))
|
||||
mux.Handle("GET /accounts", adminGet(u.handleAccountsList))
|
||||
mux.Handle("POST /accounts", admin(u.handleCreateAccount))
|
||||
mux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
|
||||
mux.Handle("PATCH /accounts/{id}/status", admin(u.handleUpdateAccountStatus))
|
||||
mux.Handle("DELETE /accounts/{id}", admin(u.handleDeleteAccount))
|
||||
mux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
|
||||
mux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
|
||||
mux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
|
||||
mux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
|
||||
mux.Handle("GET /audit", adminGet(u.handleAuditPage))
|
||||
mux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
||||
}
|
||||
|
||||
// ---- Middleware ----
|
||||
|
||||
// requireCookieAuth validates the mcias_session cookie and injects claims.
|
||||
// On failure, HTMX requests get HX-Redirect; browser requests get a redirect.
|
||||
func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
u.redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
if err != nil {
|
||||
u.clearSessionCookie(w)
|
||||
u.redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check revocation.
|
||||
rec, err := u.db.GetTokenRecord(claims.JTI)
|
||||
if err != nil || rec.IsRevoked() {
|
||||
u.clearSessionCookie(w)
|
||||
u.redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := contextWithClaims(r.Context(), claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// requireCSRF validates the CSRF token on mutating requests (POST/PUT/PATCH/DELETE).
|
||||
// The token is read from X-CSRF-Token header (HTMX) or _csrf form field (fallback).
|
||||
func (u *UIServer) requireCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
http.Error(w, "CSRF cookie missing", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Header takes precedence (HTMX sets it automatically via hx-headers on body).
|
||||
formVal := r.Header.Get("X-CSRF-Token")
|
||||
if formVal == "" {
|
||||
// Fallback: parse form and read _csrf field.
|
||||
if parseErr := r.ParseForm(); parseErr == nil {
|
||||
formVal = r.FormValue("_csrf")
|
||||
}
|
||||
}
|
||||
|
||||
if !u.csrf.Validate(cookie.Value, formVal) {
|
||||
http.Error(w, "CSRF token invalid", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
// isHTMX reports whether the request was initiated by HTMX.
|
||||
func isHTMX(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
// redirectToLogin redirects to the login page, using HX-Redirect for HTMX.
|
||||
func (u *UIServer) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// clearSessionCookie expires the session cookie.
|
||||
func (u *UIServer) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// setCSRFCookies sets the mcias_csrf cookie and returns the header value to
|
||||
// embed in the page/form.
|
||||
func (u *UIServer) setCSRFCookies(w http.ResponseWriter) (string, error) {
|
||||
cookieVal, headerVal, err := u.csrf.NewToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: cookieVal,
|
||||
Path: "/",
|
||||
// Security: non-HttpOnly so that HTMX can embed it in hx-headers;
|
||||
// SameSite=Strict is the primary CSRF defence for browser requests.
|
||||
HttpOnly: false,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return headerVal, nil
|
||||
}
|
||||
|
||||
// render executes the named template, writing the result to w.
|
||||
// Renders to a buffer first so partial template failures don't corrupt output.
|
||||
func (u *UIServer) render(w http.ResponseWriter, name string, data interface{}) {
|
||||
var buf bytes.Buffer
|
||||
if err := u.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
||||
u.logger.Error("template render error", "template", name, "error", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// renderError returns an error response appropriate for the request type.
|
||||
func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = fmt.Fprintf(w, `<div class="alert alert-error" role="alert">%s</div>`, template.HTMLEscapeString(msg))
|
||||
return
|
||||
}
|
||||
http.Error(w, msg, status)
|
||||
}
|
||||
|
||||
// clientIP extracts the client IP from RemoteAddr (best effort).
|
||||
func clientIP(r *http.Request) string {
|
||||
addr := r.RemoteAddr
|
||||
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
||||
return addr[:idx]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// ---- Page data types ----
|
||||
|
||||
// PageData is embedded in all page-level view structs.
|
||||
type PageData struct {
|
||||
CSRFToken string
|
||||
Flash string
|
||||
Error string
|
||||
}
|
||||
|
||||
// LoginData is the view model for the login page.
|
||||
type LoginData struct {
|
||||
Error string
|
||||
Username string // pre-filled on TOTP step
|
||||
Password string // pre-filled on TOTP step (value attr, never logged)
|
||||
}
|
||||
|
||||
// DashboardData is the view model for the dashboard page.
|
||||
type DashboardData struct {
|
||||
PageData
|
||||
TotalAccounts int
|
||||
ActiveAccounts int
|
||||
RecentEvents []*db.AuditEventView
|
||||
}
|
||||
|
||||
// AccountsData is the view model for the accounts list page.
|
||||
type AccountsData struct {
|
||||
PageData
|
||||
Accounts []*model.Account
|
||||
}
|
||||
|
||||
// AccountDetailData is the view model for the account detail page.
|
||||
type AccountDetailData struct {
|
||||
PageData
|
||||
Account *model.Account
|
||||
Roles []string
|
||||
AllRoles []string
|
||||
Tokens []*model.TokenRecord
|
||||
}
|
||||
|
||||
// AuditData is the view model for the audit log page.
|
||||
type AuditData struct {
|
||||
PageData
|
||||
Events []*db.AuditEventView
|
||||
EventTypes []string
|
||||
FilterType string
|
||||
Total int64
|
||||
Page int
|
||||
TotalPages int
|
||||
}
|
||||
Reference in New Issue
Block a user