mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30: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 { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user