1
This commit is contained in:
3
_docs/sql/biz_api_token_secret_cipher.sql
Normal file
3
_docs/sql/biz_api_token_secret_cipher.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 管理端可解密查看 Token 明文(AES-GCM 密文),执行一次即可
|
||||||
|
ALTER TABLE biz_api_token
|
||||||
|
ADD COLUMN secret_cipher TEXT NULL COMMENT 'Token明文' AFTER token_hash;
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal v-model="detailVisible" title="用户详情" width="800" footer-hide>
|
<Modal v-model="detailVisible" title="用户详情" width="920" footer-hide>
|
||||||
<div v-if="detail && detail.user" class="detail-user-bar mb12">
|
<div v-if="detail && detail.user" class="detail-user-bar mb12">
|
||||||
<span class="detail-user-item">ID {{ detail.user.id }}</span>
|
<span class="detail-user-item">ID {{ detail.user.id }}</span>
|
||||||
<span class="detail-user-item">{{ detail.user.name || '—' }}</span>
|
<span class="detail-user-item">{{ detail.user.name || '—' }}</span>
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
border />
|
border />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal v-model="tokenListVisible" :title="tokenListTitle" width="820" footer-hide>
|
<Modal v-model="tokenListVisible" :title="tokenListTitle" width="980" footer-hide>
|
||||||
<div v-if="tokenList_target_user" class="mb12">
|
<div v-if="tokenList_target_user" class="mb12">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -118,14 +118,14 @@
|
|||||||
:disabled="tokenList_target_user.status !== 'active'"
|
:disabled="tokenList_target_user.status !== 'active'"
|
||||||
@click="open_create_token_for_user(tokenList_target_user)"
|
@click="open_create_token_for_user(tokenList_target_user)"
|
||||||
>生成 Token</Button>
|
>生成 Token</Button>
|
||||||
<span class="text-muted ml10">点击行查看单条详情;明文仅在创建/重新生成时展示一次</span>
|
<span class="text-muted ml10">点击行查看详情;列表「明文」来自服务端加密存储(吊销后不可查看)</span>
|
||||||
</div>
|
</div>
|
||||||
<Table v-if="tokenListRows.length" :columns="tokenCols" :data="tokenListRows" size="small" border highlight-row
|
<Table v-if="tokenListRows.length" :columns="tokenCols" :data="tokenListRows" size="small" border highlight-row
|
||||||
@on-row-click="onTokenRowClick" />
|
@on-row-click="onTokenRowClick" />
|
||||||
<p v-else class="text-muted">暂无 Token</p>
|
<p v-else class="text-muted">暂无 Token</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal v-model="tokenDetailVisible" title="Token 详情" width="520" footer-hide>
|
<Modal v-model="tokenDetailVisible" title="Token 详情" width="600" footer-hide>
|
||||||
<div v-if="selectedToken" class="token-detail">
|
<div v-if="selectedToken" class="token-detail">
|
||||||
<p><span class="label">ID</span>{{ selectedToken.id }}</p>
|
<p><span class="label">ID</span>{{ selectedToken.id }}</p>
|
||||||
<p><span class="label">名称</span>{{ selectedToken.token_name }}</p>
|
<p><span class="label">名称</span>{{ selectedToken.token_name }}</p>
|
||||||
@@ -134,6 +134,14 @@
|
|||||||
<p><span class="label">状态</span>{{ selectedToken.status }}</p>
|
<p><span class="label">状态</span>{{ selectedToken.status }}</p>
|
||||||
<p><span class="label">过期时间</span>{{ selectedToken.expire_at || '—' }}</p>
|
<p><span class="label">过期时间</span>{{ selectedToken.expire_at || '—' }}</p>
|
||||||
<p><span class="label">最后使用</span>{{ selectedToken.last_used_at || '—' }}</p>
|
<p><span class="label">最后使用</span>{{ selectedToken.last_used_at || '—' }}</p>
|
||||||
|
<div class="token-plain-block">
|
||||||
|
<p><span class="label">明文</span></p>
|
||||||
|
<template v-if="selectedToken.plain_token">
|
||||||
|
<Input type="textarea" :rows="3" :value="selectedToken.plain_token" readonly class="token-plain-ta" />
|
||||||
|
<Button type="primary" size="small" class="mt8" @click="copy_text(selectedToken.plain_token)">复制全文</Button>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-muted mb0">无存储明文(旧数据或已吊销);可对 active 记录「重新生成」后查看</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,6 +300,34 @@ export default {
|
|||||||
{ title: '状态', key: 'status', width: 90 },
|
{ title: '状态', key: 'status', width: 90 },
|
||||||
{ title: '过期', key: 'expire_at', minWidth: 150 },
|
{ title: '过期', key: 'expire_at', minWidth: 150 },
|
||||||
{ title: '最后使用', key: 'last_used_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: '操作',
|
title: '操作',
|
||||||
key: 'tok_op',
|
key: 'tok_op',
|
||||||
@@ -339,6 +375,30 @@ export default {
|
|||||||
this.param.pageOption.pageSize = s
|
this.param.pageOption.pageSize = s
|
||||||
this.load(1)
|
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) {
|
openEdit(row) {
|
||||||
if (row) {
|
if (row) {
|
||||||
this.form = { ...row }
|
this.form = { ...row }
|
||||||
@@ -596,6 +656,43 @@ export default {
|
|||||||
color: #808695;
|
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 {
|
.detail-user-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ module.exports = {
|
|||||||
offset,
|
offset,
|
||||||
limit: page_size,
|
limit: page_size,
|
||||||
order: [["id", "DESC"]],
|
order: [["id", "DESC"]],
|
||||||
|
attributes: { exclude: ["secret_cipher"] },
|
||||||
});
|
});
|
||||||
ctx.success({ rows, count });
|
ctx.success({ rows, count });
|
||||||
},
|
},
|
||||||
@@ -85,6 +86,7 @@ module.exports = {
|
|||||||
where,
|
where,
|
||||||
limit: 10000,
|
limit: 10000,
|
||||||
order: [["id", "DESC"]],
|
order: [["id", "DESC"]],
|
||||||
|
attributes: { exclude: ["secret_cipher"] },
|
||||||
});
|
});
|
||||||
ctx.success({ rows });
|
ctx.success({ rows });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ const { normalize_for_write, build_search_where } = require("../utils/query_help
|
|||||||
const baseModel = require("../../middleware/baseModel");
|
const baseModel = require("../../middleware/baseModel");
|
||||||
const tokenLogic = require("../service/biz_token_logic");
|
const tokenLogic = require("../service/biz_token_logic");
|
||||||
const audit = require("../utils/biz_audit");
|
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 = {
|
module.exports = {
|
||||||
"POST /biz_user/page": async (ctx) => {
|
"POST /biz_user/page": async (ctx) => {
|
||||||
@@ -134,13 +145,13 @@ module.exports = {
|
|||||||
where: { user_id: id },
|
where: { user_id: id },
|
||||||
order: [["id", "DESC"]],
|
order: [["id", "DESC"]],
|
||||||
limit: 200,
|
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({
|
ctx.success({
|
||||||
user,
|
user,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tokenCount,
|
tokenCount,
|
||||||
tokens,
|
tokens: tokens_out,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"GET /biz_user/all": async (ctx) => {
|
"GET /biz_user/all": async (ctx) => {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ module.exports = (db) => {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
unique: true,
|
unique: true,
|
||||||
},
|
},
|
||||||
|
secret_cipher: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
type: Sequelize.ENUM("active", "revoked", "expired"),
|
type: Sequelize.ENUM("active", "revoked", "expired"),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const crypto = require("crypto");
|
|||||||
const Sequelize = require("sequelize");
|
const Sequelize = require("sequelize");
|
||||||
const op = Sequelize.Op;
|
const op = Sequelize.Op;
|
||||||
const baseModel = require("../../middleware/baseModel");
|
const baseModel = require("../../middleware/baseModel");
|
||||||
|
const biz_token_secret_cipher = require("../utils/biz_token_secret_cipher");
|
||||||
|
|
||||||
const MAX_TOKENS_PER_USER = 5;
|
const MAX_TOKENS_PER_USER = 5;
|
||||||
|
|
||||||
@@ -53,12 +54,14 @@ async function createToken(body) {
|
|||||||
|
|
||||||
const plain = generatePlainToken();
|
const plain = generatePlainToken();
|
||||||
const token_hash = hashPlainToken(plain);
|
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({
|
const row = await baseModel.biz_api_token.create({
|
||||||
user_id,
|
user_id,
|
||||||
plan_id,
|
plan_id,
|
||||||
token_name: token_name || "default",
|
token_name: token_name || "default",
|
||||||
token_hash,
|
token_hash,
|
||||||
|
secret_cipher,
|
||||||
status: "active",
|
status: "active",
|
||||||
expire_at,
|
expire_at,
|
||||||
});
|
});
|
||||||
@@ -75,7 +78,7 @@ async function revokeToken(body) {
|
|||||||
if (id == null) throw new Error("缺少 id");
|
if (id == null) throw new Error("缺少 id");
|
||||||
const row = await baseModel.biz_api_token.findByPk(id);
|
const row = await baseModel.biz_api_token.findByPk(id);
|
||||||
if (!row) throw new Error("Token 不存在");
|
if (!row) throw new Error("Token 不存在");
|
||||||
await row.update({ status: "revoked" });
|
await row.update({ status: "revoked", secret_cipher: null });
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,10 +101,12 @@ async function regenerateToken(body) {
|
|||||||
|
|
||||||
const plain = generatePlainToken();
|
const plain = generatePlainToken();
|
||||||
const token_hash = hashPlainToken(plain);
|
const token_hash = hashPlainToken(plain);
|
||||||
|
const secret_cipher = biz_token_secret_cipher.encrypt_plain_for_storage(plain);
|
||||||
|
|
||||||
await row.update({
|
await row.update({
|
||||||
token_hash,
|
token_hash,
|
||||||
plan_id,
|
plan_id,
|
||||||
|
secret_cipher,
|
||||||
});
|
});
|
||||||
await row.reload();
|
await row.reload();
|
||||||
|
|
||||||
@@ -115,7 +120,7 @@ async function regenerateToken(body) {
|
|||||||
async function revokeAllForUser(userId) {
|
async function revokeAllForUser(userId) {
|
||||||
if (userId == null) throw new Error("缺少 user_id");
|
if (userId == null) throw new Error("缺少 user_id");
|
||||||
const [n] = await baseModel.biz_api_token.update(
|
const [n] = await baseModel.biz_api_token.update(
|
||||||
{ status: "revoked" },
|
{ status: "revoked", secret_cipher: null },
|
||||||
{ where: { user_id: userId, status: "active" } }
|
{ where: { user_id: userId, status: "active" } }
|
||||||
);
|
);
|
||||||
return n;
|
return n;
|
||||||
|
|||||||
51
api/utils/biz_token_secret_cipher.js
Normal file
51
api/utils/biz_token_secret_cipher.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -39,5 +39,8 @@ module.exports = {
|
|||||||
|
|
||||||
// 官方 API 上游地址(转发层目标)
|
// 官方 API 上游地址(转发层目标)
|
||||||
"upstream_api_url": "http://113.44.162.180:7006",
|
"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,5 +42,8 @@ module.exports = {
|
|||||||
|
|
||||||
// 官方 API 上游地址(转发层目标)
|
// 官方 API 上游地址(转发层目标)
|
||||||
"upstream_api_url": "http://127.0.0.1:8888",
|
"upstream_api_url": "http://127.0.0.1:8888",
|
||||||
|
|
||||||
|
/** 生产环境务必设置环境变量 BIZ_TOKEN_ENC_KEY(长随机串) */
|
||||||
|
"biz_token_enc_key": process.env.BIZ_TOKEN_ENC_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user