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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user