mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
refactor: 引入模块钩子系统,解耦认证逻辑,支持模块/normalizer/parser 自动发现
- 新增 HookDispatcher 钩子分发器,支持 FIRST_RESULT 和 COLLECT_ALL 两种策略 - LDAP 认证逻辑从 AuthService 移至 ldap 模块钩子实现 - Management Token 前缀认证从 pipeline 硬编码改为模块钩子注册 - src/modules/ 改为自动扫描子目录发现 ModuleDefinition - normalizers 和 parsers 注册改为基于类属性自动发现 - OpenAI CLI 增加 /v1/responses/compact 端点和并行 tool_call 支持 - OpenAI CLI normalizer 支持 Chat Completions 格式自动回退 - Codex 适配器增加 compact 模式上下文传递和 header 调整 - HeaderBuilder 改进非 latin-1 字符处理(UTF-8 字节透传) - Gunicorn 增加 graceful_timeout 防止僵尸进程
This commit is contained in:
@@ -16,7 +16,6 @@ from src.core.exceptions import ConfirmationRequiredException, InvalidRequestExc
|
||||
from src.core.logger import logger
|
||||
from src.core.modules import get_module_registry
|
||||
from src.models.database import OAuthProvider, User, UserOAuthLink
|
||||
from src.services.auth.ldap import LDAPService
|
||||
from src.services.auth.oauth.base import OAuthProviderBase
|
||||
from src.services.auth.oauth.models import OAuthFlowError, OAuthUserInfo
|
||||
from src.services.auth.oauth.registry import get_oauth_provider_registry
|
||||
@@ -629,7 +628,9 @@ class OAuthService:
|
||||
- OAUTH 用户:禁用后必须仍有其它启用的 OAuth provider 绑定
|
||||
- LOCAL 用户:ldap_exclusive=true 且非 admin 时,同上
|
||||
"""
|
||||
ldap_exclusive = LDAPService.is_ldap_exclusive(db)
|
||||
from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
|
||||
|
||||
ldap_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
|
||||
|
||||
users = (
|
||||
db.query(User.id, User.auth_source, User.role)
|
||||
@@ -1002,11 +1003,10 @@ class OAuthService:
|
||||
if user.auth_source == AuthSource.LOCAL and not user.password_hash and total_links <= 1:
|
||||
raise InvalidRequestException("请先设置密码后再解绑")
|
||||
|
||||
if (
|
||||
LDAPService.is_ldap_exclusive(db)
|
||||
and user.auth_source == AuthSource.LOCAL
|
||||
and user.role != UserRole.ADMIN
|
||||
):
|
||||
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 and user.auth_source == AuthSource.LOCAL and user.role != UserRole.ADMIN:
|
||||
# ldap_exclusive=true 时,普通本地用户解绑最后一个 OAuth 会锁死(密码登录被禁用)
|
||||
if total_links <= 1:
|
||||
raise InvalidRequestException("当前处于 LDAP 专属模式,解绑后将无法登录")
|
||||
|
||||
@@ -30,7 +30,6 @@ if TYPE_CHECKING:
|
||||
|
||||
from src.models.database import ApiKey, User, UserRole
|
||||
from src.services.auth.jwt_blacklist import JWTBlacklistService
|
||||
from src.services.auth.ldap import LDAPService
|
||||
from src.services.cache.user_cache import UserCacheService
|
||||
from src.services.user.apikey import ApiKeyService
|
||||
|
||||
@@ -189,55 +188,23 @@ class AuthService:
|
||||
db: 数据库会话
|
||||
email: 邮箱/用户名
|
||||
password: 密码
|
||||
auth_type: 认证类型 ("local" 或 "ldap")
|
||||
auth_type: 认证类型 ("local" 或由模块钩子处理的其他类型)
|
||||
"""
|
||||
if auth_type == "ldap":
|
||||
# LDAP 认证
|
||||
# 预取配置,避免将 Session 传递到线程池
|
||||
config_data = LDAPService.get_config_data(db)
|
||||
if not config_data:
|
||||
logger.warning("登录失败 - LDAP 未启用或配置无效")
|
||||
return None
|
||||
# 非本地认证:通过钩子分发给对应模块处理
|
||||
if auth_type != "local":
|
||||
from src.core.modules.hooks import AUTH_AUTHENTICATE, get_hook_dispatcher
|
||||
|
||||
# 计算总体超时:LDAP 认证包含多次网络操作(连接、管理员绑定、搜索、用户绑定)
|
||||
# 超时策略:
|
||||
# - 单次操作超时(connect_timeout):控制每次网络操作的最大等待时间
|
||||
# - 总体超时:防止异常场景(如服务器响应缓慢但未超时)导致请求堆积
|
||||
# - 公式:单次超时 × 4(覆盖 4 次主要网络操作)+ 10% 缓冲
|
||||
# - 最小 20 秒(保证基本操作),最大 60 秒(避免用户等待过长)
|
||||
single_timeout = config_data.get("connect_timeout", 10)
|
||||
total_timeout = max(20, min(int(single_timeout * 4 * 1.1), 60))
|
||||
|
||||
# 在线程池中执行阻塞的 LDAP 网络请求,避免阻塞事件循环
|
||||
# 添加总体超时保护,防止异常场景下请求堆积
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
ldap_user = await asyncio.wait_for(
|
||||
run_in_threadpool(
|
||||
LDAPService.authenticate_with_config, config_data, email, password
|
||||
),
|
||||
timeout=total_timeout,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.error(f"LDAP 认证总体超时({total_timeout}秒): {email}")
|
||||
return None
|
||||
|
||||
if not ldap_user:
|
||||
return None
|
||||
|
||||
# 获取或创建本地用户
|
||||
user = await AuthService._get_or_create_ldap_user(db, ldap_user)
|
||||
if not user:
|
||||
# 已有本地账号但来源不匹配等情况
|
||||
return None
|
||||
if user.is_deleted:
|
||||
logger.warning(f"登录失败 - 用户已删除: {email}")
|
||||
return None
|
||||
if not user.is_active:
|
||||
logger.warning(f"登录失败 - 用户已禁用: {email}")
|
||||
return None
|
||||
return user
|
||||
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 的缓存对象
|
||||
@@ -254,12 +221,15 @@ class AuthService:
|
||||
logger.warning(f"登录失败 - 用户已删除: {email}")
|
||||
return None
|
||||
|
||||
# 检查 LDAP exclusive 模式:仅允许本地管理员登录(紧急恢复通道)
|
||||
if LDAPService.is_ldap_exclusive(db):
|
||||
# 检查排他登录模式(如 LDAP exclusive):仅允许本地管理员登录(紧急恢复通道)
|
||||
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(f"登录失败 - 仅允许 LDAP 登录(管理员除外): {email}")
|
||||
logger.warning(f"登录失败 - 排他登录模式下仅管理员可本地登录: {email}")
|
||||
return None
|
||||
logger.warning(f"[LDAP-EXCLUSIVE] 紧急恢复通道:本地管理员登录: {email}")
|
||||
logger.warning(f"[EXCLUSIVE-MODE] 紧急恢复通道:本地管理员登录: {email}")
|
||||
|
||||
# 检查用户认证来源
|
||||
if user.auth_source == AuthSource.LDAP:
|
||||
@@ -285,7 +255,7 @@ class AuthService:
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def _get_or_create_ldap_user(db: Session, ldap_user: dict) -> User | None:
|
||||
async def get_or_create_ldap_user(db: Session, ldap_user: dict) -> User | None:
|
||||
"""获取或创建 LDAP 用户
|
||||
|
||||
Args:
|
||||
|
||||
@@ -20,6 +20,7 @@ class CodexRequestContext:
|
||||
"""
|
||||
|
||||
account_id: str | None = None
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
_codex_request_context: contextvars.ContextVar[CodexRequestContext | None] = contextvars.ContextVar(
|
||||
|
||||
@@ -35,6 +35,10 @@ class CodexOAuthEnvelope:
|
||||
# These headers are best-effort: Codex upstream is stricter than public OpenAI API.
|
||||
# Keep them provider-scoped (via ProviderEnvelope) to avoid leaking to other upstreams.
|
||||
headers: dict[str, str] = {
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
# Codex upstream is strict about Content-Type; variants like
|
||||
# "application/json; charset=utf-8" are rejected.
|
||||
"Content-Type": "application/json",
|
||||
"x-oai-web-search-eligible": "true",
|
||||
"session_id": str(uuid.uuid4()),
|
||||
"originator": "codex_cli_rs",
|
||||
@@ -46,11 +50,11 @@ class CodexOAuthEnvelope:
|
||||
if ua:
|
||||
headers["User-Agent"] = ua
|
||||
|
||||
# Add chatgpt-account-id from context (set by wrap_request), then clear.
|
||||
# Add chatgpt-account-id from context (set by wrap_request).
|
||||
# Context is NOT cleared here — build_codex_url reads is_compact from it later.
|
||||
ctx = get_codex_request_context()
|
||||
if ctx and ctx.account_id:
|
||||
headers["chatgpt-account-id"] = ctx.account_id
|
||||
set_codex_request_context(None)
|
||||
|
||||
return headers
|
||||
|
||||
@@ -64,11 +68,20 @@ class CodexOAuthEnvelope:
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
# Extract account_id from auth_config and set context for extra_headers()
|
||||
account_id = (decrypted_auth_config or {}).get("account_id")
|
||||
# Compact sentinel may have been popped earlier by finalize_provider_request;
|
||||
# prefer the pre-set context var (set by adapter), fall back to request body.
|
||||
existing_ctx = get_codex_request_context()
|
||||
is_compact = (existing_ctx.is_compact if existing_ctx else False) or bool(
|
||||
request_body.pop("_aether_compact", False)
|
||||
)
|
||||
set_codex_request_context(
|
||||
CodexRequestContext(
|
||||
account_id=str(account_id) if account_id else None,
|
||||
is_compact=is_compact,
|
||||
)
|
||||
)
|
||||
# Context 不需要手动清理: FastAPI 每个请求运行在独立的 asyncio Task 中,
|
||||
# contextvars 天然隔离, Task 结束后自动回收。
|
||||
# No wire envelope for Codex; keep request body as-is.
|
||||
return request_body, url_model
|
||||
|
||||
|
||||
@@ -41,14 +41,24 @@ def build_codex_url(
|
||||
"""构建 Codex OAuth URL。
|
||||
|
||||
Codex upstream (chatgpt.com/backend-api/codex) 使用 /responses
|
||||
而非标准 OpenAI 的 /v1/responses。
|
||||
而非标准 OpenAI 的 /v1/responses。compact 模式使用 /responses/compact。
|
||||
"""
|
||||
_ = is_stream # Codex 不需要根据 stream 切换路径
|
||||
|
||||
from src.services.provider.adapters.codex.context import get_codex_request_context
|
||||
|
||||
ctx = get_codex_request_context()
|
||||
is_compact = ctx.is_compact if ctx else False
|
||||
|
||||
base = str(endpoint.base_url).rstrip("/")
|
||||
path = "/responses"
|
||||
# 如果用户已在 base_url 中包含了最终路径,不要重复
|
||||
url = base if base.endswith(path) else f"{base}{path}"
|
||||
# 如果用户已在 base_url 中包含了 /responses,不要重复追加
|
||||
if base.endswith("/responses"):
|
||||
url = f"{base}/compact" if is_compact else base
|
||||
elif base.endswith("/responses/compact"):
|
||||
url = base if is_compact else base.removesuffix("/compact")
|
||||
else:
|
||||
suffix = "/responses/compact" if is_compact else "/responses"
|
||||
url = f"{base}{suffix}"
|
||||
if effective_query_params:
|
||||
query_string = urlencode(effective_query_params, doseq=True)
|
||||
if query_string:
|
||||
|
||||
Reference in New Issue
Block a user