This commit is contained in:
k
2026-09-24 11:02:24 +02:00
parent 8e64c0c963
commit 6e65d84a1c
17 changed files with 2141 additions and 191 deletions
+37
View File
@@ -4,6 +4,7 @@ import {
createSession,
deleteSession,
getSession,
getSetting,
getUserById,
getUserByUsername,
toPublicUser,
@@ -109,3 +110,39 @@ export function recordAttempt(ip: string): void {
export function clearAttempts(ip: string): void {
attempts.delete(ip);
}
// --- dedicated rate limiter for password recovery (admin-configurable) ---
// Separate bucket from login: recovery is anonymous-by-design (always-ok
// answers) so it needs its own, usually stricter, envelope.
const recoveryAttempts = new Map<string, number[]>();
function clampSettingInt(raw: string, min: number, max: number, fallback: number): number {
const v = Number.parseInt(raw, 10);
if (!Number.isInteger(v)) return fallback;
return Math.min(max, Math.max(min, v));
}
export async function recoveryLimitMax(): Promise<number> {
return clampSettingInt(await getSetting("recovery_limit_max", "5"), 1, 100, 5);
}
export async function recoveryLimitWindowMs(): Promise<number> {
return clampSettingInt(await getSetting("recovery_limit_window_min", "60"), 1, 1440, 60) * 60_000;
}
export async function isRecoveryLimited(ip: string): Promise<{ limited: boolean; retryAfterSec: number }> {
const windowMs = await recoveryLimitWindowMs();
const max = await recoveryLimitMax();
const now = Date.now();
const list = (recoveryAttempts.get(ip) ?? []).filter((t) => t > now - windowMs);
recoveryAttempts.set(ip, list);
if (list.length < max) return { limited: false, retryAfterSec: 0 };
return { limited: true, retryAfterSec: Math.max(1, Math.ceil((list[0]! + windowMs - now) / 1000)) };
}
export function recordRecoveryAttempt(ip: string): void {
const list = recoveryAttempts.get(ip) ?? [];
list.push(Date.now());
recoveryAttempts.set(ip, list);
}