feat: 添加访问令牌管理功能并升级至 0.2.4

- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理
- 前端添加访问令牌管理页面,支持普通用户和管理员
- 后端实现完整的令牌生命周期管理 API
- 添加数据库迁移脚本创建 management_tokens 表
- Nginx 配置添加 gzip 压缩,优化响应传输
- Dialog 组件添加 persistent 属性,防止意外关闭
- 为管理后台 API 添加详细的中文文档注释
- 简化多处类型注解,统一代码风格
This commit is contained in:
fawney19
2026-01-07 14:55:07 +08:00
parent f6a6410626
commit 0061fc04b7
59 changed files with 6265 additions and 648 deletions

View File

@@ -2,12 +2,14 @@
认证服务
"""
from __future__ import annotations
import hashlib
import secrets
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional
import jwt
from fastapi import HTTPException, status
@@ -18,6 +20,9 @@ from sqlalchemy.orm import Session, joinedload
from src.config import config
from src.core.logger import logger
from src.core.enums import AuthSource
if TYPE_CHECKING:
from src.models.database import ManagementToken
from src.models.database import ApiKey, User, UserRole
from src.services.auth.jwt_blacklist import JWTBlacklistService
from src.services.auth.ldap import LDAPService
@@ -478,3 +483,137 @@ class AuthService:
except Exception as e:
logger.error(f"撤销 Token 失败: {e}")
return False
@staticmethod
async def authenticate_management_token(
db: Session, raw_token: str, client_ip: str
) -> Optional[tuple[User, "ManagementToken"]]:
"""Management Token 认证
Args:
db: 数据库会话
raw_token: Management Token 字符串
client_ip: 客户端 IP
Returns:
(User, ManagementToken) 元组,认证失败返回 None
Raises:
RateLimitException: 超过速率限制时抛出(用于返回 429
"""
from src.core.exceptions import RateLimitException
from src.models.database import AuditEventType, ManagementToken
from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
# 速率限制检查(防止暴力破解)
allowed, remaining, ttl = await IPRateLimiter.check_limit(
client_ip,
endpoint_type="management_token",
limit=config.management_token_rate_limit,
)
if not allowed:
logger.warning(f"Management Token 认证 - IP {client_ip} 超过速率限制")
raise RateLimitException(limit=config.management_token_rate_limit, window="分钟")
# 检查 Token 格式
if not raw_token.startswith(ManagementToken.TOKEN_PREFIX):
logger.warning("Management Token 认证失败 - 格式错误")
return None
# 哈希查找
token_hash = ManagementToken.hash_token(raw_token)
token_record = (
db.query(ManagementToken)
.options(joinedload(ManagementToken.user))
.filter(ManagementToken.token_hash == token_hash)
.first()
)
if not token_record:
logger.warning("Management Token 认证失败 - Token 不存在")
return None
# 注意:数据库查询已通过 token_hash 索引匹配,此处不再需要额外的常量时间比较
# Token 的 62^40 熵(约 238 位)加上速率限制已足够防止暴力破解
# 检查状态
if not token_record.is_active:
logger.warning(f"Management Token 认证失败 - Token 已禁用: {token_record.id}")
return None
# 检查过期(使用属性方法,确保时区安全)
if token_record.is_expired:
logger.warning(f"Management Token 认证失败 - Token 已过期: {token_record.id}")
AuditService.log_event(
db=db,
event_type=AuditEventType.MANAGEMENT_TOKEN_EXPIRED,
description=f"Management Token 已过期: {token_record.name}",
user_id=token_record.user_id,
ip_address=client_ip,
metadata={
"token_id": token_record.id,
"token_name": token_record.name,
"expired_at": (
token_record.expires_at.isoformat() if token_record.expires_at else None
),
},
)
return None
# 检查 IP 白名单
if not token_record.is_ip_allowed(client_ip):
logger.warning(
f"Management Token IP 限制 - Token: {token_record.id}, IP: {client_ip}"
)
AuditService.log_event(
db=db,
event_type=AuditEventType.MANAGEMENT_TOKEN_IP_BLOCKED,
description=f"Management Token IP 被拒绝: {token_record.name}",
user_id=token_record.user_id,
ip_address=client_ip,
metadata={
"token_id": token_record.id,
"token_name": token_record.name,
"blocked_ip": client_ip,
# 不记录 allowed_ips 以防信息泄露
},
)
return None
# 获取用户
user = token_record.user
if not user or not user.is_active:
logger.warning("Management Token 认证失败 - 用户不存在或已禁用")
return None
# 使用 SQL 原子操作更新使用统计
from sqlalchemy import func
db.query(ManagementToken).filter(ManagementToken.id == token_record.id).update(
{
ManagementToken.last_used_at: func.now(), # 使用数据库时间确保一致性
ManagementToken.last_used_ip: client_ip,
ManagementToken.usage_count: ManagementToken.usage_count + 1,
ManagementToken.updated_at: func.now(), # 显式更新,因为原子 SQL 绕过 ORM
},
synchronize_session=False,
)
# 记录 Token 使用审计日志
AuditService.log_event(
db=db,
event_type=AuditEventType.MANAGEMENT_TOKEN_USED,
description=f"Management Token 认证成功: {token_record.name}",
user_id=user.id,
ip_address=client_ip,
metadata={
"token_id": token_record.id,
"token_name": token_record.name,
},
)
db.commit()
logger.debug(f"Management Token 认证成功: user={user.email}, token={token_record.id}")
return user, token_record

View File

@@ -0,0 +1,15 @@
"""Management Token 服务模块"""
from .service import (
ManagementTokenService,
parse_expires_at,
token_to_dict,
validate_ip_list,
)
__all__ = [
"ManagementTokenService",
"parse_expires_at",
"token_to_dict",
"validate_ip_list",
]

View File

@@ -0,0 +1,416 @@
"""Management Token 服务"""
import ipaddress
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.logger import logger
from src.models.database import ManagementToken
def validate_ip_list(ips: Optional[list[str]]) -> Optional[list[str]]:
"""验证 IP 白名单格式
- None: 不限制 IP
- 非空列表: 只允许列表中的 IP
- 空列表: 不允许(会抛出错误)
"""
if ips is None:
return None
if len(ips) == 0:
raise ValueError("IP 白名单不能为空列表,如需取消限制请不提供此字段")
validated = []
for i, ip_str in enumerate(ips):
original = ip_str
ip_str = ip_str.strip()
if not ip_str:
raise ValueError(f"IP 白名单第 {i + 1} 项为空")
try:
if "/" in ip_str:
ipaddress.ip_network(ip_str, strict=False)
else:
ipaddress.ip_address(ip_str)
validated.append(ip_str)
except ValueError:
raise ValueError(f"无效的 IP 地址或 CIDR: {original}")
if not validated:
raise ValueError("IP 白名单不能为空,如需取消限制请不提供此字段")
return validated
def parse_expires_at(v, allow_past: bool = False) -> Optional[datetime]:
"""解析过期时间,确保时区安全
前端 datetime-local 输入返回本地时间字符串(无时区信息)。
后端要求:
- 如果是字符串且不含时区,视为 UTC
- 如果是 datetime 且无时区,视为 UTC
- 带时区的输入直接使用
- 默认要求过期时间必须在未来allow_past=False
Args:
v: 时间值(字符串或 datetime
allow_past: 是否允许过去的时间(用于清除过期时间等场景)
Returns:
解析后的 datetime 或 None
"""
if v is None:
return None
if isinstance(v, str):
if not v.strip():
return None
try:
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
except ValueError as e:
raise ValueError(f"无效的时间格式: {v}") from e
elif isinstance(v, datetime):
dt = v
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
raise ValueError(f"不支持的时间类型: {type(v)}")
if not allow_past and dt <= datetime.now(timezone.utc):
raise ValueError("过期时间必须在未来")
return dt
def token_to_dict(
token: ManagementToken,
raw_token: Optional[str] = None,
include_user: bool = False,
) -> dict:
"""将 ManagementToken 转换为字典
Args:
token: ManagementToken 实例
raw_token: 明文 Token仅在创建/重新生成时提供)
include_user: 是否包含用户信息(管理员视图使用)
Returns:
Token 字典表示
"""
result = {
"id": token.id,
"user_id": token.user_id,
"name": token.name,
"description": token.description,
"token_display": token.get_display_token(),
"allowed_ips": token.allowed_ips,
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
"last_used_at": token.last_used_at.isoformat() if token.last_used_at else None,
"last_used_ip": token.last_used_ip,
"usage_count": token.usage_count,
"is_active": token.is_active,
"created_at": token.created_at.isoformat() if token.created_at else None,
"updated_at": token.updated_at.isoformat() if token.updated_at else None,
}
if raw_token:
result["token"] = raw_token
if include_user and token.user:
result["user"] = {
"id": token.user.id,
"email": token.user.email,
"username": token.user.username,
"role": token.user.role.value if token.user.role else None,
}
return result
class ManagementTokenService:
"""Management Token 服务类"""
@staticmethod
def create_token(
db: Session,
user_id: str,
name: str,
description: Optional[str] = None,
allowed_ips: Optional[list[str]] = None,
expires_at: Optional[datetime] = None,
) -> tuple[ManagementToken, str]:
"""创建 Management Token
Args:
db: 数据库会话
user_id: 用户 ID
name: Token 名称
description: 描述
allowed_ips: IP 白名单
expires_at: 过期时间
Returns:
(ManagementToken, 明文 Token) 元组
Raises:
ValueError: 如果名称已存在或超过数量限制
"""
# 检查用户 Token 数量限制
token_count = (
db.query(ManagementToken).filter(ManagementToken.user_id == user_id).count()
)
max_tokens = config.management_token_max_per_user
if token_count >= max_tokens:
raise ValueError(f"已达到 Token 数量上限({max_tokens}")
# 检查名称是否已存在
existing = (
db.query(ManagementToken)
.filter(ManagementToken.user_id == user_id, ManagementToken.name == name)
.first()
)
if existing:
raise ValueError(f"已存在名为 '{name}' 的 Token")
# 生成 Token
raw_token = ManagementToken.generate_token()
# 创建记录
token = ManagementToken(
user_id=user_id,
name=name,
description=description,
allowed_ips=allowed_ips,
expires_at=expires_at,
)
token.set_token(raw_token)
db.add(token)
try:
db.commit()
except IntegrityError:
db.rollback()
# 并发创建导致唯一约束冲突
raise ValueError(f"已存在名为 '{name}' 的 Token")
db.refresh(token)
logger.info(f"创建 Management Token: {token.id} for user {user_id}")
return token, raw_token
@staticmethod
def get_token_by_id(
db: Session, token_id: str, user_id: Optional[str] = None
) -> Optional[ManagementToken]:
"""根据 ID 获取 Token
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只查询该用户的 Token
Returns:
ManagementToken 或 None
"""
query = db.query(ManagementToken).filter(ManagementToken.id == token_id)
if user_id:
query = query.filter(ManagementToken.user_id == user_id)
return query.first()
@staticmethod
def list_tokens(
db: Session,
user_id: Optional[str] = None,
is_active: Optional[bool] = None,
skip: int = 0,
limit: int = 100,
) -> tuple[list[ManagementToken], int]:
"""列出 Tokens
Args:
db: 数据库会话
user_id: 用户 ID如果提供则只查询该用户的 Token
is_active: 筛选激活状态
skip: 跳过记录数
limit: 返回记录数
Returns:
(Token 列表, 总数) 元组
"""
query = db.query(ManagementToken)
if user_id:
query = query.filter(ManagementToken.user_id == user_id)
if is_active is not None:
query = query.filter(ManagementToken.is_active == is_active)
total = query.count()
tokens = query.order_by(ManagementToken.created_at.desc()).offset(skip).limit(limit).all()
return tokens, total
@staticmethod
def update_token(
db: Session,
token_id: str,
user_id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
allowed_ips: Optional[list[str]] = None,
expires_at: Optional[datetime] = None,
is_active: Optional[bool] = None,
clear_description: bool = False,
clear_allowed_ips: bool = False,
clear_expires_at: bool = False,
) -> Optional[ManagementToken]:
"""更新 Token
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只更新该用户的 Token
name: 新名称
description: 新描述
allowed_ips: 新 IP 白名单
expires_at: 新过期时间
is_active: 新激活状态
clear_description: 是否清空描述True 时 description 被忽略)
clear_allowed_ips: 是否清空 IP 白名单True 时 allowed_ips 被忽略)
clear_expires_at: 是否清空过期时间True 时 expires_at 被忽略)
Returns:
更新后的 ManagementToken 或 None
Raises:
ValueError: 如果新名称已被其他 Token 使用
"""
token = ManagementTokenService.get_token_by_id(db, token_id, user_id)
if not token:
return None
# 如果更新名称,检查是否与其他 Token 冲突
if name is not None and name != token.name:
existing = (
db.query(ManagementToken)
.filter(
ManagementToken.user_id == token.user_id,
ManagementToken.name == name,
ManagementToken.id != token_id,
)
.first()
)
if existing:
raise ValueError(f"已存在名为 '{name}' 的 Token")
token.name = name
# 处理 description支持清空
if clear_description:
token.description = None
elif description is not None:
token.description = description
# 处理 allowed_ips支持清空
if clear_allowed_ips:
token.allowed_ips = None
elif allowed_ips is not None:
token.allowed_ips = allowed_ips if allowed_ips else None
# 处理 expires_at支持清空
if clear_expires_at:
token.expires_at = None
elif expires_at is not None:
token.expires_at = expires_at
if is_active is not None:
token.is_active = is_active
db.commit()
db.refresh(token)
logger.info(f"更新 Management Token: {token.id}")
return token
@staticmethod
def delete_token(
db: Session, token_id: str, user_id: Optional[str] = None
) -> bool:
"""删除 Token
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只删除该用户的 Token
Returns:
是否删除成功
"""
token = ManagementTokenService.get_token_by_id(db, token_id, user_id)
if not token:
return False
db.delete(token)
db.commit()
logger.info(f"删除 Management Token: {token_id}")
return True
@staticmethod
def toggle_status(
db: Session, token_id: str, user_id: Optional[str] = None
) -> Optional[ManagementToken]:
"""切换 Token 状态
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只操作该用户的 Token
Returns:
更新后的 ManagementToken 或 None
"""
token = ManagementTokenService.get_token_by_id(db, token_id, user_id)
if not token:
return None
token.is_active = not token.is_active
db.commit()
db.refresh(token)
logger.info(f"切换 Management Token 状态: {token.id} -> {token.is_active}")
return token
@staticmethod
def regenerate_token(
db: Session, token_id: str, user_id: Optional[str] = None
) -> tuple[Optional[ManagementToken], Optional[str], Optional[str]]:
"""重新生成 Token
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只操作该用户的 Token
Returns:
(ManagementToken, 新的明文 Token, 旧的 token_hash) 元组,失败返回 (None, None, None)
"""
token = ManagementTokenService.get_token_by_id(db, token_id, user_id)
if not token:
return None, None, None
# 保存旧的 token_hash 用于审计
old_token_hash = token.token_hash
# 生成新 Token
raw_token = ManagementToken.generate_token()
token.set_token(raw_token)
db.commit()
db.refresh(token)
logger.info(f"重新生成 Management Token: {token.id}")
return token, raw_token, old_token_hash