- 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
377 lines
12 KiB
JavaScript
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; |