diff --git a/src/app/api/auth/token/login/route.ts b/src/app/api/auth/token/login/route.ts index f95d343..f5f7c42 100644 --- a/src/app/api/auth/token/login/route.ts +++ b/src/app/api/auth/token/login/route.ts @@ -7,12 +7,25 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit'; import crypto from 'crypto'; const logger = RMABLogger.create('API.Auth.TokenLogin'); export async function POST(request: NextRequest) { try { + const ip = request.headers.get('x-forwarded-for') ?? 'unknown'; + const rateLimit = checkTokenLoginRateLimit(ip); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many login attempts. Please try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) }, + } + ); + } + const { token } = await request.json(); if (!token) { diff --git a/src/lib/utils/authRateLimit.ts b/src/lib/utils/authRateLimit.ts new file mode 100644 index 0000000..0d0a56c --- /dev/null +++ b/src/lib/utils/authRateLimit.ts @@ -0,0 +1,89 @@ +/** + * Component: Auth Rate Limiting + * Documentation: documentation/backend/services/auth.md + * + * In-memory fixed-window rate limiter with lazy eviction and periodic sweep + * to prevent unbounded memory growth. + */ + +type Bucket = { + count: number; + resetAt: number; +}; + +type RateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + +const buckets = new Map(); + +/** Number of checkRateLimit calls since the last full sweep */ +let checkCount = 0; + +/** How often (in calls) to perform a full sweep of expired buckets */ +const SWEEP_INTERVAL = 100; + +/** + * Sweep the entire bucket map and delete all expired entries. + * Called automatically every SWEEP_INTERVAL checks. + */ +function sweepExpiredBuckets(): void { + const now = Date.now(); + for (const [key, bucket] of buckets) { + if (now >= bucket.resetAt) { + buckets.delete(key); + } + } +} + +function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { + const now = Date.now(); + + // Periodic full sweep every SWEEP_INTERVAL calls + checkCount += 1; + if (checkCount >= SWEEP_INTERVAL) { + checkCount = 0; + sweepExpiredBuckets(); + } + + const current = buckets.get(key); + + // Lazy eviction: if the bucket is expired, delete it and start fresh + if (!current || now >= current.resetAt) { + if (current) { + buckets.delete(key); + } + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) }; + } + + if (current.count >= maxRequests) { + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; + } + + current.count += 1; + return { + allowed: true, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; +} + +/** 10 attempts per 15 minutes per IP */ +export function checkTokenLoginRateLimit(ip: string): RateLimitResult { + return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000); +} + +/** Reset all buckets and the sweep counter. For testing only. */ +export function _resetBuckets(): void { + buckets.clear(); + checkCount = 0; +} + +/** Get the current number of tracked buckets. For testing only. */ +export function _getBucketCount(): number { + return buckets.size; +} diff --git a/tests/api/auth-login-token.routes.test.ts b/tests/api/auth-login-token.routes.test.ts index c72a2eb..12015a7 100644 --- a/tests/api/auth-login-token.routes.test.ts +++ b/tests/api/auth-login-token.routes.test.ts @@ -9,6 +9,7 @@ import { createPrismaMock } from '../helpers/prisma'; const prismaMock = createPrismaMock(); const generateAccessTokenMock = vi.hoisted(() => vi.fn()); const generateRefreshTokenMock = vi.hoisted(() => vi.fn()); +const checkTokenLoginRateLimitMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -19,11 +20,23 @@ vi.mock('@/lib/utils/jwt', () => ({ generateRefreshToken: generateRefreshTokenMock, })); +vi.mock('@/lib/utils/authRateLimit', () => ({ + checkTokenLoginRateLimit: checkTokenLoginRateLimitMock, +})); + +function makeRequest(body: Record, ip = '127.0.0.1') { + return { + headers: { get: vi.fn().mockReturnValue(ip) }, + json: vi.fn().mockResolvedValue(body), + }; +} + describe('POST /api/auth/token/login', () => { beforeEach(() => { vi.clearAllMocks(); generateAccessTokenMock.mockReturnValue('access-token'); generateRefreshTokenMock.mockReturnValue('refresh-token'); + checkTokenLoginRateLimitMock.mockReturnValue({ allowed: true, retryAfterSeconds: 900 }); }); it('authenticates user with a valid token', async () => { @@ -38,8 +51,7 @@ describe('POST /api/auth/token/login', () => { prismaMock.user.update.mockResolvedValueOnce({}); const { POST } = await import('@/app/api/auth/token/login/route'); - const request = { json: vi.fn().mockResolvedValue({ token: 'rmab_valid_token' }) }; - const response = await POST(request as any); + const response = await POST(makeRequest({ token: 'rmab_valid_token' }) as any); const payload = await response.json(); expect(response.status).toBe(200); @@ -51,8 +63,7 @@ describe('POST /api/auth/token/login', () => { it('returns 400 when token parameter is missing', async () => { const { POST } = await import('@/app/api/auth/token/login/route'); - const request = { json: vi.fn().mockResolvedValue({}) }; - const response = await POST(request as any); + const response = await POST(makeRequest({}) as any); const payload = await response.json(); expect(response.status).toBe(400); @@ -63,11 +74,22 @@ describe('POST /api/auth/token/login', () => { prismaMock.user.findFirst.mockResolvedValueOnce(null); const { POST } = await import('@/app/api/auth/token/login/route'); - const request = { json: vi.fn().mockResolvedValue({ token: 'rmab_invalid' }) }; - const response = await POST(request as any); + const response = await POST(makeRequest({ token: 'rmab_invalid' }) as any); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.error).toMatch(/Invalid token/); }); + + it('returns 429 when rate limit is exceeded', async () => { + checkTokenLoginRateLimitMock.mockReturnValue({ allowed: false, retryAfterSeconds: 600 }); + + const { POST } = await import('@/app/api/auth/token/login/route'); + const response = await POST(makeRequest({ token: 'rmab_any' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(429); + expect(payload.error).toMatch(/Too many login attempts/); + expect(response.headers.get('Retry-After')).toBe('600'); + }); });