Files
pulse/internal/api/security_setup_fix.go
T
rcourtman 563a3aa06c fix(api): align the platform admin route with the capability it publishes
canAccessPlatformAdminSurface publishes billingAdmin for any instance
administrator. RequirePlatformAdmin compared the session user against
cfg.AuthUser alone, so on an instance whose only administrators are SSO
principals the UI offered the surface and the route refused it. Same
capability against enforcement split as 28fd2d1c1, on the hosted routes.

The session branch now uses sessionUserCarriesAdminPrivileges, which is what
the capability already resolves to.

A straight swap would have been worse than the bug. That helper treats any SSO
principal as an administrator when no local admin is configured, and a hosted
control plane authenticates its tenants by SSO, so on a control plane with no
local admin every tenant would have become a platform admin. The session
branch is therefore also gated on the request not being org-scoped, matching
what ensureAdminSession and the security status snapshot already do. Removing
that gate lets an org-scoped tenant session reach the surface with a 200,
which the parity test pins.

The org-scope test itself was written inline in two places and is now one
helper, sessionIsOrgScoped, so the instance-versus-tenant boundary has a
single definition rather than a copy per caller.

RequireOrgOwnerOrPlatformAdmin is untouched. It has no session branch in its
platform-admin switch by design and requires org ownership instead.

Contract-Neutral: behavioral fix on existing routes, no request or response shape change; platform admin route aligned with the billingAdmin capability it already publishes
2026-08-05 13:32:42 +01:00

847 lines
28 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
// detectServiceName detects the actual systemd service name being used
func detectServiceName() string {
// Try common service names
services := []string{"pulse-backend", "pulse", "pulse.service", "pulse-backend.service"}
for _, service := range services {
cmd := exec.Command("systemctl", "status", service)
if err := cmd.Run(); err == nil {
// Service exists
if strings.HasSuffix(service, ".service") {
return strings.TrimSuffix(service, ".service")
}
return service
}
}
// Default to pulse-backend if no service found
return "pulse-backend"
}
// validateBcryptHash ensures the hash is complete (60 characters)
func validateBcryptHash(hash string) error {
if len(hash) != 60 {
return fmt.Errorf("invalid bcrypt hash: expected 60 characters, got %d. Hash may be truncated", len(hash))
}
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
return fmt.Errorf("invalid bcrypt hash: must start with $2a$, $2b$, or $2y$")
}
return nil
}
const maxLocalAuthUsernameRunes = 128
func validateLocalAuthUsername(username string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if username != strings.TrimSpace(username) {
return fmt.Errorf("username must not begin or end with whitespace")
}
if utf8.RuneCountInString(username) > maxLocalAuthUsernameRunes {
return fmt.Errorf("username must be at most %d characters", maxLocalAuthUsernameRunes)
}
for _, r := range username {
if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("._@+-", r) {
continue
}
return fmt.Errorf("username may contain only letters, numbers, periods, underscores, hyphens, plus signs, and @")
}
return nil
}
func quoteAuthEnvValue(value string) (string, error) {
if strings.ContainsAny(value, "'\r\n\x00") {
return "", fmt.Errorf("auth environment value contains unsupported characters")
}
return "'" + value + "'", nil
}
func renderAuthEnvFile(generatedAt time.Time, username, hashedPassword string) ([]byte, error) {
quotedUsername, err := quoteAuthEnvValue(username)
if err != nil {
return nil, err
}
quotedPassword, err := quoteAuthEnvValue(hashedPassword)
if err != nil {
return nil, err
}
return []byte(fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
# Generated on %s
# IMPORTANT: Keep authentication values quoted.
PULSE_AUTH_USER=%s
PULSE_AUTH_PASS=%s
PULSE_AUDIT_LOG=true
`, generatedAt.Format(time.RFC3339), quotedUsername, quotedPassword)), nil
}
func quoteSystemdEnvironment(name, value string) (string, error) {
if strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
return "", fmt.Errorf("systemd environment value contains unsupported characters")
}
return "Environment=" + strconv.Quote(name+"="+value), nil
}
func renderSystemdAuthOverride(generatedAt time.Time, username, hashedPassword string) ([]byte, error) {
usernameLine, err := quoteSystemdEnvironment("PULSE_AUTH_USER", username)
if err != nil {
return nil, err
}
passwordLine, err := quoteSystemdEnvironment("PULSE_AUTH_PASS", hashedPassword)
if err != nil {
return nil, err
}
auditLine, err := quoteSystemdEnvironment("PULSE_AUDIT_LOG", "true")
if err != nil {
return nil, err
}
return []byte(fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
# Generated on %s
[Service]
%s
%s
%s
`, generatedAt.Format(time.RFC3339), usernameLine, passwordLine, auditLine)), nil
}
// isRunningAsRoot checks if the process has root privileges
func isRunningAsRoot() bool {
return os.Geteuid() == 0
}
func ensureAdminSession(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
// Session users must match configured admin identity for privileged operations.
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
sessionUser := strings.TrimSpace(GetSessionUsername(cookie.Value))
configuredAdmin := ""
if cfg != nil {
configuredAdmin = strings.TrimSpace(cfg.AuthUser)
}
if configuredAdmin != "" && strings.EqualFold(sessionUser, configuredAdmin) {
return true
}
// Org-scoped tenant sessions preserve canonical org management
// privileges for settings-bound routes.
orgScoped := false
if org := GetOrganization(req.Context()); org != nil {
orgID := strings.TrimSpace(org.ID)
if orgID != "" && orgID != "default" {
orgScoped = true
if org.CanUserIDManage(sessionUser) {
return true
}
}
}
if !orgScoped && sessionUserCarriesAdminPrivileges(cfg, sessionUser) {
return true
}
if configuredAdmin == "" || !strings.EqualFold(sessionUser, configuredAdmin) {
log.Warn().
Str("path", req.URL.Path).
Str("user", sessionUser).
Msg("Session user missing admin privileges for privileged operation")
http.Error(w, "Admin privileges required", http.StatusForbidden)
return false
}
}
return true
}
// sessionIsOrgScoped reports whether the request is bound to a tenant
// organization rather than the instance itself. Instance-admin rules must not
// be applied to an org-scoped session: those callers are governed by their
// organization's own management rules, and on a control plane configuring no
// local admin they would otherwise inherit the SSO fallback in
// sessionUserCarriesAdminPrivileges and each become an instance administrator.
func sessionIsOrgScoped(req *http.Request) bool {
if req == nil {
return false
}
org := GetOrganization(req.Context())
if org == nil {
return false
}
orgID := strings.TrimSpace(org.ID)
return orgID != "" && orgID != "default"
}
// sessionUserCarriesAdminPrivileges reports whether a non-org-scoped session
// username carries instance admin privileges: the configured local admin
// identity, an RBAC assignment granting the admin action (how SSO group role
// mappings make an SSO user an admin, #1533/#1535), or any SSO principal when
// the instance has no local admin configured at all — the v5 OIDC-only
// pattern, where SSO sessions are the only administrators the instance has.
func sessionUserCarriesAdminPrivileges(cfg *config.Config, sessionUser string) bool {
sessionUser = strings.TrimSpace(sessionUser)
if sessionUser == "" {
return false
}
configuredAdmin := ""
if cfg != nil {
configuredAdmin = strings.TrimSpace(cfg.AuthUser)
}
if configuredAdmin != "" && strings.EqualFold(sessionUser, configuredAdmin) {
return true
}
if sessionUserHasRBACAdminGrant(sessionUser) {
return true
}
return configuredAdmin == "" && strings.HasPrefix(sessionUser, "sso:")
}
// sessionUserHasRBACAdminGrant reports whether the user's effective RBAC
// permissions include an allow of the admin action on all resources — the
// shape of the built-in Administrator role that SSO group role mappings
// assign. A missing manager or empty assignment simply reports false.
func sessionUserHasRBACAdminGrant(username string) bool {
username = strings.TrimSpace(username)
if username == "" {
return false
}
manager := internalauth.GetManager()
if manager == nil {
return false
}
for _, perm := range manager.GetUserPermissions(username) {
if strings.EqualFold(perm.Effect, internalauth.EffectDeny) {
continue
}
if perm.Action == "admin" && perm.Resource == "*" {
return true
}
}
return false
}
func ensureSettingsScope(cfg *config.Config, w http.ResponseWriter, req *http.Request, scope string) bool {
record := getAPITokenRecordFromRequest(req)
if record != nil {
if record.HasScope(scope) {
return true
}
log.Warn().
Str("token_id", record.ID).
Str("path", req.URL.Path).
Str("required_scope", scope).
Msg("API token missing required settings scope for privileged operation")
respondMissingScope(w, scope)
return false
}
if !ensureAdminSession(cfg, w, req) {
return false
}
return true
}
func ensureSettingsReadScope(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
return ensureSettingsScope(cfg, w, req, config.ScopeSettingsRead)
}
func ensureSettingsWriteScope(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
return ensureSettingsScope(cfg, w, req, config.ScopeSettingsWrite)
}
// handleQuickSecuritySetupFixed is the fixed version of the Quick Security Setup
func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Apply rate limiting to prevent brute force attacks
clientIP := GetClientIP(req)
if !authLimiter.Allow(clientIP) {
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for security setup")
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
return
}
// Parse request body
var setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
APIToken string `json:"apiToken"`
EnableNotifications bool `json:"enableNotifications"`
DarkMode bool `json:"darkMode"`
Force bool `json:"force"`
SetupToken string `json:"setupToken"`
}
if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
authConfigured := r.config.AuthUser != "" && r.config.AuthPass != ""
setupCompleted := false
defer func() {
if setupCompleted {
r.clearBootstrapToken()
}
}()
forceRequested := setupRequest.Force
clientIP = GetClientIP(req)
recoveryToken := strings.TrimSpace(req.Header.Get("X-Recovery-Token"))
recoveryAuthorized := false
if recoveryToken != "" {
if GetRecoveryTokenStore().ValidateRecoveryTokenConstantTime(recoveryToken, clientIP) {
recoveryAuthorized = true
log.Warn().
Str("ip", clientIP).
Msg("Quick security setup invoked using recovery token")
} else {
log.Warn().
Str("ip", clientIP).
Msg("Invalid recovery token for quick security setup")
}
}
authorized := recoveryAuthorized
// Only require authentication if credentials are already configured.
if !authorized && authConfigured {
wrapped := &responseCapture{ResponseWriter: w}
if checkAuth(r.config, wrapped, req, false) {
// If proxy auth is configured, require admin role for changes.
if r.config.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid && !isAdmin {
log.Warn().
Str("ip", clientIP).
Str("username", username).
Msg("Non-admin user attempted quick security setup")
http.Error(w, "Admin privileges required", http.StatusForbidden)
return
}
}
authorized = true
} else {
if !wrapped.wrote {
http.Error(w, "Authentication required to modify existing security settings", http.StatusUnauthorized)
}
return
}
}
if !authorized && !authConfigured {
if r.bootstrapTokenHash == "" {
log.Error().Msg("Bootstrap setup token unavailable; refusing unauthenticated quick setup")
http.Error(w, "Bootstrap token unavailable; restart Pulse or inspect data directory", http.StatusServiceUnavailable)
return
}
providedToken := strings.TrimSpace(req.Header.Get(bootstrapTokenHeader))
if providedToken == "" {
providedToken = strings.TrimSpace(setupRequest.SetupToken)
}
// The bootstrap token is the security boundary for initial setup:
// only callers with filesystem access to the data dir can read it.
// A valid token authorizes setup from any origin so users running
// Pulse in a Proxmox LXC (the common case) can complete setup from
// their workstation browser. Without a token, only direct loopback
// can finish setup — that lane preserves the legacy console flow.
if providedToken == "" {
if !isDirectLoopbackRequest(req) {
log.Warn().
Str("ip", clientIP).
Msg("Rejected initial quick setup: no bootstrap token from non-loopback origin")
errorMsg := "Initial security setup requires the bootstrap token when accessed outside localhost. Retrieve it from the host:\n\n" +
"Docker: docker exec <container> /app/pulse bootstrap-token\n" +
"Bare metal: pulse bootstrap-token"
http.Error(w, errorMsg, http.StatusForbidden)
return
}
errorMsg := "Bootstrap setup token required. Retrieve it from the host:\n\n" +
"Docker: docker exec <container> /app/pulse bootstrap-token\n" +
"Bare metal: pulse bootstrap-token"
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
if !r.bootstrapTokenValid(providedToken) {
log.Warn().
Str("ip", clientIP).
Msg("Rejected quick setup with invalid bootstrap token")
errorMsg := "Invalid bootstrap setup token. Retrieve the correct token from the host:\n\n" +
"Docker: docker exec <container> /app/pulse bootstrap-token\n" +
"Bare metal: pulse bootstrap-token"
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
authorized = true
}
if authConfigured && !authorized {
log.Warn().
Str("ip", clientIP).
Msg("Unauthorized quick security setup attempt rejected")
http.Error(w, "Authentication required to modify existing security settings", http.StatusUnauthorized)
return
}
if authorized && !ensureSettingsWriteScope(r.config, w, req) {
return
}
setupRequest.Force = forceRequested && authorized
if authConfigured && !setupRequest.Force {
log.Info().Msg("Security setup skipped - password auth already configured")
response := map[string]interface{}{
"success": true,
"skipped": true,
"message": "Password authentication is already configured. Please remove existing security first if you want to reconfigure.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
if setupRequest.Force {
log.Info().Msg("Quick security setup invoked with force=true - rotating credentials")
}
// Validate inputs before any runtime or persistent security state changes.
if setupRequest.Username == "" || setupRequest.Password == "" || setupRequest.APIToken == "" {
http.Error(w, "Username, password, and API token are required", http.StatusBadRequest)
return
}
if err := validateLocalAuthUsername(setupRequest.Username); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Validate password complexity
if err := internalauth.ValidatePasswordComplexity(setupRequest.Password); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Hash the password
hashedPassword, err := internalauth.HashPassword(setupRequest.Password)
if err != nil {
log.Error().Err(err).Msg("Failed to hash password")
http.Error(w, "Failed to process password", http.StatusInternalServerError)
return
}
// Validate the bcrypt hash is complete
if err := validateBcryptHash(hashedPassword); err != nil {
log.Error().Err(err).Msg("Generated invalid bcrypt hash")
http.Error(w, "Failed to process password", http.StatusInternalServerError)
return
}
authEnvContent, err := renderAuthEnvFile(time.Now(), setupRequest.Username, hashedPassword)
if err != nil {
log.Error().Err(err).Msg("Failed to encode authentication configuration")
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
return
}
// Store the raw API token for displaying to the user
rawAPIToken := setupRequest.APIToken
tokenRecord, err := config.NewAPITokenRecord(rawAPIToken, "Primary token", nil)
if err != nil {
log.Error().Err(err).Msg("Failed to construct API token record")
http.Error(w, "Failed to process API token", http.StatusInternalServerError)
return
}
setAPITokenOwnerUserID(tokenRecord, setupRequest.Username)
if r.config.HasAPITokens() && r.config.AuthUser == "" && r.config.AuthPass == "" {
// We had API-only access before, now replacing with full security
log.Info().Msg("Replacing API-only token with new secure token")
}
// Update runtime config immediately with hashed token - no restart needed!
config.Mu.Lock()
r.config.AuthUser = setupRequest.Username
r.config.AuthPass = hashedPassword
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
r.config.SortAPITokens()
config.Mu.Unlock()
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
log.Warn().Err(err).Msg("Failed to persist API tokens during security setup")
}
}
log.Info().Msg("Runtime config updated with new security settings - active immediately")
if err := r.establishSession(w, req, setupRequest.Username); err != nil {
log.Error().Err(err).Msg("Failed to establish session after quick security setup")
http.Error(w, "Failed to establish session after setup", http.StatusInternalServerError)
return
}
// Clear any agents that connected during the brief unauthenticated setup window.
// This prevents stale/unauthorized agent data from appearing in the wizard.
if r.monitor != nil {
hostCleared, dockerCleared := r.monitor.ClearUnauthenticatedAgents()
if hostCleared > 0 || dockerCleared > 0 {
log.Info().
Int("hosts", hostCleared).
Int("dockerHosts", dockerCleared).
Msg("Cleared agents that connected before security was configured")
}
}
// Save system settings to system.json
systemSettings := config.DefaultSystemSettings()
systemSettings.ConnectionTimeout = 10 // Default seconds
systemSettings.AutoUpdateEnabled = false // Default disabled
if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")
// Continue anyway - not critical for auth setup
}
// Detect environment
isSystemd := os.Getenv("INVOCATION_ID") != ""
isDocker := os.Getenv("PULSE_DOCKER") == "true"
isRoot := isRunningAsRoot()
// Detect actual service name if systemd
serviceName := ""
if isSystemd {
serviceName = detectServiceName()
log.Info().Str("service", serviceName).Msg("Detected systemd service name")
}
// Choose appropriate method based on environment
if isDocker {
envPath, err := writeAuthEnvFile(r.config.ConfigPath, r.config.DataPath, authEnvContent)
if err != nil {
log.Error().Err(err).Msg("Failed to write .env file in Docker")
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
return
}
log.Info().Str("path", envPath).Msg("Docker security configuration saved")
response := map[string]interface{}{
"success": true,
"method": "docker",
"deploymentType": "docker",
"requiresManualRestart": false,
"message": "Security enabled immediately! Your settings are saved and active.",
"note": "Configuration saved to /data/.env for persistence across restarts.",
}
w.Header().Set("Content-Type", "application/json")
setupCompleted = true
json.NewEncoder(w).Encode(response)
} else if isSystemd && !isRoot {
// Systemd but not root (ProxmoxVE script scenario)
// Don't attempt sudo, just save config and provide instructions
envPath, err := writeAuthEnvFile(r.config.ConfigPath, r.config.DataPath, authEnvContent)
if err != nil {
log.Error().Err(err).Msg("Failed to write .env file")
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
return
}
// Create response - security is active immediately
response := map[string]interface{}{
"success": true,
"method": "systemd-nonroot",
"serviceName": serviceName,
"envFile": envPath,
"deploymentType": updates.GetDeploymentType(),
"requiresManualRestart": false,
"message": "Security enabled immediately! Your settings are saved and active.",
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
}
w.Header().Set("Content-Type", "application/json")
setupCompleted = true
json.NewEncoder(w).Encode(response)
} else if isSystemd && isRoot {
// Systemd with root - can apply directly
// Create systemd override
overridePath := fmt.Sprintf("/etc/systemd/system/%s.service.d/override.conf", serviceName)
overrideDir := filepath.Dir(overridePath)
if err := os.MkdirAll(overrideDir, 0755); err != nil {
log.Error().Err(err).Msg("Failed to create override directory")
http.Error(w, "Failed to create systemd override directory", http.StatusInternalServerError)
return
}
overrideContent, err := renderSystemdAuthOverride(time.Now(), setupRequest.Username, hashedPassword)
if err != nil {
log.Error().Err(err).Msg("Failed to encode systemd authentication configuration")
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
return
}
if err := os.WriteFile(overridePath, overrideContent, 0644); err != nil {
log.Error().Err(err).Msg("Failed to write systemd override")
http.Error(w, "Failed to write systemd override", http.StatusInternalServerError)
return
}
// Reload systemd
if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil {
log.Warn().Err(err).Msg("Failed to reload systemd daemon")
}
response := map[string]interface{}{
"success": true,
"method": "systemd-root",
"serviceName": serviceName,
"deploymentType": updates.GetDeploymentType(),
"automatic": true,
"requiresManualRestart": false,
"message": "Security enabled immediately! Your settings are saved and active.",
"note": "Systemd override created for persistence across restarts.",
}
w.Header().Set("Content-Type", "application/json")
setupCompleted = true
json.NewEncoder(w).Encode(response)
} else {
// Manual installation or development
envPath, err := writeAuthEnvFile(r.config.ConfigPath, r.config.DataPath, authEnvContent)
if err != nil {
log.Error().Err(err).Msg("Failed to write .env file")
// Still return success with manual instructions
}
// Get deployment type for restart instructions
deploymentType := updates.GetDeploymentType()
response := map[string]interface{}{
"success": true,
"method": "manual",
"envFile": envPath,
"deploymentType": deploymentType,
"requiresManualRestart": false,
"message": "Security enabled immediately! Your settings are saved and active.",
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
}
w.Header().Set("Content-Type", "application/json")
setupCompleted = true
json.NewEncoder(w).Encode(response)
}
}
}
// HandleRegenerateAPIToken generates and persists a new API token.
func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Request) {
if rq.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !CheckAuth(r.config, w, rq) {
return
}
// Check proxy auth for admin status
if r.config.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(r.config, rq); valid && !isAdmin {
log.Warn().
Str("ip", GetClientIP(rq)).
Str("username", username).
Msg("Non-admin user attempted API token regeneration")
http.Error(w, "Admin privileges required", http.StatusForbidden)
return
}
}
if !ensureSettingsWriteScope(r.config, w, rq) {
return
}
// Apply rate limiting to prevent abuse
clientIP := GetClientIP(rq)
if !authLimiter.Allow(clientIP) {
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for API token generation")
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
return
}
// Generate new token using the auth package
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
log.Error().Err(err).Msg("Failed to generate API token")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
tokenRecord, err := config.NewAPITokenRecord(rawToken, "Regenerated token", nil)
if err != nil {
log.Error().Err(err).Msg("Failed to construct API token record")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
setAPITokenOwnerUserID(tokenRecord, apiTokenOwnerUserIDForRequest(r.config, rq))
config.Mu.Lock()
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
r.config.SortAPITokens()
config.Mu.Unlock()
log.Info().Msg("Runtime config updated with new API token - active immediately")
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
log.Warn().Err(err).Msg("Failed to persist regenerated API token")
}
}
log.Info().Msg("API token regenerated successfully")
// Get deployment type for restart instructions
deploymentType := updates.GetDeploymentType()
response := map[string]interface{}{
"success": true,
"token": rawToken, // Return the raw token to the user (only shown once!)
"deploymentType": deploymentType,
"requiresRestart": false,
"message": "New API token generated and active immediately! Save this token - it won't be shown again.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleValidateAPIToken validates an API token without logging it
func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request) {
if rq.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication to prevent unauthenticated token guessing oracle
if !CheckAuth(r.config, w, rq) {
return
}
// Check proxy auth for admin status
if r.config.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(r.config, rq); valid && !isAdmin {
log.Warn().
Str("ip", GetClientIP(rq)).
Str("username", username).
Msg("Non-admin user attempted API token validation")
http.Error(w, "Admin privileges required", http.StatusForbidden)
return
}
}
if !ensureSettingsWriteScope(r.config, w, rq) {
return
}
// Apply rate limiting to prevent brute force attacks
clientIP := GetClientIP(rq)
if !authLimiter.Allow(clientIP) {
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for API token validation")
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
return
}
// Parse request body
var validateRequest struct {
Token string `json:"token"`
}
if err := json.NewDecoder(rq.Body).Decode(&validateRequest); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if validateRequest.Token == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"valid": false,
"message": "Token is required",
})
return
}
// Check if API token auth is enabled
if !r.config.HasAPITokens() {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"valid": false,
"message": "API token authentication is not configured",
})
return
}
// Validate the token (compare hash)
config.Mu.RLock()
_, isValid := r.config.ValidateAPIToken(validateRequest.Token)
config.Mu.RUnlock()
// Log validation attempt without logging the token itself
if isValid {
log.Debug().
Str("ip", clientIP).
Msg("API token validation successful")
} else {
log.Warn().
Str("ip", clientIP).
Msg("API token validation failed")
}
// Return validation result
response := map[string]interface{}{
"valid": isValid,
}
if isValid {
response["message"] = "Token is valid"
} else {
response["message"] = "Token is invalid"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}