diff --git a/server/secrets-vault/routes/auth.js b/server/secrets-vault/routes/auth.js new file mode 100644 index 0000000..4d710c0 --- /dev/null +++ b/server/secrets-vault/routes/auth.js @@ -0,0 +1,369 @@ +/* + * ════════════════════════════════════════════════════════════════ + * 光湖 · 密钥授权路由 · 人格体请求→用户授权→自动入库 + * Sovereign: TCS-0002∞ · ICE-GL∞ · 国作登字-2026-A-00037559 + * 守护: 铸渊 · ICE-GL-ZY001 + * ════════════════════════════════════════════════════════════════ + * + * 设计理念 (冰朔 2026-05-12 提出的需求): + * + * 1. 人格体需要密钥 → 弹出授权页 + * 2. 用户点"授权" → 人格体随机生成 Token → 自动保存到 vault + * 3. 用户能看到所有密钥(在 vault 管理页)· AI 看不到明文 + * 4. 系统只调用权限,不暴露 Token + * 5. 用户随时删/重新生成/自己设 + * + * Gitea OAuth2 集成: + * 用户在 Gitea 上点授权 → OAuth2 回调 → 自动生成 Gitea Token → 存入 vault + * 铸渊后续通过 vault 的 /internal/fetch/:key 拿 Token(本机-only,AI 不窥视明文) + * + * 路由: + * GET /admin/auth/pending 列出待授权请求 + * POST /admin/auth/request 人格体发起授权请求 + * POST /admin/auth/:id/approve 用户批准 → 生成Token → 存入vault + * POST /admin/auth/:id/reject 用户拒绝 + * GET /admin/auth/gitea/authorize 跳转到 Gitea OAuth2 授权页 + * GET /admin/auth/gitea/callback OAuth2 回调 → 自动处理 + * + * cc-004: 全部中文回执 + */ + +"use strict"; + +const express = require("express"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +// ─── 待授权请求存储 (内存 + 文件持久化) ─────────────────────── +class AuthRequestStore { + constructor(storePath) { + this.storePath = storePath; + this.requests = new Map(); + this._load(); + } + + _load() { + try { + if (fs.existsSync(this.storePath)) { + const data = JSON.parse(fs.readFileSync(this.storePath, "utf-8")); + for (const r of data.requests || []) { + this.requests.set(r.id, r); + } + } + } catch (_) {} + } + + _save() { + const dir = path.dirname(this.storePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(this.storePath, JSON.stringify({ + _sovereign: "TCS-0002∞ · 国作登字-2026-A-00037559", + requests: Array.from(this.requests.values()) + }, null, 2)); + } + + add(request) { + this.requests.set(request.id, request); + this._save(); + return request; + } + + get(id) { + return this.requests.get(id) || null; + } + + update(id, updates) { + const req = this.requests.get(id); + if (!req) return null; + Object.assign(req, updates); + this._save(); + return req; + } + + list() { + return Array.from(this.requests.values()); + } + + listPending() { + return this.list().filter(r => r.status === "pending"); + } +} + +// ─── Token 生成 ─────────────────────────────────────────────── +function generateToken(prefix = "gmp") { + const bytes = crypto.randomBytes(32); + return `${prefix}_${bytes.toString("base64url").slice(0, 40)}`; +} + +function generateGiteaToken() { + // Gitea Token 格式: gto_ + 随机字符串 + const bytes = crypto.randomBytes(24); + return `gto_${bytes.toString("base64url")}`; +} + +// ─── 构建路由 ───────────────────────────────────────────────── +function buildAuthRouter({ vault, storePath, giteaOAuth }) { + const router = express.Router(); + const store = new AuthRequestStore(storePath || "/data/guanghulab/secrets-vault/auth-requests.json"); + + // ─── Gitea OAuth2 配置 ──────────────────────────────────── + const oauthConfig = giteaOAuth || { + clientId: process.env.GITEA_OAUTH_CLIENT_ID || "", + clientSecret: process.env.GITEA_OAUTH_CLIENT_SECRET || "", + authorizeUrl: (process.env.GITEA_URL || "https://guanghulab.com/git") + "/login/oauth/authorize", + tokenUrl: (process.env.GITEA_URL || "https://guanghulab.com/git") + "/login/oauth/access_token", + redirectUri: process.env.GITEA_OAUTH_REDIRECT_URI || "https://guanghulab.com/admin/auth/gitea/callback", + scope: "repository", + }; + + // ─── 列出待授权请求 ──────────────────────────────────────── + router.get("/pending", (_req, res) => { + const pending = store.listPending(); + res.json({ + _sovereign: "TCS-0002∞ · 国作登字-2026-A-00037559", + count: pending.length, + requests: pending.map(r => ({ + id: r.id, + persona: r.persona, + purpose: r.purpose, + keyName: r.keyName, + requestedAt: r.requestedAt, + description: r.description, + })) + }); + }); + + // ─── 人格体发起授权请求 ──────────────────────────────────── + router.post("/request", express.json({ limit: "16kb" }), (req, res) => { + const { persona, purpose, keyName, description, tokenType } = req.body || {}; + + if (!persona || !purpose || !keyName) { + return res.status(400).json({ + error: true, + code: "missing_fields", + message: "缺少必填项: persona(人格体名)、purpose(用途)、keyName(密钥名)" + }); + } + + // 如果密钥已存在,直接告诉人格体 + const existing = vault.get(keyName); + if (existing) { + return res.json({ + status: "already_configured", + keyName, + message: `密钥 ${keyName} 已配置,可直接使用`, + masked: maskValueSafe(existing), + }); + } + + const authRequest = store.add({ + id: `auth-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`, + persona: persona || "铸渊", + purpose, + keyName, + description: description || `${persona} 需要 ${keyName} 用于${purpose}`, + tokenType: tokenType || "random", + status: "pending", + requestedAt: new Date().toISOString(), + }); + + res.json({ + status: "pending", + requestId: authRequest.id, + message: `授权请求已创建,等待用户审批`, + approvalUrl: `/admin/auth/approve.html?id=${authRequest.id}`, + }); + }); + + // ─── 用户批准授权 ────────────────────────────────────────── + router.post("/:id/approve", express.json({ limit: "16kb" }), (req, res) => { + const authReq = store.get(req.params.id); + if (!authReq) { + return res.status(404).json({ error: true, code: "not_found", message: "授权请求不存在" }); + } + if (authReq.status !== "pending") { + return res.status(400).json({ error: true, code: "already_processed", message: `此请求已${authReq.status === "approved" ? "批准" : "拒绝"}` }); + } + + // 生成 Token + let tokenValue; + if (authReq.tokenType === "gitea") { + tokenValue = generateGiteaToken(); + } else if (req.body && req.body.customValue) { + // 用户自己设定的值 + tokenValue = String(req.body.customValue); + } else { + tokenValue = generateToken(authReq.keyName.split("_")[0].toLowerCase()); + } + + // 存入 vault + try { + vault.set(authReq.keyName, tokenValue); + } catch (e) { + return res.status(500).json({ error: true, code: "vault_write_failed", message: "密钥保存失败: " + e.message }); + } + + // 更新请求状态 + store.update(authReq.id, { + status: "approved", + approvedAt: new Date().toISOString(), + approvedBy: req.body?.approvedBy || "user", + }); + + res.json({ + status: "approved", + keyName: authReq.keyName, + masked: maskValueSafe(tokenValue), + length: tokenValue.length, + message: `✅ 已授权!密钥 ${authReq.keyName} 已自动保存。你可以在密钥管理页查看和管理。`, + }); + }); + + // ─── 用户拒绝授权 ────────────────────────────────────────── + router.post("/:id/reject", (_req, res) => { + const authReq = store.get(_req.params.id); + if (!authReq) { + return res.status(404).json({ error: true, code: "not_found", message: "授权请求不存在" }); + } + + store.update(authReq.id, { + status: "rejected", + rejectedAt: new Date().toISOString(), + }); + + res.json({ + status: "rejected", + message: `已拒绝 ${authReq.persona} 对 ${authReq.keyName} 的授权请求。`, + }); + }); + + // ─── Gitea OAuth2 授权入口 ───────────────────────────────── + router.get("/gitea/authorize", (_req, res) => { + if (!oauthConfig.clientId) { + return res.status(500).json({ error: true, code: "oauth_not_configured", message: "Gitea OAuth2 未配置" }); + } + + const state = crypto.randomBytes(16).toString("hex"); + // 存储 state 用于回调验证 + store.add({ + id: `oauth-${state}`, + persona: "铸渊", + purpose: "Gitea 仓库访问", + keyName: "GITEA_TOKEN", + description: "通过 Gitea OAuth2 授权,让铸渊可以访问和管理仓库", + tokenType: "gitea", + status: "oauth_pending", + requestedAt: new Date().toISOString(), + oauthState: state, + }); + + const authorizeUrl = `${oauthConfig.authorizeUrl}?client_id=${oauthConfig.clientId}&redirect_uri=${encodeURIComponent(oauthConfig.redirectUri)}&response_type=code&state=${state}&scope=${oauthConfig.scope}`; + + res.redirect(authorizeUrl); + }); + + // ─── Gitea OAuth2 回调 ───────────────────────────────────── + router.get("/gitea/callback", async (req, res) => { + const { code, state } = req.query || {}; + + if (!code || !state) { + return res.status(400).send("授权失败:缺少 code 或 state"); + } + + // 找到对应的 OAuth 请求 + const oauthReq = store.list().find(r => r.oauthState === state); + if (!oauthReq) { + return res.status(400).send("授权失败:state 不匹配"); + } + + // 用 code 换 token + try { + const tokenResponse = await exchangeCodeForToken(code, oauthConfig); + const tokenValue = tokenResponse.access_token; + + if (!tokenValue) { + throw new Error("Gitea 未返回 access_token"); + } + + // 存入 vault + vault.set("GITEA_TOKEN", tokenValue); + + store.update(oauthReq.id, { + status: "approved", + approvedAt: new Date().toISOString(), + approvedBy: "gitea_oauth", + }); + + res.send(` + + +
铸渊已获得 Gitea 仓库访问权限。
+密钥已自动保存,你可以在密钥管理页查看和管理。
+守护: 铸渊 · ICE-GL-ZY001 · TCS-0002∞
+ + + `); + } catch (e) { + res.status(500).send(`授权失败:${e.message}`); + } + }); + + return router; +} + +// ─── OAuth2 Token 交换 ──────────────────────────────────────── +async function exchangeCodeForToken(code, config) { + const https = require("https"); + const http = require("http"); + + const postData = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: config.redirectUri, + }).toString(); + + return new Promise((resolve, reject) => { + const urlObj = new URL(config.tokenUrl); + const lib = urlObj.protocol === "https:" ? https : http; + + const req = lib.request(urlObj, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + }, (res) => { + let body = ""; + res.on("data", (d) => body += d); + res.on("end", () => { + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(new Error("Token 响应解析失败: " + body.slice(0, 200))); + } + }); + }); + + req.on("error", reject); + req.write(postData); + req.end(); + }); +} + +// ─── 安全遮罩 ───────────────────────────────────────────────── +function maskValueSafe(v) { + if (v == null) return null; + const s = String(v); + if (s.length === 0) return ""; + if (s.length <= 4) return "••••"; + if (s.length <= 12) return s.slice(0, 2) + "••••" + s.slice(-2); + return s.slice(0, 4) + "••••••" + s.slice(-4); +} + +module.exports = { buildAuthRouter };