370 lines
13 KiB
JavaScript
Raw Permalink Normal View History

/*
*
* 光湖 · 密钥授权路由 · 人格体请求用户授权自动入库
* 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 };