From f9a14b009af423e42e7561ad72a1b312da7db0ae Mon Sep 17 00:00:00 2001 From: Mathieu Date: Sun, 7 Jun 2026 00:05:14 +0200 Subject: [PATCH] feat: NextAuth credentials + JWT 8h + rate limiting Co-Authored-By: Claude Sonnet 4.6 --- jest.config.ts | 3 ++ src/__tests__/lib/auth.test.ts | 68 +++++++++++++++++++++++++ src/app/api/auth/[...nextauth]/route.ts | 5 ++ src/lib/auth.ts | 63 +++++++++++++++++++++++ src/types/next-auth.d.ts | 17 +++++++ 5 files changed, 156 insertions(+) create mode 100644 src/__tests__/lib/auth.test.ts create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/lib/auth.ts create mode 100644 src/types/next-auth.d.ts diff --git a/jest.config.ts b/jest.config.ts index 61721a5..55078db 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -7,6 +7,9 @@ const config: Config = { coverageProvider: 'v8', testEnvironment: 'node', setupFilesAfterEnv: ['/jest.setup.ts'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, } export default createJestConfig(config) diff --git a/src/__tests__/lib/auth.test.ts b/src/__tests__/lib/auth.test.ts new file mode 100644 index 0000000..282c672 --- /dev/null +++ b/src/__tests__/lib/auth.test.ts @@ -0,0 +1,68 @@ +import { authOptions } from '@/lib/auth' + +// Mock Prisma +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findUnique: jest.fn(), + }, + }, +})) + +// Mock rate-limit +jest.mock('@/lib/rate-limit', () => ({ + checkRateLimit: jest.fn(() => true), + recordFailedAttempt: jest.fn(), + clearAttempts: jest.fn(), +})) + +import { prisma } from '@/lib/prisma' +import { checkRateLimit, recordFailedAttempt, clearAttempts } from '@/lib/rate-limit' + +const mockFindUnique = prisma.user.findUnique as jest.Mock + +describe('NextAuth authorize', () => { + const authorize = (authOptions.providers[0] as any).options.authorize + + beforeEach(() => jest.clearAllMocks()) + + it('retourne null si credentials manquants', async () => { + const result = await authorize({ email: '', password: '' }, {}) + expect(result).toBeNull() + }) + + it('retourne null si utilisateur introuvable', async () => { + mockFindUnique.mockResolvedValue(null) + const result = await authorize( + { email: 'a@b.com', password: 'wrong' }, + { headers: {} } + ) + expect(result).toBeNull() + expect(recordFailedAttempt).toHaveBeenCalled() + }) + + it('retourne l\'utilisateur si mot de passe correct', async () => { + const { hash } = await import('bcryptjs') + const hashedPwd = await hash('correct', 12) + mockFindUnique.mockResolvedValue({ + id: 'user1', + email: 'a@b.com', + name: 'Alice', + role: 'gestionnaire', + password: hashedPwd, + }) + const result = await authorize( + { email: 'a@b.com', password: 'correct' }, + { headers: {} } + ) + expect(result).toEqual({ id: 'user1', email: 'a@b.com', name: 'Alice', role: 'gestionnaire' }) + expect(clearAttempts).toHaveBeenCalled() + }) + + it('lève RATE_LIMITED si bloqué', async () => { + ;(checkRateLimit as jest.Mock).mockReturnValue(false) + await expect( + authorize({ email: 'a@b.com', password: 'x' }, { headers: {} }) + ).rejects.toThrow('RATE_LIMITED') + }) +}) diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..59177ba --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,5 @@ +import NextAuth from 'next-auth' +import { authOptions } from '@/lib/auth' + +const handler = NextAuth(authOptions) +export { handler as GET, handler as POST } diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..30999db --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,63 @@ +import type { NextAuthOptions } from 'next-auth' +import CredentialsProvider from 'next-auth/providers/credentials' +import { compare } from 'bcryptjs' +import { prisma } from './prisma' +import { checkRateLimit, recordFailedAttempt, clearAttempts } from './rate-limit' + +export const authOptions: NextAuthOptions = { + session: { + strategy: 'jwt', + maxAge: 8 * 60 * 60, // 8 heures + }, + pages: { + signIn: '/login', + }, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.role = (user as any).role + token.id = user.id + } + return token + }, + async session({ session, token }) { + if (session.user) { + (session.user as any).role = token.role + ;(session.user as any).id = token.id + } + return session + }, + }, + providers: [ + CredentialsProvider({ + name: 'Credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Mot de passe', type: 'password' }, + }, + async authorize(credentials, req) { + if (!credentials?.email || !credentials?.password) return null + + const ip = + (req as any)?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ?? 'unknown' + const key = `${ip}:${credentials.email}` + + if (!checkRateLimit(key)) { + throw new Error('RATE_LIMITED') + } + + const user = await prisma.user.findUnique({ + where: { email: credentials.email }, + }) + + if (!user || !(await compare(credentials.password, user.password))) { + recordFailedAttempt(key) + return null + } + + clearAttempts(key) + return { id: user.id, email: user.email, name: user.name, role: user.role } + }, + }), + ], +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..5086236 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,17 @@ +import type { DefaultSession, DefaultJWT } from 'next-auth' + +declare module 'next-auth' { + interface Session { + user: DefaultSession['user'] & { + id: string + role: string + } + } +} + +declare module 'next-auth/jwt' { + interface JWT extends DefaultJWT { + id: string + role: string + } +}