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,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;
|
||||
});
|
||||
Reference in New Issue
Block a user