From 6433493232aeca3344bd4a88e9505c4754e6bc05 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 29 Sep 2025 17:42:10 +0000 Subject: [PATCH] Refine security settings UI and credential rotation flow --- docs/CONFIGURATION.md | 2 +- .../Settings/QuickSecuritySetup.tsx | 44 ++- .../src/components/Settings/Settings.tsx | 322 ++++++++++-------- frontend-modern/src/types/config.ts | 3 +- internal/api/security_setup_fix.go | 222 ++++++------ 5 files changed, 318 insertions(+), 275 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b9b58a450..b665117ae 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -41,7 +41,7 @@ API_TOKEN=abc123... # API token (plain text, not hashed) # Security settings DISABLE_AUTH=true # Disable authentication entirely -ENABLE_AUDIT_LOG=true # Enable security audit logging +PULSE_AUDIT_LOG=true # Enable security audit logging # Proxy/SSO Authentication (see docs/PROXY_AUTH.md for full details) PROXY_AUTH_SECRET=secret123 # Shared secret between proxy and Pulse diff --git a/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx b/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx index 6ce94e34d..a9b0c8c99 100644 --- a/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx +++ b/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx @@ -12,15 +12,19 @@ interface SecurityCredentials { interface QuickSecuritySetupProps { onConfigured?: () => void; + defaultUsername?: string; + mode?: 'initial' | 'rotate'; } export const QuickSecuritySetup: Component = (props) => { + const mode = props.mode ?? 'initial'; + const isRotation = mode === 'rotate'; const [isSettingUp, setIsSettingUp] = createSignal(false); const [credentials, setCredentials] = createSignal(null); const [showCredentials, setShowCredentials] = createSignal(false); const [copied, setCopied] = createSignal<'username' | 'password' | 'token' | null>(null); const [useCustomPassword, setUseCustomPassword] = createSignal(false); - const [customUsername, setCustomUsername] = createSignal('admin'); + const [customUsername, setCustomUsername] = createSignal(props.defaultUsername ?? 'admin'); const [customPassword, setCustomPassword] = createSignal(''); const [confirmPassword, setConfirmPassword] = createSignal(''); @@ -83,7 +87,10 @@ export const QuickSecuritySetup: Component = (props) => headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(newCredentials), + body: JSON.stringify({ + ...newCredentials, + force: isRotation, + }), credentials: 'include' // Include cookies for CSRF }); @@ -97,7 +104,7 @@ export const QuickSecuritySetup: Component = (props) => if (result.skipped) { // Security was already configured, don't show credentials - showError('Security is already configured. Please remove existing security first if you want to reconfigure.'); + showError(result.message || 'Security is already configured. Please remove existing security first if you want to reconfigure.'); if (props.onConfigured) { props.onConfigured(); } @@ -109,7 +116,7 @@ export const QuickSecuritySetup: Component = (props) => setShowCredentials(true); // Show success message - showSuccess('Security configured! Save your credentials before continuing.'); + showSuccess(isRotation ? 'Admin credentials generated. Save them before continuing.' : 'Security configured. Save your credentials before continuing.'); // DON'T notify parent yet - wait until user dismisses credentials // if (props.onConfigured) { @@ -125,7 +132,7 @@ export const QuickSecuritySetup: Component = (props) => const downloadCredentials = () => { if (!credentials()) return; - const content = `Pulse Security Credentials + const content = `Pulse Admin Credentials ${isRotation ? '(Rotated)' : ''} Generated: ${new Date().toISOString()} Basic Authentication: @@ -162,8 +169,8 @@ Important:
  • ✓ - Generate secure random password + {isRotation ? 'Generate a new secure password' : 'Generate secure random password'}
  • ✓ - Enable basic authentication + {isRotation ? 'Replace the stored admin password' : 'Enable basic authentication'}
  • ✓ - Create API token for automation + {isRotation ? 'Create a new API token for automation' : 'Create API token for automation'}
  • ✓ @@ -276,6 +283,11 @@ Important:

    {useCustomPassword() ? 'Your password will be hashed before storage' : 'Credentials will be shown only once. Save them immediately!'}

    + +

    + Existing sessions will be logged out once Pulse restarts with the new credentials. +

    +
  • @@ -291,10 +303,10 @@ Important: - Setting up security... + {isRotation ? 'Rotating credentials...' : 'Setting up security...'} ) : ( - 'Enable Security Now' + (isRotation ? 'Rotate credentials' : 'Enable Security Now') )} @@ -304,7 +316,7 @@ Important:
    - Download Credentials + Download credentials
    @@ -377,14 +389,14 @@ Important: Use this token with the X-API-Token header for automation.

    - ⚠️ This token will never be shown again. Save it now! + This token is only shown once. Save it now.

    - ✅ Security configured successfully! + Credentials saved

    The service needs to be restarted for security settings to take effect. diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 80208d2fa..4f8e59ac6 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -162,6 +162,7 @@ const Settings: Component = () => { const [apiTokenInput, setApiTokenInput] = createSignal(''); const [apiTokenModalSource, setApiTokenModalSource] = createSignal<'export' | 'import' | null>(null); const [showQuickSecuritySetup, setShowQuickSecuritySetup] = createSignal(false); + const [showQuickSecurityWizard, setShowQuickSecurityWizard] = createSignal(false); const formatTimestamp = (timestamp?: string) => { if (!timestamp) { @@ -1589,39 +1590,47 @@ const Settings: Component = () => { {/* System Settings Tab */}

    -
    - - - {/* Environment Variable Info */} -
    -
    - - - -
    -

    Configuration Priority:

    -

    • Some env vars override settings (API_TOKEN, PORTS, AUTH)

    -

    • Changes made here are saved to system.json immediately

    -

    • Settings persist unless overridden by env vars

    -
    + + + +
    + + + +
    +

    Configuration Priority

    +
      +
    • • Some env vars override settings (API_TOKEN, PORTS, AUTH)
    • +
    • • Changes made here are saved to system.json immediately
    • +
    • • Settings persist unless overridden by env vars
    • +
    - -
    - - {/* Network Settings */} -
    -

    + + +
    + +
    +

    Network Settings

    - -
    +
    -

    For reverse proxy setups (* = allow all, empty = same-origin only)

    +

    For reverse proxy setups (* = allow all, empty = same-origin only)

    { disabled={envOverrides().allowedOrigins} placeholder="* or https://example.com" class={`w-full px-3 py-1.5 text-sm border rounded-lg ${ - envOverrides().allowedOrigins - ? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75' + envOverrides().allowedOrigins + ? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75' : 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800' }`} /> @@ -1655,67 +1664,71 @@ const Settings: Component = () => { )}
    - - {/* Iframe Embedding Settings */} -
    - -

    Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)

    - -
    -
    +
    + +
    +

    + + + + + Embedding +

    +

    Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)

    +
    +
    + { + setAllowEmbedding(e.currentTarget.checked); + setHasUnsavedChanges(true); + }} + class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" + /> + +
    + + +
    + +

    Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)

    { - setAllowEmbedding(e.currentTarget.checked); + setAllowedEmbedOrigins(e.currentTarget.value); setHasUnsavedChanges(true); }} - class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" + placeholder="https://my.domain, https://dashboard.example.com" + class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800" /> - +

    + Example: If Pulse is at pulse.my.domain and your dashboard is at my.domain, add https://my.domain here. +

    - - -
    - -

    Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)

    - { - setAllowedEmbedOrigins(e.currentTarget.value); - setHasUnsavedChanges(true); - }} - placeholder="https://my.domain, https://dashboard.example.com" - class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800" - /> -

    - Example: If Pulse is at pulse.my.domain and your dashboard is at my.domain, - add https://my.domain here. -

    -
    -
    -
    +
    - -
    -

    - Port Configuration: Use systemctl edit pulse -

    -

    - [Service]
    - Environment="FRONTEND_PORT=8080"
    - Then restart: sudo systemctl restart pulse -

    -
    -

    - - {/* Update Settings */} -
    -

    + + + +

    + Port Configuration: Use systemctl edit pulse +

    +

    + [Service]
    + Environment="FRONTEND_PORT=8080"
    + Then restart: sudo systemctl restart pulse +

    +
    + + + +
    +

    @@ -1723,10 +1736,9 @@ const Settings: Component = () => { Updates

    - +
    - {/* Version Info */} -
    +

    @@ -1754,8 +1766,7 @@ const Settings: Component = () => { )}

    - - {/* Docker Message */} +

    @@ -1763,11 +1774,10 @@ const Settings: Component = () => {

    - - {/* Update Available */} + -
    -
    +
    +

    Update Available: {updateInfo()?.latestVersion}

    @@ -1775,10 +1785,9 @@ const Settings: Component = () => { Released: {updateInfo()?.releaseDate ? new Date(updateInfo()!.releaseDate).toLocaleDateString() : 'Unknown'}

    - - {/* Update Instructions based on deployment type */} -
    -

    How to update:

    + +
    +

    How to update:

    Type update in the LXC console @@ -1806,10 +1815,15 @@ const Settings: Component = () => { Pull latest changes and rebuild

    + +

    + Pull the latest Pulse Docker image and recreate your container. +

    +
    - + -
    +
    Release Notes
                                     {updateInfo()?.releaseNotes}
    @@ -1818,11 +1832,9 @@ const Settings: Component = () => {
                               
                             
    - - - {/* Update Settings */} +
    -
    +

    @@ -1842,8 +1854,8 @@ const Settings: Component = () => {

    - -
    + +

    @@ -1864,10 +1876,10 @@ const Settings: Component = () => {

    - + -
    -
    +
    +

    @@ -1888,8 +1900,8 @@ const Settings: Component = () => {

    - -
    + +

    @@ -1910,11 +1922,10 @@ const Settings: Component = () => {

    -
    - -
    +
    +

    - + {/* Backup & Restore - Moved from Security tab */} { {/* Content */}
    -
    - +
    + Need to replace both the password and API token? +
    -
    -
    Change Password
    -
    Update your login credentials
    -
    - - -
    - - - -
    -
    -
    Run quick security wizard
    -
    Refresh authentication end-to-end
    -
    -
    +
    -
    + +
    + { + setShowQuickSecurityWizard(false); + loadSecurityStatus(); + }} + /> +
    +
    + +
    diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index 2e816ea3d..df932d541 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -17,7 +17,8 @@ export interface AuthConfig { PULSE_AUTH_USER: string; // Admin username PULSE_AUTH_PASS: string; // Bcrypt hashed password API_TOKEN: string; // API authentication token - ENABLE_AUDIT_LOG?: boolean; // Enable audit logging + ENABLE_AUDIT_LOG?: boolean; // @deprecated - use PULSE_AUDIT_LOG + PULSE_AUDIT_LOG?: boolean; // Enable audit logging } /** diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index ad06ec9f8..3a7586b9a 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -20,7 +20,7 @@ import ( 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 { @@ -31,7 +31,7 @@ func detectServiceName() string { return service } } - + // Default to pulse-backend if no service found return "pulse-backend" } @@ -68,9 +68,22 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { return } - // Check if password auth is already configured - // Allow adding password auth on top of API-only access - if r.config.AuthUser != "" && r.config.AuthPass != "" { + // 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"` + } + + if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if r.config.AuthUser != "" && r.config.AuthPass != "" && !setupRequest.Force { log.Info().Msg("Security setup skipped - password auth already configured") response := map[string]interface{}{ "success": true, @@ -82,33 +95,22 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { 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"` + if setupRequest.Force { + log.Info().Msg("Quick security setup invoked with force=true - rotating credentials") } - - if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - + // Validate inputs if setupRequest.Username == "" || setupRequest.Password == "" || setupRequest.APIToken == "" { http.Error(w, "Username, password, and API token are required", 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 { @@ -116,59 +118,59 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { 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, fmt.Sprintf("Password hashing error: %v", err), http.StatusInternalServerError) return } - + // Store the raw API token for displaying to the user rawAPIToken := setupRequest.APIToken - + // Hash the API token for storage hashedAPIToken := internalauth.HashAPIToken(rawAPIToken) - + if r.config.APIToken != "" && 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! r.config.AuthUser = setupRequest.Username r.config.AuthPass = hashedPassword r.config.APIToken = hashedAPIToken r.config.APITokenEnabled = true log.Info().Msg("Runtime config updated with new security settings - active immediately") - + // Save system settings to system.json systemSettings := config.SystemSettings{ - ConnectionTimeout: 10, // Default - AutoUpdateEnabled: false, // Default + ConnectionTimeout: 10, // Default + AutoUpdateEnabled: false, // Default } 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 { // Docker: Save to /data/.env with proper quoting envPath := filepath.Join(r.config.ConfigPath, ".env") - + // CRITICAL: Use single quotes to prevent shell expansion of $ in bcrypt hash envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup # Generated on %s @@ -176,48 +178,48 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' API_TOKEN=%s -ENABLE_AUDIT_LOG=true +PULSE_AUDIT_LOG=true `, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) - + // Ensure directory exists os.MkdirAll(r.config.ConfigPath, 0755) - + if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { log.Error().Err(err).Str("path", envPath).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", + "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.", + "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") 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 := filepath.Join(r.config.ConfigPath, ".env") envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup # Generated on %s PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' API_TOKEN=%s -ENABLE_AUDIT_LOG=true +PULSE_AUDIT_LOG=true `, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) - + // Save to config directory (usually /etc/pulse) os.MkdirAll(r.config.ConfigPath, 0755) - + if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { // Try data directory as fallback envPath = filepath.Join(r.config.DataPath, ".env") @@ -228,103 +230,103 @@ ENABLE_AUDIT_LOG=true return } } - + // Create response - security is active immediately response := map[string]interface{}{ - "success": true, - "method": "systemd-nonroot", - "serviceName": serviceName, - "envFile": envPath, - "deploymentType": updates.GetDeploymentType(), + "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), + "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") 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 := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup # Generated on %s [Service] Environment="PULSE_AUTH_USER=%s" Environment="PULSE_AUTH_PASS=%s" Environment="API_TOKEN=%s" -Environment="ENABLE_AUDIT_LOG=true" +Environment="PULSE_AUDIT_LOG=true" `, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) - + if err := os.WriteFile(overridePath, []byte(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 exec.Command("systemctl", "daemon-reload").Run() - + response := map[string]interface{}{ - "success": true, - "method": "systemd-root", - "serviceName": serviceName, - "deploymentType": updates.GetDeploymentType(), - "automatic": true, + "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.", + "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") json.NewEncoder(w).Encode(response) - + } else { // Manual installation or development envPath := filepath.Join(r.config.ConfigPath, ".env") if r.config.ConfigPath == "" { envPath = "/etc/pulse/.env" } - + envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup # Generated on %s PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' API_TOKEN=%s -ENABLE_AUDIT_LOG=true +PULSE_AUDIT_LOG=true `, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) - + // Try to create directory if needed os.MkdirAll(filepath.Dir(envPath), 0755) - + if err := os.WriteFile(envPath, []byte(envContent), 0600); 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, + "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), + "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") json.NewEncoder(w).Encode(response) } @@ -339,7 +341,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques if !r.config.DisableAuth && (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) { return } - + // Check if using proxy auth and if so, verify admin status if r.config.ProxyAuthSecret != "" { if valid, username, isAdmin := CheckProxyAuth(r.config, rq); valid { @@ -351,7 +353,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques Str("method", rq.Method). Str("username", username). Msg("Non-admin user attempted to regenerate API token") - + // Return forbidden error w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) @@ -360,12 +362,12 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques } } } - + if rq.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - + // Apply rate limiting to prevent abuse clientIP := GetClientIP(rq) if !authLimiter.Allow(clientIP) { @@ -373,7 +375,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques 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 { @@ -381,26 +383,26 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } - + // Hash the token for storage hashedToken := internalauth.HashAPIToken(rawToken) - + // Update runtime config immediately with hashed token - no restart needed! r.config.APIToken = hashedToken r.config.APITokenEnabled = true log.Info().Msg("Runtime config updated with new hashed API token - active immediately") - + // Determine env file path envPath := filepath.Join(r.config.ConfigPath, ".env") if r.config.ConfigPath == "" { envPath = "/etc/pulse/.env" } - + // Docker uses /data/.env if _, err := os.Stat("/data/.env"); err == nil { envPath = "/data/.env" } - + // Read existing .env file content, err := os.ReadFile(envPath) if err != nil { @@ -408,7 +410,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques http.Error(w, "Security configuration not found", http.StatusNotFound) return } - + // Update the API_TOKEN line with the hashed token lines := strings.Split(string(content), "\n") var updated bool @@ -419,12 +421,12 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques break } } - + if !updated { // API_TOKEN line not found, add it lines = append(lines, fmt.Sprintf("API_TOKEN=%s", hashedToken)) } - + // Write updated content back newContent := strings.Join(lines, "\n") if err := os.WriteFile(envPath, []byte(newContent), 0600); err != nil { @@ -432,20 +434,20 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques http.Error(w, "Failed to save new token", http.StatusInternalServerError) return } - + 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, + "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.", + "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) -} \ No newline at end of file +}