Add throttling to frontend.

This commit is contained in:
Hendrik Belitz
2025-12-27 14:55:27 +01:00
parent a9d85cd879
commit e500020c57
6 changed files with 252 additions and 8 deletions
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": {
+16 -2
View File
@@ -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;
+124
View File
@@ -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<PinThrottleState>(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();
@@ -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 @@
</script>
<Form.Root {enhance}>
{#if isThrottled}
<div
class="bg-destructive/10 border-destructive dark:bg-destructive/20 dark:border-destructive/50 mb-4 rounded-lg border p-4"
>
<p class="text-destructive dark:text-destructive/90 font-semibold">
{m["public.steps.auth.login.throttled"]()}
</p>
<p class="text-destructive/80 dark:text-destructive/70 mt-1 text-sm">
{m["public.steps.auth.login.retry"]({ seconds: remainingSeconds })}
</p>
</div>
{/if}
<Form.Field {form} name="pin">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.pin"]()}</Form.Label>
<InputOtpCustomized {...props} bind:value={$formData.pin} />
<InputOtpCustomized {...props} bind:value={$formData.pin} disabled={isThrottled} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
@@ -63,7 +109,12 @@
</Form.Description>
</Form.Field>
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
<Form.Button
size="lg"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting || isThrottled}
>
{m["public.steps.auth.login.action"]()}
</Form.Button>
</div>
@@ -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 @@
</script>
<Form.Root {enhance}>
{#if isThrottled}
<div
class="bg-destructive/10 border-destructive dark:bg-destructive/20 dark:border-destructive/50 mb-4 rounded-lg border p-4"
>
<p class="text-destructive dark:text-destructive/90 font-semibold">
{m["public.steps.auth.login.throttled"]()}
</p>
<p class="text-destructive/80 dark:text-destructive/70 mt-1 text-sm">
{m["public.steps.auth.login.retry"]({ seconds: remainingSeconds })}
</p>
</div>
{/if}
<Form.Field {form} name="pin">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.pin"]()}</Form.Label>
<InputOtpCustomized {...props} bind:value={$formData.pin} />
<InputOtpCustomized {...props} bind:value={$formData.pin} disabled={isThrottled} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
@@ -74,7 +120,12 @@
</Form.Description>
</Form.Field>
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
<Form.Button
size="lg"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting || isThrottled}
>
{m["public.steps.auth.register.action"]()}
</Form.Button>
</div>