mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-12 04:28:28 +08:00
feat: 添加访问令牌管理功能并升级至 0.2.4
- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理 - 前端添加访问令牌管理页面,支持普通用户和管理员 - 后端实现完整的令牌生命周期管理 API - 添加数据库迁移脚本创建 management_tokens 表 - Nginx 配置添加 gzip 压缩,优化响应传输 - Dialog 组件添加 persistent 属性,防止意外关闭 - 为管理后台 API 添加详细的中文文档注释 - 简化多处类型注解,统一代码风格
This commit is contained in:
@@ -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
|
||||
|
||||
15
src/services/management_token/__init__.py
Normal file
15
src/services/management_token/__init__.py
Normal 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",
|
||||
]
|
||||
416
src/services/management_token/service.py
Normal file
416
src/services/management_token/service.py
Normal 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
|
||||
Reference in New Issue
Block a user