diff --git a/apps/waterfall-wiki/.env.example b/apps/waterfall-wiki/.env.example index 93691be..e55e056 100644 --- a/apps/waterfall-wiki/.env.example +++ b/apps/waterfall-wiki/.env.example @@ -9,3 +9,14 @@ PG_PORT=18132 S3_PORT=18133 PROMPT_IMAGE=prompt:local RELEASE_SHA=development + +# Use HTTPS and secure cookies in production; HTTP acceptance requires explicit false. +AUTH_COOKIE_SECURE=true +ACCOUNT_PUBLIC_ORIGIN=http://8.211.154.134:18131 +# Enable only behind an ingress that overwrites x-forwarded-for. +ACCOUNT_TRUST_PROXY=false +SMTP_HOST=smtpdm.aliyun.com +SMTP_PORT=465 +SMTP_USERNAME=noreply@notice.entroia.com +SMTP_FROM=noreply@notice.entroia.com +SMTP_PASSWORD= diff --git a/apps/waterfall-wiki/ACCOUNT.md b/apps/waterfall-wiki/ACCOUNT.md new file mode 100644 index 0000000..03fbf3f --- /dev/null +++ b/apps/waterfall-wiki/ACCOUNT.md @@ -0,0 +1,46 @@ +# Email accounts + +`/account` offers registration, password login, email verification/resend, and logout. +`/api/account/session` returns the current account; other actions use same-origin POST. +Passwords require at least 12 characters and at most 256 UTF-8 bytes. Only verified +accounts can log in. Verification links expire after 30 minutes and are consumed once. +Session tokens expire after seven days; logout deletes the current token immediately. +Only token SHA-256 digests are stored. Passwords use individually salted scrypt. + +## Runtime configuration + +The existing production deployment reads `/etc/prompt.env` on ces-control. Set its +ownership to root and mode to 0600. The operator should update SMTP_PASSWORD there +through a secure remote session, without putting it in command arguments, repository, +CI output, release.env, or chat. Existing compose deployment reads this file without +copying credentials into the release directory. Do not run `docker compose config` +without `--quiet` against the production environment because interpolation shows secrets. +Docker administrators can inspect container environment; access to Docker is privileged. + +Set ACCOUNT_PUBLIC_ORIGIN to the exact browser origin and SMTP_HOST=smtpdm.aliyun.com, +SMTP_PORT=465, SMTP_USERNAME and SMTP_FROM=noreply@notice.entroia.com. TLS certificate +validation stays enabled. Missing SMTP configuration returns a generic service failure; +resend with the registration password can recover an account whose initial delivery failed. + +Keep AUTH_CONFIG_FILE configured for administrator access. Missing administrator +configuration fails closed. AUTH_DISABLED_FOR_TESTS is false by default and is enabled +only by the disposable legacy integration stack; never enable it in production. +Email account cookies never satisfy administrator authorization. With the current HTTP +acceptance origin use AUTH_COOKIE_SECURE=false; use true after enabling HTTPS. + +Limits persist in PostgreSQL: 10 logins and 3 registration/resend attempts per email per +15 minutes, plus 300 requests per 15 minutes globally. ACCOUNT_TRUST_PROXY defaults +false. Enable it only when a trusted ingress overwrites x-forwarded-for; this changes +the global cap to 60 per trusted client address. Do not enable it on the direct public port. +Limits count failures and successes. Registration/resend always give the same success +response for existing/unknown emails, and login uses a dummy KDF for unknown accounts. +The rate limit itself can reveal recent activity for a known email; it does not reveal +whether an account exists. No password recovery or account deletion is included. + +## Verification + +ces-control runs crypto/origin tests, PostgreSQL verification-token replay races, +expired/revoked sessions, concurrent limit consumption and unverified login rejection. +Production-auth exercises verify that account cookies cannot authorize legacy writes. +Actual SMTP delivery and visual acceptance at http://8.211.154.134:18131/account require +operator runtime configuration and the normal manually approved deployment. diff --git a/apps/waterfall-wiki/db/014_email_accounts.sql b/apps/waterfall-wiki/db/014_email_accounts.sql new file mode 100644 index 0000000..9af5875 --- /dev/null +++ b/apps/waterfall-wiki/db/014_email_accounts.sql @@ -0,0 +1,22 @@ +BEGIN; +CREATE TABLE account_users ( + id uuid PRIMARY KEY, + email text NOT NULL UNIQUE, + password_hash text NOT NULL, + verified_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE account_tokens ( + digest text PRIMARY KEY, + user_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE, + kind text NOT NULL CHECK (kind IN ('verify','session')), + expires_at timestamptz NOT NULL +); +CREATE INDEX account_tokens_expiry ON account_tokens(expires_at); +CREATE TABLE account_limits ( + key text PRIMARY KEY, + attempts integer NOT NULL, + window_start timestamptz NOT NULL +); +INSERT INTO schema_migrations(version) VALUES ('014_email_accounts'); +COMMIT; diff --git a/apps/waterfall-wiki/package.json b/apps/waterfall-wiki/package.json index f065a77..80f258c 100644 --- a/apps/waterfall-wiki/package.json +++ b/apps/waterfall-wiki/package.json @@ -10,7 +10,7 @@ "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 tests/auth.test.ts tests/guidance.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 tests/account.test.ts tests/guidance.test.ts", "test:smoke": "node scripts/smoke.mjs", "storage:prepare": "tsx scripts/bootstrap.ts", "content:publish-learning": "tsx scripts/publish-learning-series.ts", @@ -23,6 +23,7 @@ "lucide-react": "1.61.0", "mdast-util-from-markdown": "2.1.0", "next": "16.4.2", + "nodemailer": "10.2.1", "pg": "8.23.1", "playwright-core": "1.56.1", "qrcode": "1.5.4", @@ -37,6 +38,7 @@ "@tailwindcss/postcss": "4.3.3", "@types/formidable": "3.5.1", "@types/node": "24.19.2", + "@types/nodemailer": "7.0.4", "@types/pg": "8.23.1", "@types/qrcode": "1.5.6", "@types/react": "19.3.0", diff --git a/apps/waterfall-wiki/pnpm-lock.yaml b/apps/waterfall-wiki/pnpm-lock.yaml index abcd67a..e71a565 100644 --- a/apps/waterfall-wiki/pnpm-lock.yaml +++ b/apps/waterfall-wiki/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: next: specifier: 16.4.2 version: 16.4.2(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + nodemailer: + specifier: 10.2.1 + version: 10.2.1 pg: specifier: 8.23.1 version: 8.23.1 @@ -60,6 +63,9 @@ importers: '@types/node': specifier: 24.19.2 version: 24.19.2 + '@types/nodemailer': + specifier: 7.0.4 + version: 7.0.4 '@types/pg': specifier: 8.23.1 version: 8.23.1 @@ -99,42 +105,82 @@ packages: resolution: {integrity: sha512-l3uMeIlzWVgj/RTJTh5sKYKtQz1la3cvWLUPn1cwFH4UZpcqTH66SgJHCeB2FOE9bEAjOdbmCyPKt9aw867Fng==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-sesv2@3.1158.0': + resolution: {integrity: sha512-CEhnnEhIUeyfVSEB/Q8Gm2YOu/vF6ONrQ09e9t3imkvTTBVbpMNDhVyFBlBfHxNDD63kgJ1p39DPicx5Er+Sxw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.978.5': resolution: {integrity: sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.978.6': + resolution: {integrity: sha512-ykF/MiYoM15dgZwF/WLgjJXVbAK2wU6vjPigeEnOHwyUJ0rdlraO5uekecKNVPkvOnz+oju/5zljViQs1nI1FQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.76': resolution: {integrity: sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.77': + resolution: {integrity: sha512-CN6dQCDo32a8n2gvm2HBeqGV5BNI9noG96nTj5bGO2HWtt0Rucu/A8pcnJIqkzILOHSuilnCk0Do8/7h6SOXhQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.78': resolution: {integrity: sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.79': + resolution: {integrity: sha512-Netsu0nnmvP2HiN1ZFQBeUu+JehQIuubqz+j2kHDgeSE4HDzniCpIZ7RzoWABq+LTcbCi9W03hywifUagoVIWQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.21': resolution: {integrity: sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.22': + resolution: {integrity: sha512-x7/5h58cqOoZR0RWUZC7v/cJOADQR42iSIQkvU+00HVHR4jh8ZpqEgRPGf2zei59iH5qP40lL3bkq49PuWl71g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.83': resolution: {integrity: sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.84': + resolution: {integrity: sha512-G9Ty1uIV0laEvEp2KiMbEhsuBAjgaD3KYrwVjAM5VUxpcIuPBqQI5riA7/qzrnSgYiAKQbubbgkwcqCICKCIxg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.88': resolution: {integrity: sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.89': + resolution: {integrity: sha512-O8xj7/sanrnXfFHu/LUujVBiiwn5mknYenU7xt1w6+yvhR1QoDZWXLFh1gLITaFVPtb1jWgEirC+kFNw+//tyw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.76': resolution: {integrity: sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.77': + resolution: {integrity: sha512-sWA8Jm+ASzkTxoqLizHWRbt8Gj3yZCmZ0hYdvg0uIxuLzWseYcfrDFzaSnkRWlEWSJV38rAWFimdrN0J4NR7Pg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.20': resolution: {integrity: sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.21': + resolution: {integrity: sha512-KV2LM8fbYUhDzGEBnPwb+ISNfNlhmnpLS+y6A6VsJpg1EI9aUJ0+dRTxh2GrGdneFH0T4TiuboHxz8i6+M38JA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.82': resolution: {integrity: sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.83': + resolution: {integrity: sha512-iekYilmo2Re51fC7gMkEfZ7jFDXwehSCmXjDESaYQsdgYhsLQFG7rTYr3GBvRIyj+Fkda2NxbZX7hAZDaFD0JQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.81': resolution: {integrity: sha512-jfanv1pV0sJFZw6/DPiNjvK7B478god0zOum5yIWo+/+1Gy+CSXWpGBWx+3nBJyDvjcu/2kUMr4ADUp9BHH34Q==} engines: {node: '>=20.0.0'} @@ -143,6 +189,10 @@ packages: resolution: {integrity: sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.51': + resolution: {integrity: sha512-DJK4iGIHWU8Jh8ODMvqFYcX9yGBMXV4tSZAvqlHF5EDd8hbmF3eFO9KIWOnA7wcQjPzEkyMMW1/FgZ9RU8oimQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.49': resolution: {integrity: sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==} engines: {node: '>=20.0.0'} @@ -151,6 +201,10 @@ packages: resolution: {integrity: sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1157.0': + resolution: {integrity: sha512-AeAzcwWRP2uANO2TjFpKjRdvOCzgfIu6YQl9Ceibd6E0gnq5dfOzujFnq2aeqgbLg4wRtXbUZE4gekCCPrz/OQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.975.0': resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==} engines: {node: '>=20.0.0'} @@ -566,6 +620,10 @@ packages: resolution: {integrity: sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==} engines: {node: '>=18.0.0'} + '@smithy/core@3.37.0': + resolution: {integrity: sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.5.2': resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} engines: {node: '>=18.0.0'} @@ -578,6 +636,10 @@ packages: resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.13.0': + resolution: {integrity: sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.7.4': resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} engines: {node: '>=18.0.0'} @@ -705,6 +767,9 @@ packages: '@types/node@24.19.2': resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + '@types/nodemailer@7.0.4': + resolution: {integrity: sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==} + '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} @@ -1159,6 +1224,10 @@ packages: sass: optional: true + nodemailer@10.2.1: + resolution: {integrity: sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==} + engines: {node: '>=20.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1464,6 +1533,18 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/client-sesv2@3.1158.0': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/credential-provider-node': 3.972.89 + '@aws-sdk/signature-v4-multi-region': 3.996.49 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.13.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/core@3.978.5': dependencies: '@aws-sdk/types': 3.975.0 @@ -1475,6 +1556,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.978.6': + dependencies: + '@aws-sdk/types': 3.975.0 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.37.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.76': dependencies: '@aws-sdk/core': 3.978.5 @@ -1483,6 +1575,14 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.77': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.78': dependencies: '@aws-sdk/core': 3.978.5 @@ -1493,6 +1593,16 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.79': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.13.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.21': dependencies: '@aws-sdk/core': 3.978.5 @@ -1509,6 +1619,22 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.22': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/credential-provider-env': 3.972.77 + '@aws-sdk/credential-provider-http': 3.972.79 + '@aws-sdk/credential-provider-login': 3.972.84 + '@aws-sdk/credential-provider-process': 3.972.77 + '@aws-sdk/credential-provider-sso': 3.973.21 + '@aws-sdk/credential-provider-web-identity': 3.972.83 + '@aws-sdk/nested-clients': 3.997.51 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.83': dependencies: '@aws-sdk/core': 3.978.5 @@ -1518,6 +1644,15 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.84': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/nested-clients': 3.997.51 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.88': dependencies: '@aws-sdk/credential-provider-env': 3.972.76 @@ -1532,6 +1667,20 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.89': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.77 + '@aws-sdk/credential-provider-http': 3.972.79 + '@aws-sdk/credential-provider-ini': 3.973.22 + '@aws-sdk/credential-provider-process': 3.972.77 + '@aws-sdk/credential-provider-sso': 3.973.21 + '@aws-sdk/credential-provider-web-identity': 3.972.83 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.76': dependencies: '@aws-sdk/core': 3.978.5 @@ -1540,6 +1689,14 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.77': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.20': dependencies: '@aws-sdk/core': 3.978.5 @@ -1550,6 +1707,16 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.21': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/nested-clients': 3.997.51 + '@aws-sdk/token-providers': 3.1157.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.82': dependencies: '@aws-sdk/core': 3.978.5 @@ -1559,6 +1726,15 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.83': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/nested-clients': 3.997.51 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.81': dependencies: '@aws-sdk/core': 3.978.5 @@ -1579,6 +1755,17 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.51': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/signature-v4-multi-region': 3.996.49 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.13.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.49': dependencies: '@aws-sdk/types': 3.975.0 @@ -1595,6 +1782,15 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1157.0': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/nested-clients': 3.997.51 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/types@3.975.0': dependencies: '@smithy/types': 4.19.0 @@ -1852,6 +2048,11 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@smithy/core@3.37.0': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.5.2': dependencies: '@smithy/core': 3.36.2 @@ -1870,6 +2071,12 @@ snapshots: '@smithy/types': 4.19.0 tslib: 2.8.1 + '@smithy/node-http-handler@4.13.0': + dependencies: + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@smithy/signature-v4@5.7.4': dependencies: '@smithy/core': 3.36.2 @@ -1981,6 +2188,11 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/nodemailer@7.0.4': + dependencies: + '@aws-sdk/client-sesv2': 3.1158.0 + '@types/node': 24.19.2 + '@types/pg@8.23.1': dependencies: '@types/node': 24.19.2 @@ -2629,6 +2841,8 @@ snapshots: - '@types/node' - babel-plugin-macros + nodemailer@10.2.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/apps/waterfall-wiki/scripts/auth-cli.ts b/apps/waterfall-wiki/scripts/auth-cli.ts index 1b33925..4e0d141 100644 --- a/apps/waterfall-wiki/scripts/auth-cli.ts +++ b/apps/waterfall-wiki/scripts/auth-cli.ts @@ -47,7 +47,10 @@ async function main() { if (command === 'verify' || command === 'exercise') { const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'}); const buildId = readFileSync('.next/BUILD_ID','utf8').trim(); - for (const path of ['/','/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/index.json`]) { + assert.equal((await request('/')).status,200,'Public knowledge homepage'); + assert.equal((await request('/account')).status,200,'Public account page'); + assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights'); + for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/index.json`]) { const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination); } for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path); diff --git a/apps/waterfall-wiki/src/lib/account-crypto.ts b/apps/waterfall-wiki/src/lib/account-crypto.ts new file mode 100644 index 0000000..36fb4ac --- /dev/null +++ b/apps/waterfall-wiki/src/lib/account-crypto.ts @@ -0,0 +1,29 @@ +import { randomBytes, scrypt as derive, timingSafeEqual, createHash } from 'node:crypto'; +import { promisify } from 'node:util'; +const scrypt = promisify(derive); +export const ACCOUNT_COOKIE = 'wiki_account'; +export const SESSION_TTL = 7 * 86400; +export function normalizeEmail(value: unknown) { + if (typeof value !== 'string') return null; + const email = value.trim().toLowerCase(); + return email.length <= 254 && /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/i.test(email) ? email : null; +} +export function validPassword(value: unknown): value is string { return typeof value === 'string' && value.length >= 12 && Buffer.byteLength(value) <= 256; } +export async function hashPassword(password: string) { + const salt = randomBytes(16).toString('hex'); + const hash = await scrypt(password, salt, 64) as Buffer; + return `scrypt:${salt}:${hash.toString('hex')}`; +} +export async function checkPassword(password: string, stored: string) { + const [kind,salt,hex] = stored.split(':'); + if (kind !== 'scrypt' || !/^[a-f0-9]{32}$/.test(salt) || !/^[a-f0-9]{128}$/.test(hex)) return false; + return timingSafeEqual(await scrypt(password,salt,64) as Buffer,Buffer.from(hex,'hex')); +} +export function newToken() { return randomBytes(32).toString('hex'); } +export function tokenDigest(token: string) { return createHash('sha256').update(token).digest('hex'); } +export function accountCookie(token: string, clear = false) { + return `${ACCOUNT_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_TTL}${process.env.AUTH_COOKIE_SECURE === 'false' ? '' : '; Secure'}`; +} +export function trustedOrigin(request: Request) { + try { return request.headers.get('sec-fetch-site') !== 'cross-site' && new URL(request.headers.get('origin') || '').origin === new URL(process.env.ACCOUNT_PUBLIC_ORIGIN || request.url).origin; } catch { return false; } +} diff --git a/apps/waterfall-wiki/src/lib/account-handler.ts b/apps/waterfall-wiki/src/lib/account-handler.ts new file mode 100644 index 0000000..4a7dbbb --- /dev/null +++ b/apps/waterfall-wiki/src/lib/account-handler.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { normalizeEmail,validPassword, trustedOrigin,accountCookie } from './account-crypto'; +import { rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account'; +export const runtime = 'nodejs'; +const headers = {'Cache-Control':'private, no-store','Referrer-Policy':'no-referrer'}; +const json = (body: object,status=200) => NextResponse.json(body,{status,headers}); +type Context = {params: Promise<{action:string}>}; +export async function GET(request: Request,context: Context) { + if ((await context.params).action !== 'session') return json({error:'不存在'},404); + try { return json({user:await currentAccount(request)}); } catch { return json({error:'账户服务暂不可用'},503); } +} +export async function POST(request: Request,context: Context) { + if (!trustedOrigin(request)) return json({error:'请求来源不受信任'},403); + const {action} = await context.params; + if (!['register','resend','verify','login','logout'].includes(action)) return json({error:'不存在'},404); + try { + // Use a global cap by default: forwarding headers are untrusted unless the deployment explicitly enables a trusted ingress. + const ip = process.env.ACCOUNT_TRUST_PROXY === 'true' ? request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown' : 'global'; + if (!await rateLimit('request:'+ip,process.env.ACCOUNT_TRUST_PROXY === 'true' ? 60 : 300)) return json({error:'请求过于频繁,请稍后再试'},429); + if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; } + if (Number(request.headers.get('content-length') || 0)>4096) return json({error:'请求过大'},413); + const text=await request.text(); if(text.length>4096)return json({error:'请求过大'},413); + let body;try{body=JSON.parse(text);}catch{return json({error:'请求格式错误'},400);} + if (!body || typeof body!=='object') return json({error:'请求格式错误'},400); + if (action === 'verify') { + if(typeof body.token!=='string'||! /^[a-f0-9]{64}$/.test(body.token))return json({error:'验证链接无效或已过期'},400); + return await verifyAccount(body.token) ? json({ok:true,message:'邮箱已验证,请登录'}) : json({error:'验证链接无效或已过期'},400); + } + const email=normalizeEmail(body.email); + if(!email||!validPassword(body.password))return json({error:'请输入有效邮箱及 12–256 字节密码'},400); + if(!await rateLimit(action+':'+email,action==='login'?10:3))return json({error:'请求过于频繁,请 15 分钟后再试'},429); + if(action==='register')await registerAccount(email,body.password); + if(action==='resend')await resendVerification(email,body.password); + if(action==='login') { + const token=await loginAccount(email,body.password); + if(!token)return json({error:'邮箱或密码错误,或邮箱尚未验证'},401); + const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie(token));return r; + } + return json({ok:true,message:'若账户符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。'}); + }catch{return json({error:'账户服务暂不可用,请稍后重试'},503);} +} diff --git a/apps/waterfall-wiki/src/lib/account.ts b/apps/waterfall-wiki/src/lib/account.ts new file mode 100644 index 0000000..e7046f2 --- /dev/null +++ b/apps/waterfall-wiki/src/lib/account.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'node:crypto'; +import nodemailer from 'nodemailer'; +import { db, transaction } from './db'; +import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword } from './account-crypto'; +export async function rateLimit(key: string, limit: number) { + const result = await db().query(`INSERT INTO account_limits(key,attempts,window_start) VALUES ($1,1,now()) + ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN 1 ELSE account_limits.attempts+1 END, + window_start=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN now() ELSE account_limits.window_start END RETURNING attempts`,[tokenDigest(key)]); + return result.rows[0].attempts <= limit; +} +export async function sendVerification(email: string, token: string) { + const origin = process.env.ACCOUNT_PUBLIC_ORIGIN; + if (!origin || !process.env.SMTP_PASSWORD || !process.env.SMTP_USERNAME) throw Error('SMTP unavailable'); + const url = new URL('/account',origin); url.searchParams.set('verify',token); + const smtp = nodemailer.createTransport({host:process.env.SMTP_HOST || 'smtpdm.aliyun.com',port:Number(process.env.SMTP_PORT || 465),secure:true, + auth:{user:process.env.SMTP_USERNAME,pass:process.env.SMTP_PASSWORD},connectionTimeout:10000,socketTimeout:15000}); + await smtp.sendMail({from:process.env.SMTP_FROM || process.env.SMTP_USERNAME,to:email,subject:'验证你的知识站邮箱',text:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`}); +} +export async function registerAccount(email: string,password: string) { + const hash = await hashPassword(password); + const result = await db().query('INSERT INTO account_users(id,email,password_hash) VALUES ($1,$2,$3) ON CONFLICT(email) DO NOTHING RETURNING id',[randomUUID(),email,hash]); + if (result.rowCount) await issueVerification(result.rows[0].id,email); +} +async function issueVerification(id: string,email: string) { + const token = newToken(); + // Keep a previous unexpired link usable when a resend fails. + await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'verify',now()+interval '30 minutes')",[tokenDigest(token),id]); + try { await sendVerification(email,token); } catch { await db().query('DELETE FROM account_tokens WHERE digest=$1',[tokenDigest(token)]); throw Error('SMTP unavailable'); } +} +export async function resendVerification(email: string,password: string) { + const result = await db().query('SELECT * FROM account_users WHERE email=$1',[email]); + const user = result.rows[0]; + if (user && !user.verified_at && await checkPassword(password,user.password_hash)) await issueVerification(user.id,email); +} +export async function verifyAccount(token: string) { + return transaction(async client => { + const result = await client.query("DELETE FROM account_tokens WHERE digest=$1 AND kind='verify' AND expires_at>now() RETURNING user_id",[tokenDigest(token)]); + if (!result.rowCount) return false; + await client.query('UPDATE account_users SET verified_at=COALESCE(verified_at,now()) WHERE id=$1',[result.rows[0].user_id]); + await client.query("DELETE FROM account_tokens WHERE user_id=$1 AND kind='verify'",[result.rows[0].user_id]); + return true; + }); +} +export async function loginAccount(email: string,password: string) { + const result = await db().query('SELECT * FROM account_users WHERE email=$1',[email]); + const user = result.rows[0]; + // A fixed dummy hash makes unknown accounts perform the same expensive KDF. + const hash = user?.password_hash || 'scrypt:00000000000000000000000000000000:'+'0'.repeat(128); + if (!await checkPassword(password,hash) || !user?.verified_at) return null; + const token = newToken(); + await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'session',now()+$3*interval '1 second')",[tokenDigest(token),user.id,SESSION_TTL]); + return token; +} +export function requestToken(request: Request) { + const value = request.headers.get('cookie')?.split(';').map(s=>s.trim()).find(s=>s.startsWith(ACCOUNT_COOKIE+'='))?.slice(ACCOUNT_COOKIE.length+1); + return value && /^[a-f0-9]{64}$/.test(value) ? value : null; +} +export async function currentAccount(request: Request) { + const token = requestToken(request); if (!token) return null; + const result = await db().query(`SELECT u.id,u.email,u.verified_at FROM account_users u JOIN account_tokens t ON t.user_id=u.id WHERE t.digest=$1 AND t.kind='session' AND t.expires_at>now() AND u.verified_at IS NOT NULL`,[tokenDigest(token)]); + return result.rows[0] || null; +} +export async function logoutAccount(request: Request) { + const token = requestToken(request); + if (token) await db().query("DELETE FROM account_tokens WHERE digest=$1 AND kind='session'",[tokenDigest(token)]); +} diff --git a/apps/waterfall-wiki/src/pages/account.tsx b/apps/waterfall-wiki/src/pages/account.tsx new file mode 100644 index 0000000..7bccebc --- /dev/null +++ b/apps/waterfall-wiki/src/pages/account.tsx @@ -0,0 +1,24 @@ +'use client'; +import { useEffect, useState, type FormEvent } from 'react'; +export default function AccountPage() { + const [mode,setMode]=useState('login');const [user,setUser]=useState<{email:string}|null>(null); + const [message,setMessage]=useState('');const [busy,setBusy]=useState(false);const [token,setToken]=useState(''); + async function refresh(){try{const r=await fetch('/api/account/session');const data=await r.json();if(r.ok)setUser(data.user);else setMessage(data.error);}catch{setMessage('暂时无法连接账户服务');}} + useEffect(()=>{void refresh();const u=new URL(window.location.href);setToken(u.searchParams.get('verify')||'');if(u.searchParams.has('verify'))window.history.replaceState({},'', '/account');},[]); + async function action(name:string,body:object={}) {setBusy(true);setMessage('');try{const r=await fetch('/api/account/'+name,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();setMessage(d.error||d.message||'操作成功');if(r.ok){if(name==='verify')setToken('');await refresh();}}catch{setMessage('网络异常,请稍后重试');}finally{setBusy(false);}} + function submit(e:FormEvent){e.preventDefault();const f=new FormData(e.currentTarget);void action(mode,{email:f.get('email'),password:f.get('password')});} + const button={padding:'12px 20px',border:'1px solid #394b43',borderRadius:12,cursor:'pointer',background:'#173d30',color:'white'}; + return
+ ← 返回知识站

你的知识站账户

+

用邮箱保存你的身份。邮箱账户与管理员动态码权限独立。

+ {token&&

确认邮箱验证

点击后完成验证,再使用邮箱和密码登录。

} + {user?

已登录

{user.email}

邮箱已验证

:<> + +
+ + + 密码至少 12 个字符。注册后需先验证邮箱;验证链接有效期 30 分钟。 +
} +

{message}

+
; +} diff --git a/apps/waterfall-wiki/src/pages/api/account/[action].ts b/apps/waterfall-wiki/src/pages/api/account/[action].ts new file mode 100644 index 0000000..51a0826 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/account/[action].ts @@ -0,0 +1,12 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { GET, POST } from '@/lib/account-handler'; +export default async function handler(req: NextApiRequest,res: NextApiResponse) { + res.setHeader('Cache-Control','private, no-store');res.setHeader('Referrer-Policy','no-referrer'); + if(req.method!=='GET'&&req.method!=='POST'){res.setHeader('Allow','GET, POST');return res.status(405).end();} + const headers = new Headers();for(const [key,value] of Object.entries(req.headers))if(value)headers.set(key,Array.isArray(value)?value.join(','):value); + const origin=process.env.ACCOUNT_PUBLIC_ORIGIN || 'http://'+req.headers.host; + const request=new Request(new URL(req.url || '/',origin),{method:req.method,headers,...(req.method==='POST'?{body:JSON.stringify(req.body)}:{})}); + const response=await (req.method==='GET'?GET:POST)(request,{params:Promise.resolve({action:String(req.query.action)})}); + response.headers.forEach((v,k)=>res.setHeader(k,v));res.status(response.status).send(await response.text()); +} +export const config={api:{bodyParser:{sizeLimit:'4kb'}}}; diff --git a/apps/waterfall-wiki/src/proxy.ts b/apps/waterfall-wiki/src/proxy.ts index 333c33a..65450ad 100644 --- a/apps/waterfall-wiki/src/proxy.ts +++ b/apps/waterfall-wiki/src/proxy.ts @@ -3,12 +3,16 @@ import { authConfig, SESSION_COOKIE, validSession } from './lib/auth'; export function proxy(request: NextRequest) { const path = request.nextUrl.pathname; const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'}; - // Only the brand mark, login code assets and minimal health probes are public. Other images, documents, - // rewrites and /_next/data are protected along with pages and API endpoints. + // Public knowledge pages are read-only. Account handlers enforce their own origin and session checks. + const publicRead = ['GET','HEAD'].includes(request.method) && (path === '/' || path === '/protocol' || path.startsWith('/guide/') || path.startsWith('/api/guides/')); + if (publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}}); if (path === '/brand-mark.svg' || path === '/login' || path === '/api/auth/login' || path === '/api/auth/logout' || path === '/api/health' || path === '/api/ready' || path.startsWith('/_next/static/')) return NextResponse.next({headers:responseHeaders}); let config; try { config = authConfig(); } catch { return NextResponse.json({error:'登录服务配置不可用'}, {status:503,headers:responseHeaders}); } - if (!config) return NextResponse.next(); + if (!config) { + if (process.env.AUTH_DISABLED_FOR_TESTS === 'true' && process.env.AUTH_CONFIG_FILE === '') return NextResponse.next(); + return NextResponse.json({error:'管理员登录服务尚未配置'}, {status:503,headers:responseHeaders}); + } const bearer = request.headers.get('authorization'); const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : request.cookies.get(SESSION_COOKIE)?.value; if (validSession(token,config)) { diff --git a/apps/waterfall-wiki/tests/account-integration.ts b/apps/waterfall-wiki/tests/account-integration.ts new file mode 100644 index 0000000..7d0c3c4 --- /dev/null +++ b/apps/waterfall-wiki/tests/account-integration.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { db } from '../src/lib/db'; +import { hashPassword,newToken,tokenDigest } from '../src/lib/account-crypto'; +import { loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account'; +export async function verifyEmailAccounts() { + const id=randomUUID(), email=id+'@example.com', password='a secure test password'; + const request=(token:string)=>new Request('https://wiki.example',{headers:{cookie:'wiki_account='+token}}); + try { + await db().query('INSERT INTO account_users(id,email,password_hash) VALUES ($1,$2,$3)',[id,email,await hashPassword(password)]); + assert.equal(await loginAccount(email,password),null,'unverified accounts cannot log in'); + const verification=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '1 minute')",[tokenDigest(verification),id]); + const results=await Promise.all([verifyAccount(verification),verifyAccount(verification)]);assert.deepEqual(results.sort(),[false,true]); + assert.equal(await loginAccount(email,'incorrect password'),null); + const token=await loginAccount(email,password);assert.ok(token); + assert.equal((await currentAccount(request(token))).id,id); + await db().query("UPDATE account_tokens SET expires_at=now()-interval '1 second' WHERE digest=$1",[tokenDigest(token)]); + assert.equal(await currentAccount(request(token)),null,'expired sessions rejected'); + const fresh=await loginAccount(email,password);assert.ok(fresh);await logoutAccount(request(fresh));assert.equal(await currentAccount(request(fresh)),null,'logout revokes token'); + const key=randomUUID();assert.deepEqual(await Promise.all(Array.from({length:5},()=>rateLimit(key,3))).then(a=>a.sort()),[false,false,true,true,true]); + await db().query('DELETE FROM account_limits WHERE key=$1',[tokenDigest(key)]); + } finally {await db().query('DELETE FROM account_users WHERE id=$1',[id]);} +} diff --git a/apps/waterfall-wiki/tests/account.test.ts b/apps/waterfall-wiki/tests/account.test.ts new file mode 100644 index 0000000..a95aa68 --- /dev/null +++ b/apps/waterfall-wiki/tests/account.test.ts @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeEmail,validPassword,hashPassword,checkPassword,newToken,tokenDigest,accountCookie,trustedOrigin } from '../src/lib/account-crypto'; +test('canonical email and bounded passwords',()=>{assert.equal(normalizeEmail(' Test@Example.COM '),'test@example.com');assert.equal(normalizeEmail('a@b\r\nBcc:x@y.com'),null);assert.equal(validPassword('short'),false);assert.equal(validPassword('界'.repeat(100)),false);}); +test('salted passwords and constant size opaque token digests',async()=>{const a=await hashPassword('long enough password');const b=await hashPassword('long enough password');assert.notEqual(a,b);assert.equal(await checkPassword('long enough password',a),true);assert.equal(await checkPassword('wrong password',a),false);const t=newToken();assert.equal(t.length,64);assert.equal(tokenDigest(t).length,64);assert.notEqual(tokenDigest(t),t);}); +test('origin checks reject missing and cross-site origins',()=>{const r=(origin?:string,site?:string)=>new Request('https://wiki.example/api/account/login',{headers:{...(origin?{origin}:{}),...(site?{'sec-fetch-site':site}:{})}});assert.equal(trustedOrigin(r()),false);assert.equal(trustedOrigin(r('https://evil.example')),false);assert.equal(trustedOrigin(r('https://wiki.example','cross-site')),false);assert.equal(trustedOrigin(r('https://wiki.example')),true);}); +test('account cookie is distinct, HTTP-only and expires on logout',()=>{assert.match(accountCookie('abc'),/^wiki_account=abc;/);assert.match(accountCookie('abc'),/HttpOnly; SameSite=Lax/);assert.match(accountCookie('',true),/Max-Age=0/);}); diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index 717abf7..8c55418 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -12,6 +12,7 @@ import sharp from 'sharp'; import {db} from '../src/lib/db'; import {probe,run} from '../src/lib/probe'; import seriesExample from '../fixtures/autumn-journal.json'; +import {verifyEmailAccounts} from './account-integration'; import {verifyFlatNoteMigration} from './flat-note-migration'; import {staticEntries} from '../src/lib/explore'; import {publishLearningSeries} from '../src/lib/publish-learning-series'; @@ -27,6 +28,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi try{ let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready); for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route); + await verifyEmailAccounts(); await verifyFlatNoteMigration(); const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'}); const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag'); diff --git a/compose.yml b/compose.yml index cbf9df1..48f6f12 100644 --- a/compose.yml +++ b/compose.yml @@ -12,7 +12,15 @@ x-waterfall-wiki-environment: &waterfall-wiki-environment CHROMIUM_PATH: /usr/bin/chromium RENDER_ORIGIN: http://waterfall-wiki:3000 AUTH_CONFIG_FILE: ${AUTH_CONFIG_FILE:-} + AUTH_DISABLED_FOR_TESTS: ${AUTH_DISABLED_FOR_TESTS:-false} AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-true} + ACCOUNT_PUBLIC_ORIGIN: ${ACCOUNT_PUBLIC_ORIGIN:-http://8.211.154.134:18131} + ACCOUNT_TRUST_PROXY: ${ACCOUNT_TRUST_PROXY:-false} + SMTP_HOST: ${SMTP_HOST:-smtpdm.aliyun.com} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_USERNAME: ${SMTP_USERNAME:-noreply@notice.entroia.com} + SMTP_FROM: ${SMTP_FROM:-noreply@notice.entroia.com} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} x-waterfall-wiki: &waterfall-wiki image: ${WATERFALL_WIKI_IMAGE:-waterfall-wiki:local} diff --git a/scripts/integration.sh b/scripts/integration.sh index 857a956..4e65f87 100644 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -11,6 +11,8 @@ cleanup(){ code=$?; if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall trap cleanup EXIT # Random credentials exist only for this disposable stack; no host Node required. docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$TEST_ENV" +# Explicitly isolate legacy unauthenticated integration coverage; production fails closed. +printf '\nAUTH_DISABLED_FOR_TESTS=true\n' >> "$TEST_ENV" compose up -d --no-build --wait --wait-timeout 120 postgres objects compose run --rm --no-deps migrate compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki waterfall-wiki-worker