From 0c1ed2d29268cfbe269437383d12da5234b96db0 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 27 Oct 2026 00:12:12 +0800 Subject: [PATCH] Add SSH-provisioned TOTP login with JWT sessions --- .gitea/workflows/wiki.yml | 2 + Dockerfile | 2 +- apps/wiki/Dockerfile | 2 +- apps/wiki/db/007_dynamic_code_auth.sql | 9 + apps/wiki/package.json | 10 +- apps/wiki/pnpm-lock.yaml | 226 ++++++++++++++++++ apps/wiki/scripts/auth-cli.ts | 81 +++++++ apps/wiki/src/components/Shell.tsx | 2 +- apps/wiki/src/lib/auth-store.ts | 19 ++ apps/wiki/src/lib/auth.ts | 58 +++++ apps/wiki/src/pages/api/auth/login.ts | 20 ++ apps/wiki/src/pages/api/auth/logout.ts | 8 + apps/wiki/src/pages/api/media/[id].ts | 2 +- apps/wiki/src/pages/api/preview.ts | 2 +- .../src/pages/api/skills/street-people.ts | 2 +- apps/wiki/src/pages/login.tsx | 17 ++ apps/wiki/src/proxy.ts | 25 ++ apps/wiki/src/styles/globals.css | 3 + apps/wiki/tests/auth.test.ts | 46 ++++ compose.yml | 15 ++ docs/dynamic-code-login.md | 46 ++++ docs/monorepo.md | 4 +- scripts/auth-admin.sh | 15 ++ scripts/deploy-studio.sh | 2 +- scripts/deploy.sh | 14 +- scripts/infra.sh | 5 + scripts/integration.sh | 9 + 27 files changed, 627 insertions(+), 19 deletions(-) create mode 100644 apps/wiki/db/007_dynamic_code_auth.sql create mode 100644 apps/wiki/scripts/auth-cli.ts create mode 100644 apps/wiki/src/lib/auth-store.ts create mode 100644 apps/wiki/src/lib/auth.ts create mode 100644 apps/wiki/src/pages/api/auth/login.ts create mode 100644 apps/wiki/src/pages/api/auth/logout.ts create mode 100644 apps/wiki/src/pages/login.tsx create mode 100644 apps/wiki/src/proxy.ts create mode 100644 apps/wiki/tests/auth.test.ts create mode 100644 docs/dynamic-code-login.md create mode 100644 scripts/auth-admin.sh diff --git a/.gitea/workflows/wiki.yml b/.gitea/workflows/wiki.yml index 308cbab..1abefb7 100644 --- a/.gitea/workflows/wiki.yml +++ b/.gitea/workflows/wiki.yml @@ -9,6 +9,7 @@ on: - 'compose.yml' - 'scripts/deploy.sh' - 'scripts/infra.sh' + - 'scripts/auth-admin.sh' - 'scripts/integration.sh' - 'pnpm-workspace.yaml' pull_request: @@ -19,6 +20,7 @@ on: - 'compose.yml' - 'scripts/deploy.sh' - 'scripts/infra.sh' + - 'scripts/auth-admin.sh' - 'scripts/integration.sh' - 'pnpm-workspace.yaml' workflow_dispatch: diff --git a/Dockerfile b/Dockerfile index 3c3a09f..61cd7fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ FROM base AS runtime ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \ FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe COPY --from=build --chown=node:node /app/.next/standalone/ ./ -COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./ +COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./ COPY --from=build --chown=node:node /app/db/ ./db/ COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./ COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs diff --git a/apps/wiki/Dockerfile b/apps/wiki/Dockerfile index 3c3a09f..61cd7fc 100644 --- a/apps/wiki/Dockerfile +++ b/apps/wiki/Dockerfile @@ -27,7 +27,7 @@ FROM base AS runtime ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \ FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe COPY --from=build --chown=node:node /app/.next/standalone/ ./ -COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./ +COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./ COPY --from=build --chown=node:node /app/db/ ./db/ COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./ COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs diff --git a/apps/wiki/db/007_dynamic_code_auth.sql b/apps/wiki/db/007_dynamic_code_auth.sql new file mode 100644 index 0000000..ca1659b --- /dev/null +++ b/apps/wiki/db/007_dynamic_code_auth.sql @@ -0,0 +1,9 @@ +BEGIN; +CREATE TABLE IF NOT EXISTS auth_login_state ( + id text PRIMARY KEY, + attempts integer NOT NULL DEFAULT 0, + window_start timestamptz NOT NULL DEFAULT clock_timestamp(), + last_step bigint NOT NULL DEFAULT -1 +); +INSERT INTO schema_migrations(version) VALUES ('007_dynamic_code_auth') ON CONFLICT DO NOTHING; +COMMIT; diff --git a/apps/wiki/package.json b/apps/wiki/package.json index fd071c0..2e0696d 100644 --- a/apps/wiki/package.json +++ b/apps/wiki/package.json @@ -7,10 +7,10 @@ }, "scripts": { "dev": "next dev", - "build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && node scripts/prepare-standalone.mjs", + "build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs", "start": "next start", "typecheck": "next typegen && tsc --noEmit", - "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts", + "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts", "test:smoke": "node scripts/smoke.mjs", "storage:prepare": "tsx scripts/bootstrap.ts", "content:publish-learning": "tsx scripts/publish-learning-series.ts", @@ -21,22 +21,24 @@ "@aws-sdk/client-s3": "3.1155.0", "formidable": "3.5.4", "lucide-react": "1.61.0", + "mdast-util-from-markdown": "2.1.0", "next": "16.4.2", "pg": "8.23.1", "playwright-core": "1.56.1", + "qrcode": "1.5.4", "react": "19.3.1", "react-dom": "19.3.1", "react-markdown": "10.1.0", "remark-gfm": "4.0.1", "sharp": "0.35.5", - "zod": "4.6.7", - "mdast-util-from-markdown": "2.1.0" + "zod": "4.6.7" }, "devDependencies": { "@tailwindcss/postcss": "4.3.3", "@types/formidable": "3.5.1", "@types/node": "24.19.2", "@types/pg": "8.23.1", + "@types/qrcode": "1.5.6", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "esbuild": "0.28.2", diff --git a/apps/wiki/pnpm-lock.yaml b/apps/wiki/pnpm-lock.yaml index 9d6e89a..abcd67a 100644 --- a/apps/wiki/pnpm-lock.yaml +++ b/apps/wiki/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: playwright-core: specifier: 1.56.1 version: 1.56.1 + qrcode: + specifier: 1.5.4 + version: 1.5.4 react: specifier: 19.3.1 version: 19.3.1 @@ -60,6 +63,9 @@ importers: '@types/pg': specifier: 8.23.1 version: 8.23.1 + '@types/qrcode': + specifier: 1.5.6 + version: 1.5.6 '@types/react': specifier: 19.3.0 version: 19.3.0 @@ -702,6 +708,9 @@ packages: '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -719,6 +728,14 @@ packages: '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -733,6 +750,10 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001819: resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==} @@ -754,6 +775,16 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -769,6 +800,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -786,6 +821,12 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.26.0: resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} engines: {node: '>=10.13.0'} @@ -805,6 +846,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} @@ -814,6 +859,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -838,6 +887,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -923,6 +976,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -1105,9 +1162,25 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + pg-cloudflare@1.4.1: resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} @@ -1150,6 +1223,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.23: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} @@ -1177,6 +1254,11 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + react-dom@19.3.1: resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} peerDependencies: @@ -1204,6 +1286,13 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} @@ -1212,6 +1301,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + sharp@0.35.5: resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} @@ -1232,9 +1324,17 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -1307,6 +1407,13 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -1314,6 +1421,17 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} @@ -1869,6 +1987,10 @@ snapshots: pg-protocol: 1.16.1 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.19.2 + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -1883,6 +2005,12 @@ snapshots: '@ungap/structured-clone@1.4.0': {} + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + asap@2.0.6: {} bail@2.0.2: {} @@ -1891,6 +2019,8 @@ snapshots: bowser@2.14.1: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001819: {} ccount@2.0.1: {} @@ -1905,6 +2035,18 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} csstype@3.2.3: {} @@ -1913,6 +2055,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -1930,6 +2074,10 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + dijkstrajs@1.0.3: {} + + emoji-regex@8.0.0: {} + enhanced-resolve@5.26.0: dependencies: graceful-fs: 4.2.11 @@ -1970,6 +2118,11 @@ snapshots: extend@3.0.2: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + formidable@3.5.4: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -1979,6 +2132,8 @@ snapshots: fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + graceful-fs@4.2.11: {} hast-util-to-jsx-runtime@2.3.6: @@ -2018,6 +2173,8 @@ snapshots: is-decimal@2.0.1: {} + is-fullwidth-code-point@3.0.0: {} + is-hexadecimal@2.0.1: {} is-plain-obj@4.1.0: {} @@ -2073,6 +2230,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + longest-streak@3.1.0: {} lucide-react@1.61.0(@types/react@19.3.0)(react@19.3.1): @@ -2472,6 +2633,16 @@ snapshots: dependencies: wrappy: 1.0.2 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -2482,6 +2653,8 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + path-exists@4.0.0: {} + pg-cloudflare@1.4.1: optional: true @@ -2521,6 +2694,8 @@ snapshots: playwright-core@1.56.1: {} + pngjs@5.0.0: {} + postcss@8.5.23: dependencies: nanoid: 3.3.20 @@ -2545,6 +2720,12 @@ snapshots: property-information@7.2.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + react-dom@19.3.1(react@19.3.1): dependencies: react: 19.3.1 @@ -2604,10 +2785,16 @@ snapshots: mdast-util-to-markdown: 2.2.0 unified: 11.0.5 + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + scheduler@0.28.0: {} semver@7.8.5: {} + set-blocking@2.0.0: {} + sharp@0.35.5(@types/node@24.19.2): dependencies: '@img/colour': 1.1.0 @@ -2647,11 +2834,21 @@ snapshots: split2@4.2.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -2728,10 +2925,39 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} xtend@4.0.2: {} + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + zod@4.6.7: {} zwitch@2.0.4: {} diff --git a/apps/wiki/scripts/auth-cli.ts b/apps/wiki/scripts/auth-cli.ts new file mode 100644 index 0000000..7580a50 --- /dev/null +++ b/apps/wiki/scripts/auth-cli.ts @@ -0,0 +1,81 @@ +import { mkdirSync, writeFileSync, existsSync, renameSync, chmodSync, chownSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import QRCode from 'qrcode'; +import { authConfig, newAuthConfig, signSession, totp } from '../src/lib/auth'; +import { db } from '../src/lib/db'; + +async function main() { + const command = process.argv[2]; + const file = process.env.AUTH_CONFIG_FILE; + if (!file) throw Error('Set AUTH_CONFIG_FILE before running the server authentication tool'); + if (command === 'init' || command === 'rotate') { + if (command === 'rotate' && !process.argv.includes('--confirm')) throw Error('Rotation invalidates all authenticators and sessions; pass --confirm to proceed'); + if (command === 'init' && existsSync(file)) { authConfig(); console.log('Authentication configuration already exists; preserved'); return; } + mkdirSync(dirname(file),{recursive:true,mode:0o700}); + const temporary = file + '.' + process.pid + '.tmp'; + writeFileSync(temporary,JSON.stringify(newAuthConfig())+'\n',{mode:0o600,flag:'wx'}); + if (process.getuid?.() === 0) { chownSync(dirname(file),1000,1000); chownSync(temporary,1000,1000); } + chmodSync(dirname(file),0o700); renameSync(temporary,file); + console.log('Authentication configuration saved privately. Use the qr command in an SSH terminal to bind.'); return; + } + const config = authConfig(); if (!config) throw Error('Authentication is not configured'); + if (command === 'qr') { + if (!process.stdout.isTTY) throw Error('QR display requires an interactive SSH terminal (ssh -t, docker compose exec without -T). Never send QR output to CI logs.'); + const uri = 'otpauth://totp/waterfall-wiki:shared?'+new URLSearchParams({secret:config.secret,issuer:'waterfall-wiki',algorithm:'SHA1',digits:'6',period:'30'}); + console.log('Private binding QR — everyone scanning this uses the same authenticator key.'); + console.log(await QRCode.toString(uri,{type:'terminal',small:true,errorCorrectionLevel:'M'})); + return; + } + if (command === 'token') { + if (!process.stdout.isTTY) throw Error('Tokens may 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 server-side script path'); + const original = globalThis.fetch; const token = signSession(config,undefined,600); + // Only attach the credential to the local application, never to remote fetches. + 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') { + const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'}); + for (const path of ['/','/library/import','/skills/shadow-puppet/egret-example.png','/studio','/_next/data/auth-test/index.json']) { + const response = await request(path); assert.equal(response.status,307,path); assert.match(response.headers.get('location') || '',/\/login\?/); + } + for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path); + assert.equal((await request('/api/posts',{method:'POST',body:'{}'})).status,401); + assert.equal((await request('/api/feed',{headers:{'x-middleware-subrequest':'proxy:proxy:proxy:proxy:proxy'}})).status,401); + const page = await request('/login'); assert.equal(page.status,200); const html = await page.text(); assert.ok(html.includes('输入动态码')); assert.ok(!html.includes(config.secret)); + assert.equal((await request('/api/health')).status,200); + const headers = {Authorization:'Bearer '+signSession(config,undefined,600)}; + assert.equal((await request('/api/feed',{headers})).status,200); + assert.equal((await request('/api/feed',{headers:{Authorization:headers.Authorization+'x'}})).status,401); + if (command === 'exercise') { + const origin = new URL(base).origin; + const login = (code:string,customOrigin=origin) => request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json',Origin:customOrigin},body:JSON.stringify({code})}); + assert.equal((await login('000000','https://untrusted.example')).status,403); + const code = totp(config.secret,Math.floor(Date.now()/30000)); + const results = await Promise.all([login(code),login(code)]); + assert.deepEqual(results.map(r=>r.status).sort(),[200,401],'exactly one concurrent use succeeds'); + 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('/api/feed',{headers:{Cookie:session}})).status,200); + assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:session,Origin:'https://untrusted.example'}})).status,403); + 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/); + let status = 0; for (let i=0;i<21;i++) status=(await login('invalid')).status; assert.equal(status,429); + // Durable throttling remains even after this test process exits. + assert.equal((await login(code)).status,429); + } + console.log('Dynamic-code authentication '+command+' passed (no secrets displayed)'); return; + } + throw Error('Usage: auth-cli.cjs init | qr | rotate --confirm | token | run