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:
@@ -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> = {
|
||||
|
||||
146
src/backend/utils/login-rate-limiter.ts
Normal file
146
src/backend/utils/login-rate-limiter.ts
Normal 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();
|
||||
Reference in New Issue
Block a user