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