Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions backend/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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;
Expand Down
17 changes: 14 additions & 3 deletions backend/ws/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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;
}
})
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 33 additions & 28 deletions frontend/components/auth/LoginPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,41 @@
let error = $state('');
let isLoading = $state(false);

// Rate limit countdown
let lockoutSeconds = $state(0);
let countdownInterval: ReturnType<typeof setInterval> | 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<typeof setInterval> | 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
);

Expand All @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion shared/utils/ws-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading