mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
13
_deprecated_py_src/services/auth/__init__.py
Normal file
13
_deprecated_py_src/services/auth/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
认证服务模块
|
||||
|
||||
包含认证服务、JWT 黑名单等功能。
|
||||
"""
|
||||
|
||||
from src.services.auth.jwt_blacklist import JWTBlacklistService
|
||||
from src.services.auth.service import AuthService
|
||||
|
||||
__all__ = [
|
||||
"AuthService",
|
||||
"JWTBlacklistService",
|
||||
]
|
||||
201
_deprecated_py_src/services/auth/jwt_blacklist.py
Normal file
201
_deprecated_py_src/services/auth/jwt_blacklist.py
Normal 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}"
|
||||
|
||||
# 计算 TTL(Token 过期前的剩余时间)
|
||||
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)}
|
||||
375
_deprecated_py_src/services/auth/ldap.py
Normal file
375
_deprecated_py_src/services/auth/ldap.py
Normal 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)
|
||||
5
_deprecated_py_src/services/auth/oauth/__init__.py
Normal file
5
_deprecated_py_src/services/auth/oauth/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""OAuth 认证相关服务。"""
|
||||
|
||||
from .service import OAuthService
|
||||
|
||||
__all__ = ["OAuthService"]
|
||||
111
_deprecated_py_src/services/auth/oauth/base.py
Normal file
111
_deprecated_py_src/services/auth/oauth/base.py
Normal 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")
|
||||
31
_deprecated_py_src/services/auth/oauth/models.py
Normal file
31
_deprecated_py_src/services/auth/oauth/models.py
Normal 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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""内置 OAuth providers(v1)。"""
|
||||
|
||||
from .linuxdo import LinuxDoOAuthProvider
|
||||
|
||||
__all__ = ["LinuxDoOAuthProvider"]
|
||||
171
_deprecated_py_src/services/auth/oauth/providers/linuxdo.py
Normal file
171
_deprecated_py_src/services/auth/oauth/providers/linuxdo.py
Normal 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 等
|
||||
)
|
||||
95
_deprecated_py_src/services/auth/oauth/registry.py
Normal file
95
_deprecated_py_src/services/auth/oauth/registry.py
Normal 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) 内置 providers(v1:至少保证 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
|
||||
1069
_deprecated_py_src/services/auth/oauth/service.py
Normal file
1069
_deprecated_py_src/services/auth/oauth/service.py
Normal file
File diff suppressed because it is too large
Load Diff
145
_deprecated_py_src/services/auth/oauth/state.py
Normal file
145
_deprecated_py_src/services/auth/oauth/state.py
Normal 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)
|
||||
35
_deprecated_py_src/services/auth/refresh_cookie.py
Normal file
35
_deprecated_py_src/services/auth/refresh_cookie.py
Normal 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
|
||||
903
_deprecated_py_src/services/auth/service.py
Normal file
903
_deprecated_py_src/services/auth/service.py
Normal 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
|
||||
467
_deprecated_py_src/services/auth/session_service.py
Normal file
467
_deprecated_py_src/services/auth/session_service.py
Normal 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
|
||||
Reference in New Issue
Block a user