mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-07-14 16:51:10 +00:00
security(auth): add rate limiting to token login endpoint
This commit is contained in:
@@ -7,12 +7,25 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
|
||||||
|
const rateLimit = checkTokenLoginRateLimit(ip);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many login attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { token } = await request.json();
|
const { token } = await request.json();
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Component: Auth Rate Limiting
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*
|
||||||
|
* In-memory fixed-window rate limiter with lazy eviction and periodic sweep
|
||||||
|
* to prevent unbounded memory growth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateLimitResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
retryAfterSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
/** Number of checkRateLimit calls since the last full sweep */
|
||||||
|
let checkCount = 0;
|
||||||
|
|
||||||
|
/** How often (in calls) to perform a full sweep of expired buckets */
|
||||||
|
const SWEEP_INTERVAL = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep the entire bucket map and delete all expired entries.
|
||||||
|
* Called automatically every SWEEP_INTERVAL checks.
|
||||||
|
*/
|
||||||
|
function sweepExpiredBuckets(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, bucket] of buckets) {
|
||||||
|
if (now >= bucket.resetAt) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||||
|
checkCount += 1;
|
||||||
|
if (checkCount >= SWEEP_INTERVAL) {
|
||||||
|
checkCount = 0;
|
||||||
|
sweepExpiredBuckets();
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = buckets.get(key);
|
||||||
|
|
||||||
|
// Lazy eviction: if the bucket is expired, delete it and start fresh
|
||||||
|
if (!current || now >= current.resetAt) {
|
||||||
|
if (current) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.count >= maxRequests) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
current.count += 1;
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 10 attempts per 15 minutes per IP */
|
||||||
|
export function checkTokenLoginRateLimit(ip: string): RateLimitResult {
|
||||||
|
return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all buckets and the sweep counter. For testing only. */
|
||||||
|
export function _resetBuckets(): void {
|
||||||
|
buckets.clear();
|
||||||
|
checkCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current number of tracked buckets. For testing only. */
|
||||||
|
export function _getBucketCount(): number {
|
||||||
|
return buckets.size;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { createPrismaMock } from '../helpers/prisma';
|
|||||||
const prismaMock = createPrismaMock();
|
const prismaMock = createPrismaMock();
|
||||||
const generateAccessTokenMock = vi.hoisted(() => vi.fn());
|
const generateAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||||
const generateRefreshTokenMock = vi.hoisted(() => vi.fn());
|
const generateRefreshTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
const checkTokenLoginRateLimitMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('@/lib/db', () => ({
|
vi.mock('@/lib/db', () => ({
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
@@ -19,11 +20,23 @@ vi.mock('@/lib/utils/jwt', () => ({
|
|||||||
generateRefreshToken: generateRefreshTokenMock,
|
generateRefreshToken: generateRefreshTokenMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/authRateLimit', () => ({
|
||||||
|
checkTokenLoginRateLimit: checkTokenLoginRateLimitMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeRequest(body: Record<string, unknown>, ip = '127.0.0.1') {
|
||||||
|
return {
|
||||||
|
headers: { get: vi.fn().mockReturnValue(ip) },
|
||||||
|
json: vi.fn().mockResolvedValue(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('POST /api/auth/token/login', () => {
|
describe('POST /api/auth/token/login', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
generateAccessTokenMock.mockReturnValue('access-token');
|
generateAccessTokenMock.mockReturnValue('access-token');
|
||||||
generateRefreshTokenMock.mockReturnValue('refresh-token');
|
generateRefreshTokenMock.mockReturnValue('refresh-token');
|
||||||
|
checkTokenLoginRateLimitMock.mockReturnValue({ allowed: true, retryAfterSeconds: 900 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('authenticates user with a valid token', async () => {
|
it('authenticates user with a valid token', async () => {
|
||||||
@@ -38,8 +51,7 @@ describe('POST /api/auth/token/login', () => {
|
|||||||
prismaMock.user.update.mockResolvedValueOnce({});
|
prismaMock.user.update.mockResolvedValueOnce({});
|
||||||
|
|
||||||
const { POST } = await import('@/app/api/auth/token/login/route');
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
const request = { json: vi.fn().mockResolvedValue({ token: 'rmab_valid_token' }) };
|
const response = await POST(makeRequest({ token: 'rmab_valid_token' }) as any);
|
||||||
const response = await POST(request as any);
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
@@ -51,8 +63,7 @@ describe('POST /api/auth/token/login', () => {
|
|||||||
|
|
||||||
it('returns 400 when token parameter is missing', async () => {
|
it('returns 400 when token parameter is missing', async () => {
|
||||||
const { POST } = await import('@/app/api/auth/token/login/route');
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
const request = { json: vi.fn().mockResolvedValue({}) };
|
const response = await POST(makeRequest({}) as any);
|
||||||
const response = await POST(request as any);
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
@@ -63,11 +74,22 @@ describe('POST /api/auth/token/login', () => {
|
|||||||
prismaMock.user.findFirst.mockResolvedValueOnce(null);
|
prismaMock.user.findFirst.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
const { POST } = await import('@/app/api/auth/token/login/route');
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
const request = { json: vi.fn().mockResolvedValue({ token: 'rmab_invalid' }) };
|
const response = await POST(makeRequest({ token: 'rmab_invalid' }) as any);
|
||||||
const response = await POST(request as any);
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect(payload.error).toMatch(/Invalid token/);
|
expect(payload.error).toMatch(/Invalid token/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 429 when rate limit is exceeded', async () => {
|
||||||
|
checkTokenLoginRateLimitMock.mockReturnValue({ allowed: false, retryAfterSeconds: 600 });
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/auth/token/login/route');
|
||||||
|
const response = await POST(makeRequest({ token: 'rmab_any' }) as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(payload.error).toMatch(/Too many login attempts/);
|
||||||
|
expect(response.headers.get('Retry-After')).toBe('600');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user