Add an in-memory sliding-window rate limiter and wire it into signup, resend-verification, reset-password, and verify-email routes. Store password reset tokens as SHA-256 hashes, raise bcrypt cost to 12, and switch verification codes to a CSPRNG. Stop echoing calendar access/refresh tokens back to API clients, bound the weather cache size, return JSON 401s for unauthenticated API calls instead of an HTML redirect, and drop the dead mock login route. v1.113.0
76 lines
2.3 KiB
TypeScript
76 lines
2.3 KiB
TypeScript
/**
|
|
* Minimal in-memory sliding-window rate limiter for auth-sensitive routes.
|
|
*
|
|
* Works per Node.js process. If the app ever scales to multiple instances,
|
|
* swap the backing store for Redis — the call sites won't need to change.
|
|
*/
|
|
|
|
type WindowEntry = { timestamps: number[] };
|
|
|
|
const buckets = new Map<string, WindowEntry>();
|
|
|
|
// Purge stale buckets periodically so the Map can't grow without bound.
|
|
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
|
let lastCleanup = Date.now();
|
|
|
|
function cleanup(windowMs: number) {
|
|
const now = Date.now();
|
|
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
|
lastCleanup = now;
|
|
for (const [key, entry] of buckets) {
|
|
entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);
|
|
if (entry.timestamps.length === 0) buckets.delete(key);
|
|
}
|
|
}
|
|
|
|
export interface RateLimitResult {
|
|
ok: boolean;
|
|
/** Seconds until the client may retry (only set when blocked). */
|
|
retryAfterSeconds?: number;
|
|
}
|
|
|
|
/**
|
|
* Record a hit for `key` and report whether it exceeds `limit` hits
|
|
* within the trailing `windowMs` window.
|
|
*/
|
|
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
|
const now = Date.now();
|
|
cleanup(windowMs);
|
|
|
|
const entry = buckets.get(key) ?? { timestamps: [] };
|
|
entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);
|
|
|
|
if (entry.timestamps.length >= limit) {
|
|
const oldest = entry.timestamps[0];
|
|
return {
|
|
ok: false,
|
|
retryAfterSeconds: Math.max(1, Math.ceil((oldest + windowMs - now) / 1000)),
|
|
};
|
|
}
|
|
|
|
entry.timestamps.push(now);
|
|
buckets.set(key, entry);
|
|
return { ok: true };
|
|
}
|
|
|
|
/** Best-effort client IP extraction behind a reverse proxy. */
|
|
export function clientIp(request: Request): string {
|
|
const forwarded = request.headers.get('x-forwarded-for');
|
|
if (forwarded) return forwarded.split(',')[0].trim();
|
|
return request.headers.get('x-real-ip') ?? 'unknown';
|
|
}
|
|
|
|
/** Standard 429 payload so all auth routes respond consistently. */
|
|
export function tooManyRequests(retryAfterSeconds = 60) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Too many requests. Please try again later.' }),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Retry-After': String(retryAfterSeconds),
|
|
},
|
|
}
|
|
);
|
|
}
|