mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-22 00:44:52 +02:00
Add throttling to frontend.
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
+53
-2
@@ -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>
|
||||
|
||||
+53
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user