Protect Tabularium with persistent authenticator-code login
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m28s
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m28s
This commit is contained in:
@@ -4,13 +4,15 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NEXT_TELEMETRY_DISABLED=1 next dev --hostname 127.0.0.1",
|
||||
"build": "NEXT_TELEMETRY_DISABLED=1 next build",
|
||||
"build": "NEXT_TELEMETRY_DISABLED=1 next build && node scripts/build-auth.mjs",
|
||||
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit"
|
||||
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
|
||||
"test:auth": "node scripts/test-auth.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.3",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-markdown": "10.1.0",
|
||||
@@ -20,8 +22,10 @@
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"esbuild": "0.25.12",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.4.3",
|
||||
"postcss": "8.5.31",
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
renameSync,
|
||||
chmodSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import assert from "node:assert/strict";
|
||||
import QRCode from "qrcode";
|
||||
import {
|
||||
authConfig,
|
||||
newAuthConfig,
|
||||
signSession,
|
||||
totp,
|
||||
isLoginRedirect,
|
||||
} from "../src/lib/auth";
|
||||
import { consumeCode, initializeStore } from "../src/lib/auth-store";
|
||||
|
||||
async function main() {
|
||||
process.umask(0o077);
|
||||
const command = process.argv[2];
|
||||
const file = process.env.AUTH_CONFIG_FILE;
|
||||
if (!file)
|
||||
throw Error("Set AUTH_CONFIG_FILE before using the authentication tool");
|
||||
if (command === "init" || command === "rotate") {
|
||||
if (command === "rotate" && !process.argv.includes("--confirm"))
|
||||
throw Error(
|
||||
"Rotation requires --confirm; all previous authenticators and sessions will stop working",
|
||||
);
|
||||
if (command === "init" && existsSync(file)) {
|
||||
authConfig();
|
||||
initializeStore();
|
||||
console.log("Existing authentication key preserved");
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(file), 0o700);
|
||||
const temporary = file + "." + process.pid + ".tmp";
|
||||
writeFileSync(temporary, JSON.stringify(newAuthConfig()) + "\n", {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
renameSync(temporary, file);
|
||||
initializeStore();
|
||||
console.log(
|
||||
"Private authentication configuration initialized; no key displayed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const config = authConfig();
|
||||
if (!config) throw Error("Authentication is not configured");
|
||||
if (command === "state-check") {
|
||||
assert.equal(consumeCode(config, "invalid"), "limited");
|
||||
console.log("Durable attempt limit survives restart");
|
||||
return;
|
||||
}
|
||||
if (command === "qr" || command === "qr-png") {
|
||||
if (process.env.CI)
|
||||
throw Error("Never display or export authentication QR codes in CI");
|
||||
const uri =
|
||||
"otpauth://totp/Tabularium:shared?" +
|
||||
new URLSearchParams({
|
||||
secret: config.secret,
|
||||
issuer: "Tabularium",
|
||||
algorithm: "SHA1",
|
||||
digits: "6",
|
||||
period: "30",
|
||||
});
|
||||
if (command === "qr-png") {
|
||||
process.stdout.write(
|
||||
await QRCode.toBuffer(uri, {
|
||||
width: 660,
|
||||
margin: 4,
|
||||
errorCorrectionLevel: "M",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!process.stdout.isTTY)
|
||||
throw Error(
|
||||
"Use an interactive SSH terminal for qr, or redirect qr-png to a private local PNG",
|
||||
);
|
||||
console.log(
|
||||
await QRCode.toString(uri, {
|
||||
type: "terminal",
|
||||
small: true,
|
||||
errorCorrectionLevel: "M",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command === "token") {
|
||||
if (!process.stdout.isTTY)
|
||||
throw Error(
|
||||
"Tokens can only be displayed in an interactive SSH terminal",
|
||||
);
|
||||
console.log(signSession(config, undefined, 3600));
|
||||
return;
|
||||
}
|
||||
const base = process.env.AUTH_CHECK_BASE_URL || "http://127.0.0.1:3000";
|
||||
if (command === "run") {
|
||||
const script = process.argv[3];
|
||||
if (!script) throw Error("Provide a script path");
|
||||
const original = globalThis.fetch;
|
||||
const token = signSession(config, undefined, 600);
|
||||
globalThis.fetch = (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
if (url.origin === new URL(base).origin) {
|
||||
const headers = new Headers(
|
||||
init?.headers ||
|
||||
(input instanceof Request ? input.headers : undefined),
|
||||
);
|
||||
headers.set("Authorization", "Bearer " + token);
|
||||
return original(input, { ...init, headers });
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
await import(pathToFileURL(resolve(script)).href);
|
||||
return;
|
||||
}
|
||||
if (command !== "verify" && command !== "exercise")
|
||||
throw Error(
|
||||
"Usage: init | qr | qr-png | rotate --confirm | token | run <script> | verify | exercise",
|
||||
);
|
||||
const request = (path: string, init: RequestInit = {}) =>
|
||||
fetch(base + path, { ...init, redirect: "manual" });
|
||||
const buildId = readFileSync(
|
||||
join(__dirname, ".next/BUILD_ID"),
|
||||
"utf8",
|
||||
).trim();
|
||||
const routes = Object.keys(
|
||||
JSON.parse(
|
||||
readFileSync(join(__dirname, ".next/prerender-manifest.json"), "utf8"),
|
||||
).routes,
|
||||
) as string[];
|
||||
for (const path of [
|
||||
"/",
|
||||
"/docs/00-endor-concepts-draft",
|
||||
"/figures/endor-architecture.svg",
|
||||
`/_next/data/${buildId}/index.json`,
|
||||
`/_next/data/${buildId}/docs/00-endor-concepts-draft.json`,
|
||||
]) {
|
||||
const response = await request(path);
|
||||
assert.equal(response.status, 307, path);
|
||||
assert.ok(
|
||||
isLoginRedirect(
|
||||
response.headers.get("location") ||
|
||||
response.headers.get("x-nextjs-redirect"),
|
||||
base,
|
||||
),
|
||||
path,
|
||||
);
|
||||
}
|
||||
for (const path of ["/api/private", "/api/unknown"])
|
||||
assert.equal((await request(path)).status, 401, path);
|
||||
assert.equal(
|
||||
(
|
||||
await request("/api/private", {
|
||||
headers: { "x-middleware-subrequest": "proxy:proxy:proxy:proxy:proxy" },
|
||||
})
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
const login = await request("/login");
|
||||
assert.equal(login.status, 200);
|
||||
const loginHtml = await login.text();
|
||||
assert.ok(loginHtml.includes("输入动态码"));
|
||||
assert.ok(!loginHtml.includes(config.secret));
|
||||
assert.deepEqual(await (await request("/api/health")).json(), {
|
||||
status: "ok",
|
||||
});
|
||||
const headers = {
|
||||
Authorization: "Bearer " + signSession(config, undefined, 600),
|
||||
};
|
||||
let home = "";
|
||||
for (const route of routes) {
|
||||
const response = await request(route, { headers });
|
||||
assert.equal(response.status, 200, route);
|
||||
if (route === "/") home = await response.text();
|
||||
}
|
||||
assert.ok(home.includes("TABULARIUM"));
|
||||
const assets = new Set(
|
||||
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
|
||||
m[1].replaceAll("&", "&"),
|
||||
),
|
||||
);
|
||||
assert.ok(assets.size);
|
||||
for (const path of assets)
|
||||
assert.equal((await request(path, { headers })).status, 200, path);
|
||||
for (const name of readdirSync(join(__dirname, "public/figures"))) {
|
||||
if (!name.endsWith(".svg")) continue;
|
||||
const response = await request("/figures/" + name, { headers });
|
||||
assert.equal(response.status, 200, name);
|
||||
assert.match(response.headers.get("content-type") || "", /image\/svg\+xml/);
|
||||
}
|
||||
assert.equal(
|
||||
(await request("/docs/this-document-does-not-exist", { headers })).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request("/", {
|
||||
headers: { Authorization: headers.Authorization + "x" },
|
||||
})
|
||||
).status,
|
||||
307,
|
||||
);
|
||||
if (command === "exercise") {
|
||||
const origin = new URL(base).origin;
|
||||
const postCode = (code: string, site = origin) =>
|
||||
request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Origin: site },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
assert.equal(
|
||||
(await postCode("000000", "https://untrusted.example")).status,
|
||||
403,
|
||||
);
|
||||
const code = totp(config.secret, Math.floor(Date.now() / 30000));
|
||||
const results = await Promise.all([postCode(code), postCode(code)]);
|
||||
assert.deepEqual(results.map((r) => r.status).sort(), [200, 401]);
|
||||
const cookie = results
|
||||
.find((r) => r.status === 200)!
|
||||
.headers.get("set-cookie")!;
|
||||
assert.match(cookie, /HttpOnly/);
|
||||
assert.match(cookie, /SameSite=Lax/);
|
||||
assert.match(cookie, /Max-Age=604800/);
|
||||
const session = cookie.split(";")[0];
|
||||
assert.equal(
|
||||
(await request("/", { headers: { Cookie: session } })).status,
|
||||
200,
|
||||
);
|
||||
const logout = await request("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { Cookie: session, Origin: origin },
|
||||
});
|
||||
assert.equal(logout.status, 200);
|
||||
assert.match(logout.headers.get("set-cookie")!, /Max-Age=0/);
|
||||
for (let i = 0; i < 21; i++) await postCode("invalid");
|
||||
assert.equal((await postCode(code)).status, 429);
|
||||
}
|
||||
console.log(
|
||||
`Authentication ${command} passed: protected routes/data/images, signed sessions, all documents, assets, SVG and 404; no secrets displayed`,
|
||||
);
|
||||
}
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
error instanceof Error ? error.message : "Authentication command failed",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { build } from "esbuild";
|
||||
await build({
|
||||
entryPoints: ["scripts/auth-cli.ts"],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
format: "cjs",
|
||||
outfile: ".next/standalone/apps/docs/auth-cli.cjs",
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { build } from "esbuild";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
const dir = await mkdtemp(join(tmpdir(), "tabularium-auth-tests-"));
|
||||
try {
|
||||
const file = join(dir, "auth.test.cjs");
|
||||
await build({
|
||||
entryPoints: ["tests/auth.test.ts"],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
format: "cjs",
|
||||
outfile: file,
|
||||
});
|
||||
const result = spawnSync(process.execPath, ["--test", file], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function Navigation({
|
||||
key={d.slug}
|
||||
>
|
||||
{d.title}
|
||||
{d.slug.includes("draft") && (
|
||||
{sectionFor(d.slug).label.includes("草案") && (
|
||||
<span className="tiny-dot" aria-label="草案" />
|
||||
)}
|
||||
</Link>
|
||||
@@ -131,6 +131,22 @@ export function Shell({
|
||||
<span>搜索文档…</span>
|
||||
<kbd>⌘ K</kbd>
|
||||
</button>
|
||||
<button
|
||||
className="logout-button"
|
||||
aria-label="退出登录"
|
||||
onClick={async () => {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
// Drop cached private document data when ending the session.
|
||||
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
|
||||
window.location.assign("/login");
|
||||
}
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</header>
|
||||
<div className="mobile-bar">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { chmodSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { authId, matchingStep, type AuthConfig } from "./auth";
|
||||
|
||||
function database() {
|
||||
const configFile = process.env.AUTH_CONFIG_FILE;
|
||||
if (!configFile) throw new Error("Authentication is not configured");
|
||||
const file = join(dirname(configFile), "login-state.sqlite");
|
||||
const db = new DatabaseSync(file);
|
||||
chmodSync(file, 0o600);
|
||||
db.exec(`PRAGMA busy_timeout=5000;
|
||||
CREATE TABLE IF NOT EXISTS login_state (
|
||||
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0, last_step INTEGER NOT NULL DEFAULT -1
|
||||
);`);
|
||||
return db;
|
||||
}
|
||||
|
||||
// SQLite's write transaction serializes attempts across workers and restarts.
|
||||
export function consumeCode(
|
||||
config: AuthConfig,
|
||||
code: string,
|
||||
now = Date.now(),
|
||||
): "ok" | "invalid" | "limited" {
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
const id = authId(config);
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO login_state(id,window_start) VALUES (?,?)",
|
||||
).run(id, now);
|
||||
const state = db
|
||||
.prepare("SELECT * FROM login_state WHERE id=?")
|
||||
.get(id) as {
|
||||
window_start: number;
|
||||
attempts: number;
|
||||
last_step: number;
|
||||
};
|
||||
const expired = now - state.window_start >= 300000;
|
||||
const attempts = expired ? 0 : state.attempts;
|
||||
let result: "ok" | "invalid" | "limited" = "limited";
|
||||
if (attempts < 20) {
|
||||
db.prepare(
|
||||
"UPDATE login_state SET attempts=?,window_start=? WHERE id=?",
|
||||
).run(attempts + 1, expired ? now : state.window_start, id);
|
||||
const step = matchingStep(config.secret, code, now);
|
||||
result = step !== null && step > state.last_step ? "ok" : "invalid";
|
||||
if (result === "ok")
|
||||
db.prepare("UPDATE login_state SET last_step=? WHERE id=?").run(
|
||||
step!,
|
||||
id,
|
||||
);
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
db.exec("ROLLBACK");
|
||||
} catch {
|
||||
/* Transaction may not have started. */
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeStore() {
|
||||
const db = database();
|
||||
db.close();
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export const SESSION_COOKIE = "tabularium_session";
|
||||
export const SESSION_SECONDS = 7 * 24 * 60 * 60;
|
||||
export type AuthConfig = { secret: string; jwtKey: string };
|
||||
export function authConfig(): AuthConfig | null {
|
||||
const path = process.env.AUTH_CONFIG_FILE;
|
||||
if (!path) return null;
|
||||
let value: AuthConfig;
|
||||
try {
|
||||
value = JSON.parse(readFileSync(path, "utf8")) as AuthConfig;
|
||||
if (
|
||||
!/^[A-Z2-7]{32}$/.test(value.secret) ||
|
||||
!/^[a-f0-9]{64}$/.test(value.jwtKey)
|
||||
)
|
||||
throw new Error();
|
||||
} catch {
|
||||
throw new Error("Invalid authentication configuration");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
export function base32(bytes: Buffer) {
|
||||
let bits = 0,
|
||||
value = 0,
|
||||
output = "";
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
output += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[(value >>> bits) & 31];
|
||||
}
|
||||
}
|
||||
if (bits)
|
||||
output += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[(value << (5 - bits)) & 31];
|
||||
return output;
|
||||
}
|
||||
export function newAuthConfig(): AuthConfig {
|
||||
return {
|
||||
secret: base32(randomBytes(20)),
|
||||
jwtKey: randomBytes(32).toString("hex"),
|
||||
};
|
||||
}
|
||||
export function authId(config: AuthConfig) {
|
||||
return createHash("sha256").update(config.jwtKey).digest("hex");
|
||||
}
|
||||
export function totp(secret: string, step: number, digits = 6) {
|
||||
let bits = 0,
|
||||
value = 0;
|
||||
const bytes: number[] = [];
|
||||
for (const char of secret) {
|
||||
const index = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(char);
|
||||
if (index < 0) throw Error("Invalid TOTP key");
|
||||
value = (value << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
bytes.push((value >>> bits) & 255);
|
||||
}
|
||||
}
|
||||
const counter = Buffer.alloc(8);
|
||||
counter.writeBigUInt64BE(BigInt(step));
|
||||
const hmac = createHmac("sha1", Buffer.from(bytes)).update(counter).digest();
|
||||
return String(
|
||||
(hmac.readUInt32BE(hmac[19] & 15) & 0x7fffffff) % 10 ** digits,
|
||||
).padStart(digits, "0");
|
||||
}
|
||||
export function matchingStep(secret: string, code: string, now = Date.now()) {
|
||||
if (!/^\d{6}$/.test(code)) return null;
|
||||
const current = Math.floor(now / 30000);
|
||||
for (const step of [current, current - 1, current + 1])
|
||||
if (timingSafeEqual(Buffer.from(totp(secret, step)), Buffer.from(code)))
|
||||
return step;
|
||||
return null;
|
||||
}
|
||||
export function signSession(
|
||||
config: AuthConfig,
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
lifetime = SESSION_SECONDS,
|
||||
) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
||||
).toString("base64url");
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
iss: "tabularium",
|
||||
aud: "tabularium-web",
|
||||
sub: "shared",
|
||||
iat: now,
|
||||
exp: now + lifetime,
|
||||
jti: randomBytes(16).toString("hex"),
|
||||
}),
|
||||
).toString("base64url");
|
||||
const content = header + "." + payload;
|
||||
return (
|
||||
content +
|
||||
"." +
|
||||
createHmac("sha256", Buffer.from(config.jwtKey, "hex"))
|
||||
.update(content)
|
||||
.digest("base64url")
|
||||
);
|
||||
}
|
||||
export function validSession(
|
||||
token: string | undefined,
|
||||
config: AuthConfig,
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
) {
|
||||
try {
|
||||
if (!token || token.length > 2048) return false;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return false;
|
||||
const [header, payload, signature] = parts;
|
||||
const h = JSON.parse(Buffer.from(header, "base64url").toString());
|
||||
if (h.alg !== "HS256" || h.typ !== "JWT") return false;
|
||||
const expected = createHmac("sha256", Buffer.from(config.jwtKey, "hex"))
|
||||
.update(header + "." + payload)
|
||||
.digest();
|
||||
const actual = Buffer.from(signature, "base64url");
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||
return false;
|
||||
const p = JSON.parse(Buffer.from(payload, "base64url").toString());
|
||||
return (
|
||||
p.iss === "tabularium" &&
|
||||
p.aud === "tabularium-web" &&
|
||||
p.sub === "shared" &&
|
||||
Number.isInteger(p.exp) &&
|
||||
Number.isInteger(p.iat) &&
|
||||
p.iat <= now + 30 &&
|
||||
p.exp > now &&
|
||||
p.exp > p.iat &&
|
||||
p.exp - p.iat <= SESSION_SECONDS
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function sessionCookie(token: string, clear = false) {
|
||||
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_SECONDS}${process.env.AUTH_COOKIE_SECURE === "false" ? "" : "; Secure"}`;
|
||||
}
|
||||
export function safeReturn(value: unknown) {
|
||||
return typeof value === "string" &&
|
||||
value.startsWith("/") &&
|
||||
!value.startsWith("//") &&
|
||||
!/[\\\r\n]/.test(value) &&
|
||||
!value.startsWith("/login")
|
||||
? value
|
||||
: "/";
|
||||
}
|
||||
|
||||
export function isLoginRedirect(value: string | null, base: string) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const target = new URL(value, base);
|
||||
return (
|
||||
target.origin === new URL(base).origin &&
|
||||
(target.pathname === "/login" ||
|
||||
/^\/_next\/data\/[^/]+\/login\.json$/.test(target.pathname))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ export function sectionFor(slug: string): Section {
|
||||
if (slug.includes("endor-concepts"))
|
||||
return {
|
||||
group: "架构设计",
|
||||
label: "概念草案",
|
||||
label: "系统架构",
|
||||
symbol: "◇",
|
||||
description: "从全球入口、区域运行时到对象数据,理解 Endor 的概念拓扑。",
|
||||
description: "从全球入口、区域运行时到对象数据,了解 Endor 的系统架构。",
|
||||
};
|
||||
if (slug.includes("mounting"))
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authConfig, sessionCookie, signSession } from "@/lib/auth";
|
||||
import { consumeCode } from "@/lib/auth-store";
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).end();
|
||||
}
|
||||
try {
|
||||
const origin = req.headers.origin;
|
||||
if (
|
||||
!origin ||
|
||||
new URL(origin).host !== req.headers.host ||
|
||||
req.headers["sec-fetch-site"] === "cross-site"
|
||||
)
|
||||
return res.status(403).json({ error: "请求来源不受信任" });
|
||||
const config = authConfig();
|
||||
if (!config)
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "尚未配置动态码登录,请联系管理员" });
|
||||
const code = typeof req.body?.code === "string" ? req.body.code.trim() : "";
|
||||
const result = await consumeCode(config, code);
|
||||
if (result === "limited") {
|
||||
res.setHeader("Retry-After", "300");
|
||||
return res.status(429).json({ error: "尝试次数过多,请五分钟后再试" });
|
||||
}
|
||||
if (result !== "ok")
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "动态码不正确或已使用,请等待下一组动态码" });
|
||||
res.setHeader("Set-Cookie", sessionCookie(signSession(config)));
|
||||
return res.status(200).json({ ok: true });
|
||||
} catch {
|
||||
return res.status(503).json({ error: "登录服务暂时不可用,请稍后重试" });
|
||||
}
|
||||
}
|
||||
export const config = { api: { bodyParser: { sizeLimit: "1kb" } } };
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { sessionCookie } from "@/lib/auth";
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).end();
|
||||
}
|
||||
try {
|
||||
if (
|
||||
!req.headers.origin ||
|
||||
new URL(req.headers.origin).host !== req.headers.host ||
|
||||
req.headers["sec-fetch-site"] === "cross-site"
|
||||
)
|
||||
return res.status(403).end();
|
||||
} catch {
|
||||
return res.status(403).end();
|
||||
}
|
||||
res.setHeader("Set-Cookie", sessionCookie("", true));
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export default function DocPage({
|
||||
</div>
|
||||
<div className="article-meta">
|
||||
<span className="eyebrow">{section.group}</span>
|
||||
{doc.slug.includes("draft") && (
|
||||
{section.label.includes("草案") && (
|
||||
<span className="draft-badge">讨论草案</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -183,7 +183,7 @@ export default function DocPage({
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
className={
|
||||
doc.slug.includes("draft")
|
||||
section.label.includes("草案")
|
||||
? "callout callout-draft"
|
||||
: "callout"
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export default function DocPage({
|
||||
<div className="toc-note">
|
||||
<span className="status-dot" /> {section.label}
|
||||
<p>
|
||||
{doc.slug.includes("draft")
|
||||
{section.label.includes("草案")
|
||||
? "这里记录正在讨论的设计,尚不代表现有实现。"
|
||||
: "每一次理解,都成为下一步的起点。"}
|
||||
</p>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function Home({
|
||||
<p className="card-description">{section.description}</p>
|
||||
<div className="card-bottom">
|
||||
<span>{section.label}</span>
|
||||
{doc.slug.includes("draft") && (
|
||||
{section.label.includes("草案") && (
|
||||
<span className="draft-badge">草案</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Brand } from "@/components/Shell";
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(result.error || "登录没有完成");
|
||||
setCode("");
|
||||
return;
|
||||
}
|
||||
const next = router.query.next;
|
||||
window.location.assign(
|
||||
typeof next === "string" &&
|
||||
next.startsWith("/") &&
|
||||
!next.startsWith("//") &&
|
||||
!/[\\\r\n]/.test(next) &&
|
||||
!next.startsWith("/login")
|
||||
? next
|
||||
: "/",
|
||||
);
|
||||
} catch {
|
||||
setError("暂时无法连接,请稍后重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>动态码登录 · Tabularium</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</Head>
|
||||
<main className="auth-page">
|
||||
<div className="auth-brand">
|
||||
<Brand />
|
||||
</div>
|
||||
<form className="auth-card" onSubmit={submit}>
|
||||
<p className="eyebrow">PRIVATE KNOWLEDGE ARCHIVE</p>
|
||||
<h1>输入动态码</h1>
|
||||
<p className="auth-description">
|
||||
打开验证器,输入当前的 6 位动态码。
|
||||
</p>
|
||||
<label htmlFor="auth-code">动态码</label>
|
||||
<input
|
||||
id="auth-code"
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
|
||||
placeholder="000000"
|
||||
aria-describedby="auth-error"
|
||||
/>
|
||||
<div className="auth-error" id="auth-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
<button type="submit" disabled={busy || code.length !== 6}>
|
||||
{busy ? "正在验证…" : "进入文档"}
|
||||
</button>
|
||||
<p className="auth-footnote">尚未绑定验证器?请联系管理员。</p>
|
||||
</form>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const headers = {
|
||||
"Cache-Control": "private, no-store",
|
||||
"Referrer-Policy": "same-origin",
|
||||
};
|
||||
if (
|
||||
path === "/login" ||
|
||||
path === "/api/health" ||
|
||||
path === "/api/auth/login" ||
|
||||
path === "/api/auth/logout" ||
|
||||
path.startsWith("/_next/static/")
|
||||
) {
|
||||
return NextResponse.next({ headers });
|
||||
}
|
||||
let config;
|
||||
try {
|
||||
config = authConfig();
|
||||
} catch {
|
||||
/* Missing or invalid files fail closed. */
|
||||
}
|
||||
if (!config)
|
||||
return NextResponse.json(
|
||||
{ error: "登录服务尚未配置,请联系管理员" },
|
||||
{ status: 503, headers },
|
||||
);
|
||||
const authorization = request.headers.get("authorization");
|
||||
const bearer = authorization?.startsWith("Bearer ")
|
||||
? authorization.slice(7)
|
||||
: undefined;
|
||||
const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (validSession(token, config)) {
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
|
||||
try {
|
||||
if (
|
||||
!request.headers.get("origin") ||
|
||||
new URL(request.headers.get("origin")!).host !==
|
||||
request.headers.get("host") ||
|
||||
request.headers.get("sec-fetch-site") === "cross-site"
|
||||
)
|
||||
throw new Error();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "请求来源不受信任" },
|
||||
{ status: 403, headers },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.next({ headers });
|
||||
}
|
||||
if (path.startsWith("/api/") || !["GET", "HEAD"].includes(request.method)) {
|
||||
return NextResponse.json(
|
||||
{ error: "请先使用动态码登录" },
|
||||
{ status: 401, headers },
|
||||
);
|
||||
}
|
||||
const login = request.nextUrl.clone();
|
||||
login.pathname = "/login";
|
||||
login.search = "";
|
||||
login.searchParams.set("next", path + request.nextUrl.search);
|
||||
return NextResponse.redirect(login, { headers });
|
||||
}
|
||||
@@ -1447,3 +1447,88 @@ button {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
padding: 32px 20px;
|
||||
background: #fafcf9;
|
||||
}
|
||||
.auth-brand {
|
||||
margin: 0 auto 30px;
|
||||
}
|
||||
.auth-card {
|
||||
width: min(390px, calc(100vw - 40px));
|
||||
padding: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
box-shadow: 0 15px 45px #34523c06;
|
||||
}
|
||||
.auth-card h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 550;
|
||||
letter-spacing: -0.8px;
|
||||
margin: 22px 0 12px;
|
||||
}
|
||||
.auth-description {
|
||||
font-size: 13px;
|
||||
color: #78867a;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.auth-card label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #617566;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.auth-card input {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
border: 1px solid #d8e2d7;
|
||||
border-radius: 6px;
|
||||
font:
|
||||
24px ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-card input::placeholder {
|
||||
color: #bdc8bb;
|
||||
}
|
||||
.auth-error {
|
||||
font-size: 12px;
|
||||
color: #a64e3c;
|
||||
min-height: 38px;
|
||||
padding: 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.auth-card button[type="submit"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #2c7057;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
.auth-card button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.auth-footnote {
|
||||
font-size: 11px;
|
||||
color: #92a08f;
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.logout-button {
|
||||
font-size: 11px;
|
||||
color: #788575;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.logout-button:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
authConfig,
|
||||
base32,
|
||||
matchingStep,
|
||||
newAuthConfig,
|
||||
safeReturn,
|
||||
sessionCookie,
|
||||
signSession,
|
||||
totp,
|
||||
validSession,
|
||||
} from "../src/lib/auth";
|
||||
import { consumeCode } from "../src/lib/auth-store";
|
||||
|
||||
test("RFC6238 SHA1 vectors, format and narrow time window", () => {
|
||||
const secret = base32(Buffer.from("12345678901234567890"));
|
||||
for (const [time, expected] of [
|
||||
[59, "94287082"],
|
||||
[1111111109, "07081804"],
|
||||
[2000000000, "69279037"],
|
||||
] as const) {
|
||||
assert.equal(totp(secret, Math.floor(time / 30), 8), expected);
|
||||
}
|
||||
assert.equal(matchingStep(secret, "287082", 59000), 1);
|
||||
assert.equal(matchingStep(secret, "123", 59000), null);
|
||||
assert.equal(matchingStep(secret, totp(secret, 10), 1000000), null);
|
||||
});
|
||||
test("signed sessions reject tampering, expiration, future issuance and excessive lifetime", () => {
|
||||
const config = newAuthConfig();
|
||||
const token = signSession(config, 100);
|
||||
assert.equal(validSession(token, config, 101), true);
|
||||
assert.equal(validSession(token + "x", config, 101), false);
|
||||
assert.equal(validSession(token, newAuthConfig(), 101), false);
|
||||
assert.equal(validSession(signSession(config, 100, 1), config, 101), false);
|
||||
assert.equal(validSession(signSession(config, 200), config, 100), false);
|
||||
assert.equal(
|
||||
validSession(signSession(config, 100, 604801), config, 101),
|
||||
false,
|
||||
);
|
||||
assert.equal(validSession("a.b.c", config), false);
|
||||
assert.equal(safeReturn("//untrusted.example"), "/");
|
||||
assert.equal(safeReturn("/\\untrusted.example"), "/");
|
||||
assert.equal(safeReturn("/docs/example"), "/docs/example");
|
||||
assert.match(sessionCookie("test"), /HttpOnly; SameSite=Lax/);
|
||||
assert.match(sessionCookie("", true), /Max-Age=0/);
|
||||
});
|
||||
test("durable replay protection and rate limit survive reopened SQLite connections", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "tabularium-auth-state-"));
|
||||
const previous = process.env.AUTH_CONFIG_FILE;
|
||||
try {
|
||||
const config = newAuthConfig();
|
||||
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
|
||||
writeFileSync(process.env.AUTH_CONFIG_FILE, JSON.stringify(config), {
|
||||
mode: 0o600,
|
||||
});
|
||||
assert.deepEqual(authConfig(), config);
|
||||
const now = 2000000000000;
|
||||
const code = totp(config.secret, Math.floor(now / 30000));
|
||||
assert.equal(consumeCode(config, code, now), "ok");
|
||||
assert.equal(consumeCode(config, code, now), "invalid");
|
||||
for (let i = 0; i < 18; i++)
|
||||
assert.equal(consumeCode(config, "invalid", now), "invalid");
|
||||
assert.equal(consumeCode(config, code, now), "limited");
|
||||
const later = now + 300001;
|
||||
assert.equal(
|
||||
consumeCode(
|
||||
config,
|
||||
totp(config.secret, Math.floor(later / 30000)),
|
||||
later,
|
||||
),
|
||||
"ok",
|
||||
);
|
||||
writeFileSync(process.env.AUTH_CONFIG_FILE, "{}");
|
||||
assert.throws(() => authConfig());
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
|
||||
else process.env.AUTH_CONFIG_FILE = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user