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
+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;
}
}