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>
183 lines
6.6 KiB
TypeScript
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);
|
|
}
|