Checkpoint: Pagination serveur des établissements et des messages, tri serveur, navigation utilisateur, limitation de débit sur connexion locale, messages et demandes, avec 46 tests Vitest, typage TypeScript et build validés.
This commit is contained in:
114
server/rateLimit.ts
Normal file
114
server/rateLimit.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
Reference in New Issue
Block a user