115 lines
3.4 KiB
TypeScript
115 lines
3.4 KiB
TypeScript
/**
|
|
* Limiteur à fenêtre glissante conçu pour l'instance Docker SONUM.
|
|
*
|
|
* Les clés sont conservées uniquement pendant leur fenêtre d'observation ; le
|
|
* nettoyage périodique évite une croissance non bornée lors de tentatives
|
|
* distribuées. Il ne stocke ni mot de passe ni contenu de message.
|
|
*/
|
|
export type RateLimitRule = {
|
|
scope: string;
|
|
maxAttempts: number;
|
|
windowMs: number;
|
|
};
|
|
|
|
export type RateLimitResult =
|
|
| { allowed: true; remaining: number }
|
|
| { allowed: false; retryAfterMs: number };
|
|
|
|
type Entry = {
|
|
timestamps: number[];
|
|
windowMs: number;
|
|
};
|
|
|
|
export class SlidingWindowRateLimiter {
|
|
private readonly entries = new Map<string, Entry>();
|
|
private lastCleanupAt = 0;
|
|
|
|
consume(rule: RateLimitRule, key: string, now = Date.now()): RateLimitResult {
|
|
this.cleanup(now);
|
|
|
|
const entryKey = `${rule.scope}:${key}`;
|
|
const cutoff = now - rule.windowMs;
|
|
const entry = this.entries.get(entryKey) ?? { timestamps: [], windowMs: rule.windowMs };
|
|
entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > cutoff);
|
|
entry.windowMs = rule.windowMs;
|
|
|
|
if (entry.timestamps.length >= rule.maxAttempts) {
|
|
this.entries.set(entryKey, entry);
|
|
return {
|
|
allowed: false,
|
|
retryAfterMs: Math.max(1, entry.timestamps[0] + rule.windowMs - now),
|
|
};
|
|
}
|
|
|
|
entry.timestamps.push(now);
|
|
this.entries.set(entryKey, entry);
|
|
return { allowed: true, remaining: rule.maxAttempts - entry.timestamps.length };
|
|
}
|
|
|
|
/** Réinitialise exclusivement la clé concernée après une authentification réussie. */
|
|
reset(rule: RateLimitRule, key: string) {
|
|
this.entries.delete(`${rule.scope}:${key}`);
|
|
}
|
|
|
|
clear() {
|
|
this.entries.clear();
|
|
this.lastCleanupAt = 0;
|
|
}
|
|
|
|
private cleanup(now: number) {
|
|
// Une purge par minute est suffisante et évite un parcours de Map à chaque requête.
|
|
if (now - this.lastCleanupAt < 60_000) return;
|
|
this.lastCleanupAt = now;
|
|
|
|
this.entries.forEach((entry, entryKey) => {
|
|
const cutoff = now - entry.windowMs;
|
|
entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > cutoff);
|
|
if (!entry.timestamps.length) this.entries.delete(entryKey);
|
|
});
|
|
}
|
|
}
|
|
|
|
export const rateLimiter = new SlidingWindowRateLimiter();
|
|
|
|
export const LOGIN_IDENTIFIER_RULE: RateLimitRule = {
|
|
scope: "login-identifier",
|
|
maxAttempts: 5,
|
|
windowMs: 15 * 60_000,
|
|
};
|
|
|
|
export const LOGIN_IP_RULE: RateLimitRule = {
|
|
scope: "login-ip",
|
|
maxAttempts: 20,
|
|
windowMs: 15 * 60_000,
|
|
};
|
|
|
|
export const CHANNEL_MESSAGE_RULE: RateLimitRule = {
|
|
scope: "channel-message",
|
|
maxAttempts: 30,
|
|
windowMs: 60_000,
|
|
};
|
|
|
|
export const CONTACT_REQUEST_RULE: RateLimitRule = {
|
|
scope: "contact-request",
|
|
maxAttempts: 5,
|
|
windowMs: 60 * 60_000,
|
|
};
|
|
|
|
export const INTRODUCTION_REQUEST_RULE: RateLimitRule = {
|
|
scope: "introduction-request",
|
|
maxAttempts: 5,
|
|
windowMs: 60 * 60_000,
|
|
};
|
|
|
|
/**
|
|
* Traefik renseigne `x-forwarded-for`; le premier maillon représente le client.
|
|
* En développement ou sans proxy, une clé neutre est utilisée plutôt qu'une IP
|
|
* non fiable issue d'un type Express implicite.
|
|
*/
|
|
export function getClientIp(headers: Record<string, string | string[] | undefined>): string {
|
|
const forwarded = headers["x-forwarded-for"];
|
|
const rawValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
const ip = rawValue?.split(",")[0]?.trim();
|
|
return ip || "unknown-client";
|
|
}
|