Compare commits
2 Commits
d03916290a
...
2d900ef2ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d900ef2ac | ||
|
|
1d22fb28e2 |
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;
|
||||
@@ -11,6 +11,10 @@ class TokenServer {
|
||||
return window.framework.http.post("/biz_token/revoke", row);
|
||||
}
|
||||
|
||||
async regenerate(row) {
|
||||
return window.framework.http.post("/biz_token/regenerate", row);
|
||||
}
|
||||
|
||||
async exportRows(row) {
|
||||
return window.framework.http.post("/biz_token/export", row);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="plainModal" title="请立即保存 Token 明文" width="560" :closable="false">
|
||||
<Modal v-model="plainModal" :title="plainModalTitle" width="560" :closable="false">
|
||||
<Alert type="error">仅此一次展示,关闭后无法再次查看明文。</Alert>
|
||||
<Input type="textarea" :rows="4" v-model="plainToken" readonly />
|
||||
<div slot="footer">
|
||||
@@ -92,6 +92,7 @@ export default {
|
||||
},
|
||||
createModal: false,
|
||||
plainModal: false,
|
||||
plainModalTitle: '请立即保存 Token 明文',
|
||||
plainToken: '',
|
||||
saving: false,
|
||||
createForm: {},
|
||||
@@ -110,18 +111,34 @@ export default {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'a',
|
||||
width: 100,
|
||||
render: (h, p) =>
|
||||
h(
|
||||
'Button',
|
||||
{
|
||||
props: { type: 'error', size: 'small' },
|
||||
on: {
|
||||
click: () => this.doRevoke(p.row),
|
||||
width: 178,
|
||||
render: (h, p) => {
|
||||
const btns = []
|
||||
if (p.row.status === 'active') {
|
||||
btns.push(
|
||||
h(
|
||||
'Button',
|
||||
{
|
||||
props: { type: 'warning', size: 'small' },
|
||||
on: { click: () => this.doRegenerate(p.row) },
|
||||
},
|
||||
'重新生成'
|
||||
)
|
||||
)
|
||||
}
|
||||
btns.push(
|
||||
h(
|
||||
'Button',
|
||||
{
|
||||
props: { type: 'error', size: 'small' },
|
||||
class: { ml8: btns.length > 0 },
|
||||
on: { click: () => this.doRevoke(p.row) },
|
||||
},
|
||||
},
|
||||
'吊销'
|
||||
),
|
||||
'吊销'
|
||||
)
|
||||
)
|
||||
return h('div', btns)
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -183,6 +200,7 @@ export default {
|
||||
if (res && res.code === 0) {
|
||||
if (res.data.warn) this.$Message.warning(res.data.warn)
|
||||
this.createModal = false
|
||||
this.plainModalTitle = '请立即保存 Token 明文'
|
||||
this.plainToken = res.data.plain_token
|
||||
this.plainModal = true
|
||||
this.load(1)
|
||||
@@ -193,6 +211,24 @@ export default {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
doRegenerate(row) {
|
||||
this.$Modal.confirm({
|
||||
title: '重新生成 Token',
|
||||
content: '旧密钥将立即失效,确定继续?',
|
||||
onOk: async () => {
|
||||
const res = await tokenServer.regenerate({ id: row.id })
|
||||
if (res && res.code === 0) {
|
||||
if (res.data.warn) this.$Message.warning(res.data.warn)
|
||||
this.plainModalTitle = '请保存重新生成后的 Token 明文'
|
||||
this.plainToken = res.data.plain_token
|
||||
this.plainModal = true
|
||||
this.load(1)
|
||||
} else {
|
||||
this.$Message.error((res && res.message) || '失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
doRevoke(row) {
|
||||
this.$Modal.confirm({
|
||||
title: '吊销 Token',
|
||||
@@ -245,4 +281,8 @@ export default {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ml8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,10 +56,51 @@
|
||||
<Option value="disabled">禁用</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem v-if="!form.id" label="API Token">
|
||||
<Checkbox v-model="form.auto_create_token">保存时自动创建默认 Token(明文仅展示一次)</Checkbox>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="detailVisible" title="用户详情" width="800" footer-hide>
|
||||
<Modal v-model="token_plain_modal" :title="token_plain_title" width="560" :closable="false">
|
||||
<Alert type="error">关闭后无法再次查看明文,请复制到安全位置。</Alert>
|
||||
<Input v-model="token_plain_text" type="textarea" :rows="4" readonly />
|
||||
<div slot="footer">
|
||||
<Button type="primary" @click="token_plain_modal = false">已保存</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
v-model="create_token_modal"
|
||||
title="生成 API Token"
|
||||
width="560"
|
||||
:loading="create_token_saving"
|
||||
@on-ok="submit_create_token"
|
||||
>
|
||||
<p v-if="create_token_target_user" class="text-muted mb12">
|
||||
用户:{{ create_token_target_user.name || ('#' + create_token_target_user.id) }}
|
||||
· ID {{ create_token_target_user.id }}
|
||||
</p>
|
||||
<Form :label-width="100">
|
||||
<FormItem label="名称"><Input v-model="create_token_form.token_name" placeholder="default" /></FormItem>
|
||||
<FormItem label="过期时间"><Input v-model="create_token_form.expire_at" placeholder="YYYY-MM-DD 23:59:59" /></FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="detailVisible" title="用户详情" width="920" footer-hide>
|
||||
<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">{{ detail.user.name || '—' }}</span>
|
||||
<span class="detail-user-item">{{ detail.user.mobile || '—' }}</span>
|
||||
<Tag>{{ detail.user.status }}</Tag>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="ml12"
|
||||
:disabled="detail.user.status !== 'active'"
|
||||
@click="open_create_token_for_user(detail.user)"
|
||||
>生成 Token</Button>
|
||||
</div>
|
||||
<p v-if="detail" class="mb8">Token 数量:{{ detail.tokenCount }}</p>
|
||||
<p v-if="detail && detail.tokens && detail.tokens.length" class="sub-title">API Token</p>
|
||||
<Table v-if="detail && detail.tokens && detail.tokens.length" :columns="tokenCols" :data="detail.tokens"
|
||||
@@ -69,14 +110,22 @@
|
||||
border />
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="tokenListVisible" :title="tokenListTitle" width="820" footer-hide>
|
||||
<p v-if="tokenListRows.length" class="text-muted mb8">点击表格某一行查看该 Token 详情(明文不可查,需重新创建)</p>
|
||||
<Modal v-model="tokenListVisible" :title="tokenListTitle" width="980" footer-hide>
|
||||
<div v-if="tokenList_target_user" class="mb12">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="tokenList_target_user.status !== 'active'"
|
||||
@click="open_create_token_for_user(tokenList_target_user)"
|
||||
>生成 Token</Button>
|
||||
<span class="text-muted ml10">点击行查看详情;列表「明文」来自服务端加密存储(吊销后不可查看)</span>
|
||||
</div>
|
||||
<Table v-if="tokenListRows.length" :columns="tokenCols" :data="tokenListRows" size="small" border highlight-row
|
||||
@on-row-click="onTokenRowClick" />
|
||||
<p v-else class="text-muted">暂无 Token</p>
|
||||
</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">
|
||||
<p><span class="label">ID</span>{{ selectedToken.id }}</p>
|
||||
<p><span class="label">名称</span>{{ selectedToken.token_name }}</p>
|
||||
@@ -85,6 +134,14 @@
|
||||
<p><span class="label">状态</span>{{ selectedToken.status }}</p>
|
||||
<p><span class="label">过期时间</span>{{ selectedToken.expire_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>
|
||||
</Modal>
|
||||
</div>
|
||||
@@ -92,6 +149,7 @@
|
||||
|
||||
<script>
|
||||
import userServer from '@/api/subscription/user_server.js'
|
||||
import tokenServer from '@/api/subscription/token_server.js'
|
||||
import { downloadCsvFromRows } from '@/utils/csvExport.js'
|
||||
|
||||
export default {
|
||||
@@ -118,6 +176,14 @@ export default {
|
||||
tokenListUserName: '',
|
||||
tokenDetailVisible: false,
|
||||
selectedToken: null,
|
||||
token_plain_modal: false,
|
||||
token_plain_title: '',
|
||||
token_plain_text: '',
|
||||
create_token_modal: false,
|
||||
create_token_saving: false,
|
||||
create_token_target_user: null,
|
||||
create_token_form: { token_name: 'default', expire_at: '' },
|
||||
tokenList_target_user: null,
|
||||
subCols: [
|
||||
{ title: 'ID', key: 'id', width: 80 },
|
||||
{ title: '套餐ID', key: 'plan_id', width: 90 },
|
||||
@@ -160,7 +226,7 @@ export default {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'a',
|
||||
width: 240,
|
||||
width: 312,
|
||||
render: (h, p) => {
|
||||
return h('div', [
|
||||
h(
|
||||
@@ -180,6 +246,20 @@ export default {
|
||||
},
|
||||
'详情'
|
||||
),
|
||||
h(
|
||||
'Button',
|
||||
{
|
||||
props: {
|
||||
type: 'primary',
|
||||
size: 'small',
|
||||
ghost: true,
|
||||
disabled: p.row.status !== 'active',
|
||||
},
|
||||
class: { ml8: true },
|
||||
on: { click: () => this.open_create_token_for_user(p.row) },
|
||||
},
|
||||
'生成Token'
|
||||
),
|
||||
h(
|
||||
'Dropdown',
|
||||
{
|
||||
@@ -220,6 +300,52 @@ 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',
|
||||
width: 120,
|
||||
render: (h, p) => {
|
||||
if (p.row.status !== 'active') {
|
||||
return h('span', { class: { 'text-muted': true } }, '—')
|
||||
}
|
||||
return h(
|
||||
'Button',
|
||||
{
|
||||
props: { type: 'warning', size: 'small' },
|
||||
on: { click: () => this.do_regenerate_token(p.row) },
|
||||
},
|
||||
'重新生成'
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
tokenListTitle() {
|
||||
@@ -249,11 +375,43 @@ 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 }
|
||||
delete this.form.auto_create_token
|
||||
} else {
|
||||
this.form = { name: '', mobile: '', email: '', company_name: '', status: 'active' }
|
||||
this.form = {
|
||||
name: '',
|
||||
mobile: '',
|
||||
email: '',
|
||||
company_name: '',
|
||||
status: 'active',
|
||||
auto_create_token: true,
|
||||
}
|
||||
}
|
||||
this.modal = true
|
||||
},
|
||||
@@ -269,9 +427,17 @@ export default {
|
||||
? await userServer.edit(this.form)
|
||||
: await userServer.add(this.form)
|
||||
if (res && res.code === 0) {
|
||||
this.$Message.success('保存成功')
|
||||
this.modal = false
|
||||
this.load(1)
|
||||
const data = res.data || {}
|
||||
if (!this.form.id) {
|
||||
if (data.token_error) this.$Message.error(data.token_error)
|
||||
if (data.token_warn) this.$Message.warning(data.token_warn)
|
||||
if (data.plain_token) {
|
||||
this.show_token_plain('请保存新建用户的 Token 明文', data.plain_token)
|
||||
}
|
||||
}
|
||||
this.$Message.success('保存成功')
|
||||
} else {
|
||||
this.$Message.error((res && res.message) || '保存失败')
|
||||
}
|
||||
@@ -289,11 +455,60 @@ export default {
|
||||
this.$Message.error((res && res.message) || '加载详情失败')
|
||||
}
|
||||
},
|
||||
default_token_expire_input() {
|
||||
const d = new Date()
|
||||
d.setFullYear(d.getFullYear() + 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(
|
||||
2,
|
||||
'0'
|
||||
)} 23:59:59`
|
||||
},
|
||||
open_create_token_for_user(user_row) {
|
||||
if (!user_row || user_row.status !== 'active') {
|
||||
this.$Message.warning('仅状态为「正常」的用户可创建 Token')
|
||||
return
|
||||
}
|
||||
this.create_token_target_user = user_row
|
||||
this.create_token_form = {
|
||||
token_name: 'default',
|
||||
expire_at: this.default_token_expire_input(),
|
||||
}
|
||||
this.create_token_modal = true
|
||||
},
|
||||
submit_create_token() {
|
||||
const row = this.create_token_target_user
|
||||
if (!row) return false
|
||||
this.create_token_saving = true
|
||||
this._submit_create_token()
|
||||
return false
|
||||
},
|
||||
async _submit_create_token() {
|
||||
const row = this.create_token_target_user
|
||||
try {
|
||||
const res = await tokenServer.create({
|
||||
user_id: row.id,
|
||||
token_name: this.create_token_form.token_name || 'default',
|
||||
expire_at: this.create_token_form.expire_at,
|
||||
})
|
||||
if (res && res.code === 0) {
|
||||
if (res.data.warn) this.$Message.warning(res.data.warn)
|
||||
this.create_token_modal = false
|
||||
this.show_token_plain('请保存新建 Token 明文', res.data.plain_token)
|
||||
await this.reload_user_token_views(row.id)
|
||||
this.$Message.success('已创建 Token')
|
||||
} else {
|
||||
this.$Message.error((res && res.message) || '创建失败')
|
||||
}
|
||||
} finally {
|
||||
this.create_token_saving = false
|
||||
}
|
||||
},
|
||||
async openTokenList(row) {
|
||||
const res = await userServer.detail(row.id)
|
||||
if (res && res.code === 0) {
|
||||
this.tokenListRows = res.data.tokens || []
|
||||
this.tokenListUserName = row.name || String(row.id)
|
||||
this.tokenList_target_user = res.data.user || row
|
||||
this.tokenListVisible = true
|
||||
} else {
|
||||
this.$Message.error((res && res.message) || '加载 Token 失败')
|
||||
@@ -303,6 +518,35 @@ export default {
|
||||
this.selectedToken = row
|
||||
this.tokenDetailVisible = true
|
||||
},
|
||||
show_token_plain(title, text) {
|
||||
this.token_plain_title = title
|
||||
this.token_plain_text = text || ''
|
||||
this.token_plain_modal = true
|
||||
},
|
||||
async reload_user_token_views(user_id) {
|
||||
const res = await userServer.detail(user_id)
|
||||
if (res && res.code === 0) {
|
||||
if (this.detailVisible) this.detail = res.data
|
||||
if (this.tokenListVisible) this.tokenListRows = res.data.tokens || []
|
||||
this.load(1)
|
||||
}
|
||||
},
|
||||
do_regenerate_token(row) {
|
||||
this.$Modal.confirm({
|
||||
title: '重新生成 Token',
|
||||
content: '旧密钥将立即失效,确定继续?',
|
||||
onOk: async () => {
|
||||
const res = await tokenServer.regenerate({ id: row.id })
|
||||
if (res && res.code === 0) {
|
||||
if (res.data.warn) this.$Message.warning(res.data.warn)
|
||||
this.show_token_plain('请保存重新生成后的 Token 明文', res.data.plain_token)
|
||||
await this.reload_user_token_views(row.user_id)
|
||||
} else {
|
||||
this.$Message.error((res && res.message) || '操作失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
doDel(row) {
|
||||
this.$Modal.confirm({
|
||||
title: '删除用户',
|
||||
@@ -371,6 +615,10 @@ export default {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.ml12 {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.table-page-bar {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
@@ -407,4 +655,54 @@ export default {
|
||||
width: 88px;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
|
||||
.detail-user-item {
|
||||
color: #515a6e;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,7 @@ module.exports = {
|
||||
offset,
|
||||
limit: page_size,
|
||||
order: [["id", "DESC"]],
|
||||
attributes: { exclude: ["secret_cipher"] },
|
||||
});
|
||||
ctx.success({ rows, count });
|
||||
},
|
||||
@@ -55,6 +56,27 @@ module.exports = {
|
||||
});
|
||||
ctx.success({ id: row.id, status: row.status });
|
||||
},
|
||||
"POST /biz_token/regenerate": async (ctx) => {
|
||||
const body = ctx.getBody();
|
||||
const result = await tokenLogic.regenerateToken(body);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
biz_user_id: result.row.user_id,
|
||||
action: "biz_token.regenerate",
|
||||
resource_type: "biz_api_token",
|
||||
resource_id: result.row.id,
|
||||
detail: { token_name: result.row.token_name },
|
||||
});
|
||||
ctx.success({
|
||||
id: result.row.id,
|
||||
user_id: result.row.user_id,
|
||||
plan_id: result.row.plan_id,
|
||||
token_name: result.row.token_name,
|
||||
expire_at: result.row.expire_at,
|
||||
plain_token: result.plain_token,
|
||||
warn: result.warn,
|
||||
});
|
||||
},
|
||||
"POST /biz_token/export": async (ctx) => {
|
||||
const body = ctx.getBody();
|
||||
const param = body.param || body;
|
||||
@@ -64,6 +86,7 @@ module.exports = {
|
||||
where,
|
||||
limit: 10000,
|
||||
order: [["id", "DESC"]],
|
||||
attributes: { exclude: ["secret_cipher"] },
|
||||
});
|
||||
ctx.success({ rows });
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
@@ -46,7 +57,40 @@ module.exports = {
|
||||
resource_id: row.id,
|
||||
detail: { name: row.name },
|
||||
});
|
||||
ctx.success(row);
|
||||
|
||||
const out = row.get({ plain: true });
|
||||
let plain_token = null;
|
||||
let token_warn = null;
|
||||
let token_error = null;
|
||||
const auto_token = body.auto_create_token !== false;
|
||||
if (auto_token && row.status === "active") {
|
||||
try {
|
||||
const result = await tokenLogic.createToken({
|
||||
user_id: row.id,
|
||||
token_name: body.initial_token_name || "default",
|
||||
expire_at: body.initial_token_expire_at || tokenLogic.defaultTokenExpireAt(),
|
||||
});
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
biz_user_id: row.id,
|
||||
action: "biz_token.create",
|
||||
resource_type: "biz_api_token",
|
||||
resource_id: result.row.id,
|
||||
detail: { token_name: result.row.token_name, via: "biz_user.add" },
|
||||
});
|
||||
plain_token = result.plain_token;
|
||||
token_warn = result.warn;
|
||||
} catch (e) {
|
||||
token_error = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.success({
|
||||
...out,
|
||||
plain_token,
|
||||
token_warn,
|
||||
token_error,
|
||||
});
|
||||
},
|
||||
"POST /biz_user/edit": async (ctx) => {
|
||||
const body = ctx.getBody();
|
||||
@@ -101,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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +14,13 @@ function generatePlainToken() {
|
||||
return `waw_${crypto.randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
/** 默认 Token 过期时间:一年后当日 23:59:59 */
|
||||
function defaultTokenExpireAt() {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} 23:59:59`;
|
||||
}
|
||||
|
||||
/** 当前时间在 [start,end] 内且 status=active 的订阅 */
|
||||
async function findActiveSubscriptionForUser(userId) {
|
||||
const now = new Date();
|
||||
@@ -46,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,
|
||||
});
|
||||
@@ -68,14 +78,49 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留同一条 Token 记录,仅更换密钥(旧明文立即失效)
|
||||
*/
|
||||
async function regenerateToken(body) {
|
||||
const id = body.id;
|
||||
if (id == null) throw new Error("缺少 id");
|
||||
const row = await baseModel.biz_api_token.findByPk(id);
|
||||
if (!row) throw new Error("Token 不存在");
|
||||
if (row.status !== "active") throw new Error("仅可对状态为 active 的 Token 重新生成密钥");
|
||||
|
||||
const u = await baseModel.biz_user.findByPk(row.user_id);
|
||||
if (!u) throw new Error("用户不存在");
|
||||
if (u.status !== "active") throw new Error("用户已禁用,无法轮换密钥");
|
||||
|
||||
const sub = await findActiveSubscriptionForUser(row.user_id);
|
||||
const plan_id = sub ? sub.plan_id : null;
|
||||
|
||||
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();
|
||||
|
||||
return {
|
||||
row,
|
||||
plain_token: plain,
|
||||
warn: sub ? null : "当前无生效中的订阅,鉴权将失败",
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -84,8 +129,10 @@ async function revokeAllForUser(userId) {
|
||||
module.exports = {
|
||||
hashPlainToken,
|
||||
createToken,
|
||||
regenerateToken,
|
||||
revokeToken,
|
||||
revokeAllForUser,
|
||||
findActiveSubscriptionForUser,
|
||||
defaultTokenExpireAt,
|
||||
MAX_TOKENS_PER_USER,
|
||||
};
|
||||
|
||||
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 上游地址(转发层目标)
|
||||
"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 上游地址(转发层目标)
|
||||
"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