refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,13 @@
"""
认证服务模块
包含认证服务、JWT 黑名单等功能。
"""
from src.services.auth.jwt_blacklist import JWTBlacklistService
from src.services.auth.service import AuthService
__all__ = [
"AuthService",
"JWTBlacklistService",
]

View File

@@ -0,0 +1,201 @@
"""
JWT Token 黑名单服务
使用 Redis 存储被撤销的 JWT Token防止已登出或被撤销的 Token 继续使用
"""
import hashlib
import os
from datetime import datetime, timezone
from src.clients.redis_client import get_redis_client
from src.core.logger import logger
# 安全策略配置:当 Redis 不可用时的行为
# True = fail-closed安全优先拒绝访问
# False = fail-open可用性优先允许访问
BLACKLIST_FAIL_CLOSED = os.getenv("JWT_BLACKLIST_FAIL_CLOSED", "true").lower() == "true"
class JWTBlacklistService:
"""JWT Token 黑名单服务"""
# Redis key 前缀
BLACKLIST_PREFIX = "jwt:blacklist:"
@staticmethod
def _get_token_hash(token: str) -> str:
"""
获取 Token 的哈希值(用于 Redis key
使用 SHA256 哈希避免直接存储完整 Token
"""
return hashlib.sha256(token.encode()).hexdigest()
@staticmethod
async def add_to_blacklist(token: str, exp_timestamp: int, reason: str = "logout") -> bool:
"""
将 Token 添加到黑名单
Args:
token: JWT token 字符串
exp_timestamp: Token 的过期时间戳Unix timestamp
reason: 添加到黑名单的原因logout, revoked, security
Returns:
是否成功添加到黑名单
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
logger.warning("Redis 不可用,无法将 Token 添加到黑名单(降级模式)")
return False
try:
token_hash = JWTBlacklistService._get_token_hash(token)
redis_key = f"{JWTBlacklistService.BLACKLIST_PREFIX}{token_hash}"
# 计算 TTLToken 过期前的剩余时间)
now = datetime.now(timezone.utc).timestamp()
ttl_seconds = max(int(exp_timestamp - now), 0)
if ttl_seconds <= 0:
# Token 已经过期,不需要加入黑名单
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
logger.debug("Token 已过期,无需加入黑名单: token_fp={}", token_fp)
return True
# 存储到 Redis设置 TTL 为 Token 过期时间
# 值存储为原因字符串
await redis_client.setex(redis_key, ttl_seconds, reason)
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
logger.info(
"Token 已加入黑名单: token_fp={} (原因: {}, TTL: {}s)",
token_fp,
reason,
ttl_seconds,
)
return True
except Exception as e:
logger.error(f"添加 Token 到黑名单失败: {e}")
return False
@staticmethod
async def is_blacklisted(token: str) -> bool:
"""
检查 Token 是否在黑名单中
Args:
token: JWT token 字符串
Returns:
Token 是否在黑名单中
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
# Redis 不可用时,根据安全策略决定行为
if BLACKLIST_FAIL_CLOSED:
logger.warning(
"Redis 不可用,采用 fail-closed 策略拒绝访问(可通过 JWT_BLACKLIST_FAIL_CLOSED=false 改变)"
)
return True # 返回 True 表示在黑名单中,拒绝访问
else:
logger.debug("Redis 不可用,采用 fail-open 策略允许访问")
return False
try:
token_hash = JWTBlacklistService._get_token_hash(token)
redis_key = f"{JWTBlacklistService.BLACKLIST_PREFIX}{token_hash}"
# 检查 key 是否存在
exists = await redis_client.exists(redis_key)
if exists:
# 获取黑名单原因(可选)
reason = await redis_client.get(redis_key)
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
logger.warning("检测到黑名单 Token: token_fp={} (原因: {})", token_fp, reason)
return True
return False
except Exception as e:
logger.error(f"检查 Token 黑名单状态失败: {e}")
# 发生错误时,根据安全策略决定行为
if BLACKLIST_FAIL_CLOSED:
logger.warning("黑名单检查失败,采用 fail-closed 策略拒绝访问")
return True # 安全优先,拒绝访问
else:
logger.warning("黑名单检查失败,采用 fail-open 策略允许访问")
return False
@staticmethod
async def remove_from_blacklist(token: str) -> bool:
"""
从黑名单中移除 Token用于测试或特殊情况
Args:
token: JWT token 字符串
Returns:
是否成功移除
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
logger.warning("Redis 不可用,无法从黑名单中移除 Token")
return False
try:
token_hash = JWTBlacklistService._get_token_hash(token)
redis_key = f"{JWTBlacklistService.BLACKLIST_PREFIX}{token_hash}"
deleted = await redis_client.delete(redis_key)
if deleted:
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
logger.info("Token 已从黑名单移除: token_fp={}", token_fp)
else:
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
logger.debug("Token 不在黑名单中: token_fp={}", token_fp)
return bool(deleted)
except Exception as e:
logger.error(f"从黑名单移除 Token 失败: {e}")
return False
@staticmethod
async def get_blacklist_stats() -> dict:
"""
获取黑名单统计信息
Returns:
包含统计信息的字典
"""
redis_client = await get_redis_client(require_redis=False)
if redis_client is None:
return {"available": False, "total_blacklisted": 0, "error": "Redis 不可用"}
try:
# 扫描黑名单 key
pattern = f"{JWTBlacklistService.BLACKLIST_PREFIX}*"
cursor = 0
total = 0
while True:
cursor, keys = await redis_client.scan(cursor=cursor, match=pattern, count=100)
total += len(keys)
if cursor == 0:
break
return {"available": True, "total_blacklisted": total}
except Exception as e:
logger.error(f"获取黑名单统计失败: {e}")
return {"available": False, "total_blacklisted": 0, "error": str(e)}

View File

@@ -0,0 +1,375 @@
"""LDAP 认证服务"""
from typing import Any
from urllib.parse import urlparse
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import LDAPConfig
# LDAP 连接默认超时时间(秒)
DEFAULT_LDAP_CONNECT_TIMEOUT = 10
def parse_ldap_server_url(server_url: str) -> tuple[str, int, bool]:
"""
解析 LDAP 服务器地址,支持:
- ldap://host:389
- ldaps://host:636
- host:389无 scheme 时默认 ldap
Returns:
(host, port, use_ssl)
"""
raw = (server_url or "").strip()
if not raw:
raise ValueError("LDAP server_url is required")
parsed = urlparse(raw)
if parsed.scheme in {"ldap", "ldaps"}:
host = parsed.hostname
if not host:
raise ValueError("Invalid LDAP server_url")
use_ssl = parsed.scheme == "ldaps"
port = parsed.port or (636 if use_ssl else 389)
return host, port, use_ssl
# 兼容无 scheme按 ldap:// 解析
parsed = urlparse(f"ldap://{raw}")
host = parsed.hostname
if not host:
raise ValueError("Invalid LDAP server_url")
port = parsed.port or 389
return host, port, False
def escape_ldap_filter(value: str, max_length: int = 128) -> str:
"""
转义 LDAP 过滤器中的特殊字符,防止 LDAP 注入攻击RFC 4515
Args:
value: 需要转义的字符串
max_length: 最大允许长度,默认 128 字符(覆盖大多数企业邮箱用户名)
Returns:
转义后的安全字符串
Raises:
ValueError: 输入值过长
"""
import unicodedata
# 先检查原始长度,防止 DoS 攻击
# 128 字符足够覆盖大多数企业用户名和邮箱地址
if len(value) > max_length:
raise ValueError(f"LDAP filter value too long (max {max_length} characters)")
# Unicode 规范化(使用 NFC 而非 NFKC避免兼容性字符转换导致安全问题
value = unicodedata.normalize("NFC", value)
# 再次检查规范化后的长度(防止规范化后长度突增)
if len(value) > max_length:
raise ValueError(f"LDAP filter value too long after normalization (max {max_length})")
# LDAP 过滤器特殊字符RFC 4515 + 扩展)
# 使用显式顺序处理,确保反斜杠首先转义
value = value.replace("\\", r"\5c") # 反斜杠必须首先转义
value = value.replace("*", r"\2a")
value = value.replace("(", r"\28")
value = value.replace(")", r"\29")
value = value.replace("\x00", r"\00") # NUL
value = value.replace("&", r"\26")
value = value.replace("|", r"\7c")
value = value.replace("=", r"\3d")
value = value.replace(">", r"\3e")
value = value.replace("<", r"\3c")
value = value.replace("~", r"\7e")
value = value.replace("!", r"\21")
return value
def _get_attr_value(entry: Any, attr_name: str, default: str = "") -> str:
"""
提取 LDAP 条目属性的首个值,避免返回字符串化的列表表示。
"""
attr = getattr(entry, attr_name, None)
if not attr:
return default
# ldap3 的 EntryAttribute.value 已经是单值或列表,根据类型取首个
val = getattr(attr, "value", None)
if isinstance(val, list):
val = val[0] if val else default
if val is None:
return default
return str(val)
class LDAPService:
"""LDAP 认证服务"""
@staticmethod
def get_config(db: Session) -> LDAPConfig | None:
"""获取 LDAP 配置"""
return db.query(LDAPConfig).first()
@staticmethod
def is_ldap_enabled(db: Session) -> bool:
"""检查 LDAP 是否可用(已启用且绑定密码可解密)"""
return LDAPService.get_config_data(db) is not None
@staticmethod
def is_ldap_exclusive(db: Session) -> bool:
"""检查是否仅允许 LDAP 登录(仅在 LDAP 可用时生效,避免误锁定)"""
config = LDAPService.get_config(db)
if not config or config.is_exclusive is not True:
return False
return LDAPService.get_config_data(db) is not None
@staticmethod
def get_config_data(db: Session) -> dict[str, Any] | None:
"""
提前获取并解密配置,供线程池使用,避免跨线程共享 Session。
检查顺序:
1. LDAP 模块是否激活available && enabled
2. LDAP 配置是否启用
3. 绑定密码是否可解密
"""
# 检查 LDAP 模块是否激活
from src.core.modules import get_module_registry
registry = get_module_registry()
if not registry.is_active("ldap", db):
return None
config = LDAPService.get_config(db)
if not config or config.is_enabled is not True:
return None
try:
bind_password = config.get_bind_password()
except Exception as e:
logger.error(f"LDAP 绑定密码解密失败: {e}")
return None
# 绑定密码为空时无法进行 LDAP 认证
if not bind_password:
logger.warning("LDAP 绑定密码未配置,无法进行 LDAP 认证")
return None
return {
"server_url": config.server_url,
"bind_dn": config.bind_dn,
"bind_password": bind_password,
"base_dn": config.base_dn,
"user_search_filter": config.user_search_filter,
"username_attr": config.username_attr,
"email_attr": config.email_attr,
"display_name_attr": config.display_name_attr,
"use_starttls": config.use_starttls,
"connect_timeout": config.connect_timeout or DEFAULT_LDAP_CONNECT_TIMEOUT,
}
@staticmethod
def authenticate_with_config(
config: dict[str, Any], username: str, password: str
) -> dict | None:
"""
LDAP bind 验证
Args:
config: 已解密的 LDAP 配置
username: 用户名
password: 密码
Returns:
用户属性 dict {username, email, display_name} 或 None
"""
try:
import ldap3
from ldap3 import SUBTREE, Connection, Server
from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError
except ImportError:
logger.error("ldap3 库未安装")
return None
if not config:
logger.warning("LDAP 未配置或未启用")
return None
admin_conn = None
user_conn = None
try:
# 创建服务器连接
server_url = config["server_url"]
server_host, server_port, use_ssl = parse_ldap_server_url(server_url)
timeout = config.get("connect_timeout", DEFAULT_LDAP_CONNECT_TIMEOUT)
server = Server(
server_host,
port=server_port,
use_ssl=use_ssl,
get_info=ldap3.ALL,
connect_timeout=timeout,
)
# 使用管理员账号连接
bind_password = config["bind_password"]
admin_conn = Connection(
server,
user=config["bind_dn"],
password=bind_password,
receive_timeout=timeout, # 添加读取超时,避免服务器响应缓慢时阻塞
)
if config.get("use_starttls") and not use_ssl:
admin_conn.start_tls()
if not admin_conn.bind():
logger.error(f"LDAP 管理员绑定失败: {admin_conn.result}")
return None
# 搜索用户(转义用户名防止 LDAP 注入)
safe_username = escape_ldap_filter(username)
search_filter = config["user_search_filter"].replace("{username}", safe_username)
admin_conn.search(
search_base=config["base_dn"],
search_filter=search_filter,
search_scope=SUBTREE,
size_limit=2, # 防止过滤器误配导致匹配多用户
time_limit=timeout, # 添加搜索超时,防止大型目录搜索阻塞
attributes=[
config["username_attr"],
config["email_attr"],
config["display_name_attr"],
],
)
if len(admin_conn.entries) != 1:
# 统一错误信息,避免泄露用户是否存在;日志仅记录结果数量,不泄露敏感信息
logger.warning(
f"LDAP 认证失败(用户查找阶段): 搜索返回 {len(admin_conn.entries)} 条结果"
)
return None
user_entry = admin_conn.entries[0]
user_dn = user_entry.entry_dn
# 用户密码验证
user_conn = Connection(
server,
user=user_dn,
password=password,
receive_timeout=timeout, # 添加读取超时
)
if config.get("use_starttls") and not use_ssl:
user_conn.start_tls()
if not user_conn.bind():
# 统一错误信息,避免泄露密码是否正确;日志仅记录错误码,不泄露用户 DN
bind_result = user_conn.result.get("description", "unknown")
logger.warning(f"LDAP 认证失败(密码验证阶段): {bind_result}")
return None
# 提取用户属性(优先用 LDAP 提供的值,不合法则回退默认)
ldap_username = _get_attr_value(user_entry, config["username_attr"], username)
email = _get_attr_value(user_entry, config["email_attr"], f"{username}@ldap.local")
display_name = _get_attr_value(user_entry, config["display_name_attr"], username)
logger.info(f"LDAP 认证成功: {username}")
return {
"username": ldap_username,
"ldap_username": ldap_username,
"ldap_dn": user_dn,
"email": email,
"display_name": display_name,
}
except LDAPSocketOpenError as e:
logger.error(f"LDAP 服务器连接失败: {e}")
return None
except LDAPBindError as e:
logger.error(f"LDAP 绑定失败: {e}")
return None
except Exception as e:
logger.error(f"LDAP 认证异常: {e}")
return None
finally:
# 确保连接关闭,避免失败路径泄漏
# 使用循环确保即使第一个 unbind 失败,后续连接仍会尝试关闭
for conn, name in [(admin_conn, "admin"), (user_conn, "user")]:
if conn:
try:
conn.unbind()
except Exception as e:
logger.warning(f"LDAP {name} 连接关闭失败: {e}")
@staticmethod
def test_connection_with_config(config: dict[str, Any]) -> tuple[bool, str]:
"""
测试 LDAP 连接
Returns:
(success, message)
"""
try:
import ldap3
from ldap3 import Connection, Server
except ImportError:
return False, "ldap3 库未安装"
if not config:
return False, "LDAP 配置不存在"
conn = None
try:
server_url = config["server_url"]
server_host, server_port, use_ssl = parse_ldap_server_url(server_url)
timeout = config.get("connect_timeout", DEFAULT_LDAP_CONNECT_TIMEOUT)
server = Server(
server_host,
port=server_port,
use_ssl=use_ssl,
get_info=ldap3.ALL,
connect_timeout=timeout,
)
bind_password = config["bind_password"]
conn = Connection(
server,
user=config["bind_dn"],
password=bind_password,
receive_timeout=timeout, # 添加读取超时
)
if config.get("use_starttls") and not use_ssl:
conn.start_tls()
if not conn.bind():
return False, f"绑定失败: {conn.result}"
return True, "连接成功"
except Exception as e:
# 记录详细错误到日志,但只返回通用信息给前端,避免泄露敏感信息
logger.error(f"LDAP 测试连接失败: {type(e).__name__}: {e}")
return False, "连接失败,请检查服务器地址、端口和凭据"
finally:
if conn:
try:
conn.unbind()
except Exception as e:
logger.warning(f"LDAP 测试连接关闭失败: {e}")
# 兼容旧接口:如果其他代码直接调用
@staticmethod
def authenticate(db: Session, username: str, password: str) -> dict | None:
config = LDAPService.get_config_data(db)
return LDAPService.authenticate_with_config(config, username, password) if config else None
@staticmethod
def test_connection(db: Session) -> tuple[bool, str]:
config = LDAPService.get_config_data(db)
if not config:
return False, "LDAP 配置不存在或未启用"
return LDAPService.test_connection_with_config(config)

View File

@@ -0,0 +1,5 @@
"""OAuth 认证相关服务。"""
from .service import OAuthService
__all__ = ["OAuthService"]

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from urllib.parse import urlencode, urlparse, urlunparse
import httpx
from src.services.auth.oauth.models import OAuthFlowError, OAuthToken, OAuthUserInfo
if TYPE_CHECKING:
from src.models.database import OAuthProvider
class OAuthProviderBase(ABC):
"""
OAuth Provider 基类(稳定扩展点)。
v1 收敛点:仅实现 OAuth2 授权码流程所需的最小接口。
"""
provider_type: str
display_name: str
# 允许的 host 白名单(用于端点覆盖校验,支持子域名)
allowed_domains: tuple[str, ...] = ()
authorization_url: str
token_url: str
userinfo_url: str
default_scopes: tuple[str, ...] = ()
def get_effective_authorization_url(self, config: OAuthProvider) -> str:
return config.authorization_url_override or self.authorization_url
def get_effective_token_url(self, config: OAuthProvider) -> str:
return config.token_url_override or self.token_url
def get_effective_userinfo_url(self, config: OAuthProvider) -> str:
return config.userinfo_url_override or self.userinfo_url
def get_effective_scopes(self, config: OAuthProvider) -> str:
scopes = config.scopes or list(self.default_scopes)
return " ".join(scopes)
def get_authorization_url(self, config: OAuthProvider, state: str) -> str:
"""
构造 provider 授权 URL。
redirect_uri 必须由服务端控制,不从客户端传入。
"""
base = self.get_effective_authorization_url(config)
# 避免覆盖原有 query若 provider 默认 url 带 query保留
parsed = urlparse(base)
query: dict[str, str] = {}
if parsed.query:
# 保留已有 query 参数
for kv in parsed.query.split("&"):
if not kv:
continue
if "=" in kv:
k, v = kv.split("=", 1)
query[k] = v
else:
query[kv] = ""
client_id = config.client_id
redirect_uri = config.redirect_uri
if not client_id or not redirect_uri:
raise ValueError("OAuthProvider 配置不完整client_id/redirect_uri 不能为空")
query.update(
{
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"state": state,
}
)
scopes = self.get_effective_scopes(config)
if scopes:
query["scope"] = scopes
return urlunparse(parsed._replace(query=urlencode(query)))
@abstractmethod
async def exchange_code(self, config: OAuthProvider, code: str) -> OAuthToken:
"""使用授权码兑换 token。"""
@abstractmethod
async def get_user_info(self, config: OAuthProvider, access_token: str) -> OAuthUserInfo:
"""获取用户信息。"""
async def _http_post_form(
self,
url: str,
data: dict[str, str],
*,
timeout_seconds: float = 5.0,
headers: dict[str, str] | None = None,
) -> httpx.Response:
raise OAuthFlowError("provider_unavailable", "OAuth 仅支持 Rust executor")
async def _http_get(
self,
url: str,
*,
timeout_seconds: float = 5.0,
headers: dict[str, str] | None = None,
) -> httpx.Response:
raise OAuthFlowError("provider_unavailable", "OAuth 仅支持 Rust executor")

View File

@@ -0,0 +1,31 @@
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class OAuthToken:
access_token: str
token_type: str = "bearer"
refresh_token: str | None = None
expires_in: int | None = None
id_token: str | None = None
scope: str | None = None
raw: dict[str, Any] | None = None
@dataclass(frozen=True)
class OAuthUserInfo:
id: str
username: str | None = None
email: str | None = None
email_verified: bool | None = None
raw: dict[str, Any] | None = None
class OAuthFlowError(Exception):
"""用于 OAuth 流程的可控错误(会映射到 error_code"""
def __init__(self, error_code: str, detail: str = ""):
super().__init__(error_code)
self.error_code = error_code
self.detail = detail

View File

@@ -0,0 +1,5 @@
"""内置 OAuth providersv1"""
from .linuxdo import LinuxDoOAuthProvider
__all__ = ["LinuxDoOAuthProvider"]

View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import base64
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import httpx
from src.core.logger import logger
from src.services.auth.oauth.base import OAuthProviderBase
from src.services.auth.oauth.models import OAuthFlowError, OAuthToken, OAuthUserInfo
if TYPE_CHECKING:
from src.models.database import OAuthProvider
class LinuxDoOAuthProvider(OAuthProviderBase):
"""
LinuxDo OAuth Provider。
基于论坛信任等级trust_level 0-4的 OAuth2 认证,
用于通过用户等级进行额度配给和频率限制。
参考https://linux.do/t/topic/329408
返回的用户信息示例:
{
"id": 1,
"username": "neo",
"name": "Neo",
"active": true,
"trust_level": 4,
"email": "u1@linux.do",
"avatar_url": "https://linux.do/xxxx",
"silenced": false
}
"""
provider_type = "linuxdo"
display_name = "Linux Do"
allowed_domains = ("linux.do", "connect.linux.do", "connect.linuxdo.org")
# 默认端点
authorization_url = "https://connect.linux.do/oauth2/authorize"
token_url = "https://connect.linux.do/oauth2/token"
userinfo_url = "https://connect.linux.do/api/user"
backup_token_url = "https://connect.linuxdo.org/oauth2/token"
backup_userinfo_url = "https://connect.linuxdo.org/api/user"
# LinuxDo 不需要 scope
default_scopes = ()
@staticmethod
def _build_basic_auth_header(client_id: str, client_secret: str) -> str:
credentials = f"{client_id}:{client_secret}".encode("utf-8")
return f"Basic {base64.b64encode(credentials).decode('ascii')}"
@staticmethod
def _build_candidate_urls(primary_url: str, backup_url: str) -> list[str]:
parsed = urlparse(primary_url)
host = (parsed.hostname or "").lower().rstrip(".")
backup_path = urlparse(backup_url).path
urls = [primary_url]
if parsed.scheme == "https" and host == "connect.linux.do" and parsed.path == backup_path:
urls.append(backup_url)
return urls
@staticmethod
async def _request_with_fallback(
candidate_urls: list[str],
request_fn: Callable[[str], Awaitable[httpx.Response]],
error_code: str,
label: str,
) -> httpx.Response:
resp: httpx.Response | None = None
for idx, url in enumerate(candidate_urls):
try:
resp = await request_fn(url)
break
except httpx.HTTPError as exc:
if idx < len(candidate_urls) - 1:
logger.warning("LinuxDo {} 端点不可达,尝试备用端点: {} ({})", label, url, exc)
continue
logger.warning("LinuxDo {} 请求失败: {} ({})", label, url, exc)
raise OAuthFlowError(error_code, "transport_error") from exc
if resp is None:
raise OAuthFlowError(error_code, "no_response")
return resp
async def exchange_code(self, config: OAuthProvider, code: str) -> OAuthToken:
client_secret = config.get_client_secret()
if not client_secret:
raise OAuthFlowError("provider_unavailable", "client_secret 未配置")
redirect_uri = config.redirect_uri
client_id = config.client_id
if not redirect_uri or not client_id:
raise OAuthFlowError("provider_unavailable", "redirect_uri/client_id 未配置")
candidate_urls = self._build_candidate_urls(
self.get_effective_token_url(config), self.backup_token_url
)
headers = {
"Authorization": self._build_basic_auth_header(client_id, client_secret),
"Accept": "application/json",
}
resp = await self._request_with_fallback(
candidate_urls,
lambda url: self._http_post_form(
url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
headers=headers,
),
error_code="token_exchange_failed",
label="token",
)
if resp.status_code >= 400:
logger.warning("LinuxDo token 兑换失败: status={}", resp.status_code)
raise OAuthFlowError("token_exchange_failed", f"status={resp.status_code}")
data = resp.json()
access_token = data.get("access_token")
if not access_token:
raise OAuthFlowError("token_exchange_failed", "missing access_token")
return OAuthToken(
access_token=str(access_token),
token_type=str(data.get("token_type") or "bearer"),
refresh_token=(str(data["refresh_token"]) if data.get("refresh_token") else None),
expires_in=(int(data["expires_in"]) if data.get("expires_in") is not None else None),
id_token=(str(data["id_token"]) if data.get("id_token") else None),
scope=(str(data["scope"]) if data.get("scope") else None),
raw=data,
)
async def get_user_info(self, config: OAuthProvider, access_token: str) -> OAuthUserInfo:
candidate_urls = self._build_candidate_urls(
self.get_effective_userinfo_url(config), self.backup_userinfo_url
)
resp = await self._request_with_fallback(
candidate_urls,
lambda url: self._http_get(url, headers={"Authorization": f"Bearer {access_token}"}),
error_code="userinfo_fetch_failed",
label="userinfo",
)
if resp.status_code >= 400:
logger.warning("LinuxDo userinfo 获取失败: status={}", resp.status_code)
raise OAuthFlowError("userinfo_fetch_failed", f"status={resp.status_code}")
data: dict[str, Any] = resp.json()
# LinuxDo 返回的 id 是数字类型
provider_user_id = data.get("id")
if provider_user_id is None:
raise OAuthFlowError("userinfo_fetch_failed", "missing user id")
return OAuthUserInfo(
id=str(provider_user_id),
username=data.get("username"),
email=str(data["email"]).lower() if data.get("email") else None,
email_verified=None, # LinuxDo 不返回此字段
raw=data, # 包含 trust_level, active, silenced, avatar_url, name 等
)

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from dataclasses import dataclass
from src.core.logger import logger
from src.services.auth.oauth.base import OAuthProviderBase
@dataclass(frozen=True)
class SupportedOAuthType:
provider_type: str
display_name: str
# 默认端点(用于前端 placeholder 展示)
default_authorization_url: str
default_token_url: str
default_userinfo_url: str
default_scopes: tuple[str, ...]
class OAuthProviderRegistry:
"""Provider 注册表(支持延迟 discover"""
def __init__(self) -> None:
self._providers: dict[str, OAuthProviderBase] = {}
self._discovered: bool = False
def discover_providers(self) -> None:
"""发现并注册 providers幂等"""
if self._discovered:
return
self._discovered = True
# 1) 内置 providersv1至少保证 linuxdo 可用)
try:
from src.services.auth.oauth.providers.linuxdo import LinuxDoOAuthProvider
self.register(LinuxDoOAuthProvider())
except Exception as exc:
logger.warning("OAuth 内置 provider 加载失败: {}", exc)
# 2) entry_points 插件(可选)
try:
from importlib.metadata import entry_points
eps = entry_points()
# Python 3.10+ 支持 select旧接口返回 dict
if hasattr(eps, "select"):
candidates = list(eps.select(group="aether.oauth_providers")) # type: ignore[attr-defined]
else:
candidates = list(eps.get("aether.oauth_providers", [])) # type: ignore[call-arg]
for ep in candidates:
try:
loaded = ep.load()
provider = loaded() if isinstance(loaded, type) else loaded
if not isinstance(provider, OAuthProviderBase):
logger.warning(
"OAuth provider entry_point 无效: {} (type={})", ep.name, type(provider)
)
continue
self.register(provider)
except Exception as e:
logger.warning("OAuth provider entry_point 加载失败: {}: {}", ep.name, e)
except Exception as exc:
# entry_points 不可用不影响主流程
logger.debug("OAuth entry_points discover skipped: {}", exc)
def register(self, provider: OAuthProviderBase) -> None:
self._providers[provider.provider_type] = provider
def get_provider(self, provider_type: str) -> OAuthProviderBase | None:
return self._providers.get(provider_type)
def get_supported_types(self) -> list[SupportedOAuthType]:
return [
SupportedOAuthType(
provider_type=p.provider_type,
display_name=p.display_name,
default_authorization_url=p.authorization_url,
default_token_url=p.token_url,
default_userinfo_url=p.userinfo_url,
default_scopes=p.default_scopes,
)
for p in sorted(self._providers.values(), key=lambda x: x.provider_type)
]
_registry: OAuthProviderRegistry | None = None
def get_oauth_provider_registry() -> OAuthProviderRegistry:
global _registry
if _registry is None:
_registry = OAuthProviderRegistry()
return _registry

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import json
import secrets
import time
from collections.abc import Awaitable
from dataclasses import dataclass
from typing import Any, cast
from redis.asyncio import Redis
OAUTH_STATE_TTL_SECONDS = 600
OAUTH_STATE_KEY_PREFIX = "oauth_state:"
# OAuth bind token: 用于安全地在浏览器跳转时传递用户身份
# 短期有效5分钟一次性使用
OAUTH_BIND_TOKEN_TTL_SECONDS = 300
OAUTH_BIND_TOKEN_KEY_PREFIX = "oauth_bind_token:"
CONSUME_STATE_SCRIPT = r"""
local value = redis.call("GET", KEYS[1])
if value then
redis.call("DEL", KEYS[1])
end
return value
"""
@dataclass(frozen=True)
class OAuthStateData:
nonce: str
provider_type: str
action: str # "login" | "bind"
user_id: str | None
client_device_id: str | None
created_at: int
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OAuthStateData:
return cls(
nonce=str(data.get("nonce") or ""),
provider_type=str(data.get("provider_type") or ""),
action=str(data.get("action") or ""),
user_id=data.get("user_id"),
client_device_id=data.get("client_device_id"),
created_at=int(data.get("created_at") or 0),
)
def _state_key(nonce: str) -> str:
return f"{OAUTH_STATE_KEY_PREFIX}{nonce}"
async def create_oauth_state(
redis: Redis,
*,
provider_type: str,
action: str,
user_id: str | None = None,
client_device_id: str | None = None,
) -> str:
nonce = secrets.token_urlsafe(24)
data = {
"nonce": nonce,
"provider_type": provider_type,
"action": action,
"user_id": user_id,
"client_device_id": client_device_id,
"created_at": int(time.time()),
}
await redis.setex(_state_key(nonce), OAUTH_STATE_TTL_SECONDS, json.dumps(data))
return nonce
async def consume_oauth_state(redis: Redis, nonce: str) -> OAuthStateData | None:
if not nonce:
return None
key = _state_key(nonce)
# redis-py 的类型标注在 sync/async 之间会出现 Union这里明确按 async 处理。
raw = await cast(Awaitable[str | None], redis.eval(CONSUME_STATE_SCRIPT, 1, key))
if not raw:
return None
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return None
return OAuthStateData.from_dict(parsed)
@dataclass(frozen=True)
class OAuthBindTokenData:
"""OAuth 绑定临时令牌数据,用于浏览器跳转场景的安全认证"""
token: str
user_id: str
provider_type: str
created_at: int
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OAuthBindTokenData:
return cls(
token=str(data.get("token") or ""),
user_id=str(data.get("user_id") or ""),
provider_type=str(data.get("provider_type") or ""),
created_at=int(data.get("created_at") or 0),
)
def _bind_token_key(token: str) -> str:
return f"{OAUTH_BIND_TOKEN_KEY_PREFIX}{token}"
async def create_oauth_bind_token(redis: Redis, *, user_id: str, provider_type: str) -> str:
"""创建一次性 OAuth 绑定令牌,用于浏览器跳转场景"""
token = secrets.token_urlsafe(32)
data = {
"token": token,
"user_id": user_id,
"provider_type": provider_type,
"created_at": int(time.time()),
}
await redis.setex(_bind_token_key(token), OAUTH_BIND_TOKEN_TTL_SECONDS, json.dumps(data))
return token
async def consume_oauth_bind_token(redis: Redis, token: str) -> OAuthBindTokenData | None:
"""消费验证并删除OAuth 绑定令牌,返回令牌数据或 None"""
if not token:
return None
key = _bind_token_key(token)
raw = await cast(Awaitable[str | None], redis.eval(CONSUME_STATE_SCRIPT, 1, key))
if not raw:
return None
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return None
return OAuthBindTokenData.from_dict(parsed)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from starlette.responses import Response
from src.config import config
from src.core.exceptions import ErrorResponse
from src.services.auth.service import REFRESH_TOKEN_EXPIRATION_DAYS
def set_refresh_token_cookie(response: Response, refresh_token: str) -> None:
max_age_seconds = REFRESH_TOKEN_EXPIRATION_DAYS * 24 * 60 * 60
response.set_cookie(
key=config.auth_refresh_cookie_name,
value=refresh_token,
httponly=True,
secure=config.auth_refresh_cookie_secure,
samesite=config.auth_refresh_cookie_samesite,
path="/api/auth",
max_age=max_age_seconds,
)
def clear_refresh_token_cookie(response: Response) -> None:
response.delete_cookie(
key=config.auth_refresh_cookie_name,
path="/api/auth",
secure=config.auth_refresh_cookie_secure,
samesite=config.auth_refresh_cookie_samesite,
)
def error_response_with_cleared_cookie(exc: Exception) -> Response:
response = ErrorResponse.from_exception(exc)
clear_refresh_token_cookie(response)
return response

View File

@@ -0,0 +1,903 @@
"""
认证服务
"""
from __future__ import annotations
import hashlib
import secrets
import time
import uuid
from collections import OrderedDict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from threading import Lock
from typing import TYPE_CHECKING, Any
import jwt
from fastapi import HTTPException, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from src.config import config
from src.core.enums import AuthSource
from src.core.exceptions import ForbiddenException
from src.core.logger import logger
from src.database.database import create_session
from src.services.system.config import SystemConfigService
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.cache.user_cache import UserCacheService
@dataclass
class AuthenticatedUserSnapshot:
user_id: str
email: str | None
username: str
role: UserRole
created_at: datetime | None
@dataclass
class ThreadsafeAPIKeyAuthResult:
user: User
api_key: ApiKey | None = None
balance_remaining: float | None = None
access_allowed: bool = True
access_message: str = "OK"
@property
def access_ok(self) -> bool:
return self.access_allowed
PipelineThreadsafeAuthResult = ThreadsafeAPIKeyAuthResult
# API Key last_used_at 更新节流配置
# 同一个 API Key 在此时间间隔内只会更新一次 last_used_at
_LAST_USED_UPDATE_INTERVAL = 60 # 秒
_LAST_USED_CACHE_MAX_SIZE = 10000 # LRU 缓存最大条目数
# 进程内缓存:记录每个 API Key 最后一次更新 last_used_at 的时间
# 使用 OrderedDict 实现 LRU避免内存无限增长
_api_key_last_update_times: OrderedDict[str, float] = OrderedDict()
_last_update_lock = Lock()
def _should_update_last_used(api_key_id: str) -> bool:
"""判断是否应该更新 API Key 的 last_used_at
使用节流策略,同一个 Key 在指定间隔内只更新一次。
线程安全,使用 LRU 策略限制缓存大小。
Returns:
True 表示应该更新False 表示跳过
"""
now = time.time()
with _last_update_lock:
last_update = _api_key_last_update_times.get(api_key_id, 0)
if now - last_update >= _LAST_USED_UPDATE_INTERVAL:
_api_key_last_update_times[api_key_id] = now
# LRU: 移到末尾(最近使用)
_api_key_last_update_times.move_to_end(api_key_id)
# 超过最大容量时,移除最旧的条目
while len(_api_key_last_update_times) > _LAST_USED_CACHE_MAX_SIZE:
_api_key_last_update_times.popitem(last=False)
return True
return False
# JWT配置从config读取
if not config.jwt_secret_key:
# 如果没有配置,生成一个随机密钥并警告
if config.environment == "production":
raise ValueError("JWT_SECRET_KEY must be set in production environment!")
config.jwt_secret_key = secrets.token_urlsafe(32)
logger.warning("JWT_SECRET_KEY未在环境变量中找到已生成随机密钥用于开发")
logger.warning("生产环境请设置JWT_SECRET_KEY环境变量!")
JWT_SECRET_KEY = config.jwt_secret_key
JWT_ALGORITHM = config.jwt_algorithm
JWT_EXPIRATION_HOURS = config.jwt_expiration_hours
# Refresh token 有效期设为7天
REFRESH_TOKEN_EXPIRATION_DAYS = 7
class AuthService:
"""认证服务"""
@staticmethod
def get_access_token_expiry() -> datetime:
return datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRATION_HOURS)
@staticmethod
def get_refresh_token_expiry() -> datetime:
return datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRATION_DAYS)
@staticmethod
def token_identity_matches_user(payload: dict[str, Any], user: User) -> bool:
"""
校验 token 的身份字段是否与用户一致。
兼容策略:
- email旧 token 可能包含;新 token 允许不包含(支持无邮箱用户)
- created_at用于替代 email 作为"防止身份混淆"的校验字段;旧 token 可能没有
时区处理说明:
- 本项目所有 created_at 统一使用 UTC 时区存储PostgreSQL TIMESTAMPTZ
- 对于 naive datetime无时区信息假定为 UTC
- 若历史数据使用了非 UTC 本地时区的 naive datetime可能导致校验失败
"""
token_email = payload.get("email")
if token_email is not None and user.email is not None and user.email != token_email:
return False
token_created_at = payload.get("created_at")
if not token_created_at or not user.created_at:
return True
try:
token_created = datetime.fromisoformat(str(token_created_at).replace("Z", "+00:00"))
except ValueError:
return False
# 统一时区:若是 naive datetime按 UTC 处理
# 注意:本项目约定所有时间戳使用 UTC若旧数据不符合此约定可能导致校验失败
user_created = user.created_at
if user_created.tzinfo is None:
user_created = user_created.replace(tzinfo=timezone.utc)
if token_created.tzinfo is None:
token_created = token_created.replace(tzinfo=timezone.utc)
return abs((user_created - token_created).total_seconds()) <= 1
@staticmethod
def create_access_token(data: dict) -> str:
"""创建JWT访问令牌"""
to_encode = data.copy()
expire = AuthService.get_access_token_expiry()
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
return encoded_jwt
@staticmethod
def create_refresh_token(data: dict) -> str:
"""创建JWT刷新令牌"""
to_encode = data.copy()
expire = AuthService.get_refresh_token_expiry()
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
return encoded_jwt
@staticmethod
async def verify_token(token: str, token_type: str | None = None) -> dict[str, Any]:
"""验证JWT令牌
Args:
token: JWT token字符串
token_type: 期望的token类型 ('access''refresh')None表示不验证类型
"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
# 验证token类型如果指定
if token_type:
actual_type = payload.get("type")
if actual_type != token_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token类型错误: 期望 {token_type}, 实际 {actual_type}",
)
# 检查 Token 是否在黑名单中
is_blacklisted = await JWTBlacklistService.is_blacklisted(token)
if is_blacklisted:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token已被撤销"
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token已过期")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的Token")
@staticmethod
def _authenticate_local_user_sync(
db: Session,
email: str,
password: str,
) -> User | None:
"""同步执行本地认证,供线程池隔离入口复用。"""
from sqlalchemy import or_
user = db.query(User).filter(or_(User.email == email, User.username == email)).first()
if not user:
logger.warning("登录失败 - 用户不存在: {}", email)
return None
if user.is_deleted:
logger.warning("登录失败 - 用户已删除: {}", email)
return None
from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
is_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
if is_exclusive:
if user.role != UserRole.ADMIN or user.auth_source != AuthSource.LOCAL:
logger.warning("登录失败 - 排他登录模式下仅管理员可本地登录: {}", email)
return None
logger.warning("[EXCLUSIVE-MODE] 紧急恢复通道:本地管理员登录: {}", email)
if user.auth_source == AuthSource.LDAP:
logger.warning("登录失败 - 该用户使用 LDAP 认证: {}", email)
return None
if not user.verify_password(password):
logger.warning("登录失败 - 密码错误: {}", email)
return None
if not user.is_active:
logger.warning("登录失败 - 用户已禁用: {}", email)
return None
return user
@staticmethod
def _build_authenticated_snapshot(user: User) -> AuthenticatedUserSnapshot:
return AuthenticatedUserSnapshot(
user_id=user.id,
email=user.email,
username=user.username,
role=user.role,
created_at=user.created_at,
)
@staticmethod
def _detach_instance(db: Session, instance: User | ApiKey | None) -> None:
if instance is None:
return
try:
db.expunge(instance)
except Exception as exc:
logger.debug("expunge failed: {}", exc)
@staticmethod
def _load_user_for_token_sync(db: Session, user_id: str) -> User | None:
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active or user.is_deleted:
return None
return user
@staticmethod
async def load_user_for_token_threadsafe(user_id: str) -> User | None:
"""Load the JWT user in a threadpool and return a detached object."""
def _load_in_thread() -> User | None:
thread_db = create_session()
try:
user = AuthService._load_user_for_token_sync(thread_db, user_id)
if not user:
return None
AuthService._detach_instance(thread_db, user)
return user
finally:
thread_db.close()
return await run_in_threadpool(_load_in_thread)
@staticmethod
async def load_user_for_pipeline_threadsafe(
user_id: str,
*,
include_balance: bool = False,
) -> PipelineThreadsafeAuthResult | None:
"""Compatibility helper: load a user in a threadpool and optionally prefetch balance."""
def _load_in_thread() -> PipelineThreadsafeAuthResult | None:
from src.services.wallet import WalletService
thread_db = create_session()
try:
user = AuthService._load_user_for_token_sync(thread_db, user_id)
if not user:
return None
balance_remaining: float | None = None
if include_balance:
balance = WalletService.get_balance_snapshot(thread_db, user=user)
balance_remaining = float(balance) if balance is not None else None
AuthService._detach_instance(thread_db, user)
return PipelineThreadsafeAuthResult(
user=user,
balance_remaining=balance_remaining,
)
finally:
thread_db.close()
return await run_in_threadpool(_load_in_thread)
@staticmethod
async def authenticate_api_key_threadsafe(
api_key: str,
) -> ThreadsafeAPIKeyAuthResult | None:
"""Authenticate API key and check balance in a threadpool."""
def _authenticate_in_thread() -> ThreadsafeAPIKeyAuthResult | None:
from src.services.usage.service import UsageService
thread_db = create_session()
try:
auth_result = AuthService.authenticate_api_key(thread_db, api_key)
if not auth_result:
return None
user, key_record = auth_result
balance_result = UsageService.check_request_balance_details(
thread_db,
user,
api_key=key_record,
)
AuthService._detach_instance(thread_db, user)
AuthService._detach_instance(thread_db, key_record)
return ThreadsafeAPIKeyAuthResult(
user=user,
api_key=key_record,
balance_remaining=balance_result.remaining,
access_allowed=balance_result.allowed,
access_message=balance_result.message,
)
finally:
thread_db.close()
return await run_in_threadpool(_authenticate_in_thread)
@staticmethod
async def authenticate_user_threadsafe(
db: Session, email: str, password: str, auth_type: str = "local"
) -> AuthenticatedUserSnapshot | None:
"""为异步登录路由提供线程池隔离的认证入口。
这里仅负责校验凭证并返回用户快照,不提前持久化登录成功状态。
登录成功相关的审计、会话创建和 last_login_at 统一由路由层在同一事务里提交,
避免后续会话创建失败时留下错误的成功痕迹。
"""
if auth_type != "local":
user = await AuthService.authenticate_user(db, email, password, auth_type)
if not user:
return None
return AuthService._build_authenticated_snapshot(user)
def _authenticate_in_thread() -> AuthenticatedUserSnapshot | None:
thread_db = create_session()
try:
user = AuthService._authenticate_local_user_sync(thread_db, email, password)
if not user:
return None
return AuthService._build_authenticated_snapshot(user)
finally:
thread_db.close()
snapshot = await run_in_threadpool(_authenticate_in_thread)
if not snapshot:
return None
return snapshot
@staticmethod
async def authenticate_user(
db: Session, email: str, password: str, auth_type: str = "local"
) -> User | None:
"""用户登录认证
Args:
db: 数据库会话
email: 邮箱/用户名
password: 密码
auth_type: 认证类型 ("local" 或由模块钩子处理的其他类型)
"""
# 非本地认证:通过钩子分发给对应模块处理
if auth_type != "local":
from src.core.modules.hooks import AUTH_AUTHENTICATE, get_hook_dispatcher
result = await get_hook_dispatcher().dispatch(
AUTH_AUTHENTICATE,
db=db,
email=email,
password=password,
auth_type=auth_type,
)
if result is not None:
return result
logger.warning("No handler for auth_type: {}", auth_type)
return None
# 本地认证
# 登录校验必须读取密码哈希,不能使用不包含 password_hash 的缓存对象
# 支持邮箱或用户名登录
user = AuthService._authenticate_local_user_sync(db, email, password)
if not user:
return None
# 更新最后登录时间
user.last_login_at = datetime.now(timezone.utc)
db.commit() # 立即提交事务,释放数据库锁
# 清除缓存,因为用户信息已更新
await UserCacheService.invalidate_user_cache(user.id, user.email)
logger.info(f"用户登录成功: {email} (ID: {user.id})")
return user
@staticmethod
async def get_or_create_ldap_user(db: Session, ldap_user: dict) -> User | None:
"""获取或创建 LDAP 用户
Args:
ldap_user: LDAP 用户信息 {username, email, display_name, ldap_dn, ldap_username}
注意:使用 with_for_update() 防止并发首次登录创建重复用户
"""
ldap_dn = (ldap_user.get("ldap_dn") or "").strip() or None
ldap_username = (
ldap_user.get("ldap_username") or ldap_user.get("username") or ""
).strip() or None
email = ldap_user["email"]
# 优先用稳定标识查找,避免邮箱变更/用户名冲突导致重复建号
# 使用 with_for_update() 锁定行,防止并发创建
user: User | None = None
if ldap_dn:
user = (
db.query(User)
.filter(User.auth_source == AuthSource.LDAP, User.ldap_dn == ldap_dn)
.with_for_update()
.first()
)
if not user and ldap_username:
user = (
db.query(User)
.filter(User.auth_source == AuthSource.LDAP, User.ldap_username == ldap_username)
.with_for_update()
.first()
)
if not user:
# 最后回退按 email 查找:如果存在同邮箱的本地账号,需要拒绝以避免接管
user = db.query(User).filter(User.email == email).with_for_update().first()
if user:
if user.is_deleted:
logger.warning(f"LDAP 登录失败 - 用户已删除: {email}")
return None
if user.auth_source != AuthSource.LDAP:
# 避免覆盖已有本地账户(不同来源时拒绝登录)
logger.warning(
f"LDAP 登录拒绝 - 账户来源不匹配(现有:{user.auth_source}, 请求:LDAP): {email}"
)
return None
# 同步邮箱LDAP 侧邮箱变更时更新;若新邮箱已被占用则拒绝)
if user.email != email:
email_taken = db.query(User).filter(User.email == email, User.id != user.id).first()
if email_taken:
logger.warning(f"LDAP 登录拒绝 - 新邮箱已被占用: {email}")
return None
user.email = email
user.email_verified = True
# 同步 LDAP 标识(首次填充或 LDAP 侧发生变化)
if ldap_dn and user.ldap_dn != ldap_dn:
user.ldap_dn = ldap_dn
if ldap_username and user.ldap_username != ldap_username:
user.ldap_username = ldap_username
return user
# 检查 username 是否已被占用,使用时间戳+随机数确保唯一性
base_username = ldap_username or ldap_user["username"]
username = base_username
max_retries = 3
for attempt in range(max_retries):
# 检查用户名是否已存在
existing_user_with_username = db.query(User).filter(User.username == username).first()
if existing_user_with_username:
# 如果 username 已存在,使用时间戳+随机数确保唯一性
username = f"{base_username}_ldap_{int(time.time())}{uuid.uuid4().hex[:4]}"
logger.info(f"LDAP 用户名冲突,使用新用户名: {ldap_user['username']} -> {username}")
# 读取系统配置的默认初始赠款
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# 创建新用户
user = User(
email=email,
email_verified=True,
username=username,
password_hash=None, # LDAP 用户无本地密码
auth_source=AuthSource.LDAP,
ldap_dn=ldap_dn,
ldap_username=ldap_username,
role=UserRole.USER,
is_active=True,
last_login_at=None,
)
try:
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=default_initial_gift,
unlimited=False,
description="LDAP 注册初始赠款",
)
return user
except IntegrityError as e:
db.rollback()
error_str = str(e.orig).lower() if e.orig else str(e).lower()
# 解析具体冲突类型
if "email" in error_str or "ix_users_email" in error_str:
# 邮箱冲突不应重试(前面已检查过,说明是并发创建)
logger.error(f"LDAP 用户创建失败 - 邮箱并发冲突: {email}")
return None
elif "username" in error_str or "ix_users_username" in error_str:
# 用户名冲突,重试时会生成新用户名
if attempt == max_retries - 1:
logger.error(f"LDAP 用户创建失败(用户名冲突重试耗尽): {username}")
return None
username = f"{base_username}_ldap_{int(time.time())}{uuid.uuid4().hex[:4]}"
logger.warning(
f"LDAP 用户创建用户名冲突,重试 ({attempt + 1}/{max_retries}): {username}"
)
else:
# 其他约束冲突,不重试
logger.error(f"LDAP 用户创建失败 - 未知数据库约束冲突: {e}")
return None
return None
@staticmethod
def authenticate_api_key(db: Session, api_key: str) -> tuple[User, ApiKey] | None:
"""API密钥认证"""
# 对API密钥进行哈希查找预加载 user 关系以支持后续访问限制检查
key_hash = ApiKey.hash_key(api_key)
key_record = (
db.query(ApiKey)
.options(joinedload(ApiKey.user))
.filter(ApiKey.key_hash == key_hash)
.first()
)
if not key_record:
# 只记录认证失败事件,不记录任何 key 信息以防止信息泄露
logger.warning("API认证失败 - 密钥不存在或无效")
return None
if not key_record.is_active:
logger.warning("API认证失败 - 密钥已禁用")
return None
if key_record.is_locked and not key_record.is_standalone:
logger.warning("API认证失败 - 密钥已被管理员锁定")
raise ForbiddenException("该密钥已被管理员锁定,请联系管理员")
# 检查过期时间
if key_record.expires_at:
# 确保 expires_at 是 aware datetime
expires_at = key_record.expires_at
if expires_at.tzinfo is None:
# 如果没有时区信息,假定为 UTC
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
logger.warning("API认证失败 - 密钥已过期")
return None
# 获取用户
user = key_record.user
if not user.is_active:
logger.warning(f"API认证失败 - 用户已禁用: {user.email}")
return None
if user.is_deleted:
logger.warning(f"API认证失败 - 用户已删除: {user.email}")
return None
# 更新最后使用时间(使用节流策略,减少数据库写入)
if _should_update_last_used(key_record.id):
key_record.last_used_at = datetime.now(timezone.utc)
# 这里需要 commit 来尽快释放锁,但默认 expire_on_commit=True 会让已加载对象过期,
# 导致同一请求后续访问 user/api_key 字段时触发额外 SELECT。
original_expire_on_commit = getattr(db, "expire_on_commit", None)
try:
if original_expire_on_commit is not None:
db.expire_on_commit = False
db.commit() # 立即提交事务,释放数据库锁,避免阻塞后续请求
except Exception:
db.rollback()
raise
finally:
if original_expire_on_commit is not None:
db.expire_on_commit = original_expire_on_commit
api_key_fp = hashlib.sha256(api_key.encode()).hexdigest()[:12]
logger.debug("API认证成功: 用户 {} (api_key_fp={})", user.email, api_key_fp)
return user, key_record
@staticmethod
def check_user_balance_access(user: User, estimated_cost: float = 0) -> bool:
"""按钱包余额/额度模式校验请求可用性。"""
from src.services.wallet import WalletService
_ = estimated_cost
if user.role == UserRole.ADMIN:
return True
wallet = getattr(user, "wallet", None)
if wallet is None:
return False
if wallet.status != "active":
return False
if WalletService.is_unlimited_wallet(wallet):
return True
return WalletService.get_spendable_balance_value(wallet) > 0
@staticmethod
def check_permission(user: User, required_role: UserRole = UserRole.USER) -> bool:
"""检查用户权限"""
if user.role == UserRole.ADMIN:
return True
# 避免使用字符串比较导致权限判断错误(例如 'user' >= 'admin'
role_rank = {UserRole.USER: 0, UserRole.ADMIN: 1}
# 未知用户角色默认 -1拒绝未知要求角色默认 999拒绝
if role_rank.get(user.role, -1) >= role_rank.get(required_role, 999):
return True
logger.warning(
f"权限不足: 用户 {user.email} 角色 {user.role.value} < 需要 {required_role.value}"
)
return False
@staticmethod
async def logout(token: str) -> bool:
"""
用户登出,将 Token 加入黑名单
Args:
token: JWT token字符串
Returns:
是否成功登出
"""
try:
# 解码 Token 获取过期时间(不验证黑名单)
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
exp_timestamp = payload.get("exp")
if not exp_timestamp:
logger.warning("Token 缺少过期时间,无法加入黑名单")
return False
# 将 Token 加入黑名单
success = await JWTBlacklistService.add_to_blacklist(
token=token, exp_timestamp=exp_timestamp, reason="logout"
)
if success:
user_id = payload.get("user_id")
logger.info(f"用户登出成功: user_id={user_id}")
return success
except jwt.InvalidTokenError as e:
logger.warning(f"登出失败 - 无效的 Token: {e}")
return False
except Exception as e:
logger.error(f"登出失败: {e}")
return False
@staticmethod
async def revoke_token(token: str, reason: str = "revoked") -> bool:
"""
撤销 Token管理员操作
Args:
token: JWT token字符串
reason: 撤销原因
Returns:
是否成功撤销
"""
try:
# 解码 Token 获取过期时间
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
exp_timestamp = payload.get("exp")
if not exp_timestamp:
logger.warning("Token 缺少过期时间,无法撤销")
return False
# 将 Token 加入黑名单
success = await JWTBlacklistService.add_to_blacklist(
token=token, exp_timestamp=exp_timestamp, reason=reason
)
if success:
user_id = payload.get("sub")
logger.warning(f"Token 已被撤销: user_id={user_id}, reason={reason}")
return success
except jwt.InvalidTokenError as e:
logger.warning(f"撤销失败 - 无效的 Token: {e}")
return False
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
) -> tuple[User, ManagementToken] | None:
"""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.core.modules import get_module_registry
from src.models.database import AuditEventType, ManagementToken
from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
# 检查访问令牌模块是否激活
module_registry = get_module_registry()
if not module_registry.is_active("management_tokens", db):
logger.warning("Management Token 认证失败 - 访问令牌模块未激活")
return None
# 速率限制检查(防止暴力破解)
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
if user.is_deleted:
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,467 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
from fastapi import HTTPException, Request, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import AuditEventType, User, UserSession
from src.services.system.audit import AuditService
SESSION_TOUCH_INTERVAL_SECONDS = 300
CLIENT_DEVICE_ID_HEADER = "X-Client-Device-Id"
MAX_SESSIONS_PER_USER = 20
TERMINAL_SESSION_RETENTION_DAYS = 30
_DEVICE_ID_PATTERN = re.compile(r"^[a-zA-Z0-9\-_]{1,128}$")
def _strip_hint(value: str | None) -> str | None:
if value is None:
return None
cleaned = value.strip().strip('"').strip()
return cleaned or None
def _parse_browser(user_agent: str) -> tuple[str | None, str | None]:
patterns = [
("Edge", r"Edg/([\d.]+)"),
("Opera", r"OPR/([\d.]+)"),
("Chrome", r"Chrome/([\d.]+)"),
("Firefox", r"Firefox/([\d.]+)"),
("Safari", r"Version/([\d.]+).*Safari"),
]
for name, pattern in patterns:
match = re.search(pattern, user_agent)
if match:
return name, match.group(1)
return None, None
def _parse_os(
user_agent: str, client_hints: Mapping[str, str | None]
) -> tuple[str | None, str | None]:
platform = _strip_hint(client_hints.get("sec-ch-ua-platform"))
platform_version = _strip_hint(client_hints.get("sec-ch-ua-platform-version"))
if platform:
return platform, platform_version
patterns = [
("Windows", r"Windows NT ([\d.]+)"),
("macOS", r"Mac OS X ([\d_]+)"),
("iOS", r"(?:iPhone OS|CPU OS) ([\d_]+)"),
("Android", r"Android ([\d.]+)"),
("Linux", r"Linux"),
]
for name, pattern in patterns:
match = re.search(pattern, user_agent)
if match:
version = match.group(1).replace("_", ".") if match.lastindex else None
return name, version
return None, None
def _parse_device_type(user_agent: str, client_hints: Mapping[str, str | None]) -> str:
ch_mobile = _strip_hint(client_hints.get("sec-ch-ua-mobile"))
ua_lower = user_agent.lower()
if ch_mobile == "?1":
return "mobile"
if "ipad" in ua_lower or "tablet" in ua_lower:
return "tablet"
if any(marker in ua_lower for marker in ("iphone", "android", "mobile")):
return "mobile"
if any(marker in ua_lower for marker in ("macintosh", "windows", "linux", "x11")):
return "desktop"
return "unknown"
def _build_device_label(
*,
browser_name: str | None,
os_name: str | None,
device_model: str | None,
device_type: str,
) -> str:
if device_model:
return device_model
if browser_name and os_name:
return f"{browser_name} / {os_name}"
if browser_name:
return browser_name
if os_name:
return os_name
if device_type == "mobile":
return "移动设备"
if device_type == "tablet":
return "平板设备"
if device_type == "desktop":
return "桌面设备"
return "未知设备"
@dataclass(frozen=True)
class SessionClientContext:
client_device_id: str
device_label: str
device_type: str
browser_name: str | None
browser_version: str | None
os_name: str | None
os_version: str | None
device_model: str | None
client_hints: dict[str, str | None]
ip_address: str | None
user_agent: str
class SessionService:
"""用户设备会话服务。"""
@staticmethod
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
@staticmethod
def _active_sessions_query(db: Session, *, user_id: str) -> Any:
return db.query(UserSession).filter(
UserSession.user_id == user_id,
UserSession.revoked_at.is_(None),
UserSession.expires_at > SessionService._utcnow(),
)
@staticmethod
def cleanup_user_sessions(db: Session, *, user_id: str) -> int:
"""清理该用户已进入终态且超过保留期的会话记录。"""
cutoff = SessionService._utcnow() - timedelta(days=TERMINAL_SESSION_RETENTION_DAYS)
deleted = (
db.query(UserSession)
.filter(UserSession.user_id == user_id)
.filter(
or_(
UserSession.expires_at < cutoff,
and_(UserSession.revoked_at.is_not(None), UserSession.revoked_at < cutoff),
)
)
.delete(synchronize_session=False)
)
if deleted:
db.flush()
return int(deleted or 0)
@staticmethod
def _normalize_device_id(raw: str) -> str | None:
"""校验并规范化 device id非法值返回 None。"""
cleaned = raw.strip()[:128]
if not cleaned:
return None
if _DEVICE_ID_PATTERN.match(cleaned):
return cleaned
# 不符合格式的视为无效
return None
@staticmethod
def extract_client_device_id(request: Request) -> str:
header_value = request.headers.get(CLIENT_DEVICE_ID_HEADER)
if header_value:
normalized = SessionService._normalize_device_id(header_value)
if normalized:
return normalized
query_value = request.query_params.get("client_device_id")
if query_value:
normalized = SessionService._normalize_device_id(query_value)
if normalized:
return normalized
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="缺少或无效的设备标识",
)
@staticmethod
def build_client_context(
*,
client_device_id: str,
client_ip: str | None,
user_agent: str,
headers: Mapping[str, str],
) -> SessionClientContext:
normalized_headers = {str(key).lower(): value for key, value in headers.items()}
client_hints = {
"sec-ch-ua": normalized_headers.get("sec-ch-ua"),
"sec-ch-ua-platform": normalized_headers.get("sec-ch-ua-platform"),
"sec-ch-ua-platform-version": normalized_headers.get("sec-ch-ua-platform-version"),
"sec-ch-ua-model": normalized_headers.get("sec-ch-ua-model"),
"sec-ch-ua-mobile": normalized_headers.get("sec-ch-ua-mobile"),
}
browser_name, browser_version = _parse_browser(user_agent)
os_name, os_version = _parse_os(user_agent, client_hints)
device_model = _strip_hint(client_hints.get("sec-ch-ua-model"))
device_type = _parse_device_type(user_agent, client_hints)
device_label = _build_device_label(
browser_name=browser_name,
os_name=os_name,
device_model=device_model,
device_type=device_type,
)
return SessionClientContext(
client_device_id=client_device_id,
device_label=device_label,
device_type=device_type,
browser_name=browser_name,
browser_version=browser_version,
os_name=os_name,
os_version=os_version,
device_model=device_model,
client_hints=client_hints,
ip_address=client_ip,
user_agent=user_agent,
)
@staticmethod
def get_active_session(db: Session, session_id: str, user_id: str) -> UserSession | None:
session = (
db.query(UserSession)
.filter(UserSession.id == session_id, UserSession.user_id == user_id)
.first()
)
if not session:
return None
if session.is_revoked or session.is_expired:
return None
return session
@staticmethod
def assert_session_device_matches(session: UserSession, client_device_id: str) -> None:
if session.client_device_id != client_device_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="设备标识与登录会话不匹配",
)
@staticmethod
def create_session(
db: Session,
*,
user: User,
session_id: str,
refresh_token: str,
expires_at: datetime,
client: SessionClientContext,
revoke_existing_same_device: bool = True,
) -> UserSession:
now = SessionService._utcnow()
SessionService.cleanup_user_sessions(db, user_id=user.id)
if revoke_existing_same_device:
existing_sessions = (
SessionService._active_sessions_query(db, user_id=user.id)
.filter(UserSession.client_device_id == client.client_device_id)
.all()
)
for existing in existing_sessions:
existing.revoked_at = now
existing.revoke_reason = "replaced_by_new_login"
existing.updated_at = now
# 如果活跃会话数超出限制,淘汰最旧的会话
active_count = SessionService._active_sessions_query(db, user_id=user.id).count()
if active_count >= MAX_SESSIONS_PER_USER:
oldest_sessions = (
SessionService._active_sessions_query(db, user_id=user.id)
.order_by(UserSession.last_seen_at.asc())
.limit(active_count - MAX_SESSIONS_PER_USER + 1)
.all()
)
for old_session in oldest_sessions:
old_session.revoked_at = now
old_session.revoke_reason = "session_limit_exceeded"
old_session.updated_at = now
session = UserSession(
id=session_id,
user_id=user.id,
client_device_id=client.client_device_id,
device_label=client.device_label,
device_type=client.device_type,
browser_name=client.browser_name,
browser_version=client.browser_version,
os_name=client.os_name,
os_version=client.os_version,
device_model=client.device_model,
ip_address=client.ip_address,
user_agent=client.user_agent[:1000],
client_hints=client.client_hints,
last_seen_at=now,
expires_at=expires_at,
revoked_at=None,
revoke_reason=None,
)
session.set_refresh_token(refresh_token)
db.add(session)
db.flush()
return session
@staticmethod
def rotate_refresh_token(
session: UserSession,
*,
refresh_token: str,
expires_at: datetime,
client_ip: str | None,
user_agent: str,
) -> None:
session.set_refresh_token(refresh_token)
session.expires_at = expires_at
session.last_seen_at = datetime.now(timezone.utc)
if client_ip:
session.ip_address = client_ip
if user_agent:
session.user_agent = user_agent[:1000]
@staticmethod
def touch_session(
session: UserSession,
*,
client_ip: str | None,
user_agent: str,
) -> bool:
now = datetime.now(timezone.utc)
last_seen_at = session.last_seen_at
if last_seen_at.tzinfo is None:
last_seen_at = last_seen_at.replace(tzinfo=timezone.utc)
if (now - last_seen_at).total_seconds() < SESSION_TOUCH_INTERVAL_SECONDS:
return False
session.last_seen_at = now
if client_ip:
session.ip_address = client_ip
if user_agent:
session.user_agent = user_agent[:1000]
return True
@staticmethod
def revoke_session(
db: Session,
*,
session: UserSession,
reason: str,
audit_user_id: str | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
) -> None:
if session.revoked_at is not None:
return
now = datetime.now(timezone.utc)
session.revoked_at = now
session.revoke_reason = reason[:100]
session.updated_at = now
if reason == "refresh_token_reused":
AuditService.log_event(
db=db,
event_type=AuditEventType.SUSPICIOUS_ACTIVITY,
description="Detected refresh token reuse; session revoked",
user_id=audit_user_id or session.user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata={
"session_id": session.id,
"client_device_id": session.client_device_id,
"reason": reason,
},
)
@staticmethod
def revoke_all_user_sessions(
db: Session,
*,
user_id: str,
reason: str,
exclude_session_id: str | None = None,
) -> int:
now = SessionService._utcnow()
SessionService.cleanup_user_sessions(db, user_id=user_id)
sessions = SessionService._active_sessions_query(db, user_id=user_id).all()
count = 0
for session in sessions:
if exclude_session_id and session.id == exclude_session_id:
continue
session.revoked_at = now
session.revoke_reason = reason[:100]
session.updated_at = now
count += 1
return count
@staticmethod
def list_user_sessions(db: Session, *, user_id: str) -> list[UserSession]:
SessionService.cleanup_user_sessions(db, user_id=user_id)
return (
SessionService._active_sessions_query(db, user_id=user_id)
.order_by(UserSession.last_seen_at.desc(), UserSession.created_at.desc())
.all()
)
@staticmethod
def update_session_label(session: UserSession, device_label: str) -> None:
normalized = device_label.strip()
if not normalized:
raise ValueError("设备名称不能为空")
session.device_label = normalized[:120]
session.updated_at = datetime.now(timezone.utc)
@staticmethod
def get_session_for_user(
db: Session,
*,
user_id: str,
session_id: str,
lock_for_update: bool = False,
) -> UserSession | None:
query = db.query(UserSession).filter(
UserSession.user_id == user_id,
UserSession.id == session_id,
)
if lock_for_update:
# 串行化 refresh token 轮换,避免并发刷新把合法会话误判为重放攻击。
query = query.with_for_update()
return query.first()
@staticmethod
def validate_refresh_session(
db: Session,
*,
user_id: str,
session_id: str,
refresh_token: str,
ip_address: str | None = None,
user_agent: str | None = None,
) -> tuple[UserSession | None, bool]:
session = SessionService.get_session_for_user(
db,
user_id=user_id,
session_id=session_id,
lock_for_update=True,
)
if not session or session.is_revoked or session.is_expired:
return None, False
is_valid, is_prev = session.verify_refresh_token(refresh_token)
if not is_valid:
logger.warning("Refresh token mismatch for session {}", session_id)
SessionService.revoke_session(
db,
session=session,
reason="refresh_token_reused",
audit_user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
)
db.flush()
return None, False
if is_prev:
logger.info("Grace window hit for session {} (prev token used)", session_id)
return session, is_prev