feat(security): rate-limit auth/login + auth/verify
This commit is contained in:
parent
a803afa101
commit
bb3ec5ec6a
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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue