commit e482f8860049f2cc1ada9e4ca12182a215e6ec05 Author: Codex Bot Date: Wed Nov 4 23:35:52 2026 +0800 Bootstrap Foundry Core with project-scoped Google sign-in diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c879d5b --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +PUBLIC_ORIGIN=https://api.example.com +DATABASE_URL=postgresql://foundry:replace-me@postgres.internal:5432/foundry_auth +GOOGLE_CLIENT_ID=replace-me.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=replace-me +FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}' +HOST=127.0.0.1 +PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a2a221 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# 造物工坊 · Foundry Core + +共享后端的第一版提供 Google 登录。各项目通过同一个 HTTPS API 登录,在私有 PostgreSQL 的认证库中维护独立账号和会话。项目业务库可分别配置独立的数据库和角色。 + +## 服务端配置 + +1. 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写 `https:///auth/google/callback`。所有项目信任同一个客户端,因此共享 Google 同意页面的应用名称。 +2. 在服务器私网准备 PostgreSQL。在认证库执行一次 `db/001-auth.sql`(使用 `psql -v ON_ERROR_STOP=1`),给运行账号授予这四张表所需的查询及增删改权限。 +3. 按 `.env.example` 注入运行环境变量。每个项目配置一个固定的前端回调地址,本地开发可用 localhost。将 Google 客户端密钥和数据库连接串存入服务器的密钥管理设施。 +4. 在已确认的远程 runner 上安装生产依赖。运行 Node.js 22 或以上版本,通过 HTTPS 反向代理转发至服务监听地址。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。 +5. 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(`oauth_attempts`、`login_grants`、`project_sessions`)中 `expires_at <= now()` 的记录。 + +## 前端接入 + +1. 在浏览器用 Web Crypto 生成 32 字节随机值,编码为无填充 Base64URL,作为 verifier 暂存在当前 tab 的 sessionStorage。对 verifier 字符串做 SHA-256 并用相同编码得到 challenge。 +2. 顶层导航到 `GET /projects//auth/google?challenge=`。Google 回到共享 API 后,API 将浏览器重定向至已配置的项目回调。 +3. 回调页从 URL fragment 读取 `code` 或 `error`,用 history.replaceState 清掉 fragment。收到 code 时,向 `POST /projects//auth/exchange` 发送 `X-Foundry-Code` 和 `X-Foundry-Verifier` 请求头,随后清理暂存的 verifier。code 有效期一分钟,仅可使用一次。 +4. 将返回的八小时会话凭证保存在内存中,使用 `Authorization: Bearer ` 调用 `GET /projects//auth/me`。通过 `POST /projects//auth/logout` 撤销会话并清理本地凭证。刷新页面后,内存凭证需要通过重新登录获取。 + +同一个 Google 账号首次进入不同项目时,会分别创建用户 ID。会话绑定其签发项目。Google 账号的标识 `sub` 用于认领同一项目的既有账号,邮箱只作为资料字段。当前注册模式为开放注册。 + +## 验收 + +用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。 diff --git a/db/001-auth.sql b/db/001-auth.sql new file mode 100644 index 0000000..7cd28d9 --- /dev/null +++ b/db/001-auth.sql @@ -0,0 +1,42 @@ +BEGIN; + +CREATE TABLE project_accounts ( + project text NOT NULL, + id uuid NOT NULL, + google_subject text NOT NULL, + email text, + PRIMARY KEY (project, id), + UNIQUE (project, google_subject) +); + +CREATE TABLE oauth_attempts ( + state_hash text PRIMARY KEY, + browser_hash text NOT NULL, + project text NOT NULL, + frontend_challenge text NOT NULL, + google_verifier text NOT NULL, + expires_at timestamptz NOT NULL +); + +CREATE TABLE login_grants ( + token_hash text PRIMARY KEY, + project text NOT NULL, + account_id uuid NOT NULL, + frontend_challenge text NOT NULL, + expires_at timestamptz NOT NULL, + FOREIGN KEY (project, account_id) REFERENCES project_accounts (project, id) ON DELETE CASCADE +); + +CREATE TABLE project_sessions ( + token_hash text PRIMARY KEY, + project text NOT NULL, + account_id uuid NOT NULL, + expires_at timestamptz NOT NULL, + FOREIGN KEY (project, account_id) REFERENCES project_accounts (project, id) ON DELETE CASCADE +); + +CREATE INDEX oauth_attempts_expiry ON oauth_attempts (expires_at); +CREATE INDEX login_grants_expiry ON login_grants (expires_at); +CREATE INDEX project_sessions_expiry ON project_sessions (expires_at); + +COMMIT; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e6c27fb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,164 @@ +{ + "name": "foundry-core", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "foundry-core", + "version": "0.1.0", + "dependencies": { + "pg": "8.16.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..33983be --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "foundry-core", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=22" }, + "scripts": { "start": "node src/server.js" }, + "dependencies": { "pg": "8.16.3" } +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..1ae7de4 --- /dev/null +++ b/src/server.js @@ -0,0 +1,199 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; +import pg from 'pg'; + +function required(name) { + if (!process.env[name]) throw new Error(`Missing ${name}`); + return process.env[name]; +} + +const publicOrigin = new URL(required('PUBLIC_ORIGIN')); +if (publicOrigin.protocol !== 'https:' || publicOrigin.href !== publicOrigin.origin + '/') { + throw new Error('PUBLIC_ORIGIN must be an HTTPS origin'); +} +const redirectUri = new URL('/auth/google/callback', publicOrigin).href; +const clientId = required('GOOGLE_CLIENT_ID'); +const clientSecret = required('GOOGLE_CLIENT_SECRET'); +const db = new pg.Pool({ connectionString: required('DATABASE_URL') }); +db.on('error', error => console.error('Database pool error', error.code)); + +const projects = new Map(); +for (const [name, value] of Object.entries(JSON.parse(required('FOUNDRY_PROJECTS')))) { + const callback = new URL(value); + const local = callback.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(callback.hostname); + if (!/^[a-z][a-z0-9-]*$/.test(name) || (!local && callback.protocol !== 'https:') || + callback.username || callback.password || callback.hash) { + throw new Error(`Invalid project configuration: ${name}`); + } + projects.set(name, callback); +} + +const randomToken = () => randomBytes(32).toString('base64url'); +const digest = value => createHash('sha256').update(value).digest('base64url'); +const isToken = value => typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value); +const cookieName = state => `__Host-foundry-oauth-${state}`; + +function reply(res, status, payload) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(payload === undefined ? undefined : JSON.stringify(payload)); +} + +function redirect(res, destination) { + res.writeHead(302, { Location: destination }); + res.end(); +} + +async function googleJson(url, options) { + const response = await fetch(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(10000) }); + if (!response.ok) throw new Error('Google request failed'); + return response.json(); +} + +async function start(res, url, project) { + const challenge = url.searchParams.get('challenge'); + if (!isToken(challenge)) return reply(res, 400, { error: 'invalid_challenge' }); + + const state = randomToken(); + const browser = randomToken(); + const verifier = randomToken(); + await db.query(` + INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') + `, [digest(state), digest(browser), project, challenge, verifier]); + + const google = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + google.search = new URLSearchParams({ + client_id: clientId, redirect_uri: redirectUri, response_type: 'code', + scope: 'openid email', state, code_challenge: digest(verifier), code_challenge_method: 'S256', + prompt: 'select_account', + }); + res.setHeader('Set-Cookie', `${cookieName(state)}=${browser}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600`); + redirect(res, google.href); +} + +async function callback(req, res, url) { + const state = url.searchParams.get('state'); + if (!isToken(state)) return reply(res, 400, { error: 'invalid_state' }); + const browserCookie = cookieName(state); + const browser = req.headers.cookie?.split(';').map(part => part.trim()) + .find(part => part.startsWith(browserCookie + '='))?.slice(browserCookie.length + 1); + if (!isToken(browser)) return reply(res, 400, { error: 'invalid_state' }); + + const { rows: [attempt] } = await db.query(` + DELETE FROM oauth_attempts + WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() + RETURNING project, frontend_challenge, google_verifier + `, [digest(state), digest(browser)]); + if (!attempt || !projects.has(attempt.project)) return reply(res, 400, { error: 'invalid_state' }); + res.setHeader('Set-Cookie', `${browserCookie}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`); + + const destination = new URL(projects.get(attempt.project)); + const code = url.searchParams.get('code'); + if (!code || url.searchParams.has('error')) { + destination.hash = 'error=google_login_cancelled'; + return redirect(res, destination.href); + } + + const tokens = await googleJson('https://oauth2.googleapis.com/token', { + method: 'POST', + body: new URLSearchParams({ + code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, + grant_type: 'authorization_code', code_verifier: attempt.google_verifier, + }), + }); + if (typeof tokens.access_token !== 'string') throw new Error('Missing Google access token'); + const user = await googleJson('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + if (typeof user.sub !== 'string' || !user.sub) throw new Error('Missing Google subject'); + + const grant = randomToken(); + await db.query(` + WITH account AS ( + INSERT INTO project_accounts (project, id, google_subject, email) VALUES ($1, $2, $3, $4) + ON CONFLICT (project, google_subject) DO UPDATE SET email = EXCLUDED.email + RETURNING id + ) + INSERT INTO login_grants + SELECT $5, $1, id, $6, now() + interval '1 minute' FROM account + `, [attempt.project, randomUUID(), user.sub, user.email_verified === true ? user.email : null, + digest(grant), attempt.frontend_challenge]); + + destination.hash = new URLSearchParams({ code: grant }).toString(); + redirect(res, destination.href); +} + +async function exchange(req, res, project) { + const code = req.headers['x-foundry-code']; + const verifier = req.headers['x-foundry-verifier']; + if (!isToken(code) || !isToken(verifier)) return reply(res, 400, { error: 'invalid_exchange' }); + + const session = randomToken(); + const { rowCount } = await db.query(` + WITH grant AS ( + DELETE FROM login_grants + WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now() + RETURNING account_id + ) + INSERT INTO project_sessions + SELECT $4, $2, account_id, now() + interval '8 hours' FROM grant + `, [digest(code), project, digest(verifier), digest(session)]); + if (!rowCount) return reply(res, 401, { error: 'invalid_grant' }); + reply(res, 200, { access_token: session, token_type: 'Bearer', expires_in: 28800 }); +} + +async function account(req, res, project, action) { + const token = req.headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1]; + if (!token) return reply(res, 401, { error: 'invalid_session' }); + + const params = [digest(token), project]; + if (action === 'logout') { + await db.query('DELETE FROM project_sessions WHERE token_hash = $1 AND project = $2', params); + return reply(res, 204); + } + const { rows: [user] } = await db.query(` + SELECT a.id, a.email FROM project_sessions s + JOIN project_accounts a ON a.project = s.project AND a.id = s.account_id + WHERE s.token_hash = $1 AND s.project = $2 AND s.expires_at > now() + `, params); + if (!user) return reply(res, 401, { error: 'invalid_session' }); + reply(res, 200, { project, ...user }); +} + +async function handle(req, res) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + const url = new URL(req.url, publicOrigin); + if (req.method === 'GET' && url.pathname === '/healthz') return reply(res, 200, { status: 'ok' }); + if (req.method === 'GET' && url.pathname === '/readyz') { + await db.query('SELECT 1 FROM project_accounts LIMIT 1'); + return reply(res, 200, { status: 'ready' }); + } + if (req.method === 'GET' && url.pathname === '/auth/google/callback') return callback(req, res, url); + + const [, project, action] = url.pathname.match(/^\/projects\/([a-z][a-z0-9-]*)\/auth\/(google|exchange|me|logout)$/) ?? []; + const frontend = projects.get(project); + if (!frontend) return reply(res, 404, { error: 'not_found' }); + if (action === 'google' && req.method === 'GET') return start(res, url, project); + + const origin = req.headers.origin; + res.setHeader('Vary', 'Origin'); + if (origin && origin !== frontend.origin) return reply(res, 403, { error: 'origin_denied' }); + if (origin) res.setHeader('Access-Control-Allow-Origin', origin); + if (req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Methods', 'GET, POST'); + res.setHeader('Access-Control-Allow-Headers', 'Authorization, X-Foundry-Code, X-Foundry-Verifier'); + return reply(res, 204); + } + if (action === 'exchange' && req.method === 'POST') return exchange(req, res, project); + if (action === 'me' && req.method === 'GET') return account(req, res, project, action); + if (action === 'logout' && req.method === 'POST') return account(req, res, project, action); + reply(res, 405, { error: 'method_not_allowed' }); +} + +createServer((req, res) => { + handle(req, res).catch(error => { + console.error('Request failed', error.code ?? error.name); + if (res.headersSent) return res.destroy(); + reply(res, 503, { error: 'service_unavailable' }); + }); +}).listen(Number(process.env.PORT ?? 8080), process.env.HOST ?? '127.0.0.1');