2026-01-07 14:55:07 +08:00
|
|
|
|
"""Management Token 服务"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-01-07 14:55:07 +08:00
|
|
|
|
import ipaddress
|
|
|
|
|
|
from datetime import datetime, timezone
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from typing import Any
|
2026-01-07 14:55:07 +08:00
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
from sqlalchemy import func
|
2026-01-07 14:55:07 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def validate_ip_list(ips: list[str] | None) -> list[str] | None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""验证 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
def parse_expires_at(v: Any, allow_past: bool = False) -> datetime | None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""解析过期时间,确保时区安全
|
|
|
|
|
|
|
|
|
|
|
|
前端 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,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
raw_token: str | None = None,
|
2026-01-07 14:55:07 +08:00
|
|
|
|
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,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
description: str | None = None,
|
|
|
|
|
|
allowed_ips: list[str] | None = None,
|
|
|
|
|
|
expires_at: datetime | None = None,
|
2026-01-07 14:55:07 +08:00
|
|
|
|
) -> tuple[ManagementToken, str]:
|
|
|
|
|
|
"""创建 Management Token
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
user_id: 用户 ID
|
|
|
|
|
|
name: Token 名称
|
|
|
|
|
|
description: 描述
|
|
|
|
|
|
allowed_ips: IP 白名单
|
|
|
|
|
|
expires_at: 过期时间
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(ManagementToken, 明文 Token) 元组
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
ValueError: 如果名称已存在或超过数量限制
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 检查用户 Token 数量限制
|
2026-03-03 22:04:40 +08:00
|
|
|
|
token_count = int(
|
|
|
|
|
|
db.query(func.count(ManagementToken.id))
|
|
|
|
|
|
.filter(ManagementToken.user_id == user_id)
|
|
|
|
|
|
.scalar()
|
|
|
|
|
|
or 0
|
|
|
|
|
|
)
|
2026-01-07 14:55:07 +08:00
|
|
|
|
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(
|
2026-01-30 03:10:21 +08:00
|
|
|
|
db: Session, token_id: str, user_id: str | None = None
|
|
|
|
|
|
) -> ManagementToken | None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""根据 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,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
user_id: str | None = None,
|
|
|
|
|
|
is_active: bool | None = None,
|
2026-01-07 14:55:07 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
total = int(query.with_entities(func.count(ManagementToken.id)).scalar() or 0)
|
2026-01-07 14:55:07 +08:00
|
|
|
|
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,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
user_id: str | None = None,
|
|
|
|
|
|
name: str | None = None,
|
|
|
|
|
|
description: str | None = None,
|
|
|
|
|
|
allowed_ips: list[str] | None = None,
|
|
|
|
|
|
expires_at: datetime | None = None,
|
|
|
|
|
|
is_active: bool | None = None,
|
2026-01-07 14:55:07 +08:00
|
|
|
|
clear_description: bool = False,
|
|
|
|
|
|
clear_allowed_ips: bool = False,
|
|
|
|
|
|
clear_expires_at: bool = False,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
) -> ManagementToken | None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""更新 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
|
2026-02-01 17:28:00 +08:00
|
|
|
|
def delete_token(db: Session, token_id: str, user_id: str | None = None) -> bool:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""删除 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(
|
2026-01-30 03:10:21 +08:00
|
|
|
|
db: Session, token_id: str, user_id: str | None = None
|
|
|
|
|
|
) -> ManagementToken | None:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""切换 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(
|
2026-01-30 03:10:21 +08:00
|
|
|
|
db: Session, token_id: str, user_id: str | None = None
|
|
|
|
|
|
) -> tuple[ManagementToken | None, str | None, str | None]:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""重新生成 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
|