mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance rate limiting by tracking login attempts per client IP and refining API rate limits for write operations
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user