fix: 完善 API Token 验收细节
This commit is contained in:
@@ -225,6 +225,7 @@ API Token 保存在本地 `api_tokens.json`,只保存 HMAC 哈希和显示用
|
|||||||
## API Token 与文档
|
## API Token 与文档
|
||||||
|
|
||||||
登录后进入左侧 `API 中心` 可生成、停用、设置永久或指定日期到期的 API Token,并查看内置 API 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。
|
登录后进入左侧 `API 中心` 可生成、停用、设置永久或指定日期到期的 API Token,并查看内置 API 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。
|
||||||
|
如果 Token 未设置为永久有效,则必须明确选择到期日期;到期、停用或重生成后的旧 Token 都不能继续调用业务 API。
|
||||||
|
|
||||||
Token 调用方式:
|
Token 调用方式:
|
||||||
```text
|
```text
|
||||||
@@ -237,6 +238,7 @@ X-API-Token: <token>
|
|||||||
```
|
```
|
||||||
|
|
||||||
API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。
|
API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。
|
||||||
|
桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token。
|
||||||
|
|
||||||
授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
|
授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ def _bad_expiration_error(exc: ValueError) -> HTTPException:
|
|||||||
return HTTPException(status_code=400, detail="到期日期格式无效,请使用 YYYY-MM-DD 或 ISO 日期时间")
|
return HTTPException(status_code=400, detail="到期日期格式无效,请使用 YYYY-MM-DD 或 ISO 日期时间")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_expires_at(payload: dict[str, Any]) -> str | None:
|
||||||
|
if bool(payload.get("permanent", False)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
expires_at = str(payload.get("expires_at") or "").strip()
|
||||||
|
if not expires_at:
|
||||||
|
raise HTTPException(status_code=400, detail="请选择 Token 到期日期,或设置为永久有效")
|
||||||
|
return expires_at
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/tokens")
|
@router.get("/api/tokens")
|
||||||
async def get_tokens(request: Request):
|
async def get_tokens(request: Request):
|
||||||
_require_login(request)
|
_require_login(request)
|
||||||
@@ -28,18 +38,14 @@ async def get_tokens(request: Request):
|
|||||||
async def create_api_token(request: Request, payload: dict[str, Any] = Body(...)):
|
async def create_api_token(request: Request, payload: dict[str, Any] = Body(...)):
|
||||||
_require_login(request)
|
_require_login(request)
|
||||||
name = str(payload.get("name", "")).strip()
|
name = str(payload.get("name", "")).strip()
|
||||||
expires_at = payload.get("expires_at")
|
|
||||||
enabled = bool(payload.get("enabled", True))
|
enabled = bool(payload.get("enabled", True))
|
||||||
permanent = bool(payload.get("permanent", False))
|
raw_expires_at = _resolve_expires_at(payload)
|
||||||
expires_in_days = payload.get("expires_in_days")
|
|
||||||
raw_expires_at = None if permanent else (str(expires_at).strip() if expires_at else None)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_token, record = create_token(
|
raw_token, record = create_token(
|
||||||
name=name,
|
name=name,
|
||||||
expires_at=raw_expires_at,
|
expires_at=raw_expires_at,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
expires_in_days=int(expires_in_days) if expires_in_days is not None else None,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _bad_expiration_error(exc) from exc
|
raise _bad_expiration_error(exc) from exc
|
||||||
@@ -63,7 +69,7 @@ async def update_api_token(request: Request, payload: dict[str, Any] = Body(...)
|
|||||||
token_id,
|
token_id,
|
||||||
name=payload.get("name"),
|
name=payload.get("name"),
|
||||||
enabled=payload.get("enabled"),
|
enabled=payload.get("enabled"),
|
||||||
expires_at=None if payload.get("permanent") else payload.get("expires_at"),
|
expires_at=_resolve_expires_at(payload),
|
||||||
)
|
)
|
||||||
return {"success": True, "message": "API Token 已更新", "record": record}
|
return {"success": True, "message": "API Token 已更新", "record": record}
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 项目上下文记录
|
# 项目上下文记录
|
||||||
|
|
||||||
## 2026-05-23:新增 API Token 和离线 API 文档
|
## 2026-05-23:新增 API Token 和离线 API 文档
|
||||||
|
- API Token 验收补强:非永久 Token 必须明确传入到期日期,非法或缺失到期日期返回 400;到期、停用、重生成旧 Token 均会拒绝业务 API。已实测 API Token 可执行 `/api/database/execute` 和 `/api/database/table/query`,但不能访问 `/api/tokens` 管理接口。
|
||||||
|
- `frontend/src/components/ApiCenter.vue` 的 Swagger UI 改为通过 `apiUrl('/api/openapi.json')` 加载文档,并在请求拦截器中只在未手动填写 Authorization 时补登录 JWT;桌面端或配置 `VITE_API_BASE` 时,Try it out 请求会自动补全后端基址,避免相对 `/api/*` 请求打到错误 origin。
|
||||||
- 新增 `app/services/api_tokens.py` 和 `app/api/routers/api_tokens.py`:API Token 存储在运行时 `api_tokens.json`,只保存 HMAC-SHA256 哈希、前后缀、启用状态、到期时间和最近使用信息;完整 Token 仅在创建或重生成时返回一次。`api_tokens.json` 已加入 `.gitignore`。
|
- 新增 `app/services/api_tokens.py` 和 `app/api/routers/api_tokens.py`:API Token 存储在运行时 `api_tokens.json`,只保存 HMAC-SHA256 哈希、前后缀、启用状态、到期时间和最近使用信息;完整 Token 仅在创建或重生成时返回一次。`api_tokens.json` 已加入 `.gitignore`。
|
||||||
- `app/auth.py` 增加登录态和访问态解析:登录态只接受 JWT/cookie,业务访问态接受登录 JWT、`Authorization: Bearer <api-token>` 和 `X-API-Token`。Token 管理、系统配置、授权和 API 文档仍要求登录后访问。
|
- `app/auth.py` 增加登录态和访问态解析:登录态只接受 JWT/cookie,业务访问态接受登录 JWT、`Authorization: Bearer <api-token>` 和 `X-API-Token`。Token 管理、系统配置、授权和 API 文档仍要求登录后访问。
|
||||||
- `app/main.py` 的全局鉴权中间件改为 JWT + API Token 双鉴权;业务 API 可由 API Token 调用,`/api/openapi.json` 和 `/api/docs-info` 仅登录后可见。OpenAPI schema 同时声明 `BearerAuth` 和 `ApiTokenHeader`,便于外部系统对接。
|
- `app/main.py` 的全局鉴权中间件改为 JWT + API Token 双鉴权;业务 API 可由 API Token 调用,`/api/openapi.json` 和 `/api/docs-info` 仅登录后可见。OpenAPI schema 同时声明 `BearerAuth` 和 `ApiTokenHeader`,便于外部系统对接。
|
||||||
|
|||||||
@@ -357,13 +357,17 @@ function parseFilename(disposition: string | null): string {
|
|||||||
return plainMatch?.[1] || '';
|
return plainMatch?.[1] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiUrl(url: string): string {
|
export function apiUrl(url: string): string {
|
||||||
if (!API_BASE || /^https?:\/\//i.test(url)) {
|
if (!API_BASE || /^https?:\/\//i.test(url)) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `${API_BASE}${url.startsWith('/') ? url : `/${url}`}`;
|
return `${API_BASE}${url.startsWith('/') ? url : `/${url}`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getApiBaseUrl(): string {
|
||||||
|
return API_BASE || window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const configured = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
const configured = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||||
if (configured) {
|
if (configured) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<n-space size="small">
|
<n-space size="small">
|
||||||
<n-button size="small" tertiary @click="copyHeaderSample">复制传参示例</n-button>
|
<n-button size="small" tertiary @click="copyHeaderSample">复制传参示例</n-button>
|
||||||
<n-button size="small" tertiary tag="a" href="/api/openapi.json" target="_blank">OpenAPI JSON</n-button>
|
<n-button size="small" tertiary tag="a" :href="openApiUrl" target="_blank">OpenAPI JSON</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -135,7 +135,7 @@ import { AddOutline, RefreshOutline } from '@vicons/ionicons5';
|
|||||||
import SwaggerUIBundle from 'swagger-ui-dist/swagger-ui-bundle.js';
|
import SwaggerUIBundle from 'swagger-ui-dist/swagger-ui-bundle.js';
|
||||||
import 'swagger-ui-dist/swagger-ui.css';
|
import 'swagger-ui-dist/swagger-ui.css';
|
||||||
|
|
||||||
import { apiGet, apiPost, getToken } from '../api/client';
|
import { apiGet, apiPost, apiUrl, getApiBaseUrl, getToken } from '../api/client';
|
||||||
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
|
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
|
||||||
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||||
|
|
||||||
@@ -149,6 +149,7 @@ type SwaggerSystem = {
|
|||||||
|
|
||||||
interface SwaggerRequest {
|
interface SwaggerRequest {
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
@@ -167,6 +168,7 @@ const tokenForm = reactive({
|
|||||||
expires_at: '',
|
expires_at: '',
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
|
const openApiUrl = apiUrl('/api/openapi.json');
|
||||||
let swaggerUi: SwaggerSystem | undefined;
|
let swaggerUi: SwaggerSystem | undefined;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -201,8 +203,9 @@ function initSwagger() {
|
|||||||
|
|
||||||
swaggerHost.value.innerHTML = '';
|
swaggerHost.value.innerHTML = '';
|
||||||
swaggerUi = SwaggerUIBundle({
|
swaggerUi = SwaggerUIBundle({
|
||||||
url: '/api/openapi.json',
|
url: openApiUrl,
|
||||||
domNode: swaggerHost.value,
|
domNode: swaggerHost.value,
|
||||||
|
requestSnippetsEnabled: true,
|
||||||
deepLinking: true,
|
deepLinking: true,
|
||||||
docExpansion: 'none',
|
docExpansion: 'none',
|
||||||
persistAuthorization: true,
|
persistAuthorization: true,
|
||||||
@@ -211,9 +214,12 @@ function initSwagger() {
|
|||||||
showExtensions: true,
|
showExtensions: true,
|
||||||
requestInterceptor: (request: SwaggerRequest) => {
|
requestInterceptor: (request: SwaggerRequest) => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) {
|
if (token && !request.headers.Authorization && !request.headers.authorization) {
|
||||||
request.headers.Authorization = `Bearer ${token}`;
|
request.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
if (request.url?.startsWith('/')) {
|
||||||
|
request.url = `${getApiBaseUrl()}${request.url}`;
|
||||||
|
}
|
||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user