diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index db4f32c..23d2dc0 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -1,7 +1,7 @@ import { Env, TokenResponse } from '../types'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; -import { RateLimitService } from '../services/ratelimit'; +import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; // POST /identity/connect/token @@ -26,6 +26,7 @@ export async function handleToken(request: Request, env: Env): Promise // Login with password const email = body.username?.toLowerCase(); const passwordHash = body.password; + const loginIdentifier = getClientIdentifier(request); if (!email || !passwordHash) { // Bitwarden clients expect OAuth-style error fields. @@ -33,7 +34,7 @@ export async function handleToken(request: Request, env: Env): Promise } // 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) { return identityErrorResponse( `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 const user = await storage.getUser(email); if (!user) { - await rateLimit.recordFailedLogin(email); + await rateLimit.recordFailedLogin(loginIdentifier); return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); } const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); if (!valid) { // Record failed login attempt - const result = await rateLimit.recordFailedLogin(email); + const result = await rateLimit.recordFailedLogin(loginIdentifier); if (result.locked) { return identityErrorResponse( `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 } // Successful login - clear failed attempts - await rateLimit.clearLoginAttempts(email); + await rateLimit.clearLoginAttempts(loginIdentifier); const accessToken = await auth.generateAccessToken(user); const refreshToken = await auth.generateRefreshToken(user.id); diff --git a/src/router.ts b/src/router.ts index c088f9a..b3fe3b9 100644 --- a/src/router.ts +++ b/src/router.ts @@ -224,27 +224,29 @@ export async function handleRequest(request: Request, env: Env): Promise { + 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; remainingAttempts: number; retryAfterSeconds?: number; }> { - const key = email.toLowerCase(); + await this.ensureLoginIpTable(); + + const key = ip.trim() || 'unknown'; const now = Date.now(); 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) .first<{ attempts: number; locked_until: number | null }>(); @@ -41,7 +64,7 @@ export class RateLimitService { } 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 }; } @@ -49,8 +72,10 @@ export class RateLimitService { return { allowed: true, remainingAttempts }; } - async recordFailedLogin(email: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> { - const key = email.toLowerCase(); + async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> { + await this.ensureLoginIpTable(); + + const key = ip.trim() || 'unknown'; const now = Date.now(); // 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. await this.db .prepare( - 'INSERT INTO login_attempts(email, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' + - 'ON CONFLICT(email) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at' + 'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' + + 'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at' ) .bind(key, now) .run(); const row = await this.db - .prepare('SELECT attempts FROM login_attempts WHERE email = ?') + .prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?') .bind(key) .first<{ attempts: number }>(); @@ -73,7 +98,7 @@ export class RateLimitService { if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) { const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000; 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) .run(); return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 }; @@ -82,8 +107,10 @@ export class RateLimitService { return { locked: false }; } - async clearLoginAttempts(email: string): Promise { - await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(email.toLowerCase()).run(); + async clearLoginAttempts(ip: string): Promise { + 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 }> { @@ -97,7 +124,7 @@ export class RateLimitService { .first<{ count: number }>(); const count = row?.count || 0; - if (count >= CONFIG.API_REQUESTS_PER_MINUTE) { + if (count >= CONFIG.API_WRITE_REQUESTS_PER_MINUTE) { return { allowed: false, remaining: 0, @@ -107,7 +134,7 @@ export class RateLimitService { return { allowed: true, - remaining: CONFIG.API_REQUESTS_PER_MINUTE - count, + remaining: CONFIG.API_WRITE_REQUESTS_PER_MINUTE - count, }; }