trails/apps/journal/app/routes/api.auth.login.ts
Ullrich Schäfer 6afd996e19
fix(journal): rate-limit auth endpoints
Adds per-process in-memory fixed-window rate limiting (lib/rate-limit.server.ts)
and applies it across /api/auth/login and /api/auth/register:

- All login steps: 30 attempts per IP per minute (defense against
  step-fanout flooding).
- finish-passkey: 10 per IP per minute (assertions don't usefully retry
  faster).
- magic-link generation: 3 per email per 5 min + 10 per IP per 5 min
  (defeats inbox-spam-the-victim + cross-email IP fanout).
- verify-code: 10 per email per 15 min — makes 6-digit code brute force
  (10^6 search) infeasible before code expiry.
- /api/auth/register: 10 per IP per hour (legitimate signup completes
  in 2-3 requests; sustained churn from one IP is account spam).

Single-instance is fine for the flagship's current topology (one journal
container). When we horizontally scale we revisit with a Postgres- or
Redis-backed store. Buckets self-clean on next read and a 5-minute
background sweep drops stale entries.

Client IP: honors X-Forwarded-For first entry (Caddy in front of the
journal sets it), falls back to a stable "unknown" bucket so the
limiter still bites when the header is missing.

Tests: rate-limit.server.test.ts (8 cases — exhaustion, independent
scopes/keys, remaining countdown, reset window, XFF parsing, fallback,
trimming). Existing auth tests still pass; limits sized to not trip
during the ~7 test cases that all share the "unknown" IP bucket.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:51:43 +02:00

108 lines
4 KiB
TypeScript

import { data } from "react-router";
import { getOrigin } from "~/lib/config.server";
import type { Route } from "./+types/api.auth.login";
import { startAuthentication, finishAuthentication, createMagicToken, verifyLoginCode } from "~/lib/auth.server";
import { completeAuth } from "~/lib/auth/completion.server";
import { sendMagicLink } from "~/lib/email.server";
import { consumeRateLimit, clientIp } from "~/lib/rate-limit.server";
function tooManyRequests(resetMs: number) {
return data(
{ error: `Too many attempts. Try again in ${Math.ceil(resetMs / 1000)}s.` },
{ status: 429, headers: { "Retry-After": String(Math.ceil(resetMs / 1000)) } },
);
}
export async function action({ request }: Route.ActionArgs) {
const body = await request.json();
const { step, response, challenge, email, code, returnTo } = body;
const ip = clientIp(request);
// Per-IP cap on every auth attempt regardless of step — defends
// against a single attacker fan-out across step types.
const ipLimit = consumeRateLimit({
scope: "auth-attempt-ip",
key: ip,
limit: 30,
windowMs: 60_000,
});
if (!ipLimit.allowed) return tooManyRequests(ipLimit.resetMs);
try {
if (step === "start-passkey") {
const options = await startAuthentication();
return data({ step: "challenge", options });
}
if (step === "finish-passkey") {
// Tighter per-IP cap on credential-consuming steps — passkey
// assertions don't usefully retry that fast.
const verify = consumeRateLimit({
scope: "passkey-verify",
key: ip,
limit: 10,
windowMs: 60_000,
});
if (!verify.allowed) return tooManyRequests(verify.resetMs);
const userId = await finishAuthentication(response, challenge);
return completeAuth({ userId, request, returnTo, mode: "json" });
}
if (step === "magic-link") {
// Cap magic-link generation per email so an attacker can't spam a
// victim's inbox with codes, and per IP so a single client can't
// fan out across many addresses.
if (typeof email === "string" && email.length > 0) {
const perEmail = consumeRateLimit({
scope: "magic-link-email",
key: email.toLowerCase(),
limit: 3,
windowMs: 5 * 60_000,
});
if (!perEmail.allowed) return tooManyRequests(perEmail.resetMs);
}
const perIp = consumeRateLimit({
scope: "magic-link-ip",
key: ip,
limit: 10,
windowMs: 5 * 60_000,
});
if (!perIp.allowed) return tooManyRequests(perIp.resetMs);
const { token, code: loginCode } = await createMagicToken(email);
const origin = getOrigin();
const link = `${origin}/auth/verify?token=${token}`;
// In dev, return the link and code directly
if (process.env.NODE_ENV !== "production") {
console.log(`[Magic Link] ${email}: ${link} (code: ${loginCode})`);
return data({ step: "magic-link-sent", devLink: link, code: loginCode });
}
await sendMagicLink(email, link, loginCode);
return data({ step: "magic-link-sent" });
}
if (step === "verify-code") {
if (!email || !code) {
return data({ error: "Email and code are required" }, { status: 400 });
}
// Per-email cap to defeat 6-digit code brute force — 10 attempts
// per 15 minutes gives legitimate users plenty of room while making
// a brute-force search (~10^6 codes) infeasible before the code
// expires.
const codeLimit = consumeRateLimit({
scope: "verify-code",
key: String(email).toLowerCase(),
limit: 10,
windowMs: 15 * 60_000,
});
if (!codeLimit.allowed) return tooManyRequests(codeLimit.resetMs);
const userId = await verifyLoginCode(email, code);
return completeAuth({ userId, request, returnTo, mode: "json" });
}
return data({ error: "Invalid step" }, { status: 400 });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}
}