diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 50336ab..a7427fb 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -8,6 +8,9 @@ vi.mock('next/headers', () => ({ set: vi.fn(), delete: vi.fn(), }), + headers: async () => ({ + get: () => null, + }), })); vi.mock('next/navigation', () => ({ diff --git a/src/app/actions/__tests__/auth-actions.test.ts b/src/app/actions/__tests__/auth-actions.test.ts index 766ee9b..4aec6d9 100644 --- a/src/app/actions/__tests__/auth-actions.test.ts +++ b/src/app/actions/__tests__/auth-actions.test.ts @@ -23,12 +23,20 @@ vi.mock('@/lib/db', () => ({ }, })); +let currentTestIp = '127.0.0.1'; + vi.mock('next/headers', () => ({ cookies: () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn(), }), + headers: async () => ({ + get: (name: string) => { + if (name === 'x-forwarded-for') return currentTestIp; + return null; + }, + }), })); vi.mock('next/navigation', () => ({ @@ -139,4 +147,47 @@ describe('auth actions', () => { expect(result.success).toBe(true); expect((result as any).data.ok).toBe(true); }); + + it('signInAction rate limits by email after 5 attempts', async () => { + mockVerifyPassword.mockReturnValue(false); + (db.user.findUnique as unknown as ReturnType).mockResolvedValue({ + id: 'u1', email: 'ratelimit-email@example.com', passwordHash: 'x', status: 'ACTIVE', role: 'STUDENT', name: 'A', emailVerified: new Date(), lastActiveAt: null, createdAt: new Date(), updatedAt: new Date(), deletedAt: null, + }); + + for (let i = 0; i < 5; i++) { + const result = await signInAction({ email: 'ratelimit-email@example.com', password: 'wrong' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe('Email or password is incorrect.'); + } + } + + const result = await signInAction({ email: 'ratelimit-email@example.com', password: 'wrong' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Too many attempts.'); + } + }); + + it('signInAction rate limits by IP after 10 attempts with different emails', async () => { + currentTestIp = '192.168.1.50'; // Use a fresh IP address to avoid prior test interference + mockVerifyPassword.mockReturnValue(false); + (db.user.findUnique as unknown as ReturnType).mockResolvedValue({ + id: 'u1', email: 'irrelevant@example.com', passwordHash: 'x', status: 'ACTIVE', role: 'STUDENT', name: 'A', emailVerified: new Date(), lastActiveAt: null, createdAt: new Date(), updatedAt: new Date(), deletedAt: null, + }); + + for (let i = 0; i < 10; i++) { + const result = await signInAction({ email: `ip-test-${i}@example.com`, password: 'wrong' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe('Email or password is incorrect.'); + } + } + + const result = await signInAction({ email: 'ip-test-blocked@example.com', password: 'wrong' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Too many attempts from this IP.'); + } + }); }); diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index 2f10075..bbf7092 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -5,6 +5,7 @@ 'use server'; import { redirect } from 'next/navigation'; +import { headers } from 'next/headers'; import { db } from '@/lib/db'; import { hashPassword, @@ -32,6 +33,15 @@ import { // --------------------------------------------------------------------------- export const signUpAction = createSafeAction(signUpSchema, async (data) => { + const heads = await headers(); + const ip = heads.get('x-forwarded-for') ?? heads.get('x-real-ip') ?? 'unknown'; + + // IP-based rate limiting to protect against general registration floods / spamming + const ipRl = rateLimit(`signup:ip:${ip}`, 10, 60_000); + if (!ipRl.allowed) { + throw new Error(`Too many attempts from this IP. Try again in ${ipRl.retryAfterSeconds}s.`); + } + const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000); if (!rl.allowed) { throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); @@ -125,6 +135,15 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => { export const signInAction = createSafeAction(signInSchema, async (data) => { // Rate-limit BEFORE any DB or scrypt work — the sync scrypt verify is // exactly what an attacker would use to burn the event loop. + const heads = await headers(); + const ip = heads.get('x-forwarded-for') ?? heads.get('x-real-ip') ?? 'unknown'; + + // IP-based rate limiting to prevent distributed brute force/credential stuffing + const ipRl = rateLimit(`signin:ip:${ip}`, 10, 60_000); + if (!ipRl.allowed) { + throw new Error(`Too many attempts from this IP. Try again in ${ipRl.retryAfterSeconds}s.`); + } + const rl = rateLimit(`signin:${data.email.toLowerCase()}`, 5, 60_000); if (!rl.allowed) { throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);