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,18 +1,32 @@
|
||||||
// server/api/auth/login.post.js
|
// server/api/auth/login.post.js
|
||||||
|
import { getRequestIP } from "h3";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
import { validateBody } from "../../utils/validateBody.js";
|
import { validateBody } from "../../utils/validateBody.js";
|
||||||
import { emailSchema } from "../../utils/schemas.js";
|
import { emailSchema } from "../../utils/schemas.js";
|
||||||
import { sendMagicLink } from "../../utils/magicLink.js";
|
import { sendMagicLink } from "../../utils/magicLink.js";
|
||||||
|
import { rateLimit } from "../../utils/rateLimit.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
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();
|
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.";
|
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendMagicLink(email);
|
await sendMagicLink(body.email);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: GENERIC_MESSAGE,
|
message: GENERIC_MESSAGE,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
// server/api/auth/verify.post.js
|
// server/api/auth/verify.post.js
|
||||||
|
import { getRequestIP } from 'h3'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { validateBody } from '../../utils/validateBody.js'
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
import { verifyMagicLinkSchema } from '../../utils/schemas.js'
|
import { verifyMagicLinkSchema } from '../../utils/schemas.js'
|
||||||
import { setAuthCookie } from '../../utils/auth.js'
|
import { setAuthCookie } from '../../utils/auth.js'
|
||||||
|
import { rateLimit } from '../../utils/rateLimit.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
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 { token } = await validateBody(event, verifyMagicLinkSchema)
|
||||||
|
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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', () => ({
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() }
|
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', () => {
|
describe('auth login endpoint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
resetRateLimit()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns generic success message for existing member', async () => {
|
it('returns generic success message for existing member', async () => {
|
||||||
|
|
@ -110,4 +112,92 @@ describe('auth login endpoint', () => {
|
||||||
statusMessage: 'Validation failed'
|
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 jwt from 'jsonwebtoken'
|
||||||
import Member from '../../../server/models/member.js'
|
import Member from '../../../server/models/member.js'
|
||||||
import verifyHandler from '../../../server/api/auth/verify.post.js'
|
import verifyHandler from '../../../server/api/auth/verify.post.js'
|
||||||
|
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
vi.mock('../../../server/models/member.js', () => ({
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
|
|
@ -33,6 +34,7 @@ const baseMember = {
|
||||||
describe('auth verify endpoint', () => {
|
describe('auth verify endpoint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
resetRateLimit()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects missing token with 400', async () => {
|
it('rejects missing token with 400', async () => {
|
||||||
|
|
@ -302,4 +304,79 @@ describe('auth verify endpoint', () => {
|
||||||
|
|
||||||
expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' })
|
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