feat(security): rate-limit auth/login + auth/verify
This commit is contained in:
parent
bafe24b778
commit
0eeb3c351f
4 changed files with 194 additions and 6 deletions
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue