145 lines
4.7 KiB
TypeScript
145 lines
4.7 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { NextRequest } from "next/server";
|
|
|
|
let runtimeRoot = "";
|
|
let data: typeof import("../src/lib/boss-data");
|
|
let authCookie = "";
|
|
let postLogin: (typeof import("../src/app/api/auth/login/route"))["POST"];
|
|
let postRestore: (typeof import("../src/app/api/auth/restore/route"))["POST"];
|
|
|
|
async function setup() {
|
|
if (runtimeRoot) return;
|
|
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-auth-security-"));
|
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
|
const [dataModule, authModule, loginRoute, restoreRoute] = await Promise.all([
|
|
import("../src/lib/boss-data.ts"),
|
|
import("../src/lib/boss-auth.ts"),
|
|
import("../src/app/api/auth/login/route.ts"),
|
|
import("../src/app/api/auth/restore/route.ts"),
|
|
]);
|
|
data = dataModule;
|
|
authCookie = authModule.AUTH_SESSION_COOKIE;
|
|
postLogin = loginRoute.POST;
|
|
postRestore = restoreRoute.POST;
|
|
}
|
|
|
|
test.after(async () => {
|
|
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
await setup();
|
|
delete process.env.BOSS_AUTH_AUTO_LOGIN;
|
|
const now = "2026-04-27T18:00:00+08:00";
|
|
const state = await data.readState();
|
|
await data.writeState({
|
|
...state,
|
|
authSessions: [],
|
|
authAccounts: [
|
|
{
|
|
id: "account-owner",
|
|
account: "owner@example.com",
|
|
passwordHash: data.hashPassword("StrongPass123"),
|
|
displayName: "企业管理员",
|
|
role: "highest_admin",
|
|
status: "active",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
{
|
|
id: "account-mfa",
|
|
account: "mfa@example.com",
|
|
passwordHash: data.hashPassword("StrongPass123"),
|
|
displayName: "MFA 管理员",
|
|
role: "admin",
|
|
status: "active",
|
|
mfaRequired: true,
|
|
mfaSecret: "test-mfa-secret",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
function loginRequest(body: Record<string, unknown>, headers: Record<string, string> = {}) {
|
|
return new NextRequest("http://127.0.0.1:3000/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", ...headers },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function restoreRequest(restoreToken: string) {
|
|
return new NextRequest("http://127.0.0.1:3000/api/auth/restore", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ restoreToken }),
|
|
});
|
|
}
|
|
|
|
test("auth mutations reject explicit cross-site browser posts", async () => {
|
|
const response = await postLogin(loginRequest(
|
|
{ account: "owner@example.com", password: "StrongPass123", method: "password" },
|
|
{ origin: "https://evil.example", "sec-fetch-site": "cross-site" },
|
|
));
|
|
|
|
assert.equal(response.status, 403);
|
|
assert.match((await response.json()).message, /CSRF/);
|
|
});
|
|
|
|
test("native app login is not blocked by browser CSRF headers", async () => {
|
|
const response = await postLogin(loginRequest(
|
|
{ account: "owner@example.com", password: "StrongPass123", method: "password" },
|
|
{ "x-boss-native-app": "1" },
|
|
));
|
|
|
|
assert.equal(response.status, 200);
|
|
});
|
|
|
|
test("restore token rotates on every session restore", async () => {
|
|
const session = await data.createAuthSession({
|
|
account: "owner@example.com",
|
|
role: "highest_admin",
|
|
displayName: "企业管理员",
|
|
loginMethod: "password",
|
|
});
|
|
|
|
const first = await postRestore(restoreRequest(session.restoreToken));
|
|
assert.equal(first.status, 200);
|
|
const firstPayload = await first.json();
|
|
const rotatedToken = firstPayload.session.restoreToken;
|
|
assert.notEqual(rotatedToken, session.restoreToken);
|
|
assert.match(first.headers.get("set-cookie") ?? "", new RegExp(`${authCookie}=`));
|
|
|
|
const oldToken = await postRestore(restoreRequest(session.restoreToken));
|
|
assert.equal(oldToken.status, 401);
|
|
|
|
const second = await postRestore(restoreRequest(rotatedToken));
|
|
assert.equal(second.status, 200);
|
|
});
|
|
|
|
test("MFA-protected accounts require a valid one-time code after password verification", async () => {
|
|
const missing = await postLogin(loginRequest({
|
|
account: "mfa@example.com",
|
|
password: "StrongPass123",
|
|
method: "password",
|
|
}));
|
|
assert.equal(missing.status, 400);
|
|
assert.match((await missing.json()).message, /MFA/);
|
|
|
|
const validCode = data.generateAuthAccountMfaCode("test-mfa-secret", new Date());
|
|
const passed = await postLogin(loginRequest({
|
|
account: "mfa@example.com",
|
|
password: "StrongPass123",
|
|
method: "password",
|
|
mfaCode: validCode,
|
|
}));
|
|
assert.equal(passed.status, 200);
|
|
});
|