feat(phase4): rate limit + audit archive + panel backup + DR docs
- src/lib/rate-limit.ts (Redis sliding window, fail-open) - before-hook on /sign-in/email: 5 attempts/min per IP+email - worker job audit-archive (daily 03:30, JSONL → MinIO, 90d retention) - worker job panel-backup (daily 04:00, pg_dump -Fc -Z9 → MinIO) - Dockerfile adds postgresql16-client - scripts/restore-drill.sh restores latest dump into panel_drill - docs/disaster-recovery.md + docs/phase4-deferred.md (mTLS + Infisical rationale)
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { APIError, createAuthMiddleware } from "better-auth/api";
|
||||
import { prisma } from "./db";
|
||||
import { rateLimit } from "./rate-limit";
|
||||
|
||||
const AUDITED_PATHS = new Set(["/sign-in/email", "/sign-out"]);
|
||||
const SIGN_IN_LIMIT_PER_MIN = 5;
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
||||
@@ -25,6 +27,21 @@ export const auth = betterAuth({
|
||||
useSecureCookies: process.env.NODE_ENV === "production",
|
||||
},
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path !== "/sign-in/email") return;
|
||||
const body = (ctx.body ?? {}) as { email?: string };
|
||||
const ip =
|
||||
ctx.request?.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
ctx.request?.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
const key = `auth:signin:${ip}:${(body.email ?? "").toLowerCase()}`;
|
||||
const r = await rateLimit(key, SIGN_IN_LIMIT_PER_MIN, 60);
|
||||
if (!r.ok) {
|
||||
throw new APIError("TOO_MANY_REQUESTS", {
|
||||
message: `Too many sign-in attempts. Try again in ${Math.ceil(r.resetMs / 1000)}s.`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (!AUDITED_PATHS.has(ctx.path)) return;
|
||||
const status = ctx.context.returned ? 200 : 400;
|
||||
|
||||
45
apps/web/src/lib/rate-limit.ts
Normal file
45
apps/web/src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import IORedis from "ioredis";
|
||||
|
||||
const url = process.env.REDIS_URL;
|
||||
|
||||
let _redis: IORedis | null = null;
|
||||
function getRedis(): IORedis | null {
|
||||
if (!url) return null;
|
||||
if (_redis) return _redis;
|
||||
_redis = new IORedis(url, { maxRetriesPerRequest: 1, enableReadyCheck: false, lazyConnect: false });
|
||||
_redis.on("error", (e) => console.warn("[rate-limit] redis:", e.message));
|
||||
return _redis;
|
||||
}
|
||||
|
||||
export type RateResult = { ok: boolean; remaining: number; resetMs: number };
|
||||
|
||||
/**
|
||||
* Sliding fixed-window counter: INCR a per-window key, expire it on first hit.
|
||||
* Fail-open: if Redis is down, allow the request (don't lock the owner out).
|
||||
*/
|
||||
export async function rateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowSec: number,
|
||||
): Promise<RateResult> {
|
||||
const r = getRedis();
|
||||
if (!r) return { ok: true, remaining: limit, resetMs: windowSec * 1000 };
|
||||
const bucket = Math.floor(Date.now() / 1000 / windowSec);
|
||||
const k = `rl:${key}:${bucket}`;
|
||||
try {
|
||||
const [count] = (await r.multi().incr(k).expire(k, windowSec).exec()) as [
|
||||
[Error | null, number],
|
||||
[Error | null, number],
|
||||
];
|
||||
if (count[0]) throw count[0];
|
||||
const n = count[1];
|
||||
return {
|
||||
ok: n <= limit,
|
||||
remaining: Math.max(0, limit - n),
|
||||
resetMs: (bucket + 1) * windowSec * 1000 - Date.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn("[rate-limit] failing open:", e instanceof Error ? e.message : e);
|
||||
return { ok: true, remaining: limit, resetMs: windowSec * 1000 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user