fix(auth): harden async login boundaries
This commit is contained in:
+38
-16
@@ -27,30 +27,52 @@ interface LoginPayload {
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
class LoginFailureLimiter {
|
||||
export class LoginFailureLimiter {
|
||||
private readonly usernames = new Map<string, number[]>();
|
||||
private readonly addresses = new Map<string, number[]>();
|
||||
private readonly maximumEntries: number;
|
||||
|
||||
constructor(options: { maximumEntries?: number } = {}) {
|
||||
this.maximumEntries = options.maximumEntries ?? MAX_LIMIT_ENTRIES;
|
||||
}
|
||||
|
||||
isLimited(username: string, address: string, now = Date.now()): boolean {
|
||||
return this.active(this.usernames, username, now).length >= 10
|
||||
|| this.active(this.addresses, address, now).length >= 20;
|
||||
return this.countActive(this.usernames, username, now) >= 10
|
||||
|| this.countActive(this.addresses, address, now) >= 20;
|
||||
}
|
||||
|
||||
recordFailure(username: string, address: string, now = Date.now()): void {
|
||||
this.active(this.usernames, username, now).push(now);
|
||||
this.active(this.addresses, address, now).push(now);
|
||||
recordFailure(username: string, address: string, now = Date.now()): boolean {
|
||||
this.pruneExpired(this.usernames, now);
|
||||
this.pruneExpired(this.addresses, now);
|
||||
const usernameAttempts = this.usernames.get(username) ?? [];
|
||||
const addressAttempts = this.addresses.get(address) ?? [];
|
||||
if (usernameAttempts.length >= 10 || addressAttempts.length >= 20) return false;
|
||||
if ((!this.usernames.has(username) && this.usernames.size >= this.maximumEntries)
|
||||
|| (!this.addresses.has(address) && this.addresses.size >= this.maximumEntries)) return false;
|
||||
|
||||
this.usernames.set(username, [...usernameAttempts, now]);
|
||||
this.addresses.set(address, [...addressAttempts, now]);
|
||||
return true;
|
||||
}
|
||||
|
||||
private active(bucket: Map<string, number[]>, key: string, now: number): number[] {
|
||||
const prior = bucket.get(key) ?? [];
|
||||
const current = prior.filter((timestamp) => timestamp > now - TEN_MINUTES_MS);
|
||||
if (current.length === 0) bucket.delete(key); else bucket.set(key, current);
|
||||
if (!bucket.has(key) && bucket.size >= MAX_LIMIT_ENTRIES) {
|
||||
const oldest = bucket.keys().next().value;
|
||||
if (typeof oldest === "string") bucket.delete(oldest);
|
||||
private countActive(bucket: ReadonlyMap<string, readonly number[]>, key: string, now: number): number {
|
||||
const attempts = bucket.get(key);
|
||||
if (!attempts) return 0;
|
||||
const earliest = now - TEN_MINUTES_MS;
|
||||
let count = 0;
|
||||
for (const timestamp of attempts) {
|
||||
if (timestamp > earliest) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private pruneExpired(bucket: Map<string, number[]>, now: number): void {
|
||||
const earliest = now - TEN_MINUTES_MS;
|
||||
for (const [key, attempts] of bucket) {
|
||||
const active = attempts.filter((timestamp) => timestamp > earliest);
|
||||
if (active.length === 0) bucket.delete(key);
|
||||
else if (active.length !== attempts.length) bucket.set(key, active);
|
||||
}
|
||||
if (!bucket.has(key)) bucket.set(key, current);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +129,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
||||
}
|
||||
if (verified === undefined) return loginLimited(reply);
|
||||
if (!verified || !user || !user.enabled) {
|
||||
limiter.recordFailure(normalizedUsername, sourceAddress);
|
||||
if (!limiter.recordFailure(normalizedUsername, sourceAddress)) return loginLimited(reply);
|
||||
return invalidCredentials(reply);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user