feat(security): rate-limit auth/login + auth/verify

This commit is contained in:
Jennie Robinson Faber 2026-04-27 11:20:16 +01:00
parent a803afa101
commit bb3ec5ec6a
4 changed files with 194 additions and 6 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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
})
})
})
})

View file

@ -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
})
})
})
})