feat: Enhance authentication middleware and token refresh logic with improved caching, retry mechanisms, and error handling
This commit is contained in:
parent
6e99f4f783
commit
2928d9a7ed
|
|
@ -20,7 +20,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
// Use a cached auth state to avoid excessive API calls
|
// Use a cached auth state to avoid excessive API calls
|
||||||
const nuxtApp = useNuxtApp();
|
const nuxtApp = useNuxtApp();
|
||||||
const cacheKey = 'auth:session:cache';
|
const cacheKey = 'auth:session:cache';
|
||||||
const cacheExpiry = 30000; // 30 seconds cache
|
const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds)
|
||||||
|
|
||||||
// Check if we have a cached session
|
// Check if we have a cached session
|
||||||
const cachedSession = nuxtApp.payload.data?.[cacheKey];
|
const cachedSession = nuxtApp.payload.data?.[cacheKey];
|
||||||
|
|
@ -44,14 +44,17 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check Keycloak authentication via session API with timeout
|
// Check Keycloak authentication via session API with timeout and retries
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout (increased from 5)
|
||||||
|
|
||||||
const sessionData = await $fetch('/api/auth/session', {
|
const sessionData = await $fetch('/api/auth/session', {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
retry: 1,
|
retry: 2, // Increased retry count
|
||||||
retryDelay: 500
|
retryDelay: 1000, // Increased retry delay
|
||||||
|
onRetry: ({ retries }: { retries: number }) => {
|
||||||
|
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
|
||||||
|
}
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -100,11 +103,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
console.error('[MIDDLEWARE] Auth check failed:', error);
|
console.error('[MIDDLEWARE] Auth check failed:', error);
|
||||||
|
|
||||||
// If it's a network error or timeout, check if we have a recent cached session
|
// If it's a network error or timeout, check if we have a recent cached session
|
||||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED') {
|
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||||
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
||||||
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
||||||
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 300000) { // 5 minutes
|
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 30 * 60 * 1000) { // 30 minutes grace period
|
||||||
console.log('[MIDDLEWARE] Using recent cache despite network error');
|
console.log('[MIDDLEWARE] Using recent cache despite network error (age:', Math.round((now - recentCache.timestamp) / 1000), 'seconds)');
|
||||||
if (recentCache.authenticated && recentCache.user) {
|
if (recentCache.authenticated && recentCache.user) {
|
||||||
// Store auth state for components
|
// Store auth state for components
|
||||||
if (!nuxtApp.payload.data) {
|
if (!nuxtApp.payload.data) {
|
||||||
|
|
@ -115,6 +118,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
authenticated: recentCache.authenticated,
|
authenticated: recentCache.authenticated,
|
||||||
groups: recentCache.groups || []
|
groups: recentCache.groups || []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show a warning toast if cache is older than 10 minutes
|
||||||
|
if ((now - recentCache.timestamp) > 10 * 60 * 1000) {
|
||||||
|
const toast = useToast();
|
||||||
|
toast.warning('Network connectivity issue - using cached authentication');
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ export default defineNuxtPlugin(() => {
|
||||||
|
|
||||||
let refreshTimer: NodeJS.Timeout | null = null
|
let refreshTimer: NodeJS.Timeout | null = null
|
||||||
let isRefreshing = false
|
let isRefreshing = false
|
||||||
|
let retryCount = 0
|
||||||
|
const maxRetries = 3
|
||||||
|
|
||||||
const scheduleTokenRefresh = (expiresAt: number) => {
|
const scheduleTokenRefresh = (expiresAt: number) => {
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
|
|
@ -12,11 +14,13 @@ export default defineNuxtPlugin(() => {
|
||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time until refresh (refresh 2 minutes before expiry)
|
// Calculate time until refresh (refresh 5 minutes before expiry)
|
||||||
const refreshBuffer = 2 * 60 * 1000 // 2 minutes in milliseconds
|
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds
|
||||||
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
|
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
|
||||||
|
|
||||||
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')
|
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')
|
||||||
|
console.log('[AUTH_REFRESH] Token expires at:', new Date(expiresAt))
|
||||||
|
console.log('[AUTH_REFRESH] Will refresh at:', new Date(expiresAt - refreshBuffer))
|
||||||
|
|
||||||
// Only schedule if we have time left
|
// Only schedule if we have time left
|
||||||
if (timeUntilRefresh > 0) {
|
if (timeUntilRefresh > 0) {
|
||||||
|
|
@ -28,20 +32,37 @@ export default defineNuxtPlugin(() => {
|
||||||
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
|
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
|
||||||
|
|
||||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: 1000
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.success && response.expiresAt) {
|
if (response.success && response.expiresAt) {
|
||||||
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
|
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
|
||||||
|
retryCount = 0 // Reset retry count on success
|
||||||
scheduleTokenRefresh(response.expiresAt)
|
scheduleTokenRefresh(response.expiresAt)
|
||||||
} else {
|
} else {
|
||||||
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
|
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('[AUTH_REFRESH] Token refresh error:', error)
|
console.error('[AUTH_REFRESH] Token refresh error:', error)
|
||||||
// If refresh fails, redirect to login
|
|
||||||
await navigateTo('/login')
|
// Implement exponential backoff retry
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
retryCount++
|
||||||
|
const retryDelay = Math.min(1000 * Math.pow(2, retryCount), 10000) // Max 10 seconds
|
||||||
|
console.log(`[AUTH_REFRESH] Retrying refresh in ${retryDelay}ms (attempt ${retryCount}/${maxRetries})`)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
scheduleTokenRefresh(expiresAt)
|
||||||
|
}
|
||||||
|
}, retryDelay)
|
||||||
|
} else {
|
||||||
|
console.error('[AUTH_REFRESH] Max retries reached, redirecting to login')
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
@ -56,11 +77,14 @@ export default defineNuxtPlugin(() => {
|
||||||
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
|
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
|
||||||
|
|
||||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: 1000
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.success && response.expiresAt) {
|
if (response.success && response.expiresAt) {
|
||||||
console.log('[AUTH_REFRESH] Immediate refresh successful')
|
console.log('[AUTH_REFRESH] Immediate refresh successful')
|
||||||
|
retryCount = 0 // Reset retry count on success
|
||||||
scheduleTokenRefresh(response.expiresAt)
|
scheduleTokenRefresh(response.expiresAt)
|
||||||
} else {
|
} else {
|
||||||
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
|
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
|
||||||
|
|
@ -68,7 +92,19 @@ export default defineNuxtPlugin(() => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUTH_REFRESH] Immediate refresh error:', error)
|
console.error('[AUTH_REFRESH] Immediate refresh error:', error)
|
||||||
await navigateTo('/login')
|
|
||||||
|
// Try one more time before giving up
|
||||||
|
if (retryCount === 0) {
|
||||||
|
retryCount++
|
||||||
|
console.log('[AUTH_REFRESH] Retrying immediate refresh once more...')
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
scheduleTokenRefresh(Date.now() - 1) // Force immediate refresh
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
@ -127,10 +163,20 @@ export default defineNuxtPlugin(() => {
|
||||||
|
|
||||||
// Listen for visibility changes to refresh when tab becomes active
|
// Listen for visibility changes to refresh when tab becomes active
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
|
let lastVisibilityChange = Date.now()
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
// Tab became visible, check if we need to refresh
|
const now = Date.now()
|
||||||
checkAndScheduleRefresh()
|
const timeSinceLastCheck = now - lastVisibilityChange
|
||||||
|
|
||||||
|
// If tab was hidden for more than 1 minute, check auth status
|
||||||
|
if (timeSinceLastCheck > 60000) {
|
||||||
|
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
|
||||||
|
checkAndScheduleRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVisibilityChange = now
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -655,9 +655,6 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
|
||||||
|
|
||||||
async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
const client = getMinioClient();
|
|
||||||
const bucketName = useRuntimeConfig().minio.bucketName;
|
|
||||||
|
|
||||||
// Determine the file path - try multiple possible sources
|
// Determine the file path - try multiple possible sources
|
||||||
let rawPath = null;
|
let rawPath = null;
|
||||||
|
|
||||||
|
|
@ -683,7 +680,70 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
||||||
|
|
||||||
console.log('[expenses/generate-pdf] Raw path from receipt:', rawPath);
|
console.log('[expenses/generate-pdf] Raw path from receipt:', rawPath);
|
||||||
|
|
||||||
// Extract MinIO path from S3 URL or use as-is if it's already a path
|
// Check if this is an S3 URL (HTTP/HTTPS)
|
||||||
|
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
|
||||||
|
console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch image directly from S3 URL
|
||||||
|
const response = await fetch(rawPath, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'image/*'
|
||||||
|
},
|
||||||
|
// Add timeout to prevent hanging
|
||||||
|
signal: AbortSignal.timeout(30000) // 30 second timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert response to buffer
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const imageBuffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
console.log('[expenses/generate-pdf] Successfully fetched image from S3 URL, Size:', imageBuffer.length);
|
||||||
|
return imageBuffer;
|
||||||
|
|
||||||
|
} catch (fetchError: any) {
|
||||||
|
console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message);
|
||||||
|
|
||||||
|
// If it's a timeout, try once more with a longer timeout
|
||||||
|
if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError') {
|
||||||
|
console.log('[expenses/generate-pdf] Retrying with longer timeout...');
|
||||||
|
try {
|
||||||
|
const retryResponse = await fetch(rawPath, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'image/*'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(60000) // 60 second timeout for retry
|
||||||
|
});
|
||||||
|
|
||||||
|
if (retryResponse.ok) {
|
||||||
|
const arrayBuffer = await retryResponse.arrayBuffer();
|
||||||
|
const imageBuffer = Buffer.from(arrayBuffer);
|
||||||
|
console.log('[expenses/generate-pdf] Successfully fetched image on retry, Size:', imageBuffer.length);
|
||||||
|
return imageBuffer;
|
||||||
|
}
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error('[expenses/generate-pdf] Retry also failed:', retryError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not an S3 URL, try MinIO as fallback
|
||||||
|
console.log('[expenses/generate-pdf] Not an S3 URL, trying MinIO fallback...');
|
||||||
|
|
||||||
|
const client = getMinioClient();
|
||||||
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
|
// Extract MinIO path from the raw path
|
||||||
let minioPath = extractMinioPath(rawPath);
|
let minioPath = extractMinioPath(rawPath);
|
||||||
|
|
||||||
if (!minioPath) {
|
if (!minioPath) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue