- 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.
972 lines
30 KiB
Go
972 lines
30 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 (
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"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/token"
|
|
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
|
)
|
|
|
|
// Server holds the dependencies injected into all handlers.
|
|
type Server struct {
|
|
db *db.DB
|
|
cfg *config.Config
|
|
logger *slog.Logger
|
|
privKey ed25519.PrivateKey
|
|
pubKey ed25519.PublicKey
|
|
masterKey []byte
|
|
}
|
|
|
|
// New creates a Server with the given dependencies.
|
|
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) *Server {
|
|
return &Server{
|
|
db: database,
|
|
cfg: cfg,
|
|
privKey: priv,
|
|
pubKey: pub,
|
|
masterKey: masterKey,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Handler builds and returns the root HTTP handler with all routes and middleware.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Public endpoints (no authentication required).
|
|
mux.HandleFunc("GET /v1/health", s.handleHealth)
|
|
mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey)
|
|
mux.HandleFunc("POST /v1/auth/login", s.handleLogin)
|
|
mux.HandleFunc("POST /v1/token/validate", s.handleTokenValidate)
|
|
|
|
// Authenticated endpoints.
|
|
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
|
|
requireAdmin := func(h http.Handler) http.Handler {
|
|
return requireAuth(middleware.RequireRole("admin")(h))
|
|
}
|
|
|
|
// Auth endpoints (require valid token).
|
|
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)))
|
|
|
|
// Admin-only endpoints.
|
|
mux.Handle("DELETE /v1/auth/totp", requireAdmin(http.HandlerFunc(s.handleTOTPRemove)))
|
|
mux.Handle("POST /v1/token/issue", requireAdmin(http.HandlerFunc(s.handleTokenIssue)))
|
|
mux.Handle("DELETE /v1/token/{jti}", requireAdmin(http.HandlerFunc(s.handleTokenRevoke)))
|
|
mux.Handle("GET /v1/accounts", requireAdmin(http.HandlerFunc(s.handleListAccounts)))
|
|
mux.Handle("POST /v1/accounts", requireAdmin(http.HandlerFunc(s.handleCreateAccount)))
|
|
mux.Handle("GET /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleGetAccount)))
|
|
mux.Handle("PATCH /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleUpdateAccount)))
|
|
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount)))
|
|
mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles)))
|
|
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles)))
|
|
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
|
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
|
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
|
|
|
// UI routes (HTMX-based management frontend).
|
|
uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("ui: init failed: %v", err))
|
|
}
|
|
uiSrv.Register(mux)
|
|
|
|
// Apply global middleware: logging and login-path rate limiting.
|
|
var root http.Handler = mux
|
|
root = middleware.RequestLogger(s.logger)(root)
|
|
return root
|
|
}
|
|
|
|
// ---- Public handlers ----
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
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) {
|
|
// 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(s.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", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
|
s.writeAudit(r, model.EventLoginFail, nil, nil, fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username))
|
|
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", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
|
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
|
|
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"}`)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// TOTP check (if enrolled).
|
|
if acct.TOTPRequired {
|
|
if req.TOTPCode == "" {
|
|
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.
|
|
secret, err := crypto.OpenAESGCM(s.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, err := auth.ValidateTOTP(secret, req.TOTPCode)
|
|
if err != nil || !valid {
|
|
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
|
return
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
tokenStr, claims, err := token.IssueToken(s.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, fmt.Sprintf(`{"jti":%q}`, 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, fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
newTokenStr, newClaims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Revoke the old token and track the new one atomically is not possible
|
|
// in SQLite without a transaction. We do best-effort: revoke old, track new.
|
|
if err := s.db.RevokeToken(claims.JTI, "renewed"); err != nil {
|
|
s.logger.Error("revoke old token on renew", "error", err)
|
|
}
|
|
if err := s.db.TrackToken(newClaims.JTI, acct.ID, newClaims.IssuedAt, newClaims.ExpiresAt); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.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"`
|
|
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
|
|
}
|
|
|
|
claims, err := token.ValidateToken(s.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
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, validateResponse{
|
|
Valid: true,
|
|
Subject: claims.Subject,
|
|
Roles: claims.Roles,
|
|
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
tokenStr, claims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, nil, s.cfg.ServiceExpiry())
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Revoke existing system token if any.
|
|
existing, err := s.db.GetSystemToken(acct.ID)
|
|
if err == nil && existing != nil {
|
|
_ = s.db.RevokeToken(existing.JTI, "rotated")
|
|
}
|
|
|
|
if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
if err := s.db.SetSystemToken(acct.ID, claims.JTI, 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, fmt.Sprintf(`{"jti":%q}`, 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, fmt.Sprintf(`{"jti":%q}`, 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
|
|
}
|
|
|
|
if req.Username == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "username is required", "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
|
|
}
|
|
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, fmt.Sprintf(`{"username":%q}`, 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"`
|
|
}
|
|
|
|
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, fmt.Sprintf(`{"roles":%v}`, req.Roles))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ---- TOTP endpoints ----
|
|
|
|
type totpEnrollResponse struct {
|
|
Secret string `json:"secret"` // base32-encoded
|
|
OTPAuthURI string `json:"otpauth_uri"`
|
|
}
|
|
|
|
type totpConfirmRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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.
|
|
secretEnc, secretNonce, err := crypto.SealAESGCM(s.masterKey, rawSecret)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
// Store the encrypted pending secret. The totp_required flag is NOT set
|
|
// yet — it is set only after the user confirms the code.
|
|
if err := s.db.SetTOTP(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
|
|
}
|
|
|
|
secret, err := crypto.OpenAESGCM(s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
|
if err != nil {
|
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
|
return
|
|
}
|
|
|
|
valid, err := auth.ValidateTOTP(secret, req.Code)
|
|
if err != nil || !valid {
|
|
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)
|
|
}
|
|
|
|
// ---- 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.
|
|
password, err := crypto.OpenAESGCM(s.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
|
|
}
|
|
|
|
enc, nonce, err := crypto.SealAESGCM(s.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)
|
|
}
|
|
|
|
// ---- 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.
|
|
func (s *Server) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
|
|
ip := r.RemoteAddr
|
|
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
|
|
}
|
|
}
|
|
|
|
// decodeJSON decodes a JSON request body into v.
|
|
// Returns false and writes a 400 response if decoding fails.
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
|
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.
|
|
func extractBearerFromRequest(r *http.Request) (string, error) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth == "" {
|
|
return "", fmt.Errorf("no Authorization header")
|
|
}
|
|
const prefix = "Bearer "
|
|
if len(auth) <= len(prefix) {
|
|
return "", fmt.Errorf("malformed Authorization header")
|
|
}
|
|
return auth[len(prefix):], nil
|
|
}
|
|
|
|
// 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)
|
|
}
|