diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index 5978ec7..2ca99c6 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -1,18 +1,32 @@ // server/api/auth/login.post.js +import { getRequestIP } from "h3"; import { connectDB } from "../../utils/mongoose.js"; import { validateBody } from "../../utils/validateBody.js"; import { emailSchema } from "../../utils/schemas.js"; import { sendMagicLink } from "../../utils/magicLink.js"; +import { rateLimit } from "../../utils/rateLimit.js"; export default defineEventHandler(async (event) => { + const ip = getRequestIP(event, { xForwardedFor: true }) || "unknown"; + if (!rateLimit(`auth:login:ip:${ip}`, { max: 5, windowMs: 3600_000 })) { + throw createError({ statusCode: 429, statusMessage: "Too many login attempts" }); + } + await connectDB(); - const { email } = await validateBody(event, emailSchema); + const body = await validateBody(event, emailSchema); + + if (!rateLimit(`auth:login:email:${body.email}`, { max: 3, windowMs: 3600_000 })) { + throw createError({ + statusCode: 429, + statusMessage: "Too many login attempts for this email", + }); + } const GENERIC_MESSAGE = "If this email is registered, we've sent a login link."; try { - await sendMagicLink(email); + await sendMagicLink(body.email); return { success: true, message: GENERIC_MESSAGE, diff --git a/server/api/auth/verify.post.js b/server/api/auth/verify.post.js index 1a0a6cd..173acb6 100644 --- a/server/api/auth/verify.post.js +++ b/server/api/auth/verify.post.js @@ -1,11 +1,18 @@ // server/api/auth/verify.post.js +import { getRequestIP } from 'h3' import jwt from 'jsonwebtoken' import Member from '../../models/member.js' import { validateBody } from '../../utils/validateBody.js' import { verifyMagicLinkSchema } from '../../utils/schemas.js' import { setAuthCookie } from '../../utils/auth.js' +import { rateLimit } from '../../utils/rateLimit.js' export default defineEventHandler(async (event) => { + const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown' + if (!rateLimit(`auth:verify:ip:${ip}`, { max: 5, windowMs: 3600_000 })) { + throw createError({ statusCode: 429, statusMessage: 'Too many verification attempts' }) + } + const { token } = await validateBody(event, verifyMagicLinkSchema) const config = useRuntimeConfig(event) diff --git a/tests/server/api/auth-login.test.js b/tests/server/api/auth-login.test.js index 9e98c4b..8c692e7 100644 --- a/tests/server/api/auth-login.test.js +++ b/tests/server/api/auth-login.test.js @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import Member from '../../../server/models/member.js' +import loginHandler from '../../../server/api/auth/login.post.js' +import { resetRateLimit } from '../../../server/utils/rateLimit.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() } })) @@ -20,13 +25,10 @@ vi.mock('resend', () => ({ } })) -import Member from '../../../server/models/member.js' -import loginHandler from '../../../server/api/auth/login.post.js' -import { createMockEvent } from '../helpers/createMockEvent.js' - describe('auth login endpoint', () => { beforeEach(() => { vi.clearAllMocks() + resetRateLimit() }) it('returns generic success message for existing member', async () => { @@ -110,4 +112,92 @@ describe('auth login endpoint', () => { statusMessage: 'Validation failed' }) }) + + describe('rate limiting', () => { + it('allows up to 5 login attempts from a single IP', async () => { + Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'ok@example.com' }) + + // 5 calls succeed (each with a unique email so we don't hit email limit) + for (let i = 0; i < 5; i++) { + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/login', + body: { email: `u${i}@example.com` }, + headers: { host: 'localhost:3000' }, + remoteAddress: '10.0.0.1' + }) + const result = await loginHandler(event) + expect(result.success).toBe(true) + } + }) + + it('rate-limits a single IP after 5 login attempts', async () => { + Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'ok@example.com' }) + + for (let i = 0; i < 5; i++) { + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/login', + body: { email: `u${i}@example.com` }, + headers: { host: 'localhost:3000' }, + remoteAddress: '10.0.0.1' + }) + await loginHandler(event) + } + + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/login', + body: { email: 'u6@example.com' }, + headers: { host: 'localhost:3000' }, + remoteAddress: '10.0.0.1' + }) + await expect(loginHandler(event)).rejects.toMatchObject({ + statusCode: 429 + }) + }) + + it('allows up to 3 login attempts for a single email', async () => { + Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'shared@example.com' }) + + // 3 calls from different IPs succeed + for (let i = 0; i < 3; i++) { + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/login', + body: { email: 'shared@example.com' }, + headers: { host: 'localhost:3000' }, + remoteAddress: `10.0.0.${i + 10}` + }) + const result = await loginHandler(event) + expect(result.success).toBe(true) + } + }) + + it('rate-limits a single email after 3 login attempts (different IPs)', async () => { + Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'shared@example.com' }) + + for (let i = 0; i < 3; i++) { + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/login', + body: { email: 'shared@example.com' }, + headers: { host: 'localhost:3000' }, + remoteAddress: `10.0.0.${i + 10}` + }) + await loginHandler(event) + } + + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/login', + body: { email: 'shared@example.com' }, + headers: { host: 'localhost:3000' }, + remoteAddress: '10.0.0.99' + }) + await expect(loginHandler(event)).rejects.toMatchObject({ + statusCode: 429 + }) + }) + }) }) diff --git a/tests/server/api/auth-verify.test.js b/tests/server/api/auth-verify.test.js index d018f6d..4cdfdec 100644 --- a/tests/server/api/auth-verify.test.js +++ b/tests/server/api/auth-verify.test.js @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import jwt from 'jsonwebtoken' import Member from '../../../server/models/member.js' import verifyHandler from '../../../server/api/auth/verify.post.js' +import { resetRateLimit } from '../../../server/utils/rateLimit.js' import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/models/member.js', () => ({ @@ -33,6 +34,7 @@ const baseMember = { describe('auth verify endpoint', () => { beforeEach(() => { vi.clearAllMocks() + resetRateLimit() }) it('rejects missing token with 400', async () => { @@ -302,4 +304,79 @@ describe('auth verify endpoint', () => { expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' }) }) + + describe('rate limiting', () => { + it('allows up to 5 verify attempts from a single IP', async () => { + jwt.verify.mockImplementation(() => { throw new Error('invalid') }) + + // 5 calls reach jwt.verify (and fail with 401, but not 429) + for (let i = 0; i < 5; i++) { + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/verify', + body: { token: 'bad-token' }, + remoteAddress: '10.0.0.1' + }) + await expect(verifyHandler(event)).rejects.toMatchObject({ + statusCode: 401 + }) + } + expect(jwt.verify).toHaveBeenCalledTimes(5) + }) + + it('rate-limits a single IP after 5 verify attempts', async () => { + jwt.verify.mockImplementation(() => { throw new Error('invalid') }) + + for (let i = 0; i < 5; i++) { + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/verify', + body: { token: 'bad-token' }, + remoteAddress: '10.0.0.1' + }) + await expect(verifyHandler(event)).rejects.toMatchObject({ + statusCode: 401 + }) + } + + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/verify', + body: { token: 'bad-token' }, + remoteAddress: '10.0.0.1' + }) + await expect(verifyHandler(event)).rejects.toMatchObject({ + statusCode: 429 + }) + // Rate limit fires before jwt.verify on the 6th call + expect(jwt.verify).toHaveBeenCalledTimes(5) + }) + + it('does not block different IPs (per-IP keying)', async () => { + jwt.verify.mockImplementation(() => { throw new Error('invalid') }) + + for (let i = 0; i < 5; i++) { + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/verify', + body: { token: 'bad-token' }, + remoteAddress: '10.0.0.1' + }) + await expect(verifyHandler(event)).rejects.toMatchObject({ + statusCode: 401 + }) + } + + // A different IP should still be allowed. + const event = createMockEvent({ + method: 'POST', + path: '/api/auth/verify', + body: { token: 'bad-token' }, + remoteAddress: '10.0.0.2' + }) + await expect(verifyHandler(event)).rejects.toMatchObject({ + statusCode: 401 + }) + }) + }) })