Phase 14: Full WebAuthn support for passwordless passkey login and hardware security key 2FA. - go-webauthn/webauthn v0.16.1 dependency - WebAuthnConfig with RPID/RPOrigin/DisplayName validation - Migration 000009: webauthn_credentials table - DB CRUD with ownership checks and admin operations - internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM - REST: register begin/finish, login begin/finish, list, delete - Web UI: profile enrollment, login passkey button, admin management - gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs - mciasdb: webauthn list/delete/reset subcommands - OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema - Policy: self-service enrollment rule, admin remove via wildcard - Tests: DB CRUD, adapter round-trip, interface compliance - Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14 Security: Credential IDs and public keys encrypted at rest with AES-256-GCM via vault master key. Challenge ceremonies use 128-bit nonces with 120s TTL in sync.Map. Sign counter validated on each assertion to detect cloned authenticators. Password re-auth required for registration (SEC-01 pattern). No credential material in API responses or logs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1784 lines
63 KiB
Go
1784 lines
63 KiB
Go
// Package server wires together the HTTP router, middleware, and handlers
|
|
// for the MCIAS authentication server.
|
|
//
|
|
// Security design:
|
|
// - All endpoints use HTTPS (enforced at the listener level in cmd/mciassrv).
|
|
// - Authentication state is carried via JWT; no cookies or server-side sessions.
|
|
// - Credential fields (password hash, TOTP secret, Postgres password) are
|
|
// never included in any API response.
|
|
// - All JSON parsing uses strict decoders that reject unknown fields.
|
|
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
|
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
|
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
|
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
|
"git.wntrmute.dev/kyle/mcias/web"
|
|
)
|
|
|
|
// Server holds the dependencies injected into all handlers.
|
|
type Server struct {
|
|
db *db.DB
|
|
cfg *config.Config
|
|
logger *slog.Logger
|
|
vault *vault.Vault
|
|
polEng *policy.Engine
|
|
}
|
|
|
|
// New creates a Server with the given dependencies.
|
|
// The policy engine is initialised and loaded from the database on construction.
|
|
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server {
|
|
eng := policy.NewEngine()
|
|
if err := loadEngineRules(eng, database); err != nil {
|
|
logger.Warn("policy engine initial load failed; built-in defaults will apply", "error", err)
|
|
}
|
|
return &Server{
|
|
db: database,
|
|
cfg: cfg,
|
|
vault: v,
|
|
logger: logger,
|
|
polEng: eng,
|
|
}
|
|
}
|
|
|
|
// loadEngineRules reads all policy rules from the database and loads them into eng.
|
|
// Enabled/disabled and validity-window filtering is handled by the engine itself.
|
|
func loadEngineRules(eng *policy.Engine, database *db.DB) error {
|
|
records, err := database.ListPolicyRules(false)
|
|
if err != nil {
|
|
return fmt.Errorf("list policy rules: %w", err)
|
|
}
|
|
prs := make([]policy.PolicyRecord, len(records))
|
|
for i, r := range records {
|
|
prs[i] = policy.PolicyRecord{
|
|
ID: r.ID,
|
|
Priority: r.Priority,
|
|
Description: r.Description,
|
|
RuleJSON: r.RuleJSON,
|
|
Enabled: r.Enabled,
|
|
NotBefore: r.NotBefore,
|
|
ExpiresAt: r.ExpiresAt,
|
|
}
|
|
}
|
|
return eng.SetRules(prs)
|
|
}
|
|
|
|
// reloadPolicyEngine reloads operator rules from the database into the engine.
|
|
// Called after any create, update, or delete of a policy rule so that the
|
|
// in-memory cache stays consistent with the database.
|
|
func (s *Server) reloadPolicyEngine() {
|
|
if err := loadEngineRules(s.polEng, s.db); err != nil {
|
|
s.logger.Error("reload policy engine", "error", err)
|
|
}
|
|
}
|
|
|
|
// accountTypeLookup returns an AccountTypeLookup closure that resolves the
|
|
// account type ("human" or "system") for the given subject UUID. Used by the
|
|
// RequirePolicy middleware to populate PolicyInput.AccountType.
|
|
func (s *Server) accountTypeLookup() middleware.AccountTypeLookup {
|
|
return func(subjectUUID string) string {
|
|
acct, err := s.db.GetAccountByUUID(subjectUUID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(acct.AccountType)
|
|
}
|
|
}
|
|
|
|
// policyDenyLogger returns a PolicyDenyLogger that records policy denials in
|
|
// the audit log as EventPolicyDeny events.
|
|
func (s *Server) policyDenyLogger() middleware.PolicyDenyLogger {
|
|
return func(r *http.Request, claims *token.Claims, action policy.Action, res policy.Resource, matchedRuleID int64) {
|
|
s.writeAudit(r, model.EventPolicyDeny, nil, nil,
|
|
fmt.Sprintf(`{"subject":%q,"action":%q,"resource_type":%q,"rule_id":%d}`,
|
|
claims.Subject, action, res.Type, matchedRuleID))
|
|
}
|
|
}
|
|
|
|
// buildAccountResource assembles the policy.Resource for endpoints that
|
|
// target a specific account ({id} path parameter). Looks up the account's
|
|
// UUID, username (for ServiceName), and tags from the database.
|
|
// Returns an empty Resource on lookup failure; deny-by-default in the engine
|
|
// means this safely falls through to a denial for owner-scoped rules.
|
|
func (s *Server) buildAccountResource(r *http.Request, _ *token.Claims) policy.Resource {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
return policy.Resource{}
|
|
}
|
|
acct, err := s.db.GetAccountByUUID(id)
|
|
if err != nil {
|
|
return policy.Resource{}
|
|
}
|
|
tags, _ := s.db.GetAccountTags(acct.ID)
|
|
return policy.Resource{
|
|
OwnerUUID: acct.UUID,
|
|
ServiceName: acct.Username,
|
|
Tags: tags,
|
|
}
|
|
}
|
|
|
|
// buildTokenResource assembles the policy.Resource for token-issue requests.
|
|
// The request body contains account_id (UUID); the resource owner is that account.
|
|
// Because this builder reads the body it must be called before the body is
|
|
// consumed by the handler — the middleware calls it before invoking next.
|
|
func (s *Server) buildTokenResource(r *http.Request, _ *token.Claims) policy.Resource {
|
|
// Peek at the account_id without consuming the body.
|
|
// We read the body into a small wrapper struct to get the target UUID.
|
|
// The actual handler re-reads the body via decodeJSON, so this is safe
|
|
// because http.MaxBytesReader is applied by the handler, not here.
|
|
var peek struct {
|
|
AccountID string `json:"account_id"`
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBytes))
|
|
if err != nil {
|
|
return policy.Resource{}
|
|
}
|
|
// Restore the body for the downstream handler.
|
|
r.Body = io.NopCloser(strings.NewReader(string(body)))
|
|
if err := json.Unmarshal(body, &peek); err != nil || peek.AccountID == "" {
|
|
return policy.Resource{}
|
|
}
|
|
acct, err := s.db.GetAccountByUUID(peek.AccountID)
|
|
if err != nil {
|
|
return policy.Resource{}
|
|
}
|
|
tags, _ := s.db.GetAccountTags(acct.ID)
|
|
return policy.Resource{
|
|
OwnerUUID: acct.UUID,
|
|
ServiceName: acct.Username,
|
|
Tags: tags,
|
|
}
|
|
}
|
|
|
|
// buildJTIResource assembles the policy.Resource for token-revoke requests.
|
|
// Looks up the token record by {jti} to identify the owning account.
|
|
func (s *Server) buildJTIResource(r *http.Request, _ *token.Claims) policy.Resource {
|
|
jti := r.PathValue("jti")
|
|
if jti == "" {
|
|
return policy.Resource{}
|
|
}
|
|
rec, err := s.db.GetTokenRecord(jti)
|
|
if err != nil {
|
|
return policy.Resource{}
|
|
}
|
|
acct, err := s.db.GetAccountByID(rec.AccountID)
|
|
if err != nil {
|
|
return policy.Resource{}
|
|
}
|
|
tags, _ := s.db.GetAccountTags(acct.ID)
|
|
return policy.Resource{
|
|
OwnerUUID: acct.UUID,
|
|
ServiceName: acct.Username,
|
|
Tags: tags,
|
|
}
|
|
}
|
|
|
|
// Handler builds and returns the root HTTP handler with all routes and middleware.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Security (DEF-03): parse the optional trusted-proxy address once here
|
|
// so RateLimit and audit-log helpers use consistent IP extraction.
|
|
// net.ParseIP returns nil for an empty string, which disables proxy
|
|
// trust and falls back to r.RemoteAddr.
|
|
var trustedProxy net.IP
|
|
if s.cfg.Server.TrustedProxy != "" {
|
|
trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
|
|
}
|
|
|
|
// Security: per-IP rate limiting on public auth endpoints to prevent
|
|
// brute-force login attempts and token-validation abuse. Parameters match
|
|
// the gRPC rate limiter (10 req/s sustained, burst 10).
|
|
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
|
|
|
|
// Public endpoints (no authentication required).
|
|
mux.HandleFunc("GET /v1/health", s.handleHealth)
|
|
mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey)
|
|
mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
|
|
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
|
|
|
|
// API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml.
|
|
// Files are read from the embedded web/static filesystem at startup so that
|
|
// the handlers can write bytes directly without any redirect logic.
|
|
staticFS, err := fs.Sub(web.StaticFS, "static")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("server: sub fs: %v", err))
|
|
}
|
|
docsHTML, err := fs.ReadFile(staticFS, "docs.html")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("server: read docs.html: %v", err))
|
|
}
|
|
specYAML, err := fs.ReadFile(staticFS, "openapi.yaml")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
|
|
}
|
|
swaggerJS, err := fs.ReadFile(staticFS, "swagger-ui-bundle.js")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("server: read swagger-ui-bundle.js: %v", err))
|
|
}
|
|
swaggerCSS, err := fs.ReadFile(staticFS, "swagger-ui.css")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("server: read swagger-ui.css: %v", err))
|
|
}
|
|
// Security (DEF-09): apply defensive HTTP headers to the docs handlers.
|
|
// The Swagger UI page at /docs loads JavaScript from the same origin
|
|
// and renders untrusted content (API descriptions), so it benefits from
|
|
// CSP, X-Frame-Options, and the other headers applied to the UI sub-mux.
|
|
mux.Handle("GET /docs", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(docsHTML)
|
|
})))
|
|
mux.Handle("GET /docs/openapi.yaml", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/yaml")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(specYAML)
|
|
})))
|
|
mux.Handle("GET /static/swagger-ui-bundle.js", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(swaggerJS)
|
|
})))
|
|
mux.Handle("GET /static/swagger-ui.css", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/css")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(swaggerCSS)
|
|
})))
|
|
|
|
// Vault endpoints (exempt from sealed middleware and auth).
|
|
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
|
|
mux.Handle("POST /v1/vault/unseal", unsealRateLimit(http.HandlerFunc(s.handleUnseal)))
|
|
mux.HandleFunc("GET /v1/vault/status", s.handleVaultStatus)
|
|
mux.Handle("POST /v1/vault/seal", middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)(middleware.RequireRole("admin")(http.HandlerFunc(s.handleSeal))))
|
|
|
|
// Authenticated endpoints.
|
|
requireAuth := middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)
|
|
|
|
// Policy middleware factory: chains requireAuth → RequirePolicy → next.
|
|
// All protected endpoints use this instead of the old requireAdmin wrapper
|
|
// so that operator-defined policy rules (not just the admin role) control
|
|
// access. The built-in admin wildcard rule (ID -1) preserves existing
|
|
// admin behaviour; additional operator rules can grant non-admin accounts
|
|
// access to specific actions.
|
|
//
|
|
// Security: deny-wins + default-deny in the engine mean that any
|
|
// misconfiguration results in 403, never silent permit.
|
|
acctTypeLookup := s.accountTypeLookup()
|
|
denyLogger := s.policyDenyLogger()
|
|
requirePolicy := func(
|
|
action policy.Action,
|
|
resType policy.ResourceType,
|
|
builder middleware.ResourceBuilder,
|
|
) func(http.Handler) http.Handler {
|
|
pol := middleware.RequirePolicy(s.polEng, action, resType, builder, acctTypeLookup, denyLogger)
|
|
return func(next http.Handler) http.Handler {
|
|
return requireAuth(pol(next))
|
|
}
|
|
}
|
|
|
|
// Resource builders for endpoints that target a specific account or token.
|
|
buildAcct := middleware.ResourceBuilder(s.buildAccountResource)
|
|
buildToken := middleware.ResourceBuilder(s.buildTokenResource)
|
|
buildJTI := middleware.ResourceBuilder(s.buildJTIResource)
|
|
|
|
// Auth endpoints (require valid token; self-service rules in built-in defaults
|
|
// allow any authenticated principal to perform these operations).
|
|
mux.Handle("POST /v1/auth/logout", requireAuth(http.HandlerFunc(s.handleLogout)))
|
|
mux.Handle("POST /v1/auth/renew", requireAuth(http.HandlerFunc(s.handleRenew)))
|
|
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
|
|
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
|
|
|
|
// WebAuthn registration endpoints (require valid token; self-service).
|
|
mux.Handle("POST /v1/auth/webauthn/register/begin", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterBegin)))
|
|
mux.Handle("POST /v1/auth/webauthn/register/finish", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterFinish)))
|
|
// WebAuthn login endpoints (public, rate-limited).
|
|
mux.Handle("POST /v1/auth/webauthn/login/begin", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginBegin)))
|
|
mux.Handle("POST /v1/auth/webauthn/login/finish", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginFinish)))
|
|
|
|
// Policy-gated endpoints (formerly admin-only; now controlled by the engine).
|
|
mux.Handle("DELETE /v1/auth/totp",
|
|
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
|
|
mux.Handle("POST /v1/token/issue",
|
|
requirePolicy(policy.ActionIssueToken, policy.ResourceToken, buildToken)(http.HandlerFunc(s.handleTokenIssue)))
|
|
mux.Handle("DELETE /v1/token/{jti}",
|
|
requirePolicy(policy.ActionRevokeToken, policy.ResourceToken, buildJTI)(http.HandlerFunc(s.handleTokenRevoke)))
|
|
mux.Handle("GET /v1/accounts",
|
|
requirePolicy(policy.ActionListAccounts, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleListAccounts)))
|
|
mux.Handle("POST /v1/accounts",
|
|
requirePolicy(policy.ActionCreateAccount, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleCreateAccount)))
|
|
mux.Handle("GET /v1/accounts/{id}",
|
|
requirePolicy(policy.ActionReadAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetAccount)))
|
|
mux.Handle("PATCH /v1/accounts/{id}",
|
|
requirePolicy(policy.ActionUpdateAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleUpdateAccount)))
|
|
mux.Handle("DELETE /v1/accounts/{id}",
|
|
requirePolicy(policy.ActionDeleteAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleDeleteAccount)))
|
|
mux.Handle("GET /v1/accounts/{id}/roles",
|
|
requirePolicy(policy.ActionReadRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetRoles)))
|
|
mux.Handle("PUT /v1/accounts/{id}/roles",
|
|
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleSetRoles)))
|
|
mux.Handle("POST /v1/accounts/{id}/roles",
|
|
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGrantRole)))
|
|
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}",
|
|
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleRevokeRole)))
|
|
mux.Handle("GET /v1/pgcreds", requireAuth(http.HandlerFunc(s.handleListAccessiblePGCreds)))
|
|
mux.Handle("GET /v1/accounts/{id}/pgcreds",
|
|
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
|
|
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
|
|
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
|
|
// WebAuthn credential management (policy-gated).
|
|
mux.Handle("GET /v1/accounts/{id}/webauthn",
|
|
requirePolicy(policy.ActionReadAccount, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleListWebAuthnCredentials)))
|
|
mux.Handle("DELETE /v1/accounts/{id}/webauthn/{credentialId}",
|
|
requirePolicy(policy.ActionRemoveWebAuthn, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleDeleteWebAuthnCredential)))
|
|
mux.Handle("GET /v1/audit",
|
|
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
|
|
mux.Handle("GET /v1/accounts/{id}/tags",
|
|
requirePolicy(policy.ActionReadTags, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetTags)))
|
|
mux.Handle("PUT /v1/accounts/{id}/tags",
|
|
requirePolicy(policy.ActionWriteTags, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleSetTags)))
|
|
mux.Handle("PUT /v1/accounts/{id}/password",
|
|
requirePolicy(policy.ActionUpdateAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleAdminSetPassword)))
|
|
|
|
// Self-service password change (requires valid token; actor must match target account).
|
|
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
|
|
mux.Handle("GET /v1/policy/rules",
|
|
requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleListPolicyRules)))
|
|
mux.Handle("POST /v1/policy/rules",
|
|
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleCreatePolicyRule)))
|
|
mux.Handle("GET /v1/policy/rules/{id}",
|
|
requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleGetPolicyRule)))
|
|
mux.Handle("PATCH /v1/policy/rules/{id}",
|
|
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleUpdatePolicyRule)))
|
|
mux.Handle("DELETE /v1/policy/rules/{id}",
|
|
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule)))
|
|
|
|
// UI routes (HTMX-based management frontend).
|
|
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("ui: init failed: %v", err))
|
|
}
|
|
uiSrv.Register(mux)
|
|
|
|
// Apply global middleware: request logging, sealed check, and security headers.
|
|
// Rate limiting is applied per-route above (login, token/validate).
|
|
var root http.Handler = mux
|
|
// Security: RequireUnsealed runs after the mux (so exempt routes can be
|
|
// routed) but before the logger (so sealed-blocked requests are still logged).
|
|
root = middleware.RequireUnsealed(s.vault)(root)
|
|
root = middleware.RequestLogger(s.logger)(root)
|
|
|
|
// Security (SEC-04): apply baseline security headers to ALL responses
|
|
// (both API and UI). These headers are safe for every content type:
|
|
// - X-Content-Type-Options prevents MIME-sniffing attacks.
|
|
// - Strict-Transport-Security enforces HTTPS for 2 years.
|
|
// - Cache-Control prevents caching of authenticated responses.
|
|
// The UI sub-mux already sets these plus CSP/X-Frame-Options/Referrer-Policy
|
|
// which will override where needed (last Set wins before WriteHeader).
|
|
root = globalSecurityHeaders(root)
|
|
|
|
return root
|
|
}
|
|
|
|
// ---- Public handlers ----
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
if s.vault.IsSealed() {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "sealed"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handlePublicKey returns the server's Ed25519 public key in JWK format.
|
|
// This allows relying parties to independently verify JWTs.
|
|
func (s *Server) handlePublicKey(w http.ResponseWriter, _ *http.Request) {
|
|
pubKey, err := s.vault.PubKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
// Encode the Ed25519 public key as a JWK (RFC 8037).
|
|
// The "x" parameter is the base64url-encoded public key bytes.
|
|
jwk := map[string]string{
|
|
"kty": "OKP",
|
|
"crv": "Ed25519",
|
|
"use": "sig",
|
|
"alg": "EdDSA",
|
|
"x": encodeBase64URL(pubKey),
|
|
}
|
|
writeJSON(w, http.StatusOK, jwk)
|
|
}
|
|
|
|
// ---- Auth handlers ----
|
|
|
|
// loginRequest is the request body for POST /v1/auth/login.
|
|
type loginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
TOTPCode string `json:"totp_code,omitempty"`
|
|
}
|
|
|
|
// loginResponse is the response body for a successful login.
|
|
type loginResponse struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req loginRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Username == "" || req.Password == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "username and password are required", "bad_request")
|
|
return
|
|
}
|
|
|
|
// Load account by username.
|
|
acct, err := s.db.GetAccountByUsername(req.Username)
|
|
if err != nil {
|
|
// Security: return a generic error whether the user exists or not.
|
|
// Always run a dummy Argon2 check to prevent timing-based user enumeration.
|
|
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
|
s.writeAudit(r, model.EventLoginFail, nil, nil, audit.JSON("username", req.Username, "reason", "unknown_user"))
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// Security: Check account status before credential verification to avoid
|
|
// leaking whether the account exists based on timing differences.
|
|
if acct.Status != model.AccountStatusActive {
|
|
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// Security: check per-account lockout before running Argon2 (F-08).
|
|
// We still run a dummy Argon2 to equalise timing so an attacker cannot
|
|
// distinguish a locked account from a non-existent one.
|
|
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
|
if lockErr != nil {
|
|
s.logger.Error("lockout check", "error", lockErr)
|
|
}
|
|
if locked {
|
|
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
|
|
// Security: return the same 401 "invalid credentials" as wrong-password
|
|
// to prevent user-enumeration via lockout differentiation (SEC-02).
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// Verify password. This is always run, even for system accounts (which have
|
|
// no password hash), to maintain constant timing.
|
|
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
|
if err != nil || !ok {
|
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`)
|
|
_ = s.db.RecordLoginFailure(acct.ID)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// TOTP check (if enrolled).
|
|
if acct.TOTPRequired {
|
|
if req.TOTPCode == "" {
|
|
// Security (DEF-08 / PEN-06): do NOT increment the lockout counter
|
|
// for a missing TOTP code. A missing code means the client needs to
|
|
// re-prompt the user — it is not a credential failure. Incrementing
|
|
// here would let an attacker trigger account lockout by omitting the
|
|
// code after a correct password guess, and would penalise well-behaved
|
|
// clients that call Login in two steps (password first, TOTP second).
|
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"totp_missing"}`)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "TOTP code required", "totp_required")
|
|
return
|
|
}
|
|
// Decrypt the TOTP secret.
|
|
masterKey, err := s.vault.MasterKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
|
if err != nil {
|
|
s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
valid, totpCounter, err := auth.ValidateTOTP(secret, req.TOTPCode)
|
|
if err != nil || !valid {
|
|
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
|
_ = s.db.RecordLoginFailure(acct.ID)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
// Security (CRIT-01): reject replay of a code already used within
|
|
// its ±30-second validity window.
|
|
if err := s.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
|
|
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"totp_replay"}`)
|
|
_ = s.db.RecordLoginFailure(acct.ID)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Login succeeded: clear any outstanding failure counter.
|
|
_ = s.db.ClearLoginFailures(acct.ID)
|
|
|
|
// Determine expiry.
|
|
expiry := s.cfg.DefaultExpiry()
|
|
roles, err := s.db.GetRoles(acct.ID)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
for _, r := range roles {
|
|
if r == "admin" {
|
|
expiry = s.cfg.AdminExpiry()
|
|
break
|
|
}
|
|
}
|
|
|
|
privKey, err := s.vault.PrivKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
|
if err != nil {
|
|
s.logger.Error("issue token", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
s.logger.Error("track token", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
|
|
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", claims.JTI))
|
|
|
|
writeJSON(w, http.StatusOK, loginResponse{
|
|
Token: tokenStr,
|
|
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
if err := s.db.RevokeToken(claims.JTI, "logout"); err != nil {
|
|
s.logger.Error("revoke token on logout", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
s.writeAudit(r, model.EventTokenRevoked, nil, nil, audit.JSON("jti", claims.JTI, "reason", "logout"))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
|
|
// Security: only allow renewal when the token has consumed at least 50% of
|
|
// its lifetime. This prevents indefinite renewal of stolen tokens (SEC-03).
|
|
totalLifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
|
|
elapsed := time.Since(claims.IssuedAt)
|
|
if elapsed < totalLifetime/2 {
|
|
middleware.WriteError(w, http.StatusBadRequest, "token is not yet eligible for renewal", "renewal_too_early")
|
|
return
|
|
}
|
|
|
|
// Load account to get current roles (they may have changed since token issuance).
|
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
|
return
|
|
}
|
|
if acct.Status != model.AccountStatusActive {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "account inactive", "unauthorized")
|
|
return
|
|
}
|
|
|
|
roles, err := s.db.GetRoles(acct.ID)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
expiry := s.cfg.DefaultExpiry()
|
|
for _, role := range roles {
|
|
if role == "admin" {
|
|
expiry = s.cfg.AdminExpiry()
|
|
break
|
|
}
|
|
}
|
|
|
|
privKey, err := s.vault.PrivKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
newTokenStr, newClaims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Security: revoke old + track new in a single transaction (F-03) so that a
|
|
// failure between the two steps cannot leave the user with no valid token.
|
|
if err := s.db.RenewToken(claims.JTI, "renewed", newClaims.JTI, acct.ID, newClaims.IssuedAt, newClaims.ExpiresAt); err != nil {
|
|
s.logger.Error("renew token atomic", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, audit.JSON("old_jti", claims.JTI, "new_jti", newClaims.JTI))
|
|
|
|
writeJSON(w, http.StatusOK, loginResponse{
|
|
Token: newTokenStr,
|
|
ExpiresAt: newClaims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
// ---- Token endpoints ----
|
|
|
|
type validateRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type validateResponse struct {
|
|
Subject string `json:"sub,omitempty"`
|
|
Username string `json:"username,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
Roles []string `json:"roles,omitempty"`
|
|
Valid bool `json:"valid"`
|
|
}
|
|
|
|
func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
|
|
// Accept token either from Authorization: Bearer header or JSON body.
|
|
tokenStr, err := extractBearerFromRequest(r)
|
|
if err != nil {
|
|
// Try JSON body.
|
|
var req validateRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
tokenStr = req.Token
|
|
}
|
|
|
|
if tokenStr == "" {
|
|
writeJSON(w, http.StatusOK, validateResponse{Valid: false})
|
|
return
|
|
}
|
|
|
|
pubKey, err := s.vault.PubKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
claims, err := token.ValidateToken(pubKey, tokenStr, s.cfg.Tokens.Issuer)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, validateResponse{Valid: false})
|
|
return
|
|
}
|
|
|
|
rec, err := s.db.GetTokenRecord(claims.JTI)
|
|
if err != nil || rec.IsRevoked() {
|
|
writeJSON(w, http.StatusOK, validateResponse{Valid: false})
|
|
return
|
|
}
|
|
|
|
resp := validateResponse{
|
|
Valid: true,
|
|
Subject: claims.Subject,
|
|
Roles: claims.Roles,
|
|
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
if acct, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
|
resp.Username = acct.Username
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
type issueTokenRequest struct {
|
|
AccountID string `json:"account_id"`
|
|
}
|
|
|
|
func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
|
|
var req issueTokenRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
acct, err := s.db.GetAccountByUUID(req.AccountID)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusNotFound, "account not found", "not_found")
|
|
return
|
|
}
|
|
if acct.AccountType != model.AccountTypeSystem {
|
|
middleware.WriteError(w, http.StatusBadRequest, "token issue is only for system accounts", "bad_request")
|
|
return
|
|
}
|
|
|
|
privKey, err := s.vault.PrivKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, nil, s.cfg.ServiceExpiry())
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Atomically revoke existing system token (if any), track the new token,
|
|
// and update system_tokens — all in a single transaction.
|
|
// Security: prevents inconsistent state if a crash occurs mid-operation.
|
|
var oldJTI string
|
|
existing, err := s.db.GetSystemToken(acct.ID)
|
|
if err == nil && existing != nil {
|
|
oldJTI = existing.JTI
|
|
}
|
|
if err := s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
actor := middleware.ClaimsFromContext(r.Context())
|
|
var actorID *int64
|
|
if actor != nil {
|
|
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
|
actorID = &a.ID
|
|
}
|
|
}
|
|
s.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, audit.JSON("jti", claims.JTI))
|
|
|
|
writeJSON(w, http.StatusOK, loginResponse{
|
|
Token: tokenStr,
|
|
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
|
jti := r.PathValue("jti")
|
|
if jti == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "jti is required", "bad_request")
|
|
return
|
|
}
|
|
|
|
if err := s.db.RevokeToken(jti, "admin revocation"); err != nil {
|
|
middleware.WriteError(w, http.StatusNotFound, "token not found or already revoked", "not_found")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventTokenRevoked, nil, nil, audit.JSON("jti", jti))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ---- Account endpoints ----
|
|
|
|
type createAccountRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password,omitempty"`
|
|
Type string `json:"account_type"`
|
|
}
|
|
|
|
type accountResponse struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
AccountType string `json:"account_type"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
|
}
|
|
|
|
func accountToResponse(a *model.Account) accountResponse {
|
|
resp := accountResponse{
|
|
ID: a.UUID,
|
|
Username: a.Username,
|
|
AccountType: string(a.AccountType),
|
|
Status: string(a.Status),
|
|
TOTPEnabled: a.TOTPRequired,
|
|
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (s *Server) handleListAccounts(w http.ResponseWriter, _ *http.Request) {
|
|
accounts, err := s.db.ListAccounts()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
resp := make([]accountResponse, len(accounts))
|
|
for i, a := range accounts {
|
|
resp[i] = accountToResponse(a)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
|
var req createAccountRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
// Security (F-12): validate username length and character set before any DB
|
|
// operation to prevent log injection, stored-XSS, and storage abuse.
|
|
if err := validate.Username(req.Username); err != nil {
|
|
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
|
return
|
|
}
|
|
accountType := model.AccountType(req.Type)
|
|
if accountType != model.AccountTypeHuman && accountType != model.AccountTypeSystem {
|
|
middleware.WriteError(w, http.StatusBadRequest, "account_type must be 'human' or 'system'", "bad_request")
|
|
return
|
|
}
|
|
|
|
var passwordHash string
|
|
if accountType == model.AccountTypeHuman {
|
|
if req.Password == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "password is required for human accounts", "bad_request")
|
|
return
|
|
}
|
|
// Security (F-13): enforce minimum length before hashing.
|
|
if err := validate.Password(req.Password); err != nil {
|
|
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
|
return
|
|
}
|
|
var err error
|
|
passwordHash, err = auth.HashPassword(req.Password, auth.ArgonParams{
|
|
Time: s.cfg.Argon2.Time,
|
|
Memory: s.cfg.Argon2.Memory,
|
|
Threads: s.cfg.Argon2.Threads,
|
|
})
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
}
|
|
|
|
acct, err := s.db.CreateAccount(req.Username, accountType, passwordHash)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusConflict, "username already exists", "conflict")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventAccountCreated, nil, &acct.ID, audit.JSON("username", acct.Username))
|
|
writeJSON(w, http.StatusCreated, accountToResponse(acct))
|
|
}
|
|
|
|
func (s *Server) handleGetAccount(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, accountToResponse(acct))
|
|
}
|
|
|
|
type updateAccountRequest struct {
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleUpdateAccount(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req updateAccountRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Status != "" {
|
|
newStatus := model.AccountStatus(req.Status)
|
|
if newStatus != model.AccountStatusActive && newStatus != model.AccountStatusInactive {
|
|
middleware.WriteError(w, http.StatusBadRequest, "status must be 'active' or 'inactive'", "bad_request")
|
|
return
|
|
}
|
|
if err := s.db.UpdateAccountStatus(acct.ID, newStatus); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
}
|
|
|
|
s.writeAudit(r, model.EventAccountUpdated, nil, &acct.ID, "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := s.db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
if err := s.db.RevokeAllUserTokens(acct.ID, "account deleted"); err != nil {
|
|
s.logger.Error("revoke tokens on delete", "error", err, "account_id", acct.ID)
|
|
}
|
|
|
|
s.writeAudit(r, model.EventAccountDeleted, nil, &acct.ID, "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ---- Role endpoints ----
|
|
|
|
type rolesResponse struct {
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
type setRolesRequest struct {
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
type grantRoleRequest struct {
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
roles, err := s.db.GetRoles(acct.ID)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
if roles == nil {
|
|
roles = []string{}
|
|
}
|
|
writeJSON(w, http.StatusOK, rolesResponse{Roles: roles})
|
|
}
|
|
|
|
func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req setRolesRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
actor := middleware.ClaimsFromContext(r.Context())
|
|
var grantedBy *int64
|
|
if actor != nil {
|
|
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
|
grantedBy = &a.ID
|
|
}
|
|
}
|
|
|
|
if err := s.db.SetRoles(acct.ID, req.Roles, grantedBy); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, audit.JSONWithRoles(req.Roles))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleGrantRole(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req grantRoleRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Role == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "role is required", "bad_request")
|
|
return
|
|
}
|
|
|
|
actor := middleware.ClaimsFromContext(r.Context())
|
|
var grantedBy *int64
|
|
if actor != nil {
|
|
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
|
grantedBy = &a.ID
|
|
}
|
|
}
|
|
|
|
if err := s.db.GrantRole(acct.ID, req.Role, grantedBy); err != nil {
|
|
middleware.WriteError(w, http.StatusBadRequest, "invalid role", "bad_request")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, audit.JSON("role", req.Role))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleRevokeRole(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
role := r.PathValue("role")
|
|
if role == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "role is required", "bad_request")
|
|
return
|
|
}
|
|
|
|
actor := middleware.ClaimsFromContext(r.Context())
|
|
var revokedBy *int64
|
|
if actor != nil {
|
|
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
|
revokedBy = &a.ID
|
|
}
|
|
}
|
|
|
|
if err := s.db.RevokeRole(acct.ID, role); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventRoleRevoked, revokedBy, &acct.ID, audit.JSON("role", role))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ---- TOTP endpoints ----
|
|
|
|
type totpEnrollRequest struct {
|
|
Password string `json:"password"` // security: current password required to prevent session-theft escalation
|
|
}
|
|
|
|
type totpEnrollResponse struct {
|
|
Secret string `json:"secret"` // base32-encoded
|
|
OTPAuthURI string `json:"otpauth_uri"`
|
|
}
|
|
|
|
type totpConfirmRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// handleTOTPEnroll begins TOTP enrollment for the calling account.
|
|
//
|
|
// Security (SEC-01): the current password is required in the request body to
|
|
// prevent a stolen session token from being used to enroll attacker-controlled
|
|
// MFA on the victim's account. Lockout is checked and failures are recorded
|
|
// to prevent brute-force use of this endpoint as a password oracle.
|
|
func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
|
return
|
|
}
|
|
|
|
var req totpEnrollRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Password == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "password is required", "bad_request")
|
|
return
|
|
}
|
|
|
|
// Security: check lockout before verifying (same as login and password-change flows)
|
|
// so an attacker cannot use this endpoint to brute-force the current password.
|
|
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
|
if lockErr != nil {
|
|
s.logger.Error("lockout check (TOTP enroll)", "error", lockErr)
|
|
}
|
|
if locked {
|
|
s.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
|
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
|
|
return
|
|
}
|
|
|
|
// Security: verify the current password with the same constant-time
|
|
// Argon2id path used at login to prevent timing oracles.
|
|
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
|
if verifyErr != nil || !ok {
|
|
_ = s.db.RecordLoginFailure(acct.ID)
|
|
s.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "password is incorrect", "unauthorized")
|
|
return
|
|
}
|
|
|
|
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Encrypt the secret before storing it temporarily.
|
|
// Note: we store as pending; enrollment is confirmed with /confirm.
|
|
masterKey, err := s.vault.MasterKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
|
|
// remains 0 until the user proves possession of the secret by confirming
|
|
// a valid code. If the user abandons enrollment the flag stays unset and
|
|
// they can still log in with just their password — no lockout.
|
|
if err := s.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
|
|
|
|
// Security: return the secret for display to the user. It is only shown
|
|
// once; subsequent reads are not possible (only the encrypted form is stored).
|
|
writeJSON(w, http.StatusOK, totpEnrollResponse{
|
|
Secret: b32Secret,
|
|
OTPAuthURI: otpURI,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
|
var req totpConfirmRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
|
return
|
|
}
|
|
|
|
if acct.TOTPSecretEnc == nil {
|
|
middleware.WriteError(w, http.StatusBadRequest, "TOTP enrollment not started", "bad_request")
|
|
return
|
|
}
|
|
|
|
masterKey, err := s.vault.MasterKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
valid, totpCounter, err := auth.ValidateTOTP(secret, req.Code)
|
|
if err != nil || !valid {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
|
|
return
|
|
}
|
|
// Security (CRIT-01): record the counter even during enrollment
|
|
// confirmation so the same code cannot be replayed immediately after
|
|
// confirming.
|
|
if err := s.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// Mark TOTP as confirmed and required.
|
|
if err := s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, nil, "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type totpRemoveRequest struct {
|
|
AccountID string `json:"account_id"`
|
|
}
|
|
|
|
func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
|
|
var req totpRemoveRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
acct, err := s.db.GetAccountByUUID(req.AccountID)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusNotFound, "account not found", "not_found")
|
|
return
|
|
}
|
|
|
|
if err := s.db.ClearTOTP(acct.ID); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventTOTPRemoved, nil, &acct.ID, "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ---- Password change endpoints ----
|
|
|
|
// adminSetPasswordRequest is the request body for PUT /v1/accounts/{id}/password.
|
|
// Used by admins to reset any human account's password without requiring the
|
|
// current password.
|
|
type adminSetPasswordRequest struct {
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
// handleAdminSetPassword allows an admin to reset any human account's password.
|
|
// No current-password verification is required because the admin role already
|
|
// represents a higher trust level, matching the break-glass recovery pattern.
|
|
//
|
|
// Security: new password is validated (minimum length) and hashed with Argon2id
|
|
// before storage. The plaintext is never logged. All active tokens for the
|
|
// target account are revoked so that a compromised-account recovery fully
|
|
// invalidates any outstanding sessions.
|
|
func (s *Server) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if acct.AccountType != model.AccountTypeHuman {
|
|
middleware.WriteError(w, http.StatusBadRequest, "password can only be set on human accounts", "bad_request")
|
|
return
|
|
}
|
|
|
|
var req adminSetPasswordRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
// Security (F-13): enforce minimum length before hashing.
|
|
if err := validate.Password(req.NewPassword); err != nil {
|
|
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
|
Time: s.cfg.Argon2.Time,
|
|
Memory: s.cfg.Argon2.Memory,
|
|
Threads: s.cfg.Argon2.Threads,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("hash password (admin reset)", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
|
s.logger.Error("update password hash", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Security: revoke all active sessions so a compromised account cannot
|
|
// continue to use old tokens after a password reset. Failure here means
|
|
// the API's documented guarantee ("all active sessions revoked") cannot be
|
|
// upheld, so we return 500 rather than silently succeeding.
|
|
if err := s.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
|
|
s.logger.Error("revoke tokens on password reset", "error", err, "account_id", acct.ID)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
actor := middleware.ClaimsFromContext(r.Context())
|
|
var actorID *int64
|
|
if actor != nil {
|
|
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
|
actorID = &a.ID
|
|
}
|
|
}
|
|
s.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// changePasswordRequest is the request body for PUT /v1/auth/password.
|
|
// The current_password is required to prevent token-theft attacks: an attacker
|
|
// who steals a valid JWT cannot change the password without also knowing the
|
|
// existing one.
|
|
type changePasswordRequest struct {
|
|
CurrentPassword string `json:"current_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
// handleChangePassword allows an authenticated user to change their own password.
|
|
// The current password must be verified before the new hash is written.
|
|
//
|
|
// Security: current password is verified with Argon2id (constant-time).
|
|
// Lockout is checked and failures are recorded to prevent the endpoint from
|
|
// being used as an oracle for the current password. On success, all other
|
|
// active sessions (other JTIs) are revoked so stale tokens cannot be used
|
|
// after a credential rotation.
|
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
|
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
|
return
|
|
}
|
|
if acct.AccountType != model.AccountTypeHuman {
|
|
middleware.WriteError(w, http.StatusBadRequest, "password change is only available for human accounts", "bad_request")
|
|
return
|
|
}
|
|
|
|
var req changePasswordRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.CurrentPassword == "" || req.NewPassword == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "current_password and new_password are required", "bad_request")
|
|
return
|
|
}
|
|
|
|
// Security: check lockout before verifying (same as login flow) so an
|
|
// attacker cannot use this endpoint to brute-force the current password.
|
|
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
|
if lockErr != nil {
|
|
s.logger.Error("lockout check (password change)", "error", lockErr)
|
|
}
|
|
if locked {
|
|
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
|
// Security: return the same 401 as wrong-password to prevent
|
|
// user-enumeration via lockout differentiation (SEC-02).
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// Security: verify the current password with the same constant-time
|
|
// Argon2id path used at login to prevent timing oracles.
|
|
ok, verifyErr := auth.VerifyPassword(req.CurrentPassword, acct.PasswordHash)
|
|
if verifyErr != nil || !ok {
|
|
_ = s.db.RecordLoginFailure(acct.ID)
|
|
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "current password is incorrect", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// Security (F-13): enforce minimum length on the new password before hashing.
|
|
if err := validate.Password(req.NewPassword); err != nil {
|
|
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
|
Time: s.cfg.Argon2.Time,
|
|
Memory: s.cfg.Argon2.Memory,
|
|
Threads: s.cfg.Argon2.Threads,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("hash password (self-service change)", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
|
s.logger.Error("update password hash", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Security: clear the failure counter since the user proved knowledge of
|
|
// the current password, then revoke all tokens *except* the current one so
|
|
// the caller retains their active session but any other stolen sessions are
|
|
// invalidated. Revocation failure breaks the documented guarantee so we
|
|
// return 500 rather than silently succeeding.
|
|
_ = s.db.ClearLoginFailures(acct.ID)
|
|
if err := s.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
|
|
s.logger.Error("revoke other tokens on password change", "error", err, "account_id", acct.ID)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"self_service"}`)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ---- Postgres credential endpoints ----
|
|
|
|
type pgCredRequest struct {
|
|
Host string `json:"host"`
|
|
Database string `json:"database"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
func (s *Server) handleGetPGCreds(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
cred, err := s.db.ReadPGCredentials(acct.ID)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
middleware.WriteError(w, http.StatusNotFound, "no credentials stored", "not_found")
|
|
return
|
|
}
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Decrypt the password to return it to the admin caller.
|
|
masterKey, err := s.vault.MasterKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
password, err := crypto.OpenAESGCM(masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventPGCredAccessed, nil, &acct.ID, "")
|
|
|
|
// Return including password since this is an explicit admin retrieval.
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"host": cred.PGHost,
|
|
"port": cred.PGPort,
|
|
"database": cred.PGDatabase,
|
|
"username": cred.PGUsername,
|
|
"password": string(password), // included only for admin retrieval
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
|
acct, ok := s.loadAccount(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req pgCredRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Host == "" || req.Database == "" || req.Username == "" || req.Password == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "host, database, username, and password are required", "bad_request")
|
|
return
|
|
}
|
|
if req.Port == 0 {
|
|
req.Port = 5432
|
|
}
|
|
|
|
masterKey, err := s.vault.MasterKey()
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
|
return
|
|
}
|
|
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(req.Password))
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
if err := s.db.WritePGCredentials(acct.ID, req.Host, req.Port, req.Database, req.Username, enc, nonce); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventPGCredUpdated, nil, &acct.ID, "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleListAccessiblePGCreds returns all pg_credentials accessible to the
|
|
// authenticated user: those owned + those explicitly granted. The credential ID
|
|
// is included so callers can fetch a specific credential via /v1/accounts/{id}/pgcreds.
|
|
func (s *Server) handleListAccessiblePGCreds(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
if claims == nil {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "not authenticated", "unauthorized")
|
|
return
|
|
}
|
|
|
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
|
return
|
|
}
|
|
|
|
creds, err := s.db.ListAccessiblePGCreds(acct.ID)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Convert credentials to response format with credential ID.
|
|
type pgCredResponse struct {
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Host string `json:"host"`
|
|
Database string `json:"database"`
|
|
Username string `json:"username"`
|
|
ServiceAccountID string `json:"service_account_id"`
|
|
ServiceAccountName string `json:"service_account_name,omitempty"`
|
|
ID int64 `json:"id"`
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
response := make([]pgCredResponse, len(creds))
|
|
for i, cred := range creds {
|
|
response[i] = pgCredResponse{
|
|
ID: cred.ID,
|
|
ServiceAccountID: cred.ServiceAccountUUID,
|
|
Host: cred.PGHost,
|
|
Port: cred.PGPort,
|
|
Database: cred.PGDatabase,
|
|
Username: cred.PGUsername,
|
|
CreatedAt: cred.CreatedAt,
|
|
UpdatedAt: cred.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// ---- Audit endpoints ----
|
|
|
|
// handleListAudit returns paginated audit log entries with resolved usernames.
|
|
// Query params: limit (1-200, default 50), offset, event_type, actor_id (UUID).
|
|
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
limit := parseIntParam(q.Get("limit"), 50)
|
|
if limit < 1 {
|
|
limit = 1
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
offset := parseIntParam(q.Get("offset"), 0)
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
params := db.AuditQueryParams{
|
|
EventType: q.Get("event_type"),
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
// Resolve actor_id from UUID to internal int64.
|
|
if actorUUID := q.Get("actor_id"); actorUUID != "" {
|
|
acct, err := s.db.GetAccountByUUID(actorUUID)
|
|
if err == nil {
|
|
params.AccountID = &acct.ID
|
|
}
|
|
// If actor_id is provided but not found, return empty results (correct behaviour).
|
|
}
|
|
|
|
events, total, err := s.db.ListAuditEventsPaged(params)
|
|
if err != nil {
|
|
s.logger.Error("list audit events", "error", err)
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Ensure a nil slice serialises as [] rather than null.
|
|
if events == nil {
|
|
events = []*db.AuditEventView{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"events": events,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
// parseIntParam parses a query parameter as an int, returning defaultVal on failure.
|
|
func parseIntParam(s string, defaultVal int) int {
|
|
if s == "" {
|
|
return defaultVal
|
|
}
|
|
var v int
|
|
if _, err := fmt.Sscanf(s, "%d", &v); err != nil {
|
|
return defaultVal
|
|
}
|
|
return v
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
// loadAccount retrieves an account by the {id} path parameter (UUID).
|
|
func (s *Server) loadAccount(w http.ResponseWriter, r *http.Request) (*model.Account, bool) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "account id is required", "bad_request")
|
|
return nil, false
|
|
}
|
|
acct, err := s.db.GetAccountByUUID(id)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
middleware.WriteError(w, http.StatusNotFound, "account not found", "not_found")
|
|
return nil, false
|
|
}
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return nil, false
|
|
}
|
|
return acct, true
|
|
}
|
|
|
|
// writeAudit appends an audit log entry, logging errors but not failing the request.
|
|
// The logged IP honours the trusted-proxy setting so the real client address
|
|
// is recorded rather than the proxy's address (DEF-03).
|
|
func (s *Server) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
|
var proxyIP net.IP
|
|
if s.cfg.Server.TrustedProxy != "" {
|
|
proxyIP = net.ParseIP(s.cfg.Server.TrustedProxy)
|
|
}
|
|
ip := middleware.ClientIP(r, proxyIP)
|
|
if err := s.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
|
|
s.logger.Error("write audit event", "error", err, "event_type", eventType)
|
|
}
|
|
}
|
|
|
|
// writeJSON encodes v as JSON and writes it to w with the given status code.
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
// If encoding fails, the status is already written; log but don't panic.
|
|
_ = err
|
|
}
|
|
}
|
|
|
|
// maxJSONBytes limits the size of JSON request bodies (1 MiB).
|
|
//
|
|
// Security (SEC-05): without a size limit an attacker could send a
|
|
// multi-gigabyte body and exhaust server memory. The UI layer already
|
|
// applies http.MaxBytesReader; this constant gives the REST API the
|
|
// same protection.
|
|
const maxJSONBytes = 1 << 20
|
|
|
|
// decodeJSON decodes a JSON request body into v.
|
|
// Returns false and writes a 400 response if decoding fails.
|
|
//
|
|
// Security (SEC-05): the body is wrapped with http.MaxBytesReader so
|
|
// that oversized payloads are rejected before they are fully read.
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON request body", "bad_request")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// extractBearerFromRequest extracts a Bearer token from the Authorization header.
|
|
// Security (PEN-01): validates the "Bearer" prefix using case-insensitive
|
|
// comparison before extracting the token. The previous implementation sliced
|
|
// at a fixed offset without checking the prefix, accepting any 8+ character
|
|
// Authorization value.
|
|
func extractBearerFromRequest(r *http.Request) (string, error) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth == "" {
|
|
return "", fmt.Errorf("no Authorization header")
|
|
}
|
|
parts := strings.SplitN(auth, " ", 2)
|
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
|
return "", fmt.Errorf("malformed Authorization header")
|
|
}
|
|
if parts[1] == "" {
|
|
return "", fmt.Errorf("empty Bearer token")
|
|
}
|
|
return parts[1], nil
|
|
}
|
|
|
|
// docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux
|
|
// to the /docs and /docs/openapi.yaml endpoints.
|
|
//
|
|
// globalSecurityHeaders sets baseline security headers on every response.
|
|
// Security (SEC-04): API responses previously lacked X-Content-Type-Options,
|
|
// HSTS, and Cache-Control. These three headers are safe for all content types
|
|
// and do not interfere with JSON API clients or the HTMX UI.
|
|
func globalSecurityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
|
h.Set("Cache-Control", "no-store")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// Security (DEF-09): without these headers the Swagger UI HTML page is
|
|
// served without CSP, X-Frame-Options, or HSTS, leaving it susceptible
|
|
// to clickjacking and MIME-type confusion in browsers.
|
|
func docsSecurityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'")
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// encodeBase64URL encodes bytes as base64url without padding.
|
|
func encodeBase64URL(b []byte) string {
|
|
const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
result := make([]byte, 0, (len(b)*4+2)/3)
|
|
for i := 0; i < len(b); i += 3 {
|
|
switch {
|
|
case i+2 < len(b):
|
|
result = append(result,
|
|
table[b[i]>>2],
|
|
table[(b[i]&3)<<4|b[i+1]>>4],
|
|
table[(b[i+1]&0xf)<<2|b[i+2]>>6],
|
|
table[b[i+2]&0x3f],
|
|
)
|
|
case i+1 < len(b):
|
|
result = append(result,
|
|
table[b[i]>>2],
|
|
table[(b[i]&3)<<4|b[i+1]>>4],
|
|
table[(b[i+1]&0xf)<<2],
|
|
)
|
|
default:
|
|
result = append(result,
|
|
table[b[i]>>2],
|
|
table[(b[i]&3)<<4],
|
|
)
|
|
}
|
|
}
|
|
return string(result)
|
|
}
|