- db/accounts.go: add RenewToken(oldJTI, reason, newJTI, accountID, issuedAt, expiresAt) which wraps RevokeToken + TrackToken in a single BEGIN/COMMIT transaction; if either step fails the whole tx rolls back, so the user is never left with neither old nor new token valid - server.go (handleRenewToken): replace separate RevokeToken + TrackToken calls with single RenewToken call; failure now returns 500 instead of silently losing revocation - grpcserver/auth.go (RenewToken): same replacement - db/db_test.go: TestRenewTokenAtomic verifies old token is revoked with correct reason, new token is tracked and not revoked, and a second renewal on the already-revoked old token returns an error - AUDIT.md: mark F-03 as fixed Security: without atomicity a crash/error between revoke and track could leave the old token active alongside the new one (two live tokens) or revoke the old token without tracking the new one (user locked out). The transaction ensures exactly one of the two tokens is valid at all times.
978 lines
31 KiB
Go
978 lines
31 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()
|
|
|
|
// 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)
|
|
|
|
// 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)))
|
|
|
|
// 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: request logging.
|
|
// Rate limiting is applied per-route above (login, token/validate).
|
|
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
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|