370 lines
13 KiB
JavaScript
370 lines
13 KiB
JavaScript
/*
|
||
* ════════════════════════════════════════════════════════════════
|
||
* 光湖 · 密钥授权路由 · 人格体请求→用户授权→自动入库
|
||
* 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(`
|
||
<!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 };
|