feat: enhance rate limiting by tracking login attempts per client IP and refining API rate limits for write operations

This commit is contained in:
shuaiplus
2026-02-14 20:56:34 +08:00
parent ff7b44e501
commit 719024d0fd
3 changed files with 74 additions and 44 deletions
+6 -5
View File
@@ -1,7 +1,7 @@
import { Env, TokenResponse } from '../types'; import { Env, TokenResponse } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
// POST /identity/connect/token // POST /identity/connect/token
@@ -26,6 +26,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Login with password // Login with password
const email = body.username?.toLowerCase(); const email = body.username?.toLowerCase();
const passwordHash = body.password; const passwordHash = body.password;
const loginIdentifier = getClientIdentifier(request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
@@ -33,7 +34,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(email); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) { if (!loginCheck.allowed) {
return identityErrorResponse( return identityErrorResponse(
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
@@ -44,14 +45,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const user = await storage.getUser(email); const user = await storage.getUser(email);
if (!user) { if (!user) {
await rateLimit.recordFailedLogin(email); await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
} }
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
if (!valid) { if (!valid) {
// Record failed login attempt // Record failed login attempt
const result = await rateLimit.recordFailedLogin(email); const result = await rateLimit.recordFailedLogin(loginIdentifier);
if (result.locked) { if (result.locked) {
return identityErrorResponse( return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`, `Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
@@ -63,7 +64,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Successful login - clear failed attempts // Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(email); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user); const accessToken = await auth.generateAccessToken(user);
const refreshToken = await auth.generateRefreshToken(user.id); const refreshToken = await auth.generateRefreshToken(user.id);
+23 -21
View File
@@ -224,27 +224,29 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const userId = payload.sub; const userId = payload.sub;
// API rate limiting for authenticated requests // API rate limiting only for write operations (keep reads frictionless)
const rateLimit = new RateLimitService(env.DB); const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
const clientId = getClientIdentifier(request); if (isWriteMethod) {
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId); const rateLimit = new RateLimitService(env.DB);
const clientId = getClientIdentifier(request);
if (!rateLimitCheck.allowed) { const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId + ':write');
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
}
// Increment rate limit counter if (!rateLimitCheck.allowed) {
await rateLimit.incrementApiCount(userId + ':' + clientId); return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
}
await rateLimit.incrementApiCount(userId + ':' + clientId + ':write');
}
// Block account operations that could change password or delete user // Block account operations that could change password or delete user
if (method === 'POST' || method === 'PUT' || method === 'DELETE') { if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
@@ -258,7 +260,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
'/api/accounts/delete-vault', '/api/accounts/delete-vault',
]); ]);
if (blockedAccountPaths.has(path)) { if (blockedAccountPaths.has(path)) {
return errorResponse('This operation is disabled', 403); return errorResponse('Not implemented in single-user mode', 501);
} }
} }
+45 -18
View File
@@ -1,30 +1,53 @@
// D1-backed rate limiting. // D1-backed rate limiting.
// Notes: // Notes:
// - Login attempts are tracked per email. // - Login attempts are tracked per client IP.
// - API rate is tracked per identifier per fixed window. // - API rate is tracked per identifier per fixed window.
// Rate limit configuration // Rate limit configuration
const CONFIG = { const CONFIG = {
LOGIN_MAX_ATTEMPTS: 15, // Friendly default: short cooldown instead of long lockouts.
LOGIN_LOCKOUT_MINUTES: 5, LOGIN_MAX_ATTEMPTS: 8,
LOGIN_LOCKOUT_MINUTES: 2,
API_REQUESTS_PER_MINUTE: 300, // Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
API_WRITE_REQUESTS_PER_MINUTE: 120,
API_WINDOW_SECONDS: 60, API_WINDOW_SECONDS: 60,
}; };
export class RateLimitService { export class RateLimitService {
private static loginIpTableReady = false;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
async checkLoginAttempt(email: string): Promise<{ private async ensureLoginIpTable(): Promise<void> {
if (RateLimitService.loginIpTableReady) return;
await this.db
.prepare(
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, ' +
'attempts INTEGER NOT NULL, ' +
'locked_until INTEGER, ' +
'updated_at INTEGER NOT NULL' +
')'
)
.run();
RateLimitService.loginIpTableReady = true;
}
async checkLoginAttempt(ip: string): Promise<{
allowed: boolean; allowed: boolean;
remainingAttempts: number; remainingAttempts: number;
retryAfterSeconds?: number; retryAfterSeconds?: number;
}> { }> {
const key = email.toLowerCase(); await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
const now = Date.now(); const now = Date.now();
const row = await this.db const row = await this.db
.prepare('SELECT attempts, locked_until FROM login_attempts WHERE email = ?') .prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
.bind(key) .bind(key)
.first<{ attempts: number; locked_until: number | null }>(); .first<{ attempts: number; locked_until: number | null }>();
@@ -41,7 +64,7 @@ export class RateLimitService {
} }
if (row.locked_until && row.locked_until <= now) { if (row.locked_until && row.locked_until <= now) {
await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(key).run(); await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS }; return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
} }
@@ -49,8 +72,10 @@ export class RateLimitService {
return { allowed: true, remainingAttempts }; return { allowed: true, remainingAttempts };
} }
async recordFailedLogin(email: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> { async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
const key = email.toLowerCase(); await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
const now = Date.now(); const now = Date.now();
// D1 in Workers forbids raw BEGIN/COMMIT statements. // D1 in Workers forbids raw BEGIN/COMMIT statements.
@@ -58,14 +83,14 @@ export class RateLimitService {
// This is concurrency-safe because the row is keyed by email. // This is concurrency-safe because the row is keyed by email.
await this.db await this.db
.prepare( .prepare(
'INSERT INTO login_attempts(email, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' + 'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
'ON CONFLICT(email) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at' 'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
) )
.bind(key, now) .bind(key, now)
.run(); .run();
const row = await this.db const row = await this.db
.prepare('SELECT attempts FROM login_attempts WHERE email = ?') .prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?')
.bind(key) .bind(key)
.first<{ attempts: number }>(); .first<{ attempts: number }>();
@@ -73,7 +98,7 @@ export class RateLimitService {
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) { if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000; const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
await this.db await this.db
.prepare('UPDATE login_attempts SET locked_until = ?, updated_at = ? WHERE email = ?') .prepare('UPDATE login_attempts_ip SET locked_until = ?, updated_at = ? WHERE ip = ?')
.bind(lockedUntil, now, key) .bind(lockedUntil, now, key)
.run(); .run();
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 }; return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
@@ -82,8 +107,10 @@ export class RateLimitService {
return { locked: false }; return { locked: false };
} }
async clearLoginAttempts(email: string): Promise<void> { async clearLoginAttempts(ip: string): Promise<void> {
await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(email.toLowerCase()).run(); await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
} }
async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
@@ -97,7 +124,7 @@ export class RateLimitService {
.first<{ count: number }>(); .first<{ count: number }>();
const count = row?.count || 0; const count = row?.count || 0;
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) { if (count >= CONFIG.API_WRITE_REQUESTS_PER_MINUTE) {
return { return {
allowed: false, allowed: false,
remaining: 0, remaining: 0,
@@ -107,7 +134,7 @@ export class RateLimitService {
return { return {
allowed: true, allowed: true,
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count, remaining: CONFIG.API_WRITE_REQUESTS_PER_MINUTE - count,
}; };
} }