feat: 新增 API Token 和离线 API 文档

This commit is contained in:
2026-05-23 03:00:34 +08:00
parent 211f87e65e
commit 4784ed36ca
14 changed files with 1160 additions and 14 deletions
+115
View File
@@ -0,0 +1,115 @@
from typing import Any
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth import resolve_login_context
from app.services.api_tokens import create_token, delete_token, list_tokens, regenerate_token, update_token
router = APIRouter(tags=["api-tokens"])
def _require_login(request: Request) -> None:
if resolve_login_context(request) is None:
raise HTTPException(status_code=401, detail="未登录或登录已过期")
def _bad_expiration_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail="到期日期格式无效,请使用 YYYY-MM-DD 或 ISO 日期时间")
@router.get("/api/tokens")
async def get_tokens(request: Request):
_require_login(request)
return {"success": True, "tokens": list_tokens()}
@router.post("/api/tokens/create")
async def create_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
name = str(payload.get("name", "")).strip()
expires_at = payload.get("expires_at")
enabled = bool(payload.get("enabled", True))
permanent = bool(payload.get("permanent", False))
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:
raw_token, record = create_token(
name=name,
expires_at=raw_expires_at,
enabled=enabled,
expires_in_days=int(expires_in_days) if expires_in_days is not None else None,
)
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
return {
"success": True,
"message": "API Token 创建成功",
"token": raw_token,
"record": record,
}
@router.post("/api/tokens/update")
async def update_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
try:
record = update_token(
token_id,
name=payload.get("name"),
enabled=payload.get("enabled"),
expires_at=None if payload.get("permanent") else payload.get("expires_at"),
)
return {"success": True, "message": "API Token 已更新", "record": record}
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/api/tokens/regenerate")
async def regenerate_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
try:
raw_token, record = regenerate_token(token_id)
return {
"success": True,
"message": "API Token 已重新生成",
"token": raw_token,
"record": record,
}
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/api/tokens/delete")
async def delete_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
delete_token(token_id)
return {"success": True, "message": "API Token 已删除"}
@router.get("/api/docs-info")
async def docs_info(request: Request):
_require_login(request)
return {
"success": True,
"docs_url": "/api/docs-ui",
"openapi_url": "/api/openapi.json",
"token_header": "Authorization: Bearer <token>",
"alt_header": "X-API-Token: <token>",
"note": "API 文档仅登录后可访问。",
}
+64
View File
@@ -4,8 +4,11 @@ import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from typing import Optional
from starlette.requests import Request
from app.config import BASE_DIR
@@ -15,6 +18,12 @@ DEFAULT_USERNAME = "root"
DEFAULT_PASSWORD = "Capacity"
@dataclass(frozen=True)
class AuthContext:
kind: str
payload: dict
def _ensure_auth_ini() -> None:
if AUTH_INI_PATH.exists():
return
@@ -68,6 +77,61 @@ def verify_jwt_token(token: str) -> Optional[dict]:
return None
def extract_login_token(request: Request) -> str | None:
auth_header = request.headers.get("Authorization", "").strip()
if auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1].strip()
if token:
return token
cookie_token = request.cookies.get("token", "").strip()
return cookie_token or None
def extract_access_token(request: Request) -> str | None:
auth_header = request.headers.get("Authorization", "").strip()
if auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1].strip()
if token:
return token
api_token = request.headers.get("X-API-Token", "").strip()
if api_token:
return api_token
cookie_token = request.cookies.get("token", "").strip()
return cookie_token or None
def resolve_login_context(request: Request) -> AuthContext | None:
token = extract_login_token(request)
if not token:
return None
payload = verify_jwt_token(token)
if not payload:
return None
return AuthContext(kind="jwt", payload=payload)
def resolve_access_context(request: Request) -> AuthContext | None:
token = extract_access_token(request)
if not token:
return None
payload = verify_jwt_token(token)
if payload:
return AuthContext(kind="jwt", payload=payload)
from app.services.api_tokens import verify_api_token
api_payload = verify_api_token(token)
if api_payload:
return AuthContext(kind="api_token", payload=api_payload)
return None
def _encode_json(data: dict) -> str:
raw = json.dumps(data, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
+95 -13
View File
@@ -1,21 +1,43 @@
from pathlib import Path
import argparse
from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.openapi.utils import get_openapi
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from app import state
from app.api.routers import auth, cache, config, database, health, history, license, remote, script, tasks, upload
from app.auth import verify_jwt_token
from app.api.routers import (
api_tokens,
auth,
cache,
config,
database,
health,
history,
license,
remote,
script,
tasks,
upload,
)
from app.auth import extract_access_token, resolve_access_context, resolve_login_context
from app.config import BASE_DIR
from app.services.api_tokens import touch_token_usage
APP_VERSION = "3.0.0"
APP_HOST = "0.0.0.0"
APP_PORT = 9081
FRONTEND_DIST_DIR = BASE_DIR / "frontend" / "dist"
LOGIN_ONLY_API_PREFIXES = (
"/api/config",
"/api/change-password",
"/api/license",
"/api/tokens",
)
LOGIN_ONLY_API_PATHS = {"/api/openapi.json", "/api/docs-ui", "/api/docs-info"}
def create_app() -> FastAPI:
@@ -27,6 +49,7 @@ def create_app() -> FastAPI:
redoc_url=None,
openapi_url=None,
)
app.openapi = lambda: custom_openapi(app) # type: ignore[method-assign]
app.add_middleware(
CORSMiddleware,
@@ -36,31 +59,43 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
app.middleware("http")(jwt_middleware)
app.middleware("http")(auth_middleware)
register_routes(app)
register_frontend(app)
return app
async def jwt_middleware(request: Request, call_next):
async def auth_middleware(request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
if path.startswith("/api/") and path != "/api/login":
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
if path == "/api/login":
return await call_next(request)
if _is_login_only_api(path):
if resolve_login_context(request) is None:
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
return await call_next(request)
if path.startswith("/api/"):
access_context = resolve_access_context(request)
if access_context is None:
return JSONResponse(status_code=401, content={"detail": "未授权,请提供有效的 Token"})
token = auth_header.split(" ", 1)[1]
if not verify_jwt_token(token):
return JSONResponse(status_code=401, content={"detail": "Token 无效或已过期"})
request.state.auth_context = access_context
if access_context.kind == "api_token":
access_token = extract_access_token(request)
if access_token:
client_host = request.client.host if request.client else None
touch_token_usage(access_token, client_host)
return await call_next(request)
def register_routes(app: FastAPI) -> None:
routers = [
api_tokens.router,
auth.router,
health.router,
upload.router,
@@ -78,12 +113,23 @@ def register_routes(app: FastAPI) -> None:
def register_frontend(app: FastAPI) -> None:
@app.get("/api/openapi.json", include_in_schema=False)
async def serve_openapi(request: Request):
if resolve_login_context(request) is None:
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
return JSONResponse(app.openapi())
@app.get("/api/docs-ui", include_in_schema=False)
async def serve_docs_ui(request: Request):
if resolve_login_context(request) is None:
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
return RedirectResponse(url="/api-center", status_code=302)
@app.get("/", include_in_schema=False)
@app.get("/{path:path}", include_in_schema=False)
async def serve_frontend(path: str = ""):
if path.startswith("api/"):
raise HTTPException(status_code=404, detail="接口不存在")
return _serve_frontend_path(path)
@@ -118,6 +164,42 @@ def _safe_file(root: Path, path: str) -> Path | None:
return requested
def _is_login_only_api(path: str) -> bool:
return path in LOGIN_ONLY_API_PATHS or path.startswith(LOGIN_ONLY_API_PREFIXES)
def custom_openapi(app: FastAPI) -> dict:
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes)
components = schema.setdefault("components", {})
security_schemes = components.setdefault("securitySchemes", {})
security_schemes["BearerAuth"] = {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "登录 JWT 或 API Token,均可通过 Authorization: Bearer <token> 传递;API Token 也支持 X-API-Token: <token>。",
}
security_schemes["ApiTokenHeader"] = {
"type": "apiKey",
"in": "header",
"name": "X-API-Token",
"description": "API Token 也可以通过 X-API-Token 请求头传递。",
}
for path, methods in schema.get("paths", {}).items():
if not path.startswith("/api/") or path in {"/api/login"}:
continue
security = [{"BearerAuth": []}] if _is_login_only_api(path) else [{"BearerAuth": []}, {"ApiTokenHeader": []}]
for operation in methods.values():
if isinstance(operation, dict):
operation["security"] = security
app.openapi_schema = schema
return schema
app = create_app()
+274
View File
@@ -0,0 +1,274 @@
import base64
import hashlib
import hmac
import json
import secrets
from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timedelta, timezone
from threading import RLock
from typing import Any
from app.config import BASE_DIR
API_TOKENS_PATH = BASE_DIR / "api_tokens.json"
API_TOKEN_PREFIX = "cap_"
API_TOKEN_SECRET = "CapaReportApiTokenSecret2026"
_STORE_LOCK = RLock()
@dataclass
class ApiTokenRecord:
id: str
name: str
token_hash: str
prefix: str
suffix: str
created_at: str
expires_at: str | None
enabled: bool
last_used_at: str | None = None
last_used_from: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def ensure_store() -> None:
if API_TOKENS_PATH.exists():
return
with _STORE_LOCK:
if API_TOKENS_PATH.exists():
return
API_TOKENS_PATH.write_text(json.dumps({"tokens": []}, ensure_ascii=False, indent=2), encoding="utf-8")
def list_tokens() -> list[dict[str, Any]]:
with _STORE_LOCK:
return [record_to_public_dict(record) for record in _load_records()]
def create_token(
name: str,
expires_in_days: int | None = None,
enabled: bool = True,
expires_at: str | None = None,
) -> tuple[str, dict[str, Any]]:
raw_token = generate_raw_token()
resolved_expires_at = normalize_expiration(expires_at)
if resolved_expires_at is None and expires_in_days is not None:
resolved_expires_at = expires_at_from_days(expires_in_days)
record = ApiTokenRecord(
id=secrets.token_hex(8),
name=name.strip() or "未命名 Token",
token_hash=hash_token(raw_token),
prefix=raw_token[:12],
suffix=raw_token[-12:],
created_at=utc_now(),
expires_at=resolved_expires_at,
enabled=bool(enabled),
)
with _STORE_LOCK:
records = _load_records()
records.append(record)
_save_records(records)
return raw_token, record_to_public_dict(record)
def update_token(token_id: str, **changes: Any) -> dict[str, Any]:
with _STORE_LOCK:
records = _load_records()
for index, record in enumerate(records):
if record.id != token_id:
continue
if "name" in changes and isinstance(changes["name"], str):
record.name = changes["name"].strip() or record.name
if "enabled" in changes:
record.enabled = bool(changes["enabled"])
if "expires_at" in changes:
record.expires_at = normalize_expiration(changes["expires_at"])
records[index] = record
_save_records(records)
return record_to_public_dict(record)
raise KeyError(f"Token not found: {token_id}")
def delete_token(token_id: str) -> None:
with _STORE_LOCK:
records = [record for record in _load_records() if record.id != token_id]
_save_records(records)
def regenerate_token(token_id: str) -> tuple[str, dict[str, Any]]:
with _STORE_LOCK:
records = _load_records()
for index, record in enumerate(records):
if record.id != token_id:
continue
raw_token = generate_raw_token()
record.token_hash = hash_token(raw_token)
record.prefix = raw_token[:12]
record.suffix = raw_token[-12:]
record.created_at = utc_now()
record.last_used_at = None
record.last_used_from = None
records[index] = record
_save_records(records)
return raw_token, record_to_public_dict(record)
raise KeyError(f"Token not found: {token_id}")
def verify_api_token(raw_token: str) -> dict[str, Any] | None:
ensure_store()
token_hash = hash_token(raw_token)
now = datetime.now(timezone.utc)
with _STORE_LOCK:
for record in _load_records():
if not record.enabled or record.token_hash != token_hash:
continue
expires_at = parse_datetime(record.expires_at)
if expires_at and expires_at < now:
continue
return record_to_context(record)
return None
def touch_token_usage(raw_token: str, source: str | None = None) -> None:
token_hash = hash_token(raw_token)
now = utc_now()
with _STORE_LOCK:
records = _load_records()
updated = False
for index, record in enumerate(records):
if record.token_hash != token_hash:
continue
record.last_used_at = now
record.last_used_from = source or record.last_used_from
records[index] = record
updated = True
break
if updated:
_save_records(records)
def generate_raw_token() -> str:
return API_TOKEN_PREFIX + secrets.token_urlsafe(36)
def hash_token(raw_token: str) -> str:
digest = hmac.new(API_TOKEN_SECRET.encode(), raw_token.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def normalize_expiration(value: str | None) -> str | None:
if value is None:
return None
trimmed = str(value).strip()
if not trimmed:
return None
parsed = parse_datetime(trimmed)
if parsed is None:
raise ValueError("Invalid expiration date")
return parsed.isoformat(timespec="seconds")
def expires_at_from_days(days: int) -> str:
safe_days = max(int(days), 1)
expires_at = datetime.now(timezone.utc) + timedelta(days=safe_days)
return expires_at.isoformat(timespec="seconds")
def parse_datetime(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
try:
parsed_date = date.fromisoformat(value)
except ValueError:
return None
parsed = datetime.combine(parsed_date, time.max)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def record_to_public_dict(record: ApiTokenRecord, include_hash: bool = False) -> dict[str, Any]:
expires_at = parse_datetime(record.expires_at)
data = {
"id": record.id,
"name": record.name,
"prefix": record.prefix,
"suffix": record.suffix,
"created_at": record.created_at,
"expires_at": record.expires_at,
"enabled": record.enabled,
"last_used_at": record.last_used_at,
"last_used_from": record.last_used_from,
"expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
}
if include_hash:
data["token_hash"] = record.token_hash
return data
def record_to_context(record: ApiTokenRecord) -> dict[str, Any]:
return {
"token_id": record.id,
"name": record.name,
"created_at": record.created_at,
"expires_at": record.expires_at,
"token_type": "api_token",
}
def _load_records() -> list[ApiTokenRecord]:
ensure_store()
try:
raw = json.loads(API_TOKENS_PATH.read_text(encoding="utf-8"))
except json.JSONDecodeError:
raw = {"tokens": []}
tokens = raw.get("tokens", []) if isinstance(raw, dict) else []
records: list[ApiTokenRecord] = []
for item in tokens:
if not isinstance(item, dict):
continue
records.append(
ApiTokenRecord(
id=str(item.get("id", "")) or secrets.token_hex(8),
name=str(item.get("name", "未命名 Token")),
token_hash=str(item.get("token_hash", "")),
prefix=str(item.get("prefix", "")),
suffix=str(item.get("suffix", "")),
created_at=str(item.get("created_at", utc_now())),
expires_at=item.get("expires_at"),
enabled=bool(item.get("enabled", True)),
last_used_at=item.get("last_used_at"),
last_used_from=item.get("last_used_from"),
)
)
records.sort(key=lambda record: record.created_at, reverse=True)
return records
def _save_records(records: list[ApiTokenRecord]) -> None:
payload = {"tokens": [record.to_dict() for record in records]}
API_TOKENS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")