Files
michaelschiemer/resources/js/modules/csrf-auto-refresh.js
Michael Schiemer e30753ba0e fix: resolve RedisCache array offset error and improve discovery diagnostics
- Fix RedisCache driver to handle MGET failures gracefully with fallback
- Add comprehensive discovery context comparison debug tools
- Identify root cause: WEB context discovery missing 166 items vs CLI
- WEB context missing RequestFactory class entirely (52 vs 69 commands)
- Improved exception handling with detailed binding diagnostics
2025-09-12 20:05:18 +02:00

377 lines
12 KiB
JavaScript

/**
* CSRF Token Auto-Refresh System
*
* Automatically refreshes CSRF tokens before they expire to prevent
* form submission errors when users keep forms open for extended periods.
*
* Features:
* - Automatic token refresh every 105 minutes (15 minutes before expiry)
* - Visual feedback when tokens are refreshed
* - Graceful error handling with fallback strategies
* - Page visibility optimization (pause when tab is inactive)
* - Multiple form support
*
* Usage:
* import { CsrfAutoRefresh } from './modules/csrf-auto-refresh.js';
*
* // Initialize for contact form
* const csrfRefresh = new CsrfAutoRefresh({
* formId: 'contact_form',
* refreshInterval: 105 * 60 * 1000 // 105 minutes
* });
*
* // Or auto-detect all forms with CSRF tokens
* CsrfAutoRefresh.initializeAll();
*/
export class CsrfAutoRefresh {
/**
* Default configuration
*/
static DEFAULT_CONFIG = {
formId: 'contact_form',
refreshInterval: 105 * 60 * 1000, // 105 minutes (15 min before 2h expiry)
apiEndpoint: '/api/csrf/refresh',
tokenSelector: 'input[name="_token"]',
formIdSelector: 'input[name="_form_id"]',
enableVisualFeedback: false, // Disabled to hide browser notifications
enableConsoleLogging: true,
pauseWhenHidden: true, // Pause refresh when tab is not visible
maxRetries: 3,
retryDelay: 5000 // 5 seconds
};
/**
* @param {Object} config Configuration options
*/
constructor(config = {}) {
this.config = { ...CsrfAutoRefresh.DEFAULT_CONFIG, ...config };
this.intervalId = null;
this.isActive = false;
this.retryCount = 0;
this.lastRefreshTime = null;
// Track page visibility for optimization
this.isPageVisible = !document.hidden;
this.log('CSRF Auto-Refresh initialized', this.config);
// Setup event listeners
this.setupEventListeners();
// Start auto-refresh if page is visible
if (this.isPageVisible) {
this.start();
}
}
/**
* Setup event listeners for page visibility and unload
*/
setupEventListeners() {
if (this.config.pauseWhenHidden) {
document.addEventListener('visibilitychange', () => {
this.isPageVisible = !document.hidden;
if (this.isPageVisible) {
this.log('Page became visible, resuming CSRF refresh');
this.start();
} else {
this.log('Page became hidden, pausing CSRF refresh');
this.stop();
}
});
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
this.stop();
});
}
/**
* Start the auto-refresh timer
*/
start() {
if (this.isActive) {
this.log('Auto-refresh already active');
return;
}
this.isActive = true;
// Set up interval for token refresh
this.intervalId = setInterval(() => {
this.refreshToken();
}, this.config.refreshInterval);
this.log(`Auto-refresh started. Next refresh in ${this.config.refreshInterval / 1000 / 60} minutes`);
// Show visual feedback if enabled
if (this.config.enableVisualFeedback) {
// this.showStatusMessage('CSRF protection enabled - tokens will refresh automatically', 'info');
}
}
/**
* Stop the auto-refresh timer
*/
stop() {
if (!this.isActive) {
return;
}
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.isActive = false;
this.log('Auto-refresh stopped');
}
/**
* Refresh the CSRF token via API call
*/
async refreshToken() {
if (!this.isPageVisible && this.config.pauseWhenHidden) {
this.log('Page not visible, skipping refresh');
return;
}
try {
this.log('Refreshing CSRF token...');
const response = await fetch(`${this.config.apiEndpoint}?form_id=${encodeURIComponent(this.config.formId)}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success || !data.token) {
throw new Error(data.error || 'Invalid response from server');
}
// Update token in all matching forms
this.updateTokenInForms(data.token);
this.lastRefreshTime = new Date();
this.retryCount = 0; // Reset retry count on success
this.log('CSRF token refreshed successfully', {
token: data.token.substring(0, 8) + '...',
formId: data.form_id,
expiresIn: data.expires_in
});
// Show visual feedback
if (this.config.enableVisualFeedback) {
// this.showStatusMessage('Security token refreshed', 'success');
}
} catch (error) {
this.handleRefreshError(error);
}
}
/**
* Update CSRF token in all forms on the page
*/
updateTokenInForms(newToken) {
const tokenInputs = document.querySelectorAll(this.config.tokenSelector);
let updatedCount = 0;
tokenInputs.forEach(input => {
// Check if this token belongs to our form
const form = input.closest('form');
if (form) {
const formIdInput = form.querySelector(this.config.formIdSelector);
if (formIdInput && formIdInput.value === this.config.formId) {
input.value = newToken;
updatedCount++;
}
}
});
this.log(`Updated ${updatedCount} token input(s)`);
if (updatedCount === 0) {
console.warn('CsrfAutoRefresh: No token inputs found to update. Check your selectors.');
}
return updatedCount;
}
/**
* Handle refresh errors with retry logic
*/
handleRefreshError(error) {
this.retryCount++;
console.error('CSRF token refresh failed:', error);
if (this.retryCount <= this.config.maxRetries) {
this.log(`Retrying in ${this.config.retryDelay / 1000}s (attempt ${this.retryCount}/${this.config.maxRetries})`);
setTimeout(() => {
this.refreshToken();
}, this.config.retryDelay);
// Show visual feedback for retry
if (this.config.enableVisualFeedback) {
// this.showStatusMessage(`Token refresh failed, retrying... (${this.retryCount}/${this.config.maxRetries})`, 'warning');
}
} else {
// Max retries reached
this.log('Max retries reached, stopping auto-refresh');
this.stop();
if (this.config.enableVisualFeedback) {
// this.showStatusMessage('Token refresh failed. Please refresh the page if you encounter errors.', 'error');
}
}
}
/**
* Show visual status message to user
*/
showStatusMessage(message, type = 'info') {
// Create or update status element
let statusEl = document.getElementById('csrf-status-message');
if (!statusEl) {
statusEl = document.createElement('div');
statusEl.id = 'csrf-status-message';
statusEl.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 16px;
border-radius: 6px;
font-size: 14px;
z-index: 10000;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transition: opacity 0.3s ease;
`;
document.body.appendChild(statusEl);
}
// Set message and styling based on type
statusEl.textContent = message;
statusEl.className = `csrf-status-${type}`;
// Style based on type
const styles = {
info: 'background: #e3f2fd; color: #1976d2; border-left: 4px solid #2196f3;',
success: 'background: #e8f5e8; color: #2e7d32; border-left: 4px solid #4caf50;',
warning: 'background: #fff3e0; color: #f57c00; border-left: 4px solid #ff9800;',
error: 'background: #ffebee; color: #d32f2f; border-left: 4px solid #f44336;'
};
statusEl.style.cssText += styles[type] || styles.info;
statusEl.style.opacity = '1';
// Auto-hide after delay (except for errors)
if (type !== 'error') {
setTimeout(() => {
if (statusEl) {
statusEl.style.opacity = '0';
setTimeout(() => {
if (statusEl && statusEl.parentNode) {
statusEl.parentNode.removeChild(statusEl);
}
}, 300);
}
}, type === 'success' ? 3000 : 5000);
}
}
/**
* Log messages if console logging is enabled
*/
log(message, data = null) {
if (this.config.enableConsoleLogging) {
const timestamp = new Date().toLocaleTimeString();
if (data) {
console.log(`[${timestamp}] CsrfAutoRefresh: ${message}`, data);
} else {
console.log(`[${timestamp}] CsrfAutoRefresh: ${message}`);
}
}
}
/**
* Get current status information
*/
getStatus() {
return {
isActive: this.isActive,
formId: this.config.formId,
lastRefreshTime: this.lastRefreshTime,
retryCount: this.retryCount,
isPageVisible: this.isPageVisible,
nextRefreshIn: this.isActive ?
Math.max(0, this.config.refreshInterval - (Date.now() - (this.lastRefreshTime?.getTime() || Date.now()))) :
null
};
}
/**
* Manual token refresh (useful for debugging)
*/
async manualRefresh() {
this.log('Manual refresh triggered');
await this.refreshToken();
}
/**
* Static method to initialize auto-refresh for all forms with CSRF tokens
*/
static initializeAll() {
const tokenInputs = document.querySelectorAll('input[name="_token"]');
const formIds = new Set();
// Collect unique form IDs
tokenInputs.forEach(input => {
const form = input.closest('form');
if (form) {
const formIdInput = form.querySelector('input[name="_form_id"]');
if (formIdInput && formIdInput.value) {
formIds.add(formIdInput.value);
}
}
});
// Initialize auto-refresh for each unique form ID
const instances = [];
formIds.forEach(formId => {
const instance = new CsrfAutoRefresh({ formId });
instances.push(instance);
});
console.log(`CsrfAutoRefresh: Initialized for ${instances.length} forms:`, Array.from(formIds));
return instances;
}
}
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
CsrfAutoRefresh.initializeAll();
});
} else {
CsrfAutoRefresh.initializeAll();
}
// Export for manual usage
export default CsrfAutoRefresh;