diff --git a/Dockerfile b/Dockerfile index 4e75403..9a3b297 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ FROM node:22-bookworm-slim -ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000 +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000 AUTH_CONFIG_FILE=/auth/config.json AUTH_COOKIE_SECURE=false WORKDIR /app # Context is the verified standalone artifact, never the source checkout. COPY --chown=node:node . . +RUN mkdir -p /auth && chown node:node /auth && chmod 700 /auth USER node EXPOSE 3000 HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=6 \ diff --git a/README.md b/README.md index 9a9f05a..527d161 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ uv sync --project tools/figures --locked pnpm dev ``` -访问 http://127.0.0.1:3000 。站点无外部字体或 CDN 资源。 +访问 http://127.0.0.1:3000 ,先使用验证器动态码登录。首次本地使用需按下文初始化私有认证配置;缺少配置时正文关闭访问。站点无外部字体或 CDN 资源。 ```text apps/docs/ Next.js Pages Router + React + TypeScript + Tailwind CSS @@ -47,14 +47,43 @@ main 推送通过 CI 验证后自动部署;Pull Request 只验证,不部署 拓扑检查:`uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v`。容器检查流程会运行同一检查。 +## 动态码登录与二维码 + +与 PROMPT 的参考流程一致:共享验证器的6位TOTP动态码,每30秒更新;成功登录的HttpOnly、SameSite=Lax会话有效7天。此方案是动态码单因素登录,不是密码加动态码的双因素组合。 + +正文、SVG、文档数据与API都经过认证,仅登录入口、登录所需代码资源和最小健康检查公开。20次/5分钟的共享尝试上限与已用时间步持久保存在SQLite中,跨进程和重启继续生效。缺失或损坏配置时拒绝访问,不自动降为公开站点。 + +生产认证文件位于服务器 `/opt/endorphin-tabularium-docs/private-auth`,目录0700、文件0600,只挂载进本项目容器。部署只初始化一次,后续更新保留同一验证器密钥与会话签名密钥。生产镜像、Git、CI日志和网页不包含密钥。 + +管理员通过SSH管理: + +```sh +sudo /opt/endorphin-tabularium-docs/auth qr +sudo /opt/endorphin-tabularium-docs/auth verify +``` + +`qr`只允许交互终端显示;`qr-png`用于通过SSH将PNG直接重定向到私有本地文件,不在CI中运行。二维码等同登录凭据,不放入本仓库或网页。只有明确执行 `auth rotate --confirm` 才更换密钥并使旧二维码、会话失效;重新显示或下载二维码不会轮换。 + +本地先构建CLI,再初始化独立的本地测试配置: + +```sh +pnpm build +AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json node apps/docs/.next/standalone/apps/docs/auth-cli.cjs init +AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=false pnpm dev +``` + +当前服务器入口为HTTP,与参考项目一致使用 `AUTH_COOKIE_SECURE=false`;配置HTTPS后应在独立production.env设置 `AUTH_COOKIE_SECURE=true`。HTTP本身不加密动态码和会话传输。 + +`pnpm test:auth`运行算法、会话、重放及限流测试。容器验证使用自己的匿名测试卷完成登录/并发重放/CSRF/限流测试,并在重启后检查状态仍存在。生产部署只运行无副作用的 `verify`,不会消耗用户动态码或触发生产限流。 + ## CI/CD 与缓存 `.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。 1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建脚本。 -2. 命中 `verified--<摘要>` 镜像时跳过依赖安装和 Next.js build,只做实际路由与资源冒烟检查。 +2. 命中 `verified--<摘要>` 镜像时跳过依赖安装和 Next.js build,只做认证、实际路由与资源冒烟检查。 3. 输入变化时运行 lint、类型检查、制图一致性检查与构建。pnpm store、uv 和 `.next/cache` 使用持久 Docker 卷,main 与每个 PR 独立;额外保留 Next.js SWC 下载缓存。 -4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。 +4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过动态码认证、重放/限流、重启持久化、健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。 5. 部署前检查是否仍为最新 main,使用 `--no-build` 启动,失败恢复上一版镜像。 根 README、工作流或部署配置等非构建输入变化不会触发 Next.js 重建;Markdown 正文变化会更新静态页面,仍需要一次增量构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。 diff --git a/apps/docs/package.json b/apps/docs/package.json index e757fba..0b11c46 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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", diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts new file mode 100644 index 0000000..719d830 --- /dev/null +++ b/apps/docs/scripts/auth-cli.ts @@ -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