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
This commit is contained in:
ZacharyZcR
2025-11-09 09:20:28 +08:00
parent a70ad7c6b7
commit 9a0933bf2f
2 changed files with 173 additions and 0 deletions

View File

@@ -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<string, unknown> = {

View File

@@ -0,0 +1,146 @@
interface LoginAttempt {
count: number;
firstAttempt: number;
lockedUntil?: number;
}
class LoginRateLimiter {
private ipAttempts = new Map<string, LoginAttempt>();
private usernameAttempts = new Map<string, LoginAttempt>();
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();