diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index da000974d..ae21be7ed 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -551,8 +551,10 @@ const Settings: Component = () => { return; } - // Check if API token is required but not set - if (securityStatus()?.apiTokenConfigured && !localStorage.getItem('apiToken')) { + // Only check for API token if user is not authenticated via password + // If user is logged in with password, session auth is sufficient + const hasPasswordAuth = securityStatus()?.hasAuthentication; + if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !localStorage.getItem('apiToken')) { setApiTokenModalSource('export'); setShowApiTokenModal(true); return; @@ -572,23 +574,33 @@ const Settings: Component = () => { const response = await fetch('/api/config/export', { method: 'POST', headers, + credentials: 'include', // Include cookies for session auth body: JSON.stringify({ passphrase: exportPassphrase() }), }); if (!response.ok) { const errorText = await response.text(); - // Handle authentication errors by clearing invalid token and re-prompting - if (response.status === 401 || response.status === 403 || errorText.includes('API_TOKEN') || errorText.includes('Unauthorized')) { - // Clear invalid token if we had one - const hadToken = localStorage.getItem('apiToken'); - if (hadToken) { - localStorage.removeItem('apiToken'); - showError('Invalid or expired API token. Please re-enter.'); - setApiTokenModalSource('export'); - setShowApiTokenModal(true); - return; + // Handle authentication errors + if (response.status === 401 || response.status === 403) { + // Check if we're using API token auth (not password auth) + const hasPasswordAuth = securityStatus()?.hasAuthentication; + if (!hasPasswordAuth) { + // Clear invalid token if we had one + const hadToken = localStorage.getItem('apiToken'); + if (hadToken) { + localStorage.removeItem('apiToken'); + showError('Invalid or expired API token. Please re-enter.'); + setApiTokenModalSource('export'); + setShowApiTokenModal(true); + return; + } + if (errorText.includes('API_TOKEN')) { + setApiTokenModalSource('export'); + setShowApiTokenModal(true); + return; + } } - throw new Error('Export requires authentication. Set API_TOKEN or ALLOW_UNPROTECTED_EXPORT=true in environment variables.'); + throw new Error('Export requires authentication'); } throw new Error(errorText || 'Export failed'); } @@ -627,8 +639,10 @@ const Settings: Component = () => { return; } - // Check if API token is required but not set - if (securityStatus()?.apiTokenConfigured && !localStorage.getItem('apiToken')) { + // Only check for API token if user is not authenticated via password + // If user is logged in with password, session auth is sufficient + const hasPasswordAuth = securityStatus()?.hasAuthentication; + if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !localStorage.getItem('apiToken')) { setApiTokenModalSource('import'); setShowApiTokenModal(true); return; @@ -658,6 +672,7 @@ const Settings: Component = () => { const response = await fetch('/api/config/import', { method: 'POST', headers, + credentials: 'include', // Include cookies for session auth body: JSON.stringify({ passphrase: importPassphrase(), data: exportData.data, @@ -666,18 +681,27 @@ const Settings: Component = () => { if (!response.ok) { const errorText = await response.text(); - // Handle authentication errors by clearing invalid token and re-prompting - if (response.status === 401 || response.status === 403 || errorText.includes('API_TOKEN') || errorText.includes('Unauthorized')) { - // Clear invalid token if we had one - const hadToken = localStorage.getItem('apiToken'); - if (hadToken) { - localStorage.removeItem('apiToken'); - showError('Invalid or expired API token. Please re-enter.'); - setApiTokenModalSource('import'); - setShowApiTokenModal(true); - return; + // Handle authentication errors + if (response.status === 401 || response.status === 403) { + // Check if we're using API token auth (not password auth) + const hasPasswordAuth = securityStatus()?.hasAuthentication; + if (!hasPasswordAuth) { + // Clear invalid token if we had one + const hadToken = localStorage.getItem('apiToken'); + if (hadToken) { + localStorage.removeItem('apiToken'); + showError('Invalid or expired API token. Please re-enter.'); + setApiTokenModalSource('import'); + setShowApiTokenModal(true); + return; + } + if (errorText.includes('API_TOKEN')) { + setApiTokenModalSource('import'); + setShowApiTokenModal(true); + return; + } } - throw new Error('Import requires authentication. Set API_TOKEN or ALLOW_UNPROTECTED_EXPORT=true in environment variables.'); + throw new Error('Import requires authentication'); } throw new Error(errorText || 'Import failed'); } diff --git a/internal/api/router.go b/internal/api/router.go index cf413b827..8eaecc0e1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -518,33 +518,55 @@ ENABLE_AUDIT_LOG=true } }) - // Config export/import routes (requires API token for security) + // Config export/import routes (requires authentication) r.mux.HandleFunc("/api/config/export", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodPost { - // Check for API token if configured + // Check authentication - accept either session auth or API token + hasValidSession := false + if cookie, err := req.Cookie("pulse_session"); err == nil && cookie.Value != "" { + hasValidSession = ValidateSession(cookie.Value) + } + + hasValidAPIToken := false if r.config.APIToken != "" { authHeader := req.Header.Get("X-API-Token") - if authHeader != r.config.APIToken { + hasValidAPIToken = (authHeader == r.config.APIToken) + } + + // If password auth is configured, session auth is sufficient + if r.config.AuthUser != "" && r.config.AuthPass != "" { + if !hasValidSession && !hasValidAPIToken { log.Warn(). Str("ip", req.RemoteAddr). Str("path", req.URL.Path). - Msg("Unauthorized export attempt") + Msg("Unauthorized export attempt - no valid session or API token") + http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized) + return + } + } else if r.config.APIToken != "" { + // API token configured but no password auth - require API token + if !hasValidAPIToken { + log.Warn(). + Str("ip", req.RemoteAddr). + Str("path", req.URL.Path). + Msg("Unauthorized export attempt - invalid API token") http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } else if os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true" { - // If no API token and unprotected export not explicitly allowed + // No auth configured and unprotected export not explicitly allowed log.Warn(). Str("ip", req.RemoteAddr). - Msg("Export blocked - API token required") - http.Error(w, "Export requires API_TOKEN to be set (or set ALLOW_UNPROTECTED_EXPORT=true for homelab use)", http.StatusForbidden) + Msg("Export blocked - authentication required") + http.Error(w, "Export requires authentication (set ALLOW_UNPROTECTED_EXPORT=true for homelab use)", http.StatusForbidden) return } // Log successful export attempt log.Info(). Str("ip", req.RemoteAddr). - Bool("authenticated", r.config.APIToken != ""). + Bool("session_auth", hasValidSession). + Bool("api_token_auth", hasValidAPIToken). Msg("Configuration export initiated") configHandlers.HandleExportConfig(w, req) @@ -555,30 +577,52 @@ ENABLE_AUDIT_LOG=true r.mux.HandleFunc("/api/config/import", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodPost { - // Check for API token if configured + // Check authentication - accept either session auth or API token + hasValidSession := false + if cookie, err := req.Cookie("pulse_session"); err == nil && cookie.Value != "" { + hasValidSession = ValidateSession(cookie.Value) + } + + hasValidAPIToken := false if r.config.APIToken != "" { authHeader := req.Header.Get("X-API-Token") - if authHeader != r.config.APIToken { + hasValidAPIToken = (authHeader == r.config.APIToken) + } + + // If password auth is configured, session auth is sufficient + if r.config.AuthUser != "" && r.config.AuthPass != "" { + if !hasValidSession && !hasValidAPIToken { log.Warn(). Str("ip", req.RemoteAddr). Str("path", req.URL.Path). - Msg("Unauthorized import attempt") + Msg("Unauthorized import attempt - no valid session or API token") + http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized) + return + } + } else if r.config.APIToken != "" { + // API token configured but no password auth - require API token + if !hasValidAPIToken { + log.Warn(). + Str("ip", req.RemoteAddr). + Str("path", req.URL.Path). + Msg("Unauthorized import attempt - invalid API token") http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } else if os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true" { - // If no API token and unprotected import not explicitly allowed + // No auth configured and unprotected import not explicitly allowed log.Warn(). Str("ip", req.RemoteAddr). - Msg("Import blocked - API token required") - http.Error(w, "Import requires API_TOKEN to be set (or set ALLOW_UNPROTECTED_EXPORT=true for homelab use)", http.StatusForbidden) + Msg("Import blocked - authentication required") + http.Error(w, "Import requires authentication (set ALLOW_UNPROTECTED_EXPORT=true for homelab use)", http.StatusForbidden) return } // Log successful import attempt log.Info(). Str("ip", req.RemoteAddr). - Bool("authenticated", r.config.APIToken != ""). + Bool("session_auth", hasValidSession). + Bool("api_token_auth", hasValidAPIToken). Msg("Configuration import initiated") configHandlers.HandleImportConfig(w, req)