mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 10:43:36 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user