yiekheng ebbbdbdfb8 fix(web): make session cookie secure flag conditional on production
Setting Secure on http://localhost cookies works in Chrome (localhost
exception) but Firefox/Safari silently drop them, so dev users hit
'redirect to /login on every click' after a 'successful' login. Switch
to secure: NODE_ENV === 'production'. Public deploy still gets
Secure-only.

Also swap the login footer copy from a CLI hint to 'Forget Password?
Contact IT' — operator-friendly, doesn't leak the bootstrap
mechanism on the public sign-in screen.

Test updated to assert secure=true under prod NODE_ENV and a new test
locks in secure=false in dev.

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

126 lines
4.3 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";
}
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." };
}
const ip = await clientIp();
const rl = await checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 });
if (rl.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);
}