Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

370 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* ════════════════════════════════════════════════════════════════
* 光湖 · 密钥授权路由 · 人格体请求→用户授权→自动入库
* 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本机-onlyAI 不窥视明文)
*
* 路由:
* 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(`
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>授权成功</title></head>
<body style="font-family:sans-serif;text-align:center;padding:40px;">
<h1>✅ 授权成功!</h1>
<p>铸渊已获得 Gitea 仓库访问权限。</p>
<p>密钥已自动保存,你可以在<a href="/admin/secrets">密钥管理页</a>查看和管理。</p>
<p><small>守护: 铸渊 · ICE-GL-ZY001 · TCS-0002∞</small></p>
</body>
</html>
`);
} 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 };