Files
mcias/internal/ui/handlers_accounts.go
Kyle Isom 005e734842 Fix F-16: revoke old system token before issuing new one
- ui/handlers_accounts.go (handleIssueSystemToken): call
  GetSystemToken before issuing; if one exists, call
  RevokeToken(existing.JTI, "rotated") before TrackToken
  and SetSystemToken for the new token; mirrors the pattern
  in REST handleTokenIssue and gRPC IssueServiceToken
- db/db_test.go: TestSystemTokenRotationRevokesOld verifies
  the full rotation flow: old JTI revoked with reason
  "rotated", new JTI tracked and active, GetSystemToken
  returns the new JTI
- AUDIT.md: mark F-16 as fixed
Security: without this fix an old system token remained valid
  after rotation until its natural expiry, giving a leaked or
  stolen old token extra lifetime. With the revocation the old
  JTI is immediately marked in token_revocation so any validator
  checking revocation status rejects it.
2026-03-11 20:34:57 -07:00

412 lines
12 KiB
Go

package ui
import (
"fmt"
"net/http"
"strings"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
var knownRoles = []string{"admin", "user", "service"}
// handleAccountsList renders the accounts list page.
func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
accounts, err := u.db.ListAccounts()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
return
}
u.render(w, "accounts", AccountsData{
PageData: PageData{CSRFToken: csrfToken},
Accounts: accounts,
})
}
// handleCreateAccount creates a new account and returns the account_row fragment.
func (u *UIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
accountTypeStr := r.FormValue("account_type")
if username == "" {
u.renderError(w, r, http.StatusBadRequest, "username is required")
return
}
accountType := model.AccountTypeHuman
if accountTypeStr == string(model.AccountTypeSystem) {
accountType = model.AccountTypeSystem
}
var passwordHash string
if password != "" {
argonCfg := auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,
Threads: u.cfg.Argon2.Threads,
}
var err error
passwordHash, err = auth.HashPassword(password, argonCfg)
if err != nil {
u.logger.Error("hash password", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
} else if accountType == model.AccountTypeHuman {
u.renderError(w, r, http.StatusBadRequest, "password is required for human accounts")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &acct.ID
}
}
acct, err := u.db.CreateAccount(username, accountType, passwordHash)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create account: %v", err))
return
}
u.writeAudit(r, model.EventAccountCreated, actorID, &acct.ID,
fmt.Sprintf(`{"username":%q,"type":%q}`, username, accountType))
u.render(w, "account_row", acct)
}
// handleAccountDetail renders the account detail page.
func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
return
}
tokens, err := u.db.ListTokensForAccount(acct.ID)
if err != nil {
u.logger.Warn("list tokens for account", "error", err)
tokens = nil
}
u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
Tokens: tokens,
})
}
// handleUpdateAccountStatus toggles an account between active and inactive.
func (u *UIServer) handleUpdateAccountStatus(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
statusStr := r.FormValue("status")
var newStatus model.AccountStatus
switch statusStr {
case string(model.AccountStatusActive):
newStatus = model.AccountStatusActive
case string(model.AccountStatusInactive):
newStatus = model.AccountStatusInactive
default:
u.renderError(w, r, http.StatusBadRequest, "invalid status")
return
}
if err := u.db.UpdateAccountStatus(acct.ID, newStatus); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "update failed")
return
}
acct.Status = newStatus
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventAccountUpdated, actorID, &acct.ID,
fmt.Sprintf(`{"status":%q}`, newStatus))
// Respond with the updated row (for HTMX outerHTML swap on accounts list)
// or updated status cell (for HTMX innerHTML swap on account detail).
// The hx-target in accounts.html targets the whole <tr>; in account_detail.html
// it targets #status-cell. We detect which by checking the request path context.
if strings.Contains(r.Header.Get("HX-Target"), "status-cell") || r.Header.Get("HX-Target") == "status-cell" {
data := AccountDetailData{
PageData: PageData{CSRFToken: ""},
Account: acct,
}
u.render(w, "account_status", data)
} else {
u.render(w, "account_row", acct)
}
}
// handleDeleteAccount soft-deletes an account and returns empty body (HTMX removes row).
func (u *UIServer) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if err := u.db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
return
}
// Revoke all active tokens for the deleted account.
if err := u.db.RevokeAllUserTokens(acct.ID, "account_deleted"); err != nil {
u.logger.Warn("revoke tokens for deleted account", "account_id", acct.ID, "error", err)
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventAccountDeleted, actorID, &acct.ID, "")
// Return empty body; HTMX will remove the row via hx-swap="outerHTML".
w.WriteHeader(http.StatusOK)
}
// handleRolesEditForm returns the roles editor fragment (GET — no CSRF check needed).
func (u *UIServer) handleRolesEditForm(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
return
}
u.render(w, "roles_editor", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
})
}
// handleSetRoles replaces the full role set for an account.
func (u *UIServer) handleSetRoles(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
// Collect checked roles + optional custom role.
roles := r.Form["roles"] // multi-value from checkboxes
if custom := strings.TrimSpace(r.FormValue("custom_role")); custom != "" {
roles = append(roles, custom)
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &actor.ID
}
}
if err := u.db.SetRoles(acct.ID, roles, actorID); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to set roles")
return
}
u.writeAudit(r, model.EventRoleGranted, actorID, &acct.ID,
fmt.Sprintf(`{"roles":%q}`, strings.Join(roles, ",")))
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "roles_editor", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
})
}
// handleRevokeToken revokes a specific token by JTI.
func (u *UIServer) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
jti := r.PathValue("jti")
if jti == "" {
u.renderError(w, r, http.StatusBadRequest, "missing JTI")
return
}
if err := u.db.RevokeToken(jti, "ui_revoke"); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "revoke failed")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventTokenRevoked, actorID, nil,
fmt.Sprintf(`{"jti":%q,"reason":"ui_revoke"}`, jti))
// Return empty body; HTMX removes the row.
w.WriteHeader(http.StatusOK)
}
// handleIssueSystemToken issues a long-lived service token for a system account.
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "only system accounts can have service tokens")
return
}
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
return
}
// Security: revoke the previous system token before issuing a new one (F-16).
// This matches the pattern in the REST handleTokenIssue and gRPC IssueServiceToken
// so that old tokens do not remain valid after rotation.
existing, err := u.db.GetSystemToken(acct.ID)
if err == nil {
_ = u.db.RevokeToken(existing.JTI, "rotated")
}
expiry := u.cfg.ServiceExpiry()
tokenStr, claims, err := u.issueToken(acct.UUID, roles, expiry)
if err != nil {
u.logger.Error("issue system token", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to issue token")
return
}
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
u.logger.Error("track system token", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to track token")
return
}
// Store as system token for easy retrieval.
if err := u.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
u.logger.Warn("set system token record", "error", err)
}
actorClaims := claimsFromContext(r.Context())
var actorID *int64
if actorClaims != nil {
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
// Re-fetch token list including the new token.
tokens, err := u.db.ListTokensForAccount(acct.ID)
if err != nil {
u.logger.Warn("list tokens after issue", "error", err)
tokens = nil
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
// Flash the raw token once at the top so the operator can copy it.
u.render(w, "token_list", AccountDetailData{
PageData: PageData{
CSRFToken: csrfToken,
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr),
},
Account: acct,
Tokens: tokens,
})
}