feat: 静态 Token 鉴权改造
All checks were successful
continuous-integration/drone/push Build is passing

- 将 token_secret 改为 access_token(长期有效)
- 移除 token_required 字段,统一使用 token 验证
- 生成链接简化为 ?tid=xxx&token=xxx 格式
- 前端移除签名验证开关,链接永久有效
This commit is contained in:
111
2026-01-23 18:43:04 +08:00
parent 39f33d7ac5
commit f815b29c51
4 changed files with 759 additions and 791 deletions

View File

@@ -19,8 +19,7 @@ class TenantApp(Base):
wechat_secret_encrypted = Column(Text) wechat_secret_encrypted = Column(Text)
# 鉴权配置 # 鉴权配置
token_secret = Column(String(64)) access_token = Column(String(64)) # 访问令牌(长期有效)
token_required = Column(SmallInteger, default=0)
allowed_origins = Column(Text) # JSON 数组 allowed_origins = Column(Text) # JSON 数组
# 功能权限 # 功能权限

View File

@@ -1,8 +1,5 @@
"""应用管理路由""" """应用管理路由"""
import json import json
import hmac
import hashlib
import time
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
@@ -180,15 +177,15 @@ async def delete_app(
@router.post("/generate-url") @router.post("/generate-url")
async def generate_signed_url( async def generate_url(
data: GenerateUrlRequest, data: GenerateUrlRequest,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
生成带签名的访问链接 生成访问链接
返回完整的可直接使用的 URL 返回完整的可直接使用的 URL(使用静态 token长期有效
""" """
# 获取应用信息 # 获取应用信息
app = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first() app = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first()
@@ -208,6 +205,9 @@ async def generate_signed_url(
if not tenant_app: if not tenant_app:
raise HTTPException(status_code=404, detail="租户未配置此应用") raise HTTPException(status_code=404, detail="租户未配置此应用")
if not tenant_app.access_token:
raise HTTPException(status_code=400, detail="租户应用未配置访问令牌")
# 构建基础 URL # 构建基础 URL
base_url = app.base_url.rstrip('/') base_url = app.base_url.rstrip('/')
if data.tool_code: if data.tool_code:
@@ -219,24 +219,12 @@ async def generate_signed_url(
else: else:
base_url = f"{base_url}/{data.tool_code}" base_url = f"{base_url}/{data.tool_code}"
# 构建参数 # 构建参数(静态 token长期有效
params = { params = {
"tid": data.tenant_id, "tid": data.tenant_id,
"aid": data.app_code "token": tenant_app.access_token
} }
# 如果需要签名
if tenant_app.token_required and tenant_app.token_secret:
ts = str(int(time.time()))
message = f"{data.tenant_id}{data.app_code}{ts}"
sign = hmac.new(
tenant_app.token_secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
params["ts"] = ts
params["sign"] = sign
# 组装 URL # 组装 URL
query_string = "&".join([f"{k}={v}" for k, v in params.items()]) query_string = "&".join([f"{k}={v}" for k, v in params.items()])
full_url = f"{base_url}?{query_string}" full_url = f"{base_url}?{query_string}"
@@ -245,9 +233,7 @@ async def generate_signed_url(
"success": True, "success": True,
"url": full_url, "url": full_url,
"params": params, "params": params,
"token_required": bool(tenant_app.token_required), "note": "静态链接,长期有效"
"expires_in": 300 if tenant_app.token_required else None, # 签名5分钟有效
"note": "签名链接5分钟内有效过期需重新生成" if tenant_app.token_required else "免签名链接,长期有效"
} }

View File

@@ -24,8 +24,7 @@ class TenantAppCreate(BaseModel):
wechat_corp_id: Optional[str] = None wechat_corp_id: Optional[str] = None
wechat_agent_id: Optional[str] = None wechat_agent_id: Optional[str] = None
wechat_secret: Optional[str] = None # 明文,存储时加密 wechat_secret: Optional[str] = None # 明文,存储时加密
token_secret: Optional[str] = None # 如果不传则自动生成 access_token: Optional[str] = None # 如果不传则自动生成
token_required: bool = False
allowed_origins: Optional[List[str]] = None allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None allowed_tools: Optional[List[str]] = None
@@ -35,8 +34,7 @@ class TenantAppUpdate(BaseModel):
wechat_corp_id: Optional[str] = None wechat_corp_id: Optional[str] = None
wechat_agent_id: Optional[str] = None wechat_agent_id: Optional[str] = None
wechat_secret: Optional[str] = None wechat_secret: Optional[str] = None
token_secret: Optional[str] = None access_token: Optional[str] = None
token_required: Optional[bool] = None
allowed_origins: Optional[List[str]] = None allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None allowed_tools: Optional[List[str]] = None
status: Optional[int] = None status: Optional[int] = None
@@ -101,8 +99,8 @@ async def create_tenant_app(
if exists: if exists:
raise HTTPException(status_code=400, detail="该租户应用配置已存在") raise HTTPException(status_code=400, detail="该租户应用配置已存在")
# 自动生成 token_secret # 自动生成 access_token
token_secret = data.token_secret or secrets.token_hex(32) access_token = data.access_token or secrets.token_hex(32)
# 加密 wechat_secret # 加密 wechat_secret
wechat_secret_encrypted = None wechat_secret_encrypted = None
@@ -116,8 +114,7 @@ async def create_tenant_app(
wechat_corp_id=data.wechat_corp_id, wechat_corp_id=data.wechat_corp_id,
wechat_agent_id=data.wechat_agent_id, wechat_agent_id=data.wechat_agent_id,
wechat_secret_encrypted=wechat_secret_encrypted, wechat_secret_encrypted=wechat_secret_encrypted,
token_secret=token_secret, access_token=access_token,
token_required=1 if data.token_required else 0,
allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None, allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None,
allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None, allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None,
status=1 status=1
@@ -126,7 +123,7 @@ async def create_tenant_app(
db.commit() db.commit()
db.refresh(app) db.refresh(app)
return {"success": True, "id": app.id, "token_secret": token_secret} return {"success": True, "id": app.id, "access_token": access_token}
@router.put("/{app_id}") @router.put("/{app_id}")
@@ -155,10 +152,6 @@ async def update_tenant_app(
if 'allowed_tools' in update_data: if 'allowed_tools' in update_data:
update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None
# 处理 token_required
if 'token_required' in update_data:
update_data['token_required'] = 1 if update_data['token_required'] else 0
for key, value in update_data.items(): for key, value in update_data.items():
setattr(app, key, value) setattr(app, key, value)
@@ -189,16 +182,16 @@ async def regenerate_token(
user: User = Depends(require_operator), user: User = Depends(require_operator),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""重新生成 token_secret""" """重新生成 access_token"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first() app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app: if not app:
raise HTTPException(status_code=404, detail="应用配置不存在") raise HTTPException(status_code=404, detail="应用配置不存在")
new_token = secrets.token_hex(32) new_token = secrets.token_hex(32)
app.token_secret = new_token app.access_token = new_token
db.commit() db.commit()
return {"success": True, "token_secret": new_token} return {"success": True, "access_token": new_token}
@router.get("/{app_id}/wechat-secret") @router.get("/{app_id}/wechat-secret")
@@ -229,8 +222,7 @@ def format_tenant_app(app: TenantApp, mask_secret: bool = True) -> dict:
"wechat_corp_id": app.wechat_corp_id, "wechat_corp_id": app.wechat_corp_id,
"wechat_agent_id": app.wechat_agent_id, "wechat_agent_id": app.wechat_agent_id,
"has_wechat_secret": bool(app.wechat_secret_encrypted), "has_wechat_secret": bool(app.wechat_secret_encrypted),
"token_secret": "******" if mask_secret and app.token_secret else app.token_secret, "access_token": "******" if mask_secret and app.access_token else app.access_token,
"token_required": bool(app.token_required),
"allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [], "allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [],
"allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [], "allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [],
"status": app.status, "status": app.status,

View File

@@ -32,7 +32,6 @@ const form = reactive({
wechat_corp_id: '', wechat_corp_id: '',
wechat_agent_id: '', wechat_agent_id: '',
wechat_secret: '', wechat_secret: '',
token_required: false,
allowed_tools: [] allowed_tools: []
}) })
@@ -117,7 +116,6 @@ function handleCreate() {
wechat_corp_id: '', wechat_corp_id: '',
wechat_agent_id: '', wechat_agent_id: '',
wechat_secret: '', wechat_secret: '',
token_required: false,
allowed_tools: [] allowed_tools: []
}) })
dialogVisible.value = true dialogVisible.value = true
@@ -133,7 +131,6 @@ function handleEdit(row) {
wechat_corp_id: row.wechat_corp_id || '', wechat_corp_id: row.wechat_corp_id || '',
wechat_agent_id: row.wechat_agent_id || '', wechat_agent_id: row.wechat_agent_id || '',
wechat_secret: '', // 不回显密钥 wechat_secret: '', // 不回显密钥
token_required: row.token_required,
allowed_tools: row.allowed_tools || [] allowed_tools: row.allowed_tools || []
}) })
dialogVisible.value = true dialogVisible.value = true
@@ -154,7 +151,7 @@ async function handleSubmit() {
ElMessage.success('更新成功') ElMessage.success('更新成功')
} else { } else {
const res = await api.post('/api/tenant-apps', data) const res = await api.post('/api/tenant-apps', data)
ElMessage.success(`创建成功,Token Secret: ${res.data.token_secret}`) ElMessage.success(`创建成功,Access Token: ${res.data.access_token}`)
} }
dialogVisible.value = false dialogVisible.value = false
fetchList() fetchList()
@@ -178,13 +175,13 @@ async function handleDelete(row) {
} }
async function handleRegenerateToken(row) { async function handleRegenerateToken(row) {
await ElMessageBox.confirm('重新生成 Token Secret 将使旧的签名失效,确定继续?', '提示', { await ElMessageBox.confirm('重新生成 Access Token 将使旧的链接失效,确定继续?', '提示', {
type: 'warning' type: 'warning'
}) })
try { try {
const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`) const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`)
ElMessage.success(`Token Secret: ${res.data.token_secret}`) ElMessage.success(`Access Token: ${res.data.access_token}`)
fetchList() fetchList()
} catch (e) { } catch (e) {
// 错误已在拦截器处理 // 错误已在拦截器处理
@@ -330,11 +327,10 @@ onMounted(() => {
<el-tag v-else type="info" size="small">未配置</el-tag> <el-tag v-else type="info" size="small">未配置</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Token 验证" width="100"> <el-table-column label="Access Token" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.token_required ? 'warning' : 'info'" size="small"> <el-tag v-if="row.access_token" type="success" size="small">已配置</el-tag>
{{ row.token_required ? '必须' : '可选' }} <el-tag v-else type="danger" size="small">未配置</el-tag>
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="allowed_tools" label="允许工具" min-width="150"> <el-table-column prop="allowed_tools" label="允许工具" min-width="150">
@@ -395,12 +391,8 @@ onMounted(() => {
<el-input v-model="form.wechat_secret" type="password" show-password :placeholder="editingId ? '留空则不修改' : '应用的 Secret'" /> <el-input v-model="form.wechat_secret" type="password" show-password :placeholder="editingId ? '留空则不修改' : '应用的 Secret'" />
</el-form-item> </el-form-item>
<el-divider content-position="left">权配置</el-divider> <el-divider content-position="left">配置</el-divider>
<el-form-item label="强制 Token 验证">
<el-switch v-model="form.token_required" />
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后 URL 必须携带有效签名</span>
</el-form-item>
<el-form-item label="允许的工具"> <el-form-item label="允许的工具">
<el-checkbox-group v-model="form.allowed_tools"> <el-checkbox-group v-model="form.allowed_tools">
<el-checkbox v-for="opt in toolOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-checkbox> <el-checkbox v-for="opt in toolOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-checkbox>
@@ -419,10 +411,9 @@ onMounted(() => {
<el-descriptions :column="2" border size="small" style="margin-bottom: 20px"> <el-descriptions :column="2" border size="small" style="margin-bottom: 20px">
<el-descriptions-item label="租户ID">{{ currentRow.tenant_id }}</el-descriptions-item> <el-descriptions-item label="租户ID">{{ currentRow.tenant_id }}</el-descriptions-item>
<el-descriptions-item label="应用">{{ currentRow.app_code }}</el-descriptions-item> <el-descriptions-item label="应用">{{ currentRow.app_code }}</el-descriptions-item>
<el-descriptions-item label="签名要求"> <el-descriptions-item label="Access Token">
<el-tag :type="currentRow.token_required ? 'warning' : 'success'" size="small"> <el-tag v-if="currentRow.access_token" type="success" size="small">已配置</el-tag>
{{ currentRow.token_required ? '需要签名' : '免签名' }} <el-tag v-else type="danger" size="small">未配置</el-tag>
</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="允许工具"> <el-descriptions-item label="允许工具">
{{ (currentRow.allowed_tools || []).length > 0 ? currentRow.allowed_tools.join(', ') : '全部' }} {{ (currentRow.allowed_tools || []).length > 0 ? currentRow.allowed_tools.join(', ') : '全部' }}
@@ -446,8 +437,8 @@ onMounted(() => {
<el-divider content-position="left">生成结果</el-divider> <el-divider content-position="left">生成结果</el-divider>
<el-alert <el-alert
:type="urlInfo.token_required ? 'warning' : 'success'" type="success"
:title="urlInfo.note" :title="urlInfo.note || '静态链接,长期有效'"
:closable="false" :closable="false"
style="margin-bottom: 12px" style="margin-bottom: 12px"
/> />