yiekheng f69652d43b feat(web): AES-GCM cookies + per-username/global rate limit + origin check
Three layers of login hardening pulled together — addresses the
"don't let middleman / robot easily log in by mimicking headers"
follow-up.

1. AES-256-GCM session cookie (apps/web/src/lib/auth-cookie.ts)

   The old format was base64-encoded JSON + HMAC-SHA256 signature, so
   anyone with the cookie could read userId/role straight off the
   bytes. Switched to AES-GCM authenticated encryption: the payload
   is encrypted with a 256-bit key derived from AUTH_SECRET via
   SHA-256, a fresh 12-byte nonce is drawn per encryption (never
   reused — locked in by test), and tampering with either the IV or
   ciphertext fails the GCM auth tag → decrypt throws → null.

   Cookie format: <base64url(iv)>.<base64url(ciphertext+tag)>

   Existing cookies become invalid on deploy because the IV portion
   doesn't decode to 12 bytes — middleware bounces them to /login.
   No env bump needed; users just sign in once with the new secret.

2. Three-layer rate limit on loginAction

   Old: per-IP only. An attacker with a residential-proxy pool or
   spoofed X-Forwarded-For could hop IPs and brute one account.
   New: Promise.all of three checkRateLimit calls
     - per-IP        login:<ip>          10 / 5 min
     - per-username  login-user:<lower>  5 / 15 min
     - global        login-global        100 / min (backstop)
   First-hit wins; logger captures which limit tripped (ip / username
   / global) without telling the attacker which one.

3. Action-level Origin/Host check

   serverActions.allowedOrigins already does this at the framework
   layer; running it inside loginAction lets us log the mismatch and
   reject before bcrypt + DB. Missing Origin treated as same-origin
   (RFC: same-origin POSTs may omit it). Malformed Origin → reject.

Tests:
  - auth-cookie.test.ts updated to AES-GCM (15 tests, +4 vs HMAC):
    fresh IV per encryption, ciphertext doesn't leak userId/role,
    IV-swap rejected, ciphertext-tamper rejected, wrong-length IV
    rejected, malformed b64 doesn't throw.
  - auth.test.ts adds 7 new cases: three-layer key shape, per-username
    limit alone trips, global limit alone trips, cross-origin rejected,
    same-origin accepted, missing-Origin treated as same-origin,
    malformed-Origin rejected.

Web suite 453 → 463 tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:41:49 +08:00

183 lines
6.6 KiB
TypeScript

"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
COOKIE_NAME,
DEFAULT_TTL_SECONDS,
signSession,
type Role,
} from "@/lib/auth-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { safeRedirect } from "@/lib/safe-redirect";
import { logger } from "@/lib/logger";
export type LoginResult = { ok: true } | { ok: false; error: string };
const MAX_FIELD_LEN = 256;
// Precomputed bcryptjs hash of the throwaway string "x", cost 10.
// Compared against on the user-not-found path so timing matches the
// wrong-password path. Generating fresh per request would double the
// bcrypt work and create its own timing signal.
const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
async function clientIp(): Promise<string> {
const h = await headers();
const fwd = h.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]!.trim();
return h.get("x-real-ip") ?? "unknown";
}
/**
* Compare the inbound Origin to the request's Host. Server Actions
* already get an Origin check via Next 16's
* `serverActions.allowedOrigins`, but that's a global config — running
* the same comparison here is cheap belt-and-braces and lets us log
* mismatches with action-level context. Returns true when:
* - no Origin header is present (same-origin POSTs from the same
* server), OR
* - Origin's host matches the Host header (same-origin)
* Anything else (cross-origin POST, malformed Origin, etc.) → false.
*/
async function hasSameOriginRequest(): Promise<boolean> {
const h = await headers();
const origin = h.get("origin");
if (!origin) return true; // RFC: same-origin requests may omit Origin
const host = h.get("host");
if (!host) return false;
try {
const u = new URL(origin);
return u.host === host;
} catch {
return false;
}
}
export async function loginAction(formData: FormData): Promise<LoginResult> {
const username = (formData.get("username") ?? "").toString();
const password = (formData.get("password") ?? "").toString();
const next = (formData.get("next") ?? "").toString();
if (!username.trim() || !password) {
return { ok: false, error: "Username and password are required." };
}
if (username.length > MAX_FIELD_LEN || password.length > MAX_FIELD_LEN) {
return { ok: false, error: "Input too long." };
}
// Action-level Origin check. Next 16's serverActions.allowedOrigins
// already gates this at the framework boundary, but doing it here
// with action context lets us log the mismatch and surface a clean
// error instead of relying on the global config alone.
if (!(await hasSameOriginRequest())) {
logger.warn({}, "login rejected: cross-origin request");
return { ok: false, error: "Cross-origin request blocked." };
}
const ip = await clientIp();
// Three-layer rate limit:
// per-IP — typical brute-forcer
// per-username — attacker who rotates IPs (X-Forwarded-For
// spoofing, residential proxy pool) but pounds
// a single account
// global — backstop. If the attacker controls enough
// IP+username combos to slip past the first two,
// this caps the total login attempts per minute
// across the install. Lock occurs at the FIRST
// limit hit; we don't reveal which one.
const usernameKey = username.trim().toLowerCase();
const [rlIp, rlUser, rlGlobal] = await Promise.all([
checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 }),
checkRateLimit(`login-user:${usernameKey}`, { max: 5, windowSec: 900 }),
checkRateLimit(`login-global`, { max: 100, windowSec: 60 }),
]);
if (rlIp.limited || rlUser.limited || rlGlobal.limited) {
logger.warn(
{
ip,
username: usernameKey,
limit: rlIp.limited ? "ip" : rlUser.limited ? "username" : "global",
},
"login rate-limited",
);
return { ok: false, error: "Too many attempts. Try again later." };
}
const row = await db.query.operators.findFirst({
where: (o) => sql`lower(${o.username}) = lower(${username})`,
});
// User exists but has no password configured: this is a server-side
// setup error, not a credential mismatch. Surface a distinct message
// so the operator knows to run scripts/set-password.sh. We still ran
// the DB lookup, so the username-enumeration concern is not relevant
// here (the attacker would already need a known username).
if (row && row.passwordHash === null) {
return {
ok: false,
error: "Set a password via scripts/set-password.sh before signing in.",
};
}
// Run bcrypt regardless to keep the user-not-found path timing-
// equivalent to the wrong-password path.
const hash = row?.passwordHash ?? DUMMY_HASH;
const ok = await bcrypt.compare(password, hash);
if (!row || !ok) {
logger.warn({ username, ip }, "login failed");
return { ok: false, error: "Invalid username or password." };
}
if (row.role !== "admin" && row.role !== "user") {
return { ok: false, error: "Account is not enabled." };
}
const secret = process.env.AUTH_SECRET;
if (!secret) {
logger.warn({}, "AUTH_SECRET unset — cannot issue cookie");
return { ok: false, error: "Server is not configured for sign-in." };
}
const v = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
const now = Math.floor(Date.now() / 1000);
const cookie = await signSession(
{
userId: row.id,
role: row.role as Role,
iat: now,
exp: now + DEFAULT_TTL_SECONDS,
v,
},
secret,
);
const jar = await cookies();
// Secure: only require https in production. In dev we hit
// http://localhost:9000 directly, and Firefox/Safari silently drop
// Set-Cookie when Secure is set on http origins (Chrome has a
// localhost exception, others don't), which manifested as the
// session cookie never being persisted across requests.
jar.set(COOKIE_NAME, cookie, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: DEFAULT_TTL_SECONDS,
});
// Typed-routes is on (next.config.ts experimental.typedRoutes); the
// `next` value is a runtime string from the form so we cast through any.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(safeRedirect(next) as any);
}
export async function logoutAction(): Promise<void> {
const jar = await cookies();
jar.delete(COOKIE_NAME);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/login" as any);
}