mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-11 12:08:30 +08:00
- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理 - 前端添加访问令牌管理页面,支持普通用户和管理员 - 后端实现完整的令牌生命周期管理 API - 添加数据库迁移脚本创建 management_tokens 表 - Nginx 配置添加 gzip 压缩,优化响应传输 - Dialog 组件添加 persistent 属性,防止意外关闭 - 为管理后台 API 添加详细的中文文档注释 - 简化多处类型注解,统一代码风格
578 lines
20 KiB
Python
578 lines
20 KiB
Python
"""用户 Management Token 管理端点"""
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||
from fastapi.responses import JSONResponse
|
||
from pydantic import BaseModel, Field, field_validator
|
||
from sqlalchemy.orm import Session
|
||
|
||
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
|
||
from src.api.base.context import ApiRequestContext
|
||
from src.api.base.pipeline import ApiRequestPipeline
|
||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||
from src.database import get_db
|
||
from src.models.database import AuditEventType
|
||
from src.services.management_token import (
|
||
ManagementTokenService,
|
||
parse_expires_at,
|
||
token_to_dict,
|
||
validate_ip_list,
|
||
)
|
||
|
||
router = APIRouter(prefix="/api/me/management-tokens", tags=["Management Tokens"])
|
||
pipeline = ApiRequestPipeline()
|
||
|
||
|
||
# ============== 安全基类 ==============
|
||
|
||
|
||
class ManagementTokenApiAdapter(AuthenticatedApiAdapter):
|
||
"""Management Token 管理 API 的基类
|
||
|
||
安全限制:禁止使用 Management Token 调用这些接口,
|
||
防止用户通过已有的 Token 再创建/修改/删除其他 Token。
|
||
"""
|
||
|
||
def authorize(self, context: ApiRequestContext):
|
||
# 先调用父类的认证检查
|
||
super().authorize(context)
|
||
|
||
# 禁止使用 Management Token 调用 management-tokens 相关接口
|
||
if context.management_token is not None:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="不允许使用 Management Token 管理其他 Token,请使用 Web 界面或 JWT 认证",
|
||
)
|
||
|
||
|
||
# ============== 请求/响应模型 ==============
|
||
|
||
|
||
class CreateManagementTokenRequest(BaseModel):
|
||
"""创建 Management Token 请求"""
|
||
|
||
name: str = Field(..., min_length=1, max_length=100, description="Token 名称")
|
||
description: Optional[str] = Field(None, max_length=500, description="描述")
|
||
allowed_ips: Optional[list[str]] = Field(None, description="IP 白名单")
|
||
expires_at: Optional[datetime] = Field(None, description="过期时间")
|
||
|
||
@field_validator("allowed_ips")
|
||
@classmethod
|
||
def validate_allowed_ips(cls, v: Optional[list[str]]) -> Optional[list[str]]:
|
||
return validate_ip_list(v)
|
||
|
||
@field_validator("expires_at", mode="before")
|
||
@classmethod
|
||
def parse_expires(cls, v):
|
||
return parse_expires_at(v)
|
||
|
||
|
||
class UpdateManagementTokenRequest(BaseModel):
|
||
"""更新 Management Token 请求
|
||
|
||
对于 allowed_ips 和 expires_at 字段:
|
||
- 未提供(字段不在请求中): 不修改
|
||
- 显式设为 null: 清空该字段
|
||
- 提供有效值: 更新为新值
|
||
"""
|
||
|
||
model_config = {"extra": "allow"} # 允许额外字段以便检测哪些字段被显式提供
|
||
|
||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||
description: Optional[str] = Field(None, max_length=500)
|
||
allowed_ips: Optional[list[str]] = None
|
||
expires_at: Optional[datetime] = None
|
||
|
||
# 用于追踪哪些字段被显式提供(包括显式设为 null 的情况)
|
||
_provided_fields: set[str] = set()
|
||
|
||
def __init__(self, **data):
|
||
# 记录实际传入的字段(包括值为 None 的)
|
||
provided = set(data.keys())
|
||
super().__init__(**data)
|
||
object.__setattr__(self, "_provided_fields", provided)
|
||
|
||
def is_field_provided(self, field_name: str) -> bool:
|
||
"""检查字段是否被显式提供(区分未提供和显式设为 null)"""
|
||
return field_name in self._provided_fields
|
||
|
||
@field_validator("allowed_ips")
|
||
@classmethod
|
||
def validate_allowed_ips(cls, v: Optional[list[str]]) -> Optional[list[str]]:
|
||
# 如果是 None,表示要清空,直接返回
|
||
if v is None:
|
||
return None
|
||
return validate_ip_list(v)
|
||
|
||
@field_validator("expires_at", mode="before")
|
||
@classmethod
|
||
def parse_expires(cls, v):
|
||
# 如果是 None 或空字符串,表示要清空
|
||
if v is None or (isinstance(v, str) and not v.strip()):
|
||
return None
|
||
return parse_expires_at(v)
|
||
|
||
|
||
# ============== 路由 ==============
|
||
|
||
|
||
@router.get("")
|
||
async def list_my_management_tokens(
|
||
request: Request,
|
||
is_active: Optional[bool] = Query(None, description="筛选激活状态"),
|
||
skip: int = Query(0, ge=0),
|
||
limit: int = Query(50, ge=1, le=100),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""列出当前用户的 Management Tokens
|
||
|
||
获取当前登录用户创建的所有 Management Tokens,支持按激活状态筛选和分页。
|
||
|
||
**查询参数**
|
||
- is_active (Optional[bool]): 筛选激活状态(true/false),不传则返回全部
|
||
- skip (int): 分页偏移量,默认 0
|
||
- limit (int): 每页数量,范围 1-100,默认 50
|
||
|
||
**返回字段**
|
||
- items (List[dict]): Token 列表
|
||
- id (str): Token ID
|
||
- user_id (str): 所属用户 ID
|
||
- name (str): Token 名称
|
||
- description (Optional[str]): 描述
|
||
- token_hash (str): Token 哈希值(不返回明文)
|
||
- is_active (bool): 是否激活
|
||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||
- expires_at (Optional[str]): 过期时间(ISO 8601 格式)
|
||
- last_used_at (Optional[str]): 最后使用时间
|
||
- created_at (str): 创建时间
|
||
- updated_at (str): 更新时间
|
||
- total (int): 总数量
|
||
- skip (int): 当前偏移量
|
||
- limit (int): 当前每页数量
|
||
- quota (dict): 配额信息
|
||
- used (int): 已使用数量
|
||
- max (int): 最大允许数量
|
||
"""
|
||
adapter = ListMyManagementTokensAdapter(is_active=is_active, skip=skip, limit=limit)
|
||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||
|
||
|
||
@router.post("")
|
||
async def create_my_management_token(request: Request, db: Session = Depends(get_db)):
|
||
"""创建 Management Token
|
||
|
||
为当前用户创建一个新的 Management Token。
|
||
|
||
**请求体字段**
|
||
- name (str): Token 名称,必填,长度 1-100
|
||
- description (Optional[str]): 描述,可选,最大长度 500
|
||
- allowed_ips (Optional[List[str]]): IP 白名单,可选,支持 IPv4/IPv6 和 CIDR 格式
|
||
- expires_at (Optional[datetime]): 过期时间,可选,支持 ISO 8601 格式字符串或 datetime 对象
|
||
|
||
**返回字段**
|
||
- message (str): 操作结果消息
|
||
- token (str): 生成的 Token 明文(仅在创建时返回一次,请妥善保存)
|
||
- data (dict): Token 信息
|
||
- id (str): Token ID
|
||
- user_id (str): 所属用户 ID
|
||
- name (str): Token 名称
|
||
- description (Optional[str]): 描述
|
||
- token_hash (str): Token 哈希值
|
||
- is_active (bool): 是否激活(新创建默认为 true)
|
||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||
- expires_at (Optional[str]): 过期时间
|
||
- last_used_at (Optional[str]): 最后使用时间
|
||
- created_at (str): 创建时间
|
||
- updated_at (str): 更新时间
|
||
"""
|
||
adapter = CreateMyManagementTokenAdapter()
|
||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||
|
||
|
||
@router.get("/{token_id}")
|
||
async def get_my_management_token(
|
||
token_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""获取 Management Token 详情
|
||
|
||
获取当前用户指定 Token 的详细信息。
|
||
|
||
**路径参数**
|
||
- token_id (str): Token ID
|
||
|
||
**返回字段**
|
||
- id (str): Token ID
|
||
- user_id (str): 所属用户 ID
|
||
- name (str): Token 名称
|
||
- description (Optional[str]): 描述
|
||
- token_hash (str): Token 哈希值(不返回明文)
|
||
- is_active (bool): 是否激活
|
||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||
- expires_at (Optional[str]): 过期时间(ISO 8601 格式)
|
||
- last_used_at (Optional[str]): 最后使用时间
|
||
- created_at (str): 创建时间
|
||
- updated_at (str): 更新时间
|
||
"""
|
||
adapter = GetMyManagementTokenAdapter(token_id=token_id)
|
||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||
|
||
|
||
@router.put("/{token_id}")
|
||
async def update_my_management_token(
|
||
token_id: str, request: Request, db: Session = Depends(get_db)
|
||
):
|
||
"""更新 Management Token
|
||
|
||
更新当前用户指定 Token 的信息。支持部分字段更新。
|
||
|
||
**路径参数**
|
||
- token_id (str): Token ID
|
||
|
||
**请求体字段**(所有字段均可选)
|
||
- name (Optional[str]): Token 名称,长度 1-100
|
||
- description (Optional[str]): 描述,最大长度 500,传空字符串或 null 可清空
|
||
- allowed_ips (Optional[List[str]]): IP 白名单,传 null 可清空
|
||
- expires_at (Optional[datetime]): 过期时间,传 null 可清空
|
||
|
||
注意:未提供的字段不会被修改,显式传 null 表示清空该字段。
|
||
|
||
**返回字段**
|
||
- message (str): 操作结果消息
|
||
- data (dict): 更新后的 Token 信息
|
||
- id (str): Token ID
|
||
- user_id (str): 所属用户 ID
|
||
- name (str): Token 名称
|
||
- description (Optional[str]): 描述
|
||
- token_hash (str): Token 哈希值
|
||
- is_active (bool): 是否激活
|
||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||
- expires_at (Optional[str]): 过期时间
|
||
- last_used_at (Optional[str]): 最后使用时间
|
||
- created_at (str): 创建时间
|
||
- updated_at (str): 更新时间
|
||
"""
|
||
adapter = UpdateMyManagementTokenAdapter(token_id=token_id)
|
||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||
|
||
|
||
@router.delete("/{token_id}")
|
||
async def delete_my_management_token(
|
||
token_id: str, request: Request, db: Session = Depends(get_db)
|
||
):
|
||
"""删除 Management Token
|
||
|
||
删除当前用户指定的 Token。
|
||
|
||
**路径参数**
|
||
- token_id (str): 要删除的 Token ID
|
||
|
||
**返回字段**
|
||
- message (str): 操作结果消息
|
||
"""
|
||
adapter = DeleteMyManagementTokenAdapter(token_id=token_id)
|
||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||
|
||
|
||
@router.patch("/{token_id}/status")
|
||
async def toggle_my_management_token(
|
||
token_id: str, request: Request, db: Session = Depends(get_db)
|
||
):
|
||
"""切换 Management Token 状态
|
||
|
||
启用或禁用当前用户指定的 Token。
|
||
|
||
**路径参数**
|
||
- token_id (str): Token ID
|
||
|
||
**返回字段**
|
||
- message (str): 操作结果消息("Token 已启用" 或 "Token 已禁用")
|
||
- data (dict): 更新后的 Token 信息
|
||
- id (str): Token ID
|
||
- user_id (str): 所属用户 ID
|
||
- name (str): Token 名称
|
||
- description (Optional[str]): 描述
|
||
- token_hash (str): Token 哈希值
|
||
- is_active (bool): 是否激活(已切换后的状态)
|
||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||
- expires_at (Optional[str]): 过期时间
|
||
- last_used_at (Optional[str]): 最后使用时间
|
||
- created_at (str): 创建时间
|
||
- updated_at (str): 更新时间
|
||
"""
|
||
adapter = ToggleMyManagementTokenAdapter(token_id=token_id)
|
||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||
|
||
|
||
@router.post("/{token_id}/regenerate")
|
||
async def regenerate_my_management_token(
|
||
token_id: str, request: Request, db: Session = Depends(get_db)
|
||
):
|
||
"""重新生成 Management Token
|
||
|
||
重新生成当前用户指定 Token 的值,旧 Token 将立即失效。
|
||
|
||
**路径参数**
|
||
- token_id (str): Token ID
|
||
|
||
**返回字段**
|
||
- message (str): 操作结果消息
|
||
- token (str): 新生成的 Token 明文(仅在重新生成时返回一次,请妥善保存)
|
||
- data (dict): Token 信息
|
||
- id (str): Token ID
|
||
- user_id (str): 所属用户 ID
|
||
- name (str): Token 名称
|
||
- description (Optional[str]): 描述
|
||
- token_hash (str): 新的 Token 哈希值
|
||
- is_active (bool): 是否激活
|
||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||
- expires_at (Optional[str]): 过期时间
|
||
- last_used_at (Optional[str]): 最后使用时间(重置为 null)
|
||
- created_at (str): 创建时间
|
||
- updated_at (str): 更新时间
|
||
"""
|
||
adapter = RegenerateMyManagementTokenAdapter(token_id=token_id)
|
||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||
|
||
|
||
# ============== 适配器 ==============
|
||
|
||
|
||
@dataclass
|
||
class ListMyManagementTokensAdapter(ManagementTokenApiAdapter):
|
||
"""列出用户的 Management Tokens"""
|
||
|
||
name: str = "list_my_management_tokens"
|
||
is_active: Optional[bool] = None
|
||
skip: int = 0
|
||
limit: int = 50
|
||
|
||
async def handle(self, context: ApiRequestContext):
|
||
from src.config.settings import config
|
||
|
||
tokens, total = ManagementTokenService.list_tokens(
|
||
db=context.db,
|
||
user_id=context.user.id,
|
||
is_active=self.is_active,
|
||
skip=self.skip,
|
||
limit=self.limit,
|
||
)
|
||
|
||
# 获取用户 Token 总数(用于配额显示)
|
||
max_tokens = config.management_token_max_per_user
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"items": [token_to_dict(t) for t in tokens],
|
||
"total": total,
|
||
"skip": self.skip,
|
||
"limit": self.limit,
|
||
"quota": {
|
||
"used": total,
|
||
"max": max_tokens,
|
||
},
|
||
}
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class CreateMyManagementTokenAdapter(ManagementTokenApiAdapter):
|
||
"""创建 Management Token"""
|
||
|
||
name: str = "create_my_management_token"
|
||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_CREATED
|
||
|
||
async def handle(self, context: ApiRequestContext):
|
||
body = context.ensure_json_body()
|
||
|
||
try:
|
||
req = CreateManagementTokenRequest(**body)
|
||
except Exception as e:
|
||
raise InvalidRequestException(str(e))
|
||
|
||
try:
|
||
token, raw_token = ManagementTokenService.create_token(
|
||
db=context.db,
|
||
user_id=context.user.id,
|
||
name=req.name,
|
||
description=req.description,
|
||
allowed_ips=req.allowed_ips,
|
||
expires_at=req.expires_at,
|
||
)
|
||
except ValueError as e:
|
||
raise InvalidRequestException(str(e))
|
||
|
||
context.add_audit_metadata(token_id=token.id, token_name=token.name)
|
||
|
||
return JSONResponse(
|
||
status_code=201,
|
||
content={
|
||
"message": "Management Token 创建成功",
|
||
"token": raw_token, # 仅在创建时返回一次
|
||
"data": token_to_dict(token),
|
||
},
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class GetMyManagementTokenAdapter(ManagementTokenApiAdapter):
|
||
"""获取 Management Token 详情"""
|
||
|
||
name: str = "get_my_management_token"
|
||
token_id: str = ""
|
||
|
||
async def handle(self, context: ApiRequestContext):
|
||
token = ManagementTokenService.get_token_by_id(
|
||
db=context.db, token_id=self.token_id, user_id=context.user.id
|
||
)
|
||
|
||
if not token:
|
||
raise NotFoundException("Management Token 不存在")
|
||
|
||
return JSONResponse(content=token_to_dict(token))
|
||
|
||
|
||
@dataclass
|
||
class UpdateMyManagementTokenAdapter(ManagementTokenApiAdapter):
|
||
"""更新 Management Token"""
|
||
|
||
name: str = "update_my_management_token"
|
||
token_id: str = ""
|
||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
|
||
|
||
async def handle(self, context: ApiRequestContext):
|
||
body = context.ensure_json_body()
|
||
|
||
try:
|
||
req = UpdateManagementTokenRequest(**body)
|
||
except Exception as e:
|
||
raise InvalidRequestException(str(e))
|
||
|
||
# 构建更新参数,只包含显式提供的字段
|
||
update_kwargs: dict = {
|
||
"db": context.db,
|
||
"token_id": self.token_id,
|
||
"user_id": context.user.id,
|
||
}
|
||
|
||
# 对于普通字段,只有提供了才更新
|
||
if req.is_field_provided("name"):
|
||
update_kwargs["name"] = req.name
|
||
if req.is_field_provided("description"):
|
||
update_kwargs["description"] = req.description
|
||
update_kwargs["clear_description"] = req.description is None or req.description == ""
|
||
|
||
# 对于可清空字段,需要传递特殊标记
|
||
if req.is_field_provided("allowed_ips"):
|
||
update_kwargs["allowed_ips"] = req.allowed_ips
|
||
update_kwargs["clear_allowed_ips"] = req.allowed_ips is None
|
||
if req.is_field_provided("expires_at"):
|
||
update_kwargs["expires_at"] = req.expires_at
|
||
update_kwargs["clear_expires_at"] = req.expires_at is None
|
||
|
||
try:
|
||
token = ManagementTokenService.update_token(**update_kwargs)
|
||
except ValueError as e:
|
||
raise InvalidRequestException(str(e))
|
||
|
||
if not token:
|
||
raise NotFoundException("Management Token 不存在")
|
||
|
||
context.add_audit_metadata(token_id=token.id, token_name=token.name)
|
||
|
||
return JSONResponse(
|
||
content={"message": "更新成功", "data": token_to_dict(token)}
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class DeleteMyManagementTokenAdapter(ManagementTokenApiAdapter):
|
||
"""删除 Management Token"""
|
||
|
||
name: str = "delete_my_management_token"
|
||
token_id: str = ""
|
||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_DELETED
|
||
|
||
async def handle(self, context: ApiRequestContext):
|
||
# 先获取 token 信息用于审计
|
||
token = ManagementTokenService.get_token_by_id(
|
||
db=context.db, token_id=self.token_id, user_id=context.user.id
|
||
)
|
||
|
||
if not token:
|
||
raise NotFoundException("Management Token 不存在")
|
||
|
||
context.add_audit_metadata(token_id=token.id, token_name=token.name)
|
||
|
||
success = ManagementTokenService.delete_token(
|
||
db=context.db, token_id=self.token_id, user_id=context.user.id
|
||
)
|
||
|
||
if not success:
|
||
raise NotFoundException("Management Token 不存在")
|
||
|
||
return JSONResponse(content={"message": "删除成功"})
|
||
|
||
|
||
@dataclass
|
||
class ToggleMyManagementTokenAdapter(ManagementTokenApiAdapter):
|
||
"""切换 Management Token 状态"""
|
||
|
||
name: str = "toggle_my_management_token"
|
||
token_id: str = ""
|
||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
|
||
|
||
async def handle(self, context: ApiRequestContext):
|
||
token = ManagementTokenService.toggle_status(
|
||
db=context.db, token_id=self.token_id, user_id=context.user.id
|
||
)
|
||
|
||
if not token:
|
||
raise NotFoundException("Management Token 不存在")
|
||
|
||
context.add_audit_metadata(
|
||
token_id=token.id, token_name=token.name, is_active=token.is_active
|
||
)
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"message": f"Token 已{'启用' if token.is_active else '禁用'}",
|
||
"data": token_to_dict(token),
|
||
}
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class RegenerateMyManagementTokenAdapter(ManagementTokenApiAdapter):
|
||
"""重新生成 Management Token"""
|
||
|
||
name: str = "regenerate_my_management_token"
|
||
token_id: str = ""
|
||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
|
||
|
||
async def handle(self, context: ApiRequestContext):
|
||
token, raw_token, old_token_hash = ManagementTokenService.regenerate_token(
|
||
db=context.db, token_id=self.token_id, user_id=context.user.id
|
||
)
|
||
|
||
if not token:
|
||
raise NotFoundException("Management Token 不存在")
|
||
|
||
context.add_audit_metadata(
|
||
token_id=token.id,
|
||
token_name=token.name,
|
||
regenerated=True,
|
||
)
|
||
|
||
return JSONResponse(
|
||
content={
|
||
"message": "Token 已重新生成",
|
||
"token": raw_token, # 仅在重新生成时返回一次
|
||
"data": token_to_dict(token),
|
||
}
|
||
)
|