diff --git a/apps/meteor/app/authentication/server/hooks/login.ts b/apps/meteor/app/authentication/server/hooks/login.ts index 191639e208dee..13917953c9c59 100644 --- a/apps/meteor/app/authentication/server/hooks/login.ts +++ b/apps/meteor/app/authentication/server/hooks/login.ts @@ -5,26 +5,33 @@ import { settings } from '../../../settings/server'; import type { ILoginAttempt } from '../ILoginAttempt'; import { logFailedLoginAttempts } from '../lib/logLoginAttempts'; import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../lib/restrictLoginAttempts'; +import { anomalyAggregator } from '../lib/LoginAnomalyAggregator'; const ignoredErrorTypes = ['totp-required', 'error-login-blocked-for-user']; Accounts.onLoginFailure(async (login: ILoginAttempt) => { - // do not save specific failed login attempts - if ( - settings.get('Block_Multiple_Failed_Logins_Enabled') && - login.error?.error && - !ignoredErrorTypes.includes(String(login.error.error)) - ) { - await saveFailedLoginAttempts(login); - } - - logFailedLoginAttempts(login); + const ip = login.connection.clientAddress; + const userId = login.user?._id; + + if ( + settings.get('Block_Multiple_Failed_Logins_Enabled') && + login.error?.error && + !ignoredErrorTypes.includes(String(login.error.error)) + ) { + await saveFailedLoginAttempts(login); + + if (ip) { + anomalyAggregator.logFailure(ip, userId); + } + } + + logFailedLoginAttempts(login); }); callbacks.add('afterValidateLogin', (login: ILoginAttempt) => { - if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { - return; - } + if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { + return; + } - return saveSuccessfulLogin(login); -}); + return saveSuccessfulLogin(login); +}); \ No newline at end of file diff --git a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts new file mode 100644 index 0000000000000..d6df7ec531883 --- /dev/null +++ b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts @@ -0,0 +1,83 @@ +interface FailedAttemptInfo { + key: string; + count: number; + firstAttempt: Date; + lastAttempt: Date; + timeoutId?: ReturnType; +} + +class LoginAnomalyAggregator { + private buffer: Map = new Map(); + private readonly FLUSH_WINDOW_MS = 45 * 1000; + private readonly MAX_BUFFER_SIZE = 1000; + + public logFailure(ip: string, userId?: string): void { + const now = new Date(); + + const keys = [`ip:${ip}`]; + if (userId) { + keys.push(`user:${userId}`); + } + + for (const key of keys) { + this.processFailureForKey(key, now); + } + } + + private processFailureForKey(key: string, now: Date): void { + if (this.buffer.size >= this.MAX_BUFFER_SIZE && !this.buffer.has(key)) { + const oldestKey = this.buffer.keys().next().value; + if (oldestKey) { + void this.flushEvent(oldestKey); + } + } + + if (this.buffer.has(key)) { + const entry = this.buffer.get(key)!; + entry.count += 1; + entry.lastAttempt = now; + } else { + const entry: FailedAttemptInfo = { + key, + count: 1, + firstAttempt: now, + lastAttempt: now, + }; + + entry.timeoutId = setTimeout(() => { + void this.flushEvent(key); + }, this.FLUSH_WINDOW_MS); + this.buffer.set(key, entry); + } + } + + private async flushEvent(key: string): Promise { + const entry = this.buffer.get(key); + if (!entry) return; + + if (entry.timeoutId) { + clearTimeout(entry.timeoutId); + } + + this.buffer.delete(key); + + if (entry.count > 3) { + await this.recordAnomaly(entry); + } + } + + private async recordAnomaly(entry: Omit): Promise { + const severity = entry.count >= 10 ? 'high' : 'medium'; + const durationInSeconds = Math.round((entry.lastAttempt.getTime() - entry.firstAttempt.getTime()) / 1000); + + console.error( + `🔥 [Auth Anomaly Aggregator] ` + + `Target: ${entry.key} | ` + + `Attempts: ${entry.count} | ` + + `Window: ${durationInSeconds}s | ` + + `Severity: ${severity}` + ); + } +} + +export const anomalyAggregator = new LoginAnomalyAggregator(); \ No newline at end of file