fix: simplify export/import authentication flow

- Allow export/import with session auth when logged in with password
- No longer require API token when user is already authenticated
- Backend now accepts either session cookies OR API token
- Frontend only prompts for API token if no password auth exists
- Improved UX by eliminating redundant authentication requests
This commit is contained in:
Pulse Monitor
2025-08-14 09:32:24 +00:00
parent c845bfb9ee
commit 01bf22b419
2 changed files with 109 additions and 41 deletions
@@ -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');
}
+59 -15
View File
@@ -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)