diff --git a/backend/auth/rate-limiter.ts b/backend/auth/rate-limiter.ts index 35b6d1c8..62ba18f0 100644 --- a/backend/auth/rate-limiter.ts +++ b/backend/auth/rate-limiter.ts @@ -52,9 +52,9 @@ class AuthRateLimiter { /** * Check if an action is rate-limited for the given identifier. - * Returns null if allowed, or an error message if blocked. + * Returns null if allowed, or an error object with message and lockedUntil timestamp if blocked. */ - check(identifier: string, action: string): string | null { + check(identifier: string, action: string): { message: string; lockedUntil: number } | null { if (!RATE_LIMITED_ROUTES.has(action)) { return null; // Not a rate-limited route } @@ -70,7 +70,10 @@ class AuthRateLimiter { if (record.lockedUntil > now) { const remainingSec = Math.ceil((record.lockedUntil - now) / 1000); debug.warn('auth', `Rate limited: ${identifier} (${remainingSec}s remaining, ${record.failures} failures)`); - return `Too many failed attempts. Try again in ${remainingSec} seconds.`; + return { + message: `Too many failed attempts. Try again in ${remainingSec} seconds.`, + lockedUntil: record.lockedUntil + }; } return null; diff --git a/backend/ws/auth/login.ts b/backend/ws/auth/login.ts index 5a44102f..5c05e2d5 100644 --- a/backend/ws/auth/login.ts +++ b/backend/ws/auth/login.ts @@ -156,7 +156,9 @@ export const loginHandler = createRouter() if (isRateLimited) { const rateLimitError = authRateLimiter.check(ip, 'auth:login'); if (rateLimitError) { - throw new Error(rateLimitError); + const error = new Error(rateLimitError.message) as Error & { lockedUntil?: number }; + error.lockedUntil = rateLimitError.lockedUntil; + throw error; } } @@ -188,6 +190,13 @@ export const loginHandler = createRouter() if (isRateLimited) { authRateLimiter.recordFailure(ip, 'auth:login'); } + + // Re-throw with lockedUntil if it exists + if (err instanceof Error && 'lockedUntil' in err) { + const extendedError = new Error(err.message) as Error & { lockedUntil?: number }; + extendedError.lockedUntil = (err as any).lockedUntil; + throw extendedError; + } throw err; } }) @@ -210,7 +219,9 @@ export const loginHandler = createRouter() // Rate limit check const rateLimitError = authRateLimiter.check(ip, 'auth:accept-invite'); if (rateLimitError) { - throw new Error(rateLimitError); + const error = new Error(rateLimitError.message) as Error & { lockedUntil?: number }; + error.lockedUntil = rateLimitError.lockedUntil; + throw error; } try { @@ -254,7 +265,7 @@ export const loginHandler = createRouter() const rateLimitError = authRateLimiter.check(ip, 'auth:validate-invite'); if (rateLimitError) { - return { valid: false, error: rateLimitError }; + return { valid: false, error: rateLimitError.message }; } const result = validateInviteToken(data.inviteToken); diff --git a/frontend/components/auth/LoginPage.svelte b/frontend/components/auth/LoginPage.svelte index b07a1961..0d05bcc5 100644 --- a/frontend/components/auth/LoginPage.svelte +++ b/frontend/components/auth/LoginPage.svelte @@ -5,41 +5,41 @@ let error = $state(''); let isLoading = $state(false); - // Rate limit countdown - let lockoutSeconds = $state(0); - let countdownInterval: ReturnType | null = null; - - function parseRateLimitSeconds(message: string): number { - const match = message.match(/Try again in (\d+) seconds/); - return match ? parseInt(match[1], 10) : 0; - } - - function startCountdown(seconds: number) { - stopCountdown(); - lockoutSeconds = seconds; - countdownInterval = setInterval(() => { - lockoutSeconds -= 1; - if (lockoutSeconds <= 0) { - stopCountdown(); + // Server-controlled lockout based on timestamp + let serverLockedUntil = $state(0); + let clientTime = $state(Date.now()); + let syncInterval: ReturnType | null = null; + + // Start client time sync when locked out + function startTimerSync() { + if (syncInterval) return; + syncInterval = setInterval(() => { + clientTime = Date.now(); + if (clientTime >= serverLockedUntil) { + stopTimerSync(); error = ''; } - }, 1000); + }, 100); // Update every 100ms for smoother countdown } - function stopCountdown() { - lockoutSeconds = 0; - if (countdownInterval) { - clearInterval(countdownInterval); - countdownInterval = null; + function stopTimerSync() { + if (syncInterval) { + clearInterval(syncInterval); + syncInterval = null; } + serverLockedUntil = 0; } - const isLockedOut = $derived(lockoutSeconds > 0); + // Derived lockout state based on server timestamp + const isLockedOut = $derived(clientTime < serverLockedUntil); + const remainingSeconds = $derived( + isLockedOut ? Math.ceil((serverLockedUntil - clientTime) / 1000) : 0 + ); - // Build display error — replace server seconds with live countdown + // Build display error with remaining time const displayError = $derived( isLockedOut - ? `Too many failed attempts. Try again in ${lockoutSeconds} seconds.` + ? `Too many failed attempts. Try again in ${remainingSeconds} seconds.` : error ); @@ -61,13 +61,18 @@ try { await authStore.login(trimmed); + // Success - clear any lockout + stopTimerSync(); } catch (err) { const message = err instanceof Error ? err.message : 'Login failed'; error = message; - const seconds = parseRateLimitSeconds(message); - if (seconds > 0) { - startCountdown(seconds); + // Check if error has lockedUntil timestamp from server + const lockedUntil = (err as any)?.lockedUntil; + if (typeof lockedUntil === 'number' && lockedUntil > Date.now()) { + serverLockedUntil = lockedUntil; + clientTime = Date.now(); + startTimerSync(); } } finally { isLoading = false; diff --git a/shared/utils/ws-server.ts b/shared/utils/ws-server.ts index 8616e18f..30f9d564 100644 --- a/shared/utils/ws-server.ts +++ b/shared/utils/ws-server.ts @@ -558,8 +558,13 @@ export class WSRouter< } catch (err) { // Catch ANY error and send wrapped in { success: false, error, requestId } const errorMessage = err instanceof Error ? err.message : String(err); + + // Preserve additional error properties (like lockedUntil for rate limiting) + const errorResponse: any = { success: false, error: errorMessage, requestId }; + if (err instanceof Error && 'lockedUntil' in err) { + errorResponse.lockedUntil = (err as any).lockedUntil; + } - const errorResponse = { success: false, error: errorMessage, requestId }; conn.send(JSON.stringify({ action: responseAction, payload: errorResponse })); debug.error('websocket', `HTTP error [${action}]:`, errorMessage); }