| |
| |
| |
| |
|
|
| import { NextRequest, NextResponse } from "next/server"; |
| import type { Redis } from "ioredis"; |
|
|
| interface BruteForceConfig { |
| maxAttempts: number; |
| windowMs: number; |
| lockoutDurationMs: number; |
| progressiveDelay: boolean; |
| } |
|
|
| const DEFAULT_BRUTE_FORCE_CONFIG: BruteForceConfig = { |
| maxAttempts: 5, |
| windowMs: 15 * 60, |
| lockoutDurationMs: 30 * 60, |
| progressiveDelay: true, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| export async function checkBruteForce( |
| identifier: string, |
| config: Partial<BruteForceConfig> = {} |
| ) { |
| const finalConfig = { ...DEFAULT_BRUTE_FORCE_CONFIG, ...config }; |
|
|
| |
| |
| const redisClient: Redis | null = null; |
|
|
| if (!redisClient) { |
| return { |
| allowed: true, |
| attempts: 0, |
| remaining: finalConfig.maxAttempts, |
| lockedOut: false, |
| delayMs: 0, |
| }; |
| } |
|
|
| |
| const redis: Redis = redisClient; |
|
|
| try { |
| const lockKey = `bruteforce:locked:${identifier}`; |
| const attemptsKey = `bruteforce:attempts:${identifier}`; |
|
|
| |
| const isLocked = await redis.exists(lockKey); |
| if (isLocked) { |
| const ttl = await redis.ttl(lockKey); |
| return { |
| allowed: false, |
| attempts: finalConfig.maxAttempts, |
| remaining: 0, |
| lockedOut: true, |
| lockDurationSeconds: Math.max(ttl, 0), |
| error: "Too many failed attempts. Please try again later.", |
| }; |
| } |
|
|
| |
| const attemptsStr = await redis.get(attemptsKey); |
| const attempts = parseInt(attemptsStr || "0", 10); |
|
|
| |
| if (attempts >= finalConfig.maxAttempts) { |
| |
| await redis.setex( |
| lockKey, |
| finalConfig.lockoutDurationMs, |
| "1" |
| ); |
|
|
| return { |
| allowed: false, |
| attempts: finalConfig.maxAttempts, |
| remaining: 0, |
| lockedOut: true, |
| lockDurationSeconds: finalConfig.lockoutDurationMs, |
| error: "Account temporarily locked due to too many failed attempts.", |
| }; |
| } |
|
|
| |
| let delayMs = 0; |
| if (finalConfig.progressiveDelay) { |
| |
| delayMs = 100 * Math.pow(attempts + 1, 2); |
| } |
|
|
| return { |
| allowed: true, |
| attempts, |
| remaining: finalConfig.maxAttempts - attempts, |
| lockedOut: false, |
| delayMs, |
| }; |
| } catch (error) { |
| console.error("Brute-force check failed:", error); |
| |
| return { |
| allowed: true, |
| attempts: 0, |
| remaining: finalConfig.maxAttempts, |
| lockedOut: false, |
| delayMs: 0, |
| }; |
| } |
| } |
|
|
| |
| |
| |
| export async function recordFailedAttempt( |
| identifier: string, |
| config: Partial<BruteForceConfig> = {} |
| ) { |
| const finalConfig = { ...DEFAULT_BRUTE_FORCE_CONFIG, ...config }; |
| const redisClient: Redis | null = null; |
|
|
| if (!redisClient) { |
| return; |
| } |
|
|
| const redis: Redis = redisClient; |
|
|
| try { |
| const attemptsKey = `bruteforce:attempts:${identifier}`; |
| const attempts = await redis.incr(attemptsKey); |
|
|
| |
| if (attempts === 1) { |
| await redis.expire(attemptsKey, finalConfig.windowMs); |
| } |
|
|
| |
| if (attempts >= finalConfig.maxAttempts) { |
| const lockKey = `bruteforce:locked:${identifier}`; |
| await redis.setex( |
| lockKey, |
| finalConfig.lockoutDurationMs, |
| "1" |
| ); |
| } |
| } catch (error) { |
| console.error("Failed to record attempt:", error); |
| } |
| } |
|
|
| |
| |
| |
| export async function recordSuccessfulAttempt(identifier: string) { |
| const redisClient: Redis | null = null; |
| if (!redisClient) return; |
|
|
| const redis: Redis = redisClient; |
|
|
| try { |
| const attemptsKey = `bruteforce:attempts:${identifier}`; |
| await redis.del(attemptsKey); |
| } catch (error) { |
| console.error("Failed to reset attempts:", error); |
| } |
| } |
|
|
| |
| |
| |
| export async function withBruteForceProtection( |
| handler: (req: NextRequest) => Promise<Response>, |
| getIdentifier: (req: NextRequest) => string, |
| config?: Partial<BruteForceConfig> |
| ) { |
| return async (req: NextRequest) => { |
| const identifier = getIdentifier(req); |
| const check = await checkBruteForce(identifier, config); |
|
|
| if (!check.allowed) { |
| return NextResponse.json( |
| { |
| error: check.error, |
| lockDurationSeconds: check.lockDurationSeconds, |
| }, |
| { |
| status: 429, |
| headers: { |
| "Retry-After": String(check.lockDurationSeconds || 300), |
| }, |
| } |
| ); |
| } |
|
|
| |
| if (check.delayMs && check.delayMs > 0) { |
| await new Promise((resolve) => setTimeout(resolve, check.delayMs)); |
| } |
|
|
| try { |
| const response = await handler(req); |
|
|
| |
| if (response.status >= 200 && response.status < 300) { |
| await recordSuccessfulAttempt(identifier); |
| } else if (response.status === 401 || response.status === 403) { |
| |
| await recordFailedAttempt(identifier, config); |
| } |
|
|
| return response; |
| } catch (error) { |
| |
| await recordFailedAttempt(identifier, config); |
| throw error; |
| } |
| }; |
| } |
|
|
| |
| |
| |
| |
| export async function clearBruteForceRecords(identifier: string) { |
| const redisClient: Redis | null = null; |
| if (!redisClient) return; |
|
|
| const redis: Redis = redisClient; |
|
|
| try { |
| const lockKey = `bruteforce:locked:${identifier}`; |
| const attemptsKey = `bruteforce:attempts:${identifier}`; |
|
|
| await redis.del(lockKey); |
| await redis.del(attemptsKey); |
| } catch (error) { |
| console.error("Failed to clear brute-force records:", error); |
| } |
| } |
|
|
| |
| |
| |
| export async function getBruteForceStats() { |
| const redisClient: Redis | null = null; |
|
|
| if (!redisClient) { |
| return { |
| totalTrackedIdentifiers: 0, |
| currentlyLockedAccounts: 0, |
| accountsWithFailedAttempts: 0, |
| }; |
| } |
|
|
| const redis: Redis = redisClient; |
|
|
| try { |
| const lockedAccounts = await redis.keys("bruteforce:locked:*"); |
| const attemptedAccounts = await redis.keys("bruteforce:attempts:*"); |
|
|
| return { |
| totalTrackedIdentifiers: new Set([ |
| ...lockedAccounts, |
| ...attemptedAccounts, |
| ]).size, |
| currentlyLockedAccounts: lockedAccounts.length, |
| accountsWithFailedAttempts: attemptedAccounts.length, |
| }; |
| } catch (error) { |
| console.error("Failed to get brute-force stats:", error); |
| return null; |
| } |
| } |
|
|