Added tenant-specific throttling for clients

This commit is contained in:
Hendrik Belitz
2026-05-16 18:59:50 +02:00
parent a65c00bb8c
commit 461925a6e4
8 changed files with 63 additions and 17 deletions
+2
View File
@@ -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();
+38 -7
View File
@@ -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,