Protect Tabularium with persistent authenticator-code login
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m28s

This commit is contained in:
2026-10-27 21:48:16 +08:00
parent 0a4057ac2a
commit 9fcfbdb694
31 changed files with 1549 additions and 60 deletions
+17 -1
View File
@@ -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
+72
View File
@@ -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();
}
+169
View File
@@ -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;
}
}
+2 -2
View File
@@ -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 {
+42
View File
@@ -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" } } };
+21
View File
@@ -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 });
}
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+85
View File
@@ -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>
</>
);
}
+65
View File
@@ -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 });
}
+85
View File
@@ -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);
}