70 lines
2.5 KiB
JavaScript
70 lines
2.5 KiB
JavaScript
|
|
import { Ratelimit } from '@upstash/ratelimit';
|
||
|
|
import { Redis } from '@upstash/redis';
|
||
|
|
|
||
|
|
// Lazy singleton. Module-load init would throw in environments without
|
||
|
|
// Upstash env vars (local dev pre-onboarding, tests that transitively
|
||
|
|
// import the auth handlers, Vercel build-time bundling). Defer construction
|
||
|
|
// until the first request actually arrives.
|
||
|
|
let cached = null;
|
||
|
|
|
||
|
|
function init() {
|
||
|
|
// Env-var names match Vercel's Upstash Marketplace integration, which
|
||
|
|
// auto-provisions KV_REST_API_URL and KV_REST_API_TOKEN. See
|
||
|
|
// https://upstash.com/docs/redis/howto/vercelintegration. Single-source-
|
||
|
|
// of-truth — do NOT alias to UPSTASH_REDIS_REST_*.
|
||
|
|
const url = process.env.KV_REST_API_URL;
|
||
|
|
const token = process.env.KV_REST_API_TOKEN;
|
||
|
|
|
||
|
|
if (url && token) {
|
||
|
|
const redis = new Redis({ url, token });
|
||
|
|
const ratelimit = new Ratelimit({
|
||
|
|
redis,
|
||
|
|
limiter: Ratelimit.slidingWindow(5, '15 m'),
|
||
|
|
prefix: 'tcgvault:auth',
|
||
|
|
});
|
||
|
|
return { mode: 'live', ratelimit };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (process.env.NODE_ENV === 'production') {
|
||
|
|
// Fail-closed in production. A single failed login is a better outcome
|
||
|
|
// than silently disabling brute-force protection on the live site.
|
||
|
|
throw new Error(
|
||
|
|
'[rate-limit] Upstash not configured. Set KV_REST_API_URL and KV_REST_API_TOKEN in the deployment environment (auto-provisioned by the Vercel Upstash Marketplace integration) before serving auth traffic.'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.warn(
|
||
|
|
'[rate-limit] KV_REST_API_URL / KV_REST_API_TOKEN not set — rate limiting disabled (dev/test only)'
|
||
|
|
);
|
||
|
|
return { mode: 'noop' };
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractIdentifier(req) {
|
||
|
|
const xff = req.headers?.['x-forwarded-for'];
|
||
|
|
const firstHop = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim();
|
||
|
|
return firstHop || req.socket?.remoteAddress || 'anonymous';
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function checkAuthRateLimit(req) {
|
||
|
|
if (!cached) {
|
||
|
|
cached = init();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (cached.mode === 'noop') {
|
||
|
|
return { allowed: true, remaining: Infinity, reset: 0 };
|
||
|
|
}
|
||
|
|
|
||
|
|
const identifier = extractIdentifier(req);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const { success, remaining, reset } = await cached.ratelimit.limit(identifier);
|
||
|
|
return { allowed: success, remaining, reset };
|
||
|
|
} catch (err) {
|
||
|
|
// Fail-open on Upstash outage. A hard outage at the rate-limit backend
|
||
|
|
// should not lock the entire user base out of login. Brute-force
|
||
|
|
// protection lives behind defense-in-depth (Vercel firewall, etc.).
|
||
|
|
console.error('[rate-limit]', err);
|
||
|
|
return { allowed: true, remaining: Infinity, reset: 0 };
|
||
|
|
}
|
||
|
|
}
|