mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Added tenant-specific throttling for clients
This commit is contained in:
@@ -242,6 +242,8 @@ export const userInvite = pgTable(
|
||||
export const challengeThrottle = pgTable("challenge_throttle", {
|
||||
/** Primary key - identifier (email hash for PIN challenges, email for passkey challenges) */
|
||||
id: text("id").primaryKey(),
|
||||
/** Tenant ID for scoping throttles to single tenants to restrict global lock-out. Might be null for global throttles on administrative accounts */
|
||||
tenantId: uuid("tenant_id").references(() => tenant.id, { onDelete: "cascade" }),
|
||||
/** Number of failed attempts */
|
||||
failedAttempts: integer("failed_attempts").default(0).notNull(),
|
||||
/** When the throttle was last updated */
|
||||
|
||||
@@ -853,7 +853,11 @@ describe("AppointmentService", () => {
|
||||
);
|
||||
|
||||
expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123");
|
||||
expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin");
|
||||
expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith(
|
||||
"email-hash-123",
|
||||
"pin",
|
||||
"tenant-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when challenge is not found", async () => {
|
||||
@@ -922,6 +926,7 @@ describe("AppointmentService", () => {
|
||||
expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledWith(
|
||||
"email-hash-123",
|
||||
"pin",
|
||||
"tenant-123",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1001,13 +1001,13 @@ export class AppointmentService {
|
||||
});
|
||||
|
||||
// Record failed attempt for throttling
|
||||
await challengeThrottleService.recordFailedAttempt(emailHash, "pin");
|
||||
await challengeThrottleService.recordFailedAttempt(emailHash, "pin", this.tenantId);
|
||||
|
||||
throw new ValidationError("Invalid challenge response");
|
||||
}
|
||||
|
||||
// Clear throttle on successful verification
|
||||
await challengeThrottleService.clearThrottle(emailHash, "pin");
|
||||
await challengeThrottleService.clearThrottle(emailHash, "pin", this.tenantId);
|
||||
|
||||
const db = await this.getDb();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { centralDb } from "$lib/server/db";
|
||||
import { challengeThrottle } from "$lib/server/db/central-schema";
|
||||
import { eq, lt, sql } from "drizzle-orm";
|
||||
import { eq, lt, sql, and } from "drizzle-orm";
|
||||
import { logger } from "$lib/logger";
|
||||
|
||||
export type ThrottleType = "pin" | "passkey" | "passphrase";
|
||||
@@ -51,15 +51,25 @@ 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)
|
||||
* @param tenantId - Tenant ID for scoping the throttle check
|
||||
*/
|
||||
async checkThrottle(identifier: string, type: ThrottleType): Promise<ThrottleResult> {
|
||||
async checkThrottle(
|
||||
identifier: string,
|
||||
type: ThrottleType,
|
||||
tenantId?: string,
|
||||
): Promise<ThrottleResult> {
|
||||
const now = new Date();
|
||||
|
||||
// Get throttle record from central DB
|
||||
const records = await centralDb
|
||||
.select()
|
||||
.from(challengeThrottle)
|
||||
.where(eq(challengeThrottle.id, identifier))
|
||||
.where(
|
||||
and(
|
||||
eq(challengeThrottle.id, identifier),
|
||||
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (records.length === 0) {
|
||||
@@ -72,7 +82,14 @@ class ChallengeThrottleService {
|
||||
// Check if throttle has expired
|
||||
if (now > record.resetAt) {
|
||||
// Throttle expired, clean up and allow
|
||||
await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier));
|
||||
await centralDb
|
||||
.delete(challengeThrottle)
|
||||
.where(
|
||||
and(
|
||||
eq(challengeThrottle.id, identifier),
|
||||
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
|
||||
),
|
||||
);
|
||||
return { allowed: true, retryAfterMs: 0, failedAttempts: 0 };
|
||||
}
|
||||
|
||||
@@ -104,8 +121,13 @@ class ChallengeThrottleService {
|
||||
* Record a failed challenge attempt
|
||||
* @param identifier - Email hash for PIN challenges, email for passkey challenges
|
||||
* @param type - Type of challenge (pin or passkey)
|
||||
* @param tenantId - Tenant ID for scoping the throttle record
|
||||
*/
|
||||
async recordFailedAttempt(identifier: string, type: ThrottleType): Promise<void> {
|
||||
async recordFailedAttempt(
|
||||
identifier: string,
|
||||
type: ThrottleType,
|
||||
tenantId?: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS);
|
||||
@@ -116,6 +138,7 @@ class ChallengeThrottleService {
|
||||
failedAttempts: 1,
|
||||
lastAttemptAt: now,
|
||||
resetAt,
|
||||
tenantId: tenantId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: challengeThrottle.id,
|
||||
@@ -135,9 +158,17 @@ class ChallengeThrottleService {
|
||||
* Clear throttle for successful authentication
|
||||
* @param identifier - Email hash for PIN challenges, email for passkey challenges
|
||||
* @param type - Type of challenge (pin or passkey)
|
||||
* @param tenantId - Tenant ID for scoping the throttle clearance
|
||||
*/
|
||||
async clearThrottle(identifier: string, type: ThrottleType): Promise<void> {
|
||||
await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier));
|
||||
async clearThrottle(identifier: string, type: ThrottleType, tenantId?: string): Promise<void> {
|
||||
await centralDb
|
||||
.delete(challengeThrottle)
|
||||
.where(
|
||||
and(
|
||||
eq(challengeThrottle.id, identifier),
|
||||
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
logger.debug(`Cleared ${type} challenge throttle`, {
|
||||
identifier: identifier.slice(0, 8),
|
||||
|
||||
@@ -107,7 +107,11 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
emailHash: body.emailHash,
|
||||
});
|
||||
|
||||
const throttleResult = await challengeThrottleService.checkThrottle(binding, "passkey");
|
||||
const throttleResult = await challengeThrottleService.checkThrottle(
|
||||
binding,
|
||||
"passkey",
|
||||
tenantId,
|
||||
);
|
||||
if (!throttleResult.allowed) {
|
||||
return json(
|
||||
{
|
||||
|
||||
@@ -124,7 +124,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
}
|
||||
|
||||
if (storedChallenge.emailHash !== binding) {
|
||||
await challengeThrottleService.recordFailedAttempt(binding, "passkey");
|
||||
await challengeThrottleService.recordFailedAttempt(binding, "passkey", tenantId);
|
||||
throw new ValidationError("Invalid bootstrap challenge binding");
|
||||
}
|
||||
|
||||
@@ -136,11 +136,11 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
});
|
||||
|
||||
if (!matchesPowDifficulty(digest, NEW_CLIENT_BOOTSTRAP_DIFFICULTY)) {
|
||||
await challengeThrottleService.recordFailedAttempt(binding, "passkey");
|
||||
await challengeThrottleService.recordFailedAttempt(binding, "passkey", tenantId);
|
||||
throw new ValidationError("Invalid bootstrap proof of work");
|
||||
}
|
||||
|
||||
await challengeThrottleService.clearThrottle(binding, "passkey");
|
||||
await challengeThrottleService.clearThrottle(binding, "passkey", tenantId);
|
||||
|
||||
const bookingAccessToken = await generateNewClientBootstrapToken({
|
||||
tenantId,
|
||||
|
||||
@@ -139,7 +139,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
const { emailHash } = requestSchema.parse(body);
|
||||
|
||||
// Check throttling
|
||||
const throttleResult = await challengeThrottleService.checkThrottle(emailHash, "pin");
|
||||
const throttleResult = await challengeThrottleService.checkThrottle(emailHash, "pin", tenantId);
|
||||
|
||||
if (!throttleResult.allowed) {
|
||||
logger.warn("PIN challenge throttled", {
|
||||
|
||||
@@ -169,7 +169,11 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
});
|
||||
|
||||
// Record failed attempt for throttling
|
||||
await challengeThrottleService.recordFailedAttempt(storedChallenge.emailHash, "pin");
|
||||
await challengeThrottleService.recordFailedAttempt(
|
||||
storedChallenge.emailHash,
|
||||
"pin",
|
||||
tenantId,
|
||||
);
|
||||
|
||||
throw new ValidationError("Invalid challenge response");
|
||||
}
|
||||
@@ -209,7 +213,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
});
|
||||
|
||||
// Clear throttle on successful verification
|
||||
await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin");
|
||||
await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin", tenantId);
|
||||
|
||||
const response: ChallengeVerificationResponse = {
|
||||
valid: true,
|
||||
|
||||
Reference in New Issue
Block a user