From 9a0933bf2fc229c846875a9f7b102edc06d6e4ff Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:20:28 +0800 Subject: [PATCH] security: Add login rate limiting to prevent brute force attacks - Implement LoginRateLimiter with IP and username-based tracking - Block after 5 failed attempts within 15 minutes - Lock account/IP for 15 minutes after threshold - Automatic cleanup of expired entries every 5 minutes - Track remaining attempts in logs for monitoring - Return 429 status with remaining time on rate limit - Reset counters on successful login - Dual protection: both IP-based and username-based limits --- src/backend/database/routes/users.ts | 27 +++++ src/backend/utils/login-rate-limiter.ts | 146 ++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/backend/utils/login-rate-limiter.ts diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 92ea8746..bbabf4bc 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -27,6 +27,7 @@ import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { parseUserAgent } from "../../utils/user-agent-parser.js"; +import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; const authManager = AuthManager.getInstance(); @@ -862,6 +863,7 @@ router.get("/oidc/callback", async (req, res) => { // POST /users/login router.post("/login", async (req, res) => { const { username, password } = req.body; + const clientIp = req.ip || req.socket.remoteAddress || "unknown"; if (!isNonEmptyString(username) || !isNonEmptyString(password)) { authLogger.warn("Invalid traditional login attempt", { @@ -872,6 +874,21 @@ router.post("/login", async (req, res) => { return res.status(400).json({ error: "Invalid username or password" }); } + // Check rate limiting + const lockStatus = loginRateLimiter.isLocked(clientIp, username); + if (lockStatus.locked) { + authLogger.warn("Login attempt blocked due to rate limiting", { + operation: "user_login_blocked", + username, + ip: clientIp, + remainingTime: lockStatus.remainingTime, + }); + return res.status(429).json({ + error: "Too many login attempts. Please try again later.", + remainingTime: lockStatus.remainingTime, + }); + } + try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") @@ -896,9 +913,12 @@ router.post("/login", async (req, res) => { .where(eq(users.username, username)); if (!user || user.length === 0) { + loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: user not found`, { operation: "user_login", username, + ip: clientIp, + remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), }); return res.status(401).json({ error: "Invalid username or password" }); } @@ -918,10 +938,13 @@ router.post("/login", async (req, res) => { const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { + loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: incorrect password`, { operation: "user_login", username, userId: userRecord.id, + ip: clientIp, + remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), }); return res.status(401).json({ error: "Invalid username or password" }); } @@ -965,6 +988,9 @@ router.post("/login", async (req, res) => { deviceInfo: deviceInfo.deviceInfo, }); + // Reset rate limiter on successful login + loginRateLimiter.resetAttempts(clientIp, username); + authLogger.success(`User logged in successfully: ${username}`, { operation: "user_login_success", username, @@ -972,6 +998,7 @@ router.post("/login", async (req, res) => { dataUnlocked: true, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, + ip: clientIp, }); const response: Record = { diff --git a/src/backend/utils/login-rate-limiter.ts b/src/backend/utils/login-rate-limiter.ts new file mode 100644 index 00000000..4e5ed704 --- /dev/null +++ b/src/backend/utils/login-rate-limiter.ts @@ -0,0 +1,146 @@ +interface LoginAttempt { + count: number; + firstAttempt: number; + lockedUntil?: number; +} + +class LoginRateLimiter { + private ipAttempts = new Map(); + private usernameAttempts = new Map(); + + private readonly MAX_ATTEMPTS = 5; + private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes + private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes + + // Clean up old entries periodically + constructor() { + setInterval(() => this.cleanup(), 5 * 60 * 1000); // Clean every 5 minutes + } + + private cleanup(): void { + const now = Date.now(); + + // Clean IP attempts + for (const [ip, attempt] of this.ipAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.ipAttempts.delete(ip); + } else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) { + this.ipAttempts.delete(ip); + } + } + + // Clean username attempts + for (const [username, attempt] of this.usernameAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.usernameAttempts.delete(username); + } else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) { + this.usernameAttempts.delete(username); + } + } + } + + recordFailedAttempt(ip: string, username?: string): void { + const now = Date.now(); + + // Record IP attempt + const ipAttempt = this.ipAttempts.get(ip); + if (!ipAttempt) { + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else if ((now - ipAttempt.firstAttempt) > this.WINDOW_MS) { + // Reset if outside window + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else { + ipAttempt.count++; + if (ipAttempt.count >= this.MAX_ATTEMPTS) { + ipAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + + // Record username attempt if provided + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (!userAttempt) { + this.usernameAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else if ((now - userAttempt.firstAttempt) > this.WINDOW_MS) { + // Reset if outside window + this.usernameAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else { + userAttempt.count++; + if (userAttempt.count >= this.MAX_ATTEMPTS) { + userAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + } + } + + resetAttempts(ip: string, username?: string): void { + this.ipAttempts.delete(ip); + if (username) { + this.usernameAttempts.delete(username); + } + } + + isLocked(ip: string, username?: string): { locked: boolean; remainingTime?: number } { + const now = Date.now(); + + // Check IP lockout + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000), + }; + } + + // Check username lockout + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((userAttempt.lockedUntil - now) / 1000), + }; + } + } + + return { locked: false }; + } + + getRemainingAttempts(ip: string, username?: string): number { + const now = Date.now(); + let minRemaining = this.MAX_ATTEMPTS; + + // Check IP attempts + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt && (now - ipAttempt.firstAttempt) <= this.WINDOW_MS) { + const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count); + minRemaining = Math.min(minRemaining, ipRemaining); + } + + // Check username attempts + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (userAttempt && (now - userAttempt.firstAttempt) <= this.WINDOW_MS) { + const userRemaining = Math.max(0, this.MAX_ATTEMPTS - userAttempt.count); + minRemaining = Math.min(minRemaining, userRemaining); + } + } + + return minRemaining; + } +} + +// Export singleton instance +export const loginRateLimiter = new LoginRateLimiter();