🔑 密钥授权路由·人格体请求→用户审批→自动生成Token→存入vault·支持Gitea OAuth2
This commit is contained in:
parent
fcfbb71037
commit
e484eed438
369
server/secrets-vault/routes/auth.js
Normal file
369
server/secrets-vault/routes/auth.js
Normal file
@ -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(`
|
||||
<!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 };
|
||||
Loading…
x
Reference in New Issue
Block a user