feat: add Go password session lifecycle

This commit is contained in:
2026-08-13 15:34:46 +08:00
parent 772795e7eb
commit d0207fcebe
19 changed files with 2483 additions and 16 deletions

View File

@@ -0,0 +1,84 @@
import { readFile } from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as logoutRoute from "@/app/api/auth/logout/route";
type LogoutFixture = {
version: 1;
path: string;
methods: string[];
status: number;
location: string;
requiresAuthentication: boolean;
cookieFixture: string;
duplicateBaseCookieWrite: boolean;
};
type SessionCookieFixture = {
cookie: {
chunkNames: string[];
attributes: { httpOnly: boolean; sameSite: string; path: string };
clear: { value: string; maxAgeSeconds: number };
};
};
const fixtureUrl = new URL("../contracts/auth/logout-v1.json", import.meta.url);
async function loadFixture(): Promise<LogoutFixture> {
return JSON.parse(await readFile(fixtureUrl, "utf8")) as LogoutFixture;
}
async function loadCookieFixture(relativePath: string): Promise<SessionCookieFixture> {
return JSON.parse(await readFile(new URL(`../contracts/auth/${relativePath}`, import.meta.url), "utf8")) as SessionCookieFixture;
}
function setCookieLines(response: Response): string[] {
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
return headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""];
}
afterEach(() => vi.unstubAllEnvs());
describe("logout HTTP v1 cross-language contract", () => {
it("allows anonymous GET and POST and returns the same 307 redirect", async () => {
const fixture = await loadFixture();
expect({ version: fixture.version, path: fixture.path, methods: fixture.methods }).toEqual({
version: 1,
path: "/api/auth/logout",
methods: ["GET", "POST"]
});
expect(Object.keys(logoutRoute).sort()).toEqual(["GET", "POST", "runtime"]);
expect(fixture.requiresAuthentication).toBe(false);
for (const method of fixture.methods) {
const response = await logoutRoute[method as "GET" | "POST"](
new Request("https://app.example.test/api/auth/logout", { method })
);
expect(response.status, method).toBe(fixture.status);
expect(response.headers.get("location"), method).toBe(fixture.location);
}
});
it("clears all 20 legacy chunk names and preserves the externally visible duplicate base write", async () => {
const fixture = await loadFixture();
const cookieFixture = await loadCookieFixture(fixture.cookieFixture);
vi.stubEnv("ZHINIAN_AUTH_COOKIE_SECURE", "true");
const response = await logoutRoute.POST(new Request("http://127.0.0.1/api/auth/logout", { method: "POST" }));
const lines = setCookieLines(response);
const names = lines.map((line) => line.slice(0, line.indexOf("=")));
expect(lines).toHaveLength(cookieFixture.cookie.chunkNames.length + (fixture.duplicateBaseCookieWrite ? 1 : 0));
expect(names).toEqual([
...cookieFixture.cookie.chunkNames,
...(fixture.duplicateBaseCookieWrite ? [cookieFixture.cookie.chunkNames[0]] : [])
]);
for (const line of lines) {
expect(line).toContain("HttpOnly");
expect(line).toContain("Path=/");
expect(line).toContain("SameSite=lax");
expect(line).toContain("Secure");
expect(line).toContain(`Max-Age=${cookieFixture.cookie.clear.maxAgeSeconds}`);
}
});
});

View File

@@ -0,0 +1,260 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { safeNextPath } from "@/lib/auth/config";
import { parseSessionCookieValue } from "@/lib/auth/session";
import {
createPlatformOrganization,
createPlatformUser,
updatePlatformOrganization,
updatePlatformUser
} from "@/lib/server/account-store";
import { resetLocalAuthRateLimitForTests } from "@/lib/server/auth/local";
import type { PlatformOrganization, PlatformUserRecord } from "@/lib/types";
import * as passwordRoute from "@/app/api/auth/password/route";
type PasswordLoginFixture = {
version: 1;
path: string;
method: "POST";
localSessionTtlSeconds: number;
inputCases: Array<{
name: string;
body: Record<string, unknown>;
expectedRedirect: string;
}>;
safeNextCases: Array<{ input: string | null; expected: string }>;
success: {
topLevelKeys: string[];
publicUserKeys: string[];
forbiddenSerializedKeys: string[];
};
errors: Record<"invalidInput" | "invalidCredentials" | "disabledAccount" | "disabledOrganization" | "lockedAccount" | "rateLimited" | "unconfigured", {
status: number;
body: { error: string };
}>;
rateLimit: { attemptsPerIp: number; windowSeconds: number };
cookieFixture: string;
};
type SessionCookieFixture = {
cookie: {
chunkNames: string[];
attributes: { httpOnly: boolean; sameSite: string; path: string };
clear: { maxAgeSeconds: number };
};
};
const fixtureUrl = new URL("../contracts/auth/password-login-v1.json", import.meta.url);
const authEnvironmentKeys = [
"NODE_ENV",
"ZHINIAN_DATA_DIR",
"ZHINIAN_DATA_BACKEND",
"ZHINIAN_AUTH_REQUIRED",
"ZHINIAN_AUTH_DISABLED",
"ZHINIAN_AUTH_SESSION_SECRET",
"AUTH_SESSION_SECRET",
"NEXTAUTH_SECRET",
"ZHINIAN_AUTH_COOKIE_SECURE",
"NEXT_PUBLIC_APP_URL",
"ZHINIAN_PUBLIC_BASE_URL"
] as const;
const originalEnvironment = new Map(authEnvironmentKeys.map((key) => [key, process.env[key]]));
const sessionSecret = "password-contract-session-secret-with-enough-entropy";
let runtimeDir = "";
let organization: PlatformOrganization;
let user: PlatformUserRecord;
async function loadFixture(): Promise<PasswordLoginFixture> {
return JSON.parse(await readFile(fixtureUrl, "utf8")) as PasswordLoginFixture;
}
async function loadCookieFixture(relativePath: string): Promise<SessionCookieFixture> {
return JSON.parse(await readFile(new URL(`../contracts/auth/${relativePath}`, import.meta.url), "utf8")) as SessionCookieFixture;
}
function login(body: unknown, ip = "192.0.2.10", url = "http://127.0.0.1/api/auth/password") {
return passwordRoute.POST(new Request(url, {
method: "POST",
headers: { "content-type": "application/json", "x-forwarded-for": ip },
body: typeof body === "string" ? body : JSON.stringify(body)
}));
}
function setCookieLines(response: Response): string[] {
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
return headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""];
}
function unsignedCookiePayload(cookieValue: string): Record<string, unknown> {
const [payload] = cookieValue.split(".");
return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64url").toString("utf8")) as Record<string, unknown>;
}
beforeEach(async () => {
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-password-contract-"));
vi.stubEnv("NODE_ENV", "test");
vi.stubEnv("ZHINIAN_DATA_DIR", runtimeDir);
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1");
vi.stubEnv("ZHINIAN_AUTH_DISABLED", "");
vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", sessionSecret);
vi.stubEnv("ZHINIAN_AUTH_COOKIE_SECURE", "false");
resetLocalAuthRateLimitForTests();
organization = await createPlatformOrganization("契约测试组织");
user = await createPlatformUser({
phone: "13800138000",
displayName: "契约测试用户",
password: "TestPass123",
role: "user",
organizationId: organization.id
});
});
afterEach(async () => {
resetLocalAuthRateLimitForTests();
vi.unstubAllEnvs();
for (const key of authEnvironmentKeys) {
const original = originalEnvironment.get(key);
if (original === undefined) Reflect.deleteProperty(process.env, key);
else Reflect.set(process.env, key, original);
}
await rm(runtimeDir, { recursive: true, force: true });
});
describe("password login HTTP v1 cross-language contract", () => {
it("freezes route identity, input aliases, trimming, ignored authMode, and safe redirects", async () => {
const fixture = await loadFixture();
expect({ version: fixture.version, path: fixture.path, method: fixture.method }).toEqual({
version: 1,
path: "/api/auth/password",
method: "POST"
});
expect(Object.keys(passwordRoute).sort()).toEqual(["POST", "dynamic", "runtime"]);
expect(passwordRoute.runtime).toBe("nodejs");
expect(passwordRoute.dynamic).toBe("force-dynamic");
for (const testCase of fixture.safeNextCases) {
expect(safeNextPath(testCase.input), testCase.input ?? "null").toBe(testCase.expected);
}
for (const [index, testCase] of fixture.inputCases.entries()) {
const response = await login(testCase.body, `192.0.2.${20 + index}`);
expect(response.status, testCase.name).toBe(200);
const body = await response.json();
expect(body.redirectTo, testCase.name).toBe(testCase.expectedRedirect);
expect(body.authMode, testCase.name).toBe("user");
}
});
it("returns the exact public body and a parseable one-day session without sensitive fields", async () => {
const fixture = await loadFixture();
const response = await login({ phone: user.phone, password: "TestPass123", next: "/assets?tab=mine#recent" });
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toEqual({
ok: true,
redirectTo: "/assets?tab=mine#recent",
user: {
id: user.id,
subject: user.id,
username: user.phone,
phone: user.phone,
displayName: user.displayName,
clientId: "platform",
organizationId: organization.id,
organizationName: organization.name,
role: "user",
status: "active",
authorities: ["ROLE_USER"],
scope: []
},
authMode: "user"
});
expect(Object.keys(body).sort()).toEqual([...fixture.success.topLevelKeys].sort());
expect(Object.keys(body.user).sort()).toEqual([...fixture.success.publicUserKeys].sort());
for (const key of fixture.success.forbiddenSerializedKeys) {
expect(JSON.stringify(body)).not.toContain(`"${key}"`);
}
const cookieValue = response.cookies.get("zhinian_session")?.value;
const session = await parseSessionCookieValue(cookieValue, sessionSecret, 0);
expect(session).not.toBeNull();
expect(session?.expiresAt! - session?.issuedAt!).toBe(fixture.localSessionTtlSeconds);
expect(session).toMatchObject({
version: 1,
authMode: "user",
sessionVersion: user.sessionVersion,
user: body.user
});
const rawSession = unsignedCookiePayload(cookieValue!);
expect(rawSession).not.toHaveProperty("accessToken");
expect(rawSession).not.toHaveProperty("tokenType");
});
it("writes the shared Cookie name set and legacy attributes, including stale-chunk clears", async () => {
const fixture = await loadFixture();
const cookieFixture = await loadCookieFixture(fixture.cookieFixture);
const response = await login({ phone: user.phone, password: "TestPass123" });
const lines = setCookieLines(response);
expect(lines).toHaveLength(cookieFixture.cookie.chunkNames.length);
expect(lines.map((line) => line.slice(0, line.indexOf("=")))).toEqual(cookieFixture.cookie.chunkNames);
expect(lines[0]).toContain("HttpOnly");
expect(lines[0]).toContain("Path=/");
expect(lines[0]).toContain("SameSite=lax");
expect(lines[0]).not.toContain("Secure");
expect(lines[0]).toContain("Expires=");
for (const line of lines.slice(1)) {
expect(line).toContain("Max-Age=0");
expect(line).not.toContain("Expires=");
}
});
it("freezes 400, 401, 403, 423, 429, and 503 public error semantics", async () => {
const { errors, rateLimit } = await loadFixture();
const invalidInput = await login("not-json", "192.0.2.30");
expect({ status: invalidInput.status, body: await invalidInput.json() }).toEqual(errors.invalidInput);
const invalidCredentials = await login({ username: " 19900000000 ", password: "wrong" }, "192.0.2.31");
expect({ status: invalidCredentials.status, body: await invalidCredentials.json() }).toEqual(errors.invalidCredentials);
await updatePlatformUser(user.id, { status: "disabled" });
const disabled = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.32");
expect({ status: disabled.status, body: await disabled.json() }).toEqual(errors.disabledAccount);
await updatePlatformUser(user.id, { status: "active" });
let locked: Response | undefined;
for (let attempt = 0; attempt < 5; attempt += 1) {
locked = await login({ phone: user.phone, password: "wrong" }, `192.0.2.${40 + attempt}`);
}
expect({ status: locked?.status, body: await locked?.json() }).toEqual(errors.lockedAccount);
for (let attempt = 0; attempt < rateLimit.attemptsPerIp; attempt += 1) {
const response = await login({ phone: "19900000000", password: "wrong" }, "192.0.2.50");
expect(response.status, `allowed IP attempt ${attempt + 1}`).toBe(401);
}
const rateLimited = await login({ phone: "19900000000", password: "wrong" }, "192.0.2.50");
expect({ status: rateLimited.status, body: await rateLimited.json() }).toEqual(errors.rateLimited);
vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", "");
vi.stubEnv("AUTH_SESSION_SECRET", "");
vi.stubEnv("NEXTAUTH_SECRET", "");
const unconfigured = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.60");
expect({ status: unconfigured.status, body: await unconfigured.json() }).toEqual(errors.unconfigured);
expect(rateLimit.windowSeconds).toBe(15 * 60);
});
it("rejects a user whose organization is disabled", async () => {
const fixture = await loadFixture();
await updatePlatformOrganization(organization.id, { status: "disabled" });
const response = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.70");
expect({ status: response.status, body: await response.json() }).toEqual(fixture.errors.disabledOrganization);
});
});