From e500020c57f54d9fa383b977cbc199fbeebee393 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Sat, 27 Dec 2025 14:55:27 +0100 Subject: [PATCH] Add throttling to frontend. --- project.inlang/messages/de.json | 4 +- project.inlang/messages/en.json | 4 +- src/lib/client/appointment-crypto.ts | 18 ++- src/lib/stores/pin-throttle.ts | 124 ++++++++++++++++++ .../auth/book-appointment-login-form.svelte | 55 +++++++- .../book-appointment-register-form.svelte | 55 +++++++- 6 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 src/lib/stores/pin-throttle.ts diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index ac6c353..1ffe690 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -859,7 +859,9 @@ "login": { "title": "Anmelden", "description": "Bitte geben Sie Ihre PIN ein:", - "action": "Zusammenfassung anzeigen" + "action": "Zusammenfassung anzeigen", + "throttled": "Zu viele Anmeldeversuche. Bitte warten Sie einige Minuten, bevor Sie es erneut versuchen.", + "retry": "Versuchen Sie es erneut in {seconds} Sekunden" } }, "summary": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 1af7752..2b6e3ee 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -868,7 +868,9 @@ "login": { "title": "Login", "description": "Please enter the PIN number, you've set:", - "action": "Show summary" + "action": "Show summary", + "throttled": "Too many failed attempts. Please wait a few minutes before trying again.", + "retry": "Please try again in {seconds} seconds" } }, "summary": { diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index fdb524f..2dd752d 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -48,6 +48,7 @@ */ import { OptimizedArgon2 } from "$lib/crypto/hashing"; +import { pinThrottleStore } from "$lib/stores/pin-throttle"; import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils"; // Type definitions for unified cryptography @@ -205,7 +206,12 @@ export class UnifiedAppointmentCrypto { if (!challengeResponse.ok) { if (challengeResponse.status === 429) { const errorData = await challengeResponse.json(); - const retryAfterSeconds = Math.ceil((errorData.retryAfterMs || 60000) / 1000); + const retryAfterMs = errorData.retryAfterMs || 60000; + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + + // Store throttle state for frontend to enforce + pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0); + if (retryAfterSeconds > 0) { throw new Error( `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, @@ -245,7 +251,12 @@ export class UnifiedAppointmentCrypto { const errorData = await verificationResponse.json(); console.error("❌ Challenge verification failed:", errorData); if (verificationResponse.status === 429) { - const retryAfterSeconds = Math.ceil((errorData.retryAfterMs || 60000) / 1000); + const retryAfterMs = errorData.retryAfterMs || 60000; + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + + // Store throttle state for frontend to enforce + pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0); + if (retryAfterSeconds > 0) { throw new Error( `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, @@ -264,6 +275,9 @@ export class UnifiedAppointmentCrypto { this.tunnelId = verificationData.tunnelId; this.clientAuthenticated = true; + + // Clear throttle on successful authentication + pinThrottleStore.clearThrottle(); } catch (error) { console.error("❌ Error during client login:", error); throw error; diff --git a/src/lib/stores/pin-throttle.ts b/src/lib/stores/pin-throttle.ts new file mode 100644 index 0000000..5b58e79 --- /dev/null +++ b/src/lib/stores/pin-throttle.ts @@ -0,0 +1,124 @@ +import { writable } from "svelte/store"; + +/** + * PIN Throttle Store + * + * Persists throttle state across page reloads and component remounts. + * Tracks when the user can next attempt PIN authentication. + */ + +interface PinThrottleState { + emailHash: string | null; + throttleUntil: number | null; // Timestamp when throttle expires (milliseconds) + failedAttempts: number; +} + +const STORAGE_KEY = "pin-throttle-state"; + +const createPinThrottleStore = () => { + // Initialize from localStorage if available + const initialState: PinThrottleState = { + emailHash: null, + throttleUntil: null, + failedAttempts: 0, + }; + + if (typeof window !== "undefined") { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + // Only restore if throttle hasn't expired yet + if (parsed.throttleUntil && parsed.throttleUntil > Date.now()) { + Object.assign(initialState, parsed); + } else { + // Throttle expired, clear storage + localStorage.removeItem(STORAGE_KEY); + } + } catch (e) { + console.error("Failed to parse throttle state:", e); + localStorage.removeItem(STORAGE_KEY); + } + } + } + + const store = writable(initialState); + + return { + subscribe: store.subscribe, + + /** + * Set throttle state when a 429 response is received + * @param emailHash The email hash that is throttled + * @param retryAfterMs How many milliseconds to wait before retry + * @param failedAttempts Current number of failed attempts + */ + setThrottle: (emailHash: string, retryAfterMs: number, failedAttempts: number = 0) => { + const throttleUntil = Date.now() + retryAfterMs; + const newState: PinThrottleState = { + emailHash, + throttleUntil, + failedAttempts, + }; + + store.set(newState); + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + } + }, + + /** + * Clear throttle state after successful authentication + */ + clearThrottle: () => { + store.set({ + emailHash: null, + throttleUntil: null, + failedAttempts: 0, + }); + if (typeof window !== "undefined") { + localStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Check if a specific email hash is currently throttled + * @param emailHash The email hash to check + * @returns true if throttled, false otherwise + */ + isThrottled: (emailHash: string): boolean => { + let isThrottled = false; + store.subscribe((state) => { + if ( + state.emailHash === emailHash && + state.throttleUntil && + state.throttleUntil > Date.now() + ) { + isThrottled = true; + } + })(); + return isThrottled; + }, + + /** + * Get remaining throttle time in milliseconds + * @param emailHash The email hash to check + * @returns Milliseconds remaining, or 0 if not throttled + */ + getRemainingTime: (emailHash: string): number => { + let remaining = 0; + store.subscribe((state) => { + if ( + state.emailHash === emailHash && + state.throttleUntil && + state.throttleUntil > Date.now() + ) { + remaining = state.throttleUntil - Date.now(); + } + })(); + return Math.max(0, remaining); + }, + }; +}; + +export const pinThrottleStore = createPinThrottleStore(); diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte index 566e1a8..b2c40e5 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte @@ -4,6 +4,7 @@ import * as Form from "$lib/components/ui/form"; import InputOtpCustomized from "$lib/components/ui/input-top-customized/input-otp-customized.svelte"; import { publicStore } from "$lib/stores/public"; + import { pinThrottleStore } from "$lib/stores/pin-throttle"; import type { TPublicAppointment } from "$lib/types/public"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; @@ -14,6 +15,38 @@ const tenant = $derived($publicStore.tenant); const appointment = $derived($publicStore.newAppointment); + // Subscribe to throttle state + let throttleState = $derived($pinThrottleStore); + let timeCounter = $state(0); + + let isThrottled = $derived.by(() => { + // Re-evaluate whenever timeCounter changes + timeCounter; // Read for reactivity + if (!appointment.data?.email || !throttleState.throttleUntil) return false; + return throttleState.throttleUntil > Date.now(); + }); + + let remainingSeconds = $derived.by(() => { + // Re-evaluate whenever timeCounter changes + timeCounter; // Read for reactivity + if (!throttleState.throttleUntil) return 0; + const remaining = throttleState.throttleUntil - Date.now(); + return Math.max(0, Math.ceil(remaining / 1000)); + }); + + // Timer to update timeCounter every second + $effect(() => { + if (!isThrottled) { + return; + } + + const interval = setInterval(() => { + timeCounter++; + }, 1000); + + return () => clearInterval(interval); + }); + const form = superForm( { pin: "", @@ -50,11 +83,24 @@ + {#if isThrottled} +
+

+ {m["public.steps.auth.login.throttled"]()} +

+

+ {m["public.steps.auth.login.retry"]({ seconds: remainingSeconds })} +

+
+ {/if} + {#snippet children({ props })} {m["form.pin"]()} - + {/snippet} @@ -63,7 +109,12 @@
- + {m["public.steps.auth.login.action"]()}
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte index 8e92603..3c468dd 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte @@ -4,6 +4,7 @@ import * as Form from "$lib/components/ui/form"; import InputOtpCustomized from "$lib/components/ui/input-top-customized/input-otp-customized.svelte"; import { publicStore } from "$lib/stores/public"; + import { pinThrottleStore } from "$lib/stores/pin-throttle"; import type { TPublicAppointment } from "$lib/types/public"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; @@ -14,6 +15,38 @@ const tenant = $derived($publicStore.tenant); const appointment = $derived($publicStore.newAppointment); + // Subscribe to throttle state + let throttleState = $derived($pinThrottleStore); + let timeCounter = $state(0); + + let isThrottled = $derived.by(() => { + // Re-evaluate whenever timeCounter changes + timeCounter; // Read for reactivity + if (!appointment.data?.email || !throttleState.throttleUntil) return false; + return throttleState.throttleUntil > Date.now(); + }); + + let remainingSeconds = $derived.by(() => { + // Re-evaluate whenever timeCounter changes + timeCounter; // Read for reactivity + if (!throttleState.throttleUntil) return 0; + const remaining = throttleState.throttleUntil - Date.now(); + return Math.max(0, Math.ceil(remaining / 1000)); + }); + + // Timer to update timeCounter every second + $effect(() => { + if (!isThrottled) { + return; + } + + const interval = setInterval(() => { + timeCounter++; + }, 1000); + + return () => clearInterval(interval); + }); + const form = superForm( { pin: "", @@ -61,11 +94,24 @@ + {#if isThrottled} +
+

+ {m["public.steps.auth.login.throttled"]()} +

+

+ {m["public.steps.auth.login.retry"]({ seconds: remainingSeconds })} +

+
+ {/if} + {#snippet children({ props })} {m["form.pin"]()} - + {/snippet} @@ -74,7 +120,12 @@
- + {m["public.steps.auth.register.action"]()}