Files
appointment-booking-software/src/lib/server/services/challenge-throttle.ts
T
CopilotGitHubhbelcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Hendrik Belitz
337fc2e70b Add throttling to challenge APIs for brute force protection (#145)
* Initial plan

* Add throttling implementation for challenge APIs

Co-authored-by: hbel <7416029+hbel@users.noreply.github.com>

* Add tests for throttling and fix linting issues

Co-authored-by: hbel <7416029+hbel@users.noreply.github.com>

* Address code review feedback - improve error handling and documentation

Co-authored-by: hbel <7416029+hbel@users.noreply.github.com>

* Consolidate throttle storage to central DB per review feedback

Co-authored-by: hbel <7416029+hbel@users.noreply.github.com>

* Fixed errors in challenge-throlle.

* Add throttling to frontend.

* incorporated Reviewer comments

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: hbel <7416029+hbel@users.noreply.github.com>
Co-authored-by: Hendrik Belitz <hendrik@innovation-through-understanding.de>
2026-01-02 13:14:40 +01:00

168 lines
5.0 KiB
TypeScript

/**
* Challenge Throttling Service
*
* Implements throttling for challenge-based authentication to prevent brute force attacks.
* All throttle data is stored centrally in the central database.
* - PIN challenges (appointments): Escalating delays (2s, 10s, 1m, 5m, 30m)
* - Passkey challenges (auth): Fixed delay after 3 attempts (1 minute)
*/
import { centralDb } from "$lib/server/db";
import { challengeThrottle } from "$lib/server/db/central-schema";
import { eq, lt, sql } from "drizzle-orm";
import { logger } from "$lib/logger";
export type ThrottleType = "pin" | "passkey";
interface ThrottleResult {
allowed: boolean;
retryAfterMs: number;
failedAttempts: number;
}
// Throttle reset duration: 1 hour
const THROTTLE_RESET_DURATION_MS = 60 * 60 * 1000;
/**
* Calculate escalating delay for PIN challenges
* Uses a fixed escalation pattern that increases with each failed attempt:
*/
function calculatePinThrottleDelay(failedAttempts: number): number {
if (failedAttempts < 4) return 0;
if (failedAttempts === 4) return 60 * 1000; // 1 minute
if (failedAttempts === 5) return 5 * 60 * 1000; // 5 minutes
if (failedAttempts === 6) return 30 * 60 * 1000; // 30 minutes
if (failedAttempts >= 7) return 60 * 60 * 1000; // 60 minutes
return 0;
}
/**
* Calculate throttle delay for passkey challenges
* First 3 tries: immediate
* Afterwards: wait for one minute
*/
function calculatePasskeyThrottleDelay(failedAttempts: number): number {
if (failedAttempts < 3) return 0;
return 60 * 1000; // 1 minute
}
class ChallengeThrottleService {
/**
* Check if a challenge request should be throttled
* @param identifier - Email hash for PIN challenges, email for passkey challenges
* @param type - Type of challenge (pin or passkey)
*/
async checkThrottle(identifier: string, type: ThrottleType): Promise<ThrottleResult> {
const now = new Date();
// Get throttle record from central DB
const records = await centralDb
.select()
.from(challengeThrottle)
.where(eq(challengeThrottle.id, identifier))
.limit(1);
if (records.length === 0) {
// No throttle record, allow request
return { allowed: true, retryAfterMs: 0, failedAttempts: 0 };
}
const record = records[0];
// Check if throttle has expired
if (now > record.resetAt) {
// Throttle expired, clean up and allow
await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier));
return { allowed: true, retryAfterMs: 0, failedAttempts: 0 };
}
// Calculate how long to wait based on challenge type
const delay =
type === "pin"
? calculatePinThrottleDelay(record.failedAttempts)
: calculatePasskeyThrottleDelay(record.failedAttempts);
const timeSinceLastAttempt = now.getTime() - record.lastAttemptAt.getTime();
if (timeSinceLastAttempt < delay) {
// Still throttled
return {
allowed: false,
retryAfterMs: delay - timeSinceLastAttempt,
failedAttempts: record.failedAttempts,
};
}
// Enough time has passed, allow the request
return {
allowed: true,
retryAfterMs: 0,
failedAttempts: record.failedAttempts,
};
}
/**
* Record a failed challenge attempt
* @param identifier - Email hash for PIN challenges, email for passkey challenges
* @param type - Type of challenge (pin or passkey)
*/
async recordFailedAttempt(identifier: string, type: ThrottleType): Promise<void> {
const now = new Date();
const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS);
await centralDb
.insert(challengeThrottle)
.values({
id: identifier,
failedAttempts: 1,
lastAttemptAt: now,
resetAt,
})
.onConflictDoUpdate({
target: challengeThrottle.id,
set: {
failedAttempts: sql`${challengeThrottle.failedAttempts} + 1`,
lastAttemptAt: now,
},
});
logger.info(`Recorded failed ${type} challenge attempt`, {
identifier: identifier.slice(0, 8),
type,
});
}
/**
* Clear throttle for successful authentication
* @param identifier - Email hash for PIN challenges, email for passkey challenges
* @param type - Type of challenge (pin or passkey)
*/
async clearThrottle(identifier: string, type: ThrottleType): Promise<void> {
await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier));
logger.debug(`Cleared ${type} challenge throttle`, {
identifier: identifier.slice(0, 8),
type,
});
}
/**
* Clean up expired throttle records
* Should be called periodically
*/
async cleanupExpired(): Promise<void> {
const now = new Date();
try {
await centralDb.delete(challengeThrottle).where(lt(challengeThrottle.resetAt, now));
logger.debug("Cleaned up expired challenge throttles");
} catch (error) {
logger.warn("Failed to cleanup expired throttles", {
error: String(error),
});
}
}
}
export const challengeThrottleService = new ChallengeThrottleService();