207 lines
8.1 KiB
JavaScript
207 lines
8.1 KiB
JavaScript
// CSRF Token Management with Auto-Refresh
|
|
class CSRFManager {
|
|
constructor() {
|
|
this.token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
this.refreshPromise = null;
|
|
this.setupFetchInterceptor();
|
|
}
|
|
|
|
async refreshToken() {
|
|
// Prevent multiple simultaneous refresh requests
|
|
if (this.refreshPromise) {
|
|
return this.refreshPromise;
|
|
}
|
|
|
|
this.refreshPromise = this.performTokenRefresh();
|
|
try {
|
|
const newToken = await this.refreshPromise;
|
|
return newToken;
|
|
} finally {
|
|
this.refreshPromise = null;
|
|
}
|
|
}
|
|
|
|
async performTokenRefresh() {
|
|
try {
|
|
console.log('Refreshing CSRF token...');
|
|
|
|
// Use the original fetch to avoid recursion
|
|
const originalFetch = window.originalFetch || fetch;
|
|
const response = await originalFetch('/api/csrf-token', {
|
|
method: 'GET',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to refresh CSRF token: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type');
|
|
if (!contentType || !contentType.includes('application/json')) {
|
|
const text = await response.text();
|
|
throw new Error(`Expected JSON response but got ${contentType}. Response: ${text.substring(0, 200)}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.csrf_token) {
|
|
this.token = data.csrf_token;
|
|
// Update the meta tag for any other code that might read it
|
|
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
|
if (metaTag) {
|
|
metaTag.setAttribute('content', this.token);
|
|
}
|
|
|
|
// Update Vue.js reactive token if available
|
|
if (window.app && window.app.csrfToken !== undefined) {
|
|
window.app.csrfToken = this.token;
|
|
}
|
|
|
|
console.log('CSRF token refreshed successfully');
|
|
return this.token;
|
|
} else {
|
|
throw new Error('No CSRF token in response');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to refresh CSRF token:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
setupFetchInterceptor() {
|
|
// Store original fetch if not already stored
|
|
if (!window.originalFetch) {
|
|
window.originalFetch = window.fetch;
|
|
}
|
|
|
|
const originalFetch = window.originalFetch;
|
|
const self = this;
|
|
|
|
window.fetch = async function(url, options = {}) {
|
|
// Skip CSRF token for the token refresh endpoint to avoid recursion
|
|
if (url.includes('/api/csrf-token')) {
|
|
return originalFetch(url, options);
|
|
}
|
|
|
|
// Add CSRF token to headers for API requests
|
|
const newOptions = { ...options };
|
|
if (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
|
|
url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers')) {
|
|
|
|
newOptions.headers = {
|
|
'X-CSRFToken': self.token,
|
|
...newOptions.headers
|
|
};
|
|
}
|
|
|
|
// Make the request
|
|
let response = await originalFetch(url, newOptions);
|
|
|
|
// Check for CSRF token expiration
|
|
if ((response.status === 400 || response.status === 403) &&
|
|
(url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
|
|
url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers'))) {
|
|
|
|
try {
|
|
// Try to parse as JSON first
|
|
const responseClone = response.clone();
|
|
let isCSRFError = false;
|
|
|
|
try {
|
|
const errorData = await responseClone.json();
|
|
const errorMessage = errorData.error || '';
|
|
isCSRFError = errorMessage.toLowerCase().includes('csrf') ||
|
|
errorMessage.toLowerCase().includes('token');
|
|
} catch (jsonError) {
|
|
// If JSON parsing fails, check if it's an HTML error page
|
|
const textResponse = await response.clone().text();
|
|
isCSRFError = textResponse.toLowerCase().includes('csrf') ||
|
|
textResponse.toLowerCase().includes('token') ||
|
|
textResponse.includes('<!doctype') || // HTML error page
|
|
textResponse.includes('<html');
|
|
}
|
|
|
|
if (isCSRFError) {
|
|
console.log('CSRF token expired, attempting refresh and retry...');
|
|
|
|
try {
|
|
// Refresh the token
|
|
await self.refreshToken();
|
|
|
|
// Retry the original request with the new token
|
|
newOptions.headers['X-CSRFToken'] = self.token;
|
|
response = await originalFetch(url, newOptions);
|
|
|
|
if (response.ok) {
|
|
console.log('Request succeeded after CSRF token refresh');
|
|
} else {
|
|
console.warn('Request still failed after CSRF token refresh:', response.status);
|
|
}
|
|
} catch (refreshError) {
|
|
console.error('Failed to refresh CSRF token during retry:', refreshError);
|
|
// Return the original failed response
|
|
}
|
|
}
|
|
} catch (parseError) {
|
|
console.warn('Could not parse error response for CSRF check:', parseError);
|
|
}
|
|
}
|
|
|
|
return response;
|
|
};
|
|
}
|
|
|
|
// Method to manually get current token
|
|
getToken() {
|
|
return this.token;
|
|
}
|
|
|
|
// Method to manually refresh token (for periodic refresh)
|
|
async manualRefresh() {
|
|
try {
|
|
await this.refreshToken();
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Manual CSRF token refresh failed:', error);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize CSRF manager when DOM is loaded
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.csrfManager = new CSRFManager();
|
|
|
|
// Set up periodic token refresh every 45 minutes (before 1-hour expiration)
|
|
setInterval(() => {
|
|
if (window.csrfManager) {
|
|
console.log('Performing periodic CSRF token refresh...');
|
|
window.csrfManager.manualRefresh();
|
|
}
|
|
}, 45 * 60 * 1000); // 45 minutes
|
|
|
|
// Refresh CSRF token when page becomes visible again (wake from sleep, tab switch)
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible' && window.csrfManager) {
|
|
console.log('[CSRF] Page visible again, refreshing token...');
|
|
window.csrfManager.manualRefresh();
|
|
}
|
|
});
|
|
|
|
// Heartbeat gap detection: if setInterval drifts > 2 min, system was asleep
|
|
let lastHeartbeat = Date.now();
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
const drift = now - lastHeartbeat - 60000;
|
|
if (drift > 120000) {
|
|
console.log(`[CSRF] Heartbeat drift ${Math.round(drift / 1000)}s detected, refreshing token...`);
|
|
if (window.csrfManager) {
|
|
window.csrfManager.manualRefresh();
|
|
}
|
|
}
|
|
lastHeartbeat = now;
|
|
}, 60000);
|
|
});
|