From 2d900ef2ac485037bed8599fb578140e1217310b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=88=90?= Date: Wed, 1 Apr 2026 13:42:29 +0800 Subject: [PATCH] 1 --- _docs/sql/biz_api_token_secret_cipher.sql | 3 + admin/src/views/subscription/users.vue | 105 +++++++++++++++++++++- api/controller_admin/biz_token.js | 2 + api/controller_admin/biz_user.js | 15 +++- api/model/biz_api_token.js | 4 + api/service/biz_token_logic.js | 9 +- api/utils/biz_token_secret_cipher.js | 51 +++++++++++ config/config.development.js | 3 + config/config.production.js | 3 + 9 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 _docs/sql/biz_api_token_secret_cipher.sql create mode 100644 api/utils/biz_token_secret_cipher.js diff --git a/_docs/sql/biz_api_token_secret_cipher.sql b/_docs/sql/biz_api_token_secret_cipher.sql new file mode 100644 index 0000000..df1e20a --- /dev/null +++ b/_docs/sql/biz_api_token_secret_cipher.sql @@ -0,0 +1,3 @@ +-- 管理端可解密查看 Token 明文(AES-GCM 密文),执行一次即可 +ALTER TABLE biz_api_token + ADD COLUMN secret_cipher TEXT NULL COMMENT 'Token明文' AFTER token_hash; diff --git a/admin/src/views/subscription/users.vue b/admin/src/views/subscription/users.vue index 962b9ae..adcf294 100644 --- a/admin/src/views/subscription/users.vue +++ b/admin/src/views/subscription/users.vue @@ -87,7 +87,7 @@ - +
ID {{ detail.user.id }} {{ detail.user.name || '—' }} @@ -110,7 +110,7 @@ border /> - +
- 点击行查看单条详情;明文仅在创建/重新生成时展示一次 + 点击行查看详情;列表「明文」来自服务端加密存储(吊销后不可查看)

暂无 Token

- +

ID{{ selectedToken.id }}

名称{{ selectedToken.token_name }}

@@ -134,6 +134,14 @@

状态{{ selectedToken.status }}

过期时间{{ selectedToken.expire_at || '—' }}

最后使用{{ selectedToken.last_used_at || '—' }}

+
+

明文

+ +

无存储明文(旧数据或已吊销);可对 active 记录「重新生成」后查看

+
@@ -292,6 +300,34 @@ export default { { title: '状态', key: 'status', width: 90 }, { title: '过期', key: 'expire_at', minWidth: 150 }, { title: '最后使用', key: 'last_used_at', minWidth: 150 }, + { + title: '明文', + key: 'plain_token', + minWidth: 200, + render: (h, p) => { + const v = p.row.plain_token + if (!v) { + return h('span', { class: { 'text-muted': true } }, '—') + } + const snip = v.length > 28 ? `${v.slice(0, 28)}…` : v + return h('div', { class: 'plain-token-row' }, [ + h('span', { class: 'plain-token-snip', attrs: { title: v } }, snip), + h( + 'Button', + { + props: { type: 'text', size: 'small' }, + on: { + click: (e) => { + e.stopPropagation() + this.copy_text(v) + }, + }, + }, + '复制' + ), + ]) + }, + }, { title: '操作', key: 'tok_op', @@ -339,6 +375,30 @@ export default { this.param.pageOption.pageSize = s this.load(1) }, + copy_text(text) { + if (text == null || text === '') return + const s = String(text) + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(s).then(() => this.$Message.success('已复制')).catch(() => this._copy_text_fallback(s)) + } else { + this._copy_text_fallback(s) + } + }, + _copy_text_fallback(s) { + const ta = document.createElement('textarea') + ta.value = s + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + try { + document.execCommand('copy') + this.$Message.success('已复制') + } catch (e) { + this.$Message.error('复制失败') + } + document.body.removeChild(ta) + }, openEdit(row) { if (row) { this.form = { ...row } @@ -596,6 +656,43 @@ export default { color: #808695; } +.token-plain-block { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e8eaec; +} + +.token-plain-block .label { + margin-bottom: 6px; +} + +.token-plain-ta :deep(textarea) { + font-family: monospace; + font-size: 12px; +} + +.mb0 { + margin-bottom: 0; +} + +.mt8 { + margin-top: 8px; +} + +.plain-token-row { + display: flex; + align-items: center; + gap: 6px; +} + +.plain-token-snip { + flex: 1; + min-width: 0; + font-family: monospace; + font-size: 12px; + word-break: break-all; +} + .detail-user-bar { display: flex; flex-wrap: wrap; diff --git a/api/controller_admin/biz_token.js b/api/controller_admin/biz_token.js index b01df66..dfc4192 100644 --- a/api/controller_admin/biz_token.js +++ b/api/controller_admin/biz_token.js @@ -19,6 +19,7 @@ module.exports = { offset, limit: page_size, order: [["id", "DESC"]], + attributes: { exclude: ["secret_cipher"] }, }); ctx.success({ rows, count }); }, @@ -85,6 +86,7 @@ module.exports = { where, limit: 10000, order: [["id", "DESC"]], + attributes: { exclude: ["secret_cipher"] }, }); ctx.success({ rows }); }, diff --git a/api/controller_admin/biz_user.js b/api/controller_admin/biz_user.js index 97cca34..ac94308 100644 --- a/api/controller_admin/biz_user.js +++ b/api/controller_admin/biz_user.js @@ -3,6 +3,17 @@ const { normalize_for_write, build_search_where } = require("../utils/query_help const baseModel = require("../../middleware/baseModel"); const tokenLogic = require("../service/biz_token_logic"); const audit = require("../utils/biz_audit"); +const biz_token_secret_cipher = require("../utils/biz_token_secret_cipher"); + +function map_tokens_for_admin_detail(token_rows) { + return token_rows.map((t) => { + const o = t.get ? t.get({ plain: true }) : { ...t }; + const cipher = o.secret_cipher; + delete o.secret_cipher; + o.plain_token = biz_token_secret_cipher.decrypt_plain_from_storage(cipher); + return o; + }); +} module.exports = { "POST /biz_user/page": async (ctx) => { @@ -134,13 +145,13 @@ module.exports = { where: { user_id: id }, order: [["id", "DESC"]], limit: 200, - attributes: ["id", "user_id", "plan_id", "token_name", "status", "expire_at", "last_used_at"], }); + const tokens_out = map_tokens_for_admin_detail(tokens); ctx.success({ user, subscriptions, tokenCount, - tokens, + tokens: tokens_out, }); }, "GET /biz_user/all": async (ctx) => { diff --git a/api/model/biz_api_token.js b/api/model/biz_api_token.js index 1b08b6a..20232a4 100644 --- a/api/model/biz_api_token.js +++ b/api/model/biz_api_token.js @@ -23,6 +23,10 @@ module.exports = (db) => { allowNull: false, unique: true, }, + secret_cipher: { + type: Sequelize.TEXT, + allowNull: true, + }, status: { type: Sequelize.ENUM("active", "revoked", "expired"), allowNull: false, diff --git a/api/service/biz_token_logic.js b/api/service/biz_token_logic.js index 1d73ec1..b788c73 100644 --- a/api/service/biz_token_logic.js +++ b/api/service/biz_token_logic.js @@ -2,6 +2,7 @@ const crypto = require("crypto"); const Sequelize = require("sequelize"); const op = Sequelize.Op; const baseModel = require("../../middleware/baseModel"); +const biz_token_secret_cipher = require("../utils/biz_token_secret_cipher"); const MAX_TOKENS_PER_USER = 5; @@ -53,12 +54,14 @@ async function createToken(body) { const plain = generatePlainToken(); const token_hash = hashPlainToken(plain); + const secret_cipher = biz_token_secret_cipher.encrypt_plain_for_storage(plain); const row = await baseModel.biz_api_token.create({ user_id, plan_id, token_name: token_name || "default", token_hash, + secret_cipher, status: "active", expire_at, }); @@ -75,7 +78,7 @@ async function revokeToken(body) { if (id == null) throw new Error("缺少 id"); const row = await baseModel.biz_api_token.findByPk(id); if (!row) throw new Error("Token 不存在"); - await row.update({ status: "revoked" }); + await row.update({ status: "revoked", secret_cipher: null }); return row; } @@ -98,10 +101,12 @@ async function regenerateToken(body) { const plain = generatePlainToken(); const token_hash = hashPlainToken(plain); + const secret_cipher = biz_token_secret_cipher.encrypt_plain_for_storage(plain); await row.update({ token_hash, plan_id, + secret_cipher, }); await row.reload(); @@ -115,7 +120,7 @@ async function regenerateToken(body) { async function revokeAllForUser(userId) { if (userId == null) throw new Error("缺少 user_id"); const [n] = await baseModel.biz_api_token.update( - { status: "revoked" }, + { status: "revoked", secret_cipher: null }, { where: { user_id: userId, status: "active" } } ); return n; diff --git a/api/utils/biz_token_secret_cipher.js b/api/utils/biz_token_secret_cipher.js new file mode 100644 index 0000000..dd603a1 --- /dev/null +++ b/api/utils/biz_token_secret_cipher.js @@ -0,0 +1,51 @@ +const crypto = require("crypto"); +const config = require("../../config/config"); + +/** + * 管理端查看 Token 明文用:AES-256-GCM,密钥来自环境变量或 config.biz_token_enc_key。 + */ +function get_key_32() { + const raw = process.env.BIZ_TOKEN_ENC_KEY || config.biz_token_enc_key; + if (!raw) { + return crypto.createHash("sha256").update("dev-biz-token-enc-set-BIZ_TOKEN_ENC_KEY", "utf8").digest(); + } + return crypto.createHash("sha256").update(String(raw), "utf8").digest(); +} + +function encrypt_plain_for_storage(plain) { + if (plain == null || plain === "") return null; + const key = get_key_32(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const enc = Buffer.concat([cipher.update(String(plain), "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + const payload = { + v: 1, + iv: iv.toString("base64"), + tag: tag.toString("base64"), + data: enc.toString("base64"), + }; + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); +} + +function decrypt_plain_from_storage(stored) { + if (stored == null || stored === "") return null; + try { + const payload = JSON.parse(Buffer.from(stored, "base64").toString("utf8")); + if (payload.v !== 1) return null; + const key = get_key_32(); + const iv = Buffer.from(payload.iv, "base64"); + const tag = Buffer.from(payload.tag, "base64"); + const data = Buffer.from(payload.data, "base64"); + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8"); + } catch { + return null; + } +} + +module.exports = { + encrypt_plain_for_storage, + decrypt_plain_from_storage, +}; diff --git a/config/config.development.js b/config/config.development.js index fdb29ff..cd4bf99 100644 --- a/config/config.development.js +++ b/config/config.development.js @@ -39,5 +39,8 @@ module.exports = { // 官方 API 上游地址(转发层目标) "upstream_api_url": "http://113.44.162.180:7006", + + /** 用于加密存储 Token 明文供管理端查看(生产请用环境变量 BIZ_TOKEN_ENC_KEY) */ + "biz_token_enc_key": process.env.BIZ_TOKEN_ENC_KEY || "wechat-admin-dev-token-enc-key-change-me", } diff --git a/config/config.production.js b/config/config.production.js index 598633f..0585909 100644 --- a/config/config.production.js +++ b/config/config.production.js @@ -42,5 +42,8 @@ module.exports = { // 官方 API 上游地址(转发层目标) "upstream_api_url": "http://127.0.0.1:8888", + + /** 生产环境务必设置环境变量 BIZ_TOKEN_ENC_KEY(长随机串) */ + "biz_token_enc_key": process.env.BIZ_TOKEN_ENC_KEY, }