mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-08-06 04:53:35 +08:00
feat: add Codex auth login and export flow
Add Codex Auth support in account management so selected accounts can complete a Codex-compatible OAuth login flow and export usable auth.json files. This commit includes: - account-management UI entrypoints for Codex Auth login and auth.json download - backend SSE routes for single-account and batch Codex Auth login execution - persistence of freshly returned Codex-compatible tokens back into the account database - Codex auth export support for direct auth.json download and batch zip packaging - tests covering the Codex Auth login flow and export behavior The OTP verification failure was caused by manually sending a second OTP after password verification. The flow now reuses the existing proven login path: login re-entry, password verification, automatic OTP reception, consent page handling, workspace selection, and OAuth callback exchange. Successful logins now also persist workspace_id together with the refreshed Codex-compatible tokens, making later re-export of auth.json possible without requiring the browser-downloaded file to still exist locally. Change-Id: I59df518ef4dc05f8bc52c734dd1b738fcb0b7a4e
This commit is contained in:
@@ -48,6 +48,7 @@
|
|||||||
- 单个账号导出为独立 `.json` 文件
|
- 单个账号导出为独立 `.json` 文件
|
||||||
- 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件
|
- 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件
|
||||||
- Sub2API 格式所有账号合并为单个 JSON
|
- Sub2API 格式所有账号合并为单个 JSON
|
||||||
|
- Codex Auth 格式需先在账号管理中手动执行 `Codex Auth 登录` 成功后才能导出
|
||||||
- 上传目标(直连不走代理):
|
- 上传目标(直连不走代理):
|
||||||
- **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url`
|
- **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url`
|
||||||
- **Sub2API**:支持多服务配置,标准 sub2api-data 格式
|
- **Sub2API**:支持多服务配置,标准 sub2api-data 格式
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
|||||||
OAUTH_REDIRECT_URI = "http://localhost:15555/auth/callback"
|
OAUTH_REDIRECT_URI = "http://localhost:15555/auth/callback"
|
||||||
OAUTH_SCOPE = "openid email profile offline_access"
|
OAUTH_SCOPE = "openid email profile offline_access"
|
||||||
|
|
||||||
|
# Codex CLI 专用 OAuth 参数(用于生成 Codex 兼容的 auth.json)
|
||||||
|
CODEX_OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback"
|
||||||
|
CODEX_OAUTH_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"
|
||||||
|
CODEX_OAUTH_ORIGINATOR = "codex_cli_rs"
|
||||||
|
|
||||||
# OpenAI API 端点
|
# OpenAI API 端点
|
||||||
OPENAI_API_ENDPOINTS = {
|
OPENAI_API_ENDPOINTS = {
|
||||||
"sentinel": "https://sentinel.openai.com/backend-api/sentinel/req",
|
"sentinel": "https://sentinel.openai.com/backend-api/sentinel/req",
|
||||||
|
|||||||
213
src/core/codex_auth.py
Normal file
213
src/core/codex_auth.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Codex Auth 登录引擎
|
||||||
|
复用仓库里已经验证通过的登录状态流,为已有账号生成 Codex CLI 可用的 auth.json。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from .openai.oauth import OAuthManager
|
||||||
|
from .register import PhaseContext, RegistrationEngine
|
||||||
|
from ..config.constants import (
|
||||||
|
CODEX_OAUTH_ORIGINATOR,
|
||||||
|
CODEX_OAUTH_REDIRECT_URI,
|
||||||
|
CODEX_OAUTH_SCOPE,
|
||||||
|
)
|
||||||
|
from ..config.settings import get_settings
|
||||||
|
from ..services.base import BaseEmailService
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CodexAuthResult:
|
||||||
|
"""Codex Auth 登录结果"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
email: str = ""
|
||||||
|
workspace_id: str = ""
|
||||||
|
auth_json: Optional[Dict[str, Any]] = None
|
||||||
|
error_message: str = ""
|
||||||
|
logs: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CodexAuthEngine(RegistrationEngine):
|
||||||
|
"""
|
||||||
|
对已有账号执行 Codex CLI 兼容 OAuth 登录流程。
|
||||||
|
|
||||||
|
这里直接复用 RegistrationEngine 中已经跑通的:
|
||||||
|
登录重入 → 密码校验 → OTP 校验 → consent/workspace → callback
|
||||||
|
这条链路,避免与成功路径产生分叉。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
email_service: BaseEmailService,
|
||||||
|
proxy_url: Optional[str] = None,
|
||||||
|
callback_logger: Optional[Callable[[str], None]] = None,
|
||||||
|
email_service_id: Optional[str] = None,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
email_service=email_service,
|
||||||
|
proxy_url=proxy_url,
|
||||||
|
callback_logger=callback_logger,
|
||||||
|
)
|
||||||
|
self.email = email
|
||||||
|
self.password = password
|
||||||
|
self.email_service_id = email_service_id
|
||||||
|
self.email_info = {"email": email}
|
||||||
|
if email_service_id:
|
||||||
|
self.email_info["service_id"] = email_service_id
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
self.oauth_manager = OAuthManager(
|
||||||
|
client_id=settings.openai_client_id,
|
||||||
|
auth_url=settings.openai_auth_url,
|
||||||
|
token_url=settings.openai_token_url,
|
||||||
|
redirect_uri=CODEX_OAUTH_REDIRECT_URI,
|
||||||
|
scope=CODEX_OAUTH_SCOPE,
|
||||||
|
proxy_url=proxy_url,
|
||||||
|
originator=CODEX_OAUTH_ORIGINATOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_auth_json(self, token_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""构造 Codex CLI 兼容的 auth.json 内容。"""
|
||||||
|
now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
return {
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"OPENAI_API_KEY": None,
|
||||||
|
"tokens": {
|
||||||
|
"id_token": token_info.get("id_token", ""),
|
||||||
|
"access_token": token_info.get("access_token", ""),
|
||||||
|
"refresh_token": token_info.get("refresh_token", ""),
|
||||||
|
"account_id": token_info.get("account_id", ""),
|
||||||
|
},
|
||||||
|
"last_refresh": now_rfc3339,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_workspace_id(self, consent_url: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
OTP 校验成功后优先请求 consent 页面提取 workspace。
|
||||||
|
若页面未显式暴露 workspace_id,再回退到 Cookie 解析路径。
|
||||||
|
"""
|
||||||
|
if not self.session or not self.oauth_start:
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth_target = consent_url or self.oauth_start.auth_url
|
||||||
|
try:
|
||||||
|
self._log(f"请求 consent 页面: {auth_target[:120]}...")
|
||||||
|
started_at = time.time()
|
||||||
|
response = self.session.get(auth_target, timeout=20)
|
||||||
|
self._log_timed_http_result("获取 consent 页面", started_at, response)
|
||||||
|
|
||||||
|
workspace_id = self._extract_workspace_id_from_response(
|
||||||
|
response=response,
|
||||||
|
html=response.text or "",
|
||||||
|
url=str(getattr(response, "url", "") or "").strip(),
|
||||||
|
)
|
||||||
|
if workspace_id:
|
||||||
|
self._log(f"Workspace ID: {workspace_id}")
|
||||||
|
return workspace_id
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"请求 consent 页面失败: {e}", "warning")
|
||||||
|
|
||||||
|
self._log("consent 页面缺少 workspace_id,回退到 Cookie 解析路径", "warning")
|
||||||
|
return RegistrationEngine._get_workspace_id(self)
|
||||||
|
|
||||||
|
def run(self) -> CodexAuthResult:
|
||||||
|
"""执行 Codex Auth 登录并产出 auth.json。"""
|
||||||
|
result = CodexAuthResult(success=False, email=self.email, logs=self.logs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._log("=" * 50)
|
||||||
|
self._log(f"开始 Codex Auth 登录: {self.email}")
|
||||||
|
self._log("=" * 50)
|
||||||
|
|
||||||
|
self._log("1. 初始化会话...")
|
||||||
|
if not RegistrationEngine._init_session(self):
|
||||||
|
result.error_message = "初始化会话失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("2. 开始 Codex OAuth 流程...")
|
||||||
|
if not RegistrationEngine._start_oauth(self):
|
||||||
|
result.error_message = "OAuth 流程启动失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("3. 获取 Device ID...")
|
||||||
|
did = RegistrationEngine._get_device_id(self)
|
||||||
|
if not did:
|
||||||
|
result.error_message = "获取 Device ID 失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("4. 重新进入登录流程...")
|
||||||
|
if not self._try_reenter_login_flow():
|
||||||
|
result.error_message = "进入登录流程失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("5. 提交密码...")
|
||||||
|
self._otp_sent_at = time.time()
|
||||||
|
if not self._submit_login_password_step():
|
||||||
|
result.error_message = "密码验证失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("6. 等待验证码...")
|
||||||
|
otp_started_at = time.time()
|
||||||
|
code, otp_phase = self._phase_otp_secondary(
|
||||||
|
PhaseContext(otp_sent_at=self._otp_sent_at),
|
||||||
|
started_at=otp_started_at,
|
||||||
|
)
|
||||||
|
if not code:
|
||||||
|
result.error_message = otp_phase.error_message or "获取验证码失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("7. 验证验证码...")
|
||||||
|
otp_valid, consent_url = self._validate_verification_code_and_get_continue_url(code)
|
||||||
|
if not otp_valid:
|
||||||
|
result.error_message = "验证码校验失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("8. 获取 Workspace ID...")
|
||||||
|
workspace_id = self._resolve_workspace_id(consent_url)
|
||||||
|
if not workspace_id:
|
||||||
|
result.error_message = "获取 Workspace ID 失败"
|
||||||
|
return result
|
||||||
|
result.workspace_id = workspace_id
|
||||||
|
|
||||||
|
self._log("9. 选择 Workspace...")
|
||||||
|
continue_url = RegistrationEngine._select_workspace(self, workspace_id)
|
||||||
|
if not continue_url:
|
||||||
|
result.error_message = "选择 Workspace 失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("10. 跟随重定向...")
|
||||||
|
callback_url = RegistrationEngine._follow_redirects(self, continue_url)
|
||||||
|
if not callback_url:
|
||||||
|
result.error_message = "获取回调 URL 失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._log("11. 处理 OAuth 回调...")
|
||||||
|
token_info = RegistrationEngine._handle_oauth_callback(self, callback_url)
|
||||||
|
if not token_info:
|
||||||
|
result.error_message = "OAuth 回调处理失败"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result.auth_json = self._build_auth_json(token_info)
|
||||||
|
result.success = True
|
||||||
|
|
||||||
|
self._log("=" * 50)
|
||||||
|
self._log(f"Codex Auth 登录成功: {self.email}")
|
||||||
|
self._log(f"Account ID: {token_info.get('account_id', '')}")
|
||||||
|
self._log(f"Workspace ID: {workspace_id}")
|
||||||
|
self._log("=" * 50)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"Codex Auth 登录异常: {e}", "error")
|
||||||
|
result.error_message = str(e)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self.http_client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -190,7 +190,8 @@ def generate_oauth_url(
|
|||||||
*,
|
*,
|
||||||
redirect_uri: str = OAUTH_REDIRECT_URI,
|
redirect_uri: str = OAUTH_REDIRECT_URI,
|
||||||
scope: str = OAUTH_SCOPE,
|
scope: str = OAUTH_SCOPE,
|
||||||
client_id: str = OAUTH_CLIENT_ID
|
client_id: str = OAUTH_CLIENT_ID,
|
||||||
|
originator: Optional[str] = None
|
||||||
) -> OAuthStart:
|
) -> OAuthStart:
|
||||||
"""
|
"""
|
||||||
生成 OAuth 授权 URL
|
生成 OAuth 授权 URL
|
||||||
@@ -199,6 +200,7 @@ def generate_oauth_url(
|
|||||||
redirect_uri: 回调地址
|
redirect_uri: 回调地址
|
||||||
scope: 权限范围
|
scope: 权限范围
|
||||||
client_id: OpenAI Client ID
|
client_id: OpenAI Client ID
|
||||||
|
originator: 来源标识(如 codex_cli_rs)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OAuthStart 对象,包含授权 URL 和必要参数
|
OAuthStart 对象,包含授权 URL 和必要参数
|
||||||
@@ -219,6 +221,8 @@ def generate_oauth_url(
|
|||||||
"id_token_add_organizations": "true",
|
"id_token_add_organizations": "true",
|
||||||
"codex_cli_simplified_flow": "true",
|
"codex_cli_simplified_flow": "true",
|
||||||
}
|
}
|
||||||
|
if originator:
|
||||||
|
params["originator"] = originator
|
||||||
auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
|
auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
|
||||||
return OAuthStart(
|
return OAuthStart(
|
||||||
auth_url=auth_url,
|
auth_url=auth_url,
|
||||||
@@ -321,7 +325,8 @@ class OAuthManager:
|
|||||||
token_url: str = OAUTH_TOKEN_URL,
|
token_url: str = OAUTH_TOKEN_URL,
|
||||||
redirect_uri: str = OAUTH_REDIRECT_URI,
|
redirect_uri: str = OAUTH_REDIRECT_URI,
|
||||||
scope: str = OAUTH_SCOPE,
|
scope: str = OAUTH_SCOPE,
|
||||||
proxy_url: Optional[str] = None
|
proxy_url: Optional[str] = None,
|
||||||
|
originator: Optional[str] = None
|
||||||
):
|
):
|
||||||
self.client_id = client_id
|
self.client_id = client_id
|
||||||
self.auth_url = auth_url
|
self.auth_url = auth_url
|
||||||
@@ -329,13 +334,15 @@ class OAuthManager:
|
|||||||
self.redirect_uri = redirect_uri
|
self.redirect_uri = redirect_uri
|
||||||
self.scope = scope
|
self.scope = scope
|
||||||
self.proxy_url = proxy_url
|
self.proxy_url = proxy_url
|
||||||
|
self.originator = originator
|
||||||
|
|
||||||
def start_oauth(self) -> OAuthStart:
|
def start_oauth(self) -> OAuthStart:
|
||||||
"""开始 OAuth 流程"""
|
"""开始 OAuth 流程"""
|
||||||
return generate_oauth_url(
|
return generate_oauth_url(
|
||||||
redirect_uri=self.redirect_uri,
|
redirect_uri=self.redirect_uri,
|
||||||
scope=self.scope,
|
scope=self.scope,
|
||||||
client_id=self.client_id
|
client_id=self.client_id,
|
||||||
|
originator=self.originator
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle_callback(
|
def handle_callback(
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
账号管理 API 路由
|
账号管理 API 路由
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body
|
from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -29,6 +31,98 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_account_extra_data(account: Account) -> Dict[str, Any]:
|
||||||
|
extra_data = account.extra_data
|
||||||
|
if isinstance(extra_data, dict):
|
||||||
|
return dict(extra_data)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_codex_auth_extra_data(
|
||||||
|
existing_extra_data: Optional[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
workspace_id: str = "",
|
||||||
|
generated_at: Optional[datetime] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
extra_data = dict(existing_extra_data or {})
|
||||||
|
codex_auth = dict(extra_data.get("codex_auth") or {})
|
||||||
|
codex_auth["generated"] = True
|
||||||
|
codex_auth["generated_at"] = (generated_at or datetime.utcnow()).isoformat()
|
||||||
|
if workspace_id:
|
||||||
|
codex_auth["workspace_id"] = workspace_id
|
||||||
|
extra_data["codex_auth"] = codex_auth
|
||||||
|
return extra_data
|
||||||
|
|
||||||
|
|
||||||
|
def _has_generated_codex_auth(account: Account) -> bool:
|
||||||
|
codex_auth = _get_account_extra_data(account).get("codex_auth")
|
||||||
|
return isinstance(codex_auth, dict) and bool(codex_auth.get("generated"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_codex_auth_export_ready(accounts: List[Account]) -> None:
|
||||||
|
missing = [acc.email for acc in accounts if not _has_generated_codex_auth(acc)]
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
missing_summary = "、".join(missing[:10])
|
||||||
|
if len(missing) > 10:
|
||||||
|
missing_summary += f" 等 {len(missing)} 个账号"
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"以下账号尚未生成 Codex Auth,请先在账号管理中点击「Codex Auth 登录」后再导出:"
|
||||||
|
f"{missing_summary}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_codex_auth_result(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
account_id: int,
|
||||||
|
auth_json: Dict[str, Any],
|
||||||
|
workspace_id: str = "",
|
||||||
|
) -> None:
|
||||||
|
account = crud.get_account_by_id(db, account_id)
|
||||||
|
if not account:
|
||||||
|
raise ValueError(f"账号不存在: {account_id}")
|
||||||
|
|
||||||
|
tokens = auth_json.get("tokens") or {}
|
||||||
|
openai_account_id = str(tokens.get("account_id") or "").strip()
|
||||||
|
workspace_id = str(workspace_id or "").strip()
|
||||||
|
|
||||||
|
update_kwargs = {
|
||||||
|
"access_token": tokens.get("access_token", ""),
|
||||||
|
"refresh_token": tokens.get("refresh_token", ""),
|
||||||
|
"id_token": tokens.get("id_token", ""),
|
||||||
|
"last_refresh": datetime.utcnow(),
|
||||||
|
"extra_data": _build_codex_auth_extra_data(
|
||||||
|
_get_account_extra_data(account),
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if openai_account_id:
|
||||||
|
update_kwargs["account_id"] = openai_account_id
|
||||||
|
if workspace_id:
|
||||||
|
update_kwargs["workspace_id"] = workspace_id
|
||||||
|
|
||||||
|
for key, value in update_kwargs.items():
|
||||||
|
setattr(account, key, value)
|
||||||
|
|
||||||
|
token_values = {
|
||||||
|
"access_token": account.access_token,
|
||||||
|
"refresh_token": account.refresh_token,
|
||||||
|
"id_token": account.id_token,
|
||||||
|
"session_token": account.session_token,
|
||||||
|
}
|
||||||
|
account.token_sync_status = "pending" if any(token_values.values()) else "not_ready"
|
||||||
|
account.token_sync_updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(account)
|
||||||
|
|
||||||
|
|
||||||
def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]:
|
def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]:
|
||||||
"""获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置"""
|
"""获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置"""
|
||||||
if request_proxy:
|
if request_proxy:
|
||||||
@@ -537,6 +631,351 @@ async def export_accounts_cpa(request: BatchExportRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/export/codex_auth")
|
||||||
|
async def export_accounts_codex_auth(request: BatchExportRequest):
|
||||||
|
"""导出账号为 Codex CLI auth.json 格式"""
|
||||||
|
with get_db() as db:
|
||||||
|
ids = resolve_account_ids(
|
||||||
|
db, request.ids, request.select_all,
|
||||||
|
request.status_filter, request.email_service_filter, request.search_filter
|
||||||
|
)
|
||||||
|
accounts = db.query(Account).filter(Account.id.in_(ids)).all()
|
||||||
|
if not accounts:
|
||||||
|
raise HTTPException(status_code=400, detail="没有可导出的账号")
|
||||||
|
|
||||||
|
_ensure_codex_auth_export_ready(accounts)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
def build_auth_json(acc):
|
||||||
|
return {
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"OPENAI_API_KEY": None,
|
||||||
|
"tokens": {
|
||||||
|
"id_token": acc.id_token or "",
|
||||||
|
"access_token": acc.access_token or "",
|
||||||
|
"refresh_token": acc.refresh_token or "",
|
||||||
|
"account_id": acc.account_id or ""
|
||||||
|
},
|
||||||
|
"last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(accounts) == 1:
|
||||||
|
acc = accounts[0]
|
||||||
|
auth_data = build_auth_json(acc)
|
||||||
|
content = json.dumps(auth_data, ensure_ascii=False, indent=2)
|
||||||
|
filename = "auth.json"
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([content]),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 多个账号打包为 ZIP
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for acc in accounts:
|
||||||
|
auth_data = build_auth_json(acc)
|
||||||
|
content = json.dumps(auth_data, ensure_ascii=False, indent=2)
|
||||||
|
zf.writestr(f"{acc.email}/auth.json", content)
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
zip_filename = f"codex_auth_{timestamp}.zip"
|
||||||
|
return StreamingResponse(
|
||||||
|
zip_buffer,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={zip_filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Codex Auth 登录导出 ==============
|
||||||
|
|
||||||
|
def _build_email_service_for_account(db, account: Account):
|
||||||
|
"""根据账号的邮箱服务类型,复用收件箱逻辑构建邮箱服务实例(用于读取 OTP)"""
|
||||||
|
from ...services import EmailServiceFactory, EmailServiceType
|
||||||
|
|
||||||
|
email_service_type = account.email_service
|
||||||
|
if not email_service_type:
|
||||||
|
raise ValueError(f"账号 {account.email} 没有关联的邮箱服务类型")
|
||||||
|
|
||||||
|
try:
|
||||||
|
service_type = EmailServiceType(email_service_type)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"不支持的邮箱服务类型: {email_service_type}")
|
||||||
|
|
||||||
|
config = _build_inbox_config(db, service_type, account.email)
|
||||||
|
if config is None:
|
||||||
|
raise ValueError(f"未找到可用的 {email_service_type} 邮箱服务配置")
|
||||||
|
|
||||||
|
# 添加代理
|
||||||
|
proxy_url = _get_proxy()
|
||||||
|
if proxy_url and 'proxy_url' not in config:
|
||||||
|
config['proxy_url'] = proxy_url
|
||||||
|
|
||||||
|
return EmailServiceFactory.create(service_type, config)
|
||||||
|
|
||||||
|
|
||||||
|
class CodexAuthLoginRequest(BaseModel):
|
||||||
|
"""Codex Auth 登录请求"""
|
||||||
|
account_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/codex-auth-login")
|
||||||
|
async def codex_auth_login(request: CodexAuthLoginRequest):
|
||||||
|
"""
|
||||||
|
对指定账号执行 Codex CLI 登录流程,获取 Codex 兼容的 auth.json。
|
||||||
|
使用 SSE 推送实时日志,最终返回 auth.json 数据。
|
||||||
|
"""
|
||||||
|
import queue
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
account = db.query(Account).filter(Account.id == request.account_id).first()
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
|
|
||||||
|
if not account.password:
|
||||||
|
raise HTTPException(status_code=400, detail=f"账号 {account.email} 没有密码,无法登录")
|
||||||
|
|
||||||
|
# 提取需要的数据(避免跨线程 session 问题)
|
||||||
|
email = account.email
|
||||||
|
password = account.password
|
||||||
|
account_db_id = account.id
|
||||||
|
email_svc_id = account.email_service_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_service = _build_email_service_for_account(db, account)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
proxy_url = _get_proxy()
|
||||||
|
log_queue = queue.Queue()
|
||||||
|
|
||||||
|
def log_callback(msg: str):
|
||||||
|
log_queue.put(("log", msg))
|
||||||
|
|
||||||
|
def run_login():
|
||||||
|
from ...core.codex_auth import CodexAuthEngine
|
||||||
|
try:
|
||||||
|
engine = CodexAuthEngine(
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
email_service=email_service,
|
||||||
|
proxy_url=proxy_url,
|
||||||
|
callback_logger=log_callback,
|
||||||
|
email_service_id=email_svc_id,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
log_queue.put(("result", {
|
||||||
|
"success": result.success,
|
||||||
|
"email": result.email,
|
||||||
|
"workspace_id": result.workspace_id,
|
||||||
|
"auth_json": result.auth_json,
|
||||||
|
"error_message": result.error_message,
|
||||||
|
}))
|
||||||
|
except Exception as e:
|
||||||
|
log_queue.put(("result", {
|
||||||
|
"success": False,
|
||||||
|
"email": email,
|
||||||
|
"workspace_id": "",
|
||||||
|
"auth_json": None,
|
||||||
|
"error_message": str(e),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
thread = threading.Thread(target=run_login, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# 非阻塞轮询队列
|
||||||
|
try:
|
||||||
|
msg_type, msg_data = log_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
if not thread.is_alive() and log_queue.empty():
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg_type == "log":
|
||||||
|
yield f"data: {json.dumps({'type': 'log', 'message': msg_data}, ensure_ascii=False)}\n\n"
|
||||||
|
elif msg_type == "result":
|
||||||
|
# 如果登录成功,同时更新数据库中的 token
|
||||||
|
if msg_data["success"] and msg_data["auth_json"]:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
_persist_codex_auth_result(
|
||||||
|
db,
|
||||||
|
account_id=account_db_id,
|
||||||
|
auth_json=msg_data["auth_json"],
|
||||||
|
workspace_id=str(msg_data.get("workspace_id") or "").strip(),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"更新数据库 token 失败: {e}")
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'type': 'result', **msg_data}, ensure_ascii=False)}\n\n"
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CodexAuthBatchRequest(BaseModel):
|
||||||
|
"""批量 Codex Auth 登录请求"""
|
||||||
|
ids: List[int] = []
|
||||||
|
select_all: bool = False
|
||||||
|
status_filter: Optional[str] = None
|
||||||
|
email_service_filter: Optional[str] = None
|
||||||
|
search_filter: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/codex-auth-login/batch")
|
||||||
|
async def codex_auth_login_batch(request: CodexAuthBatchRequest):
|
||||||
|
"""
|
||||||
|
批量 Codex Auth 登录。
|
||||||
|
逐个执行登录,通过 SSE 推送每个账号的进度和结果。
|
||||||
|
全部完成后打包下载。
|
||||||
|
"""
|
||||||
|
import queue
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
ids = resolve_account_ids(
|
||||||
|
db, request.ids, request.select_all,
|
||||||
|
request.status_filter, request.email_service_filter, request.search_filter
|
||||||
|
)
|
||||||
|
accounts_data = []
|
||||||
|
for acc in db.query(Account).filter(Account.id.in_(ids)).all():
|
||||||
|
if not acc.password:
|
||||||
|
continue
|
||||||
|
accounts_data.append({
|
||||||
|
"id": acc.id,
|
||||||
|
"email": acc.email,
|
||||||
|
"password": acc.password,
|
||||||
|
"email_service": acc.email_service,
|
||||||
|
"email_service_id": acc.email_service_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not accounts_data:
|
||||||
|
raise HTTPException(status_code=400, detail="没有符合条件的账号(需要有密码)")
|
||||||
|
|
||||||
|
log_queue = queue.Queue()
|
||||||
|
|
||||||
|
def run_batch():
|
||||||
|
from ...core.codex_auth import CodexAuthEngine
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for i, acc_data in enumerate(accounts_data):
|
||||||
|
log_queue.put(("progress", {
|
||||||
|
"current": i + 1,
|
||||||
|
"total": len(accounts_data),
|
||||||
|
"email": acc_data["email"],
|
||||||
|
}))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
account = db.query(Account).filter(Account.id == acc_data["id"]).first()
|
||||||
|
if not account:
|
||||||
|
continue
|
||||||
|
email_service = _build_email_service_for_account(db, account)
|
||||||
|
|
||||||
|
proxy_url = _get_proxy()
|
||||||
|
|
||||||
|
def log_cb(msg, email=acc_data["email"]):
|
||||||
|
log_queue.put(("log", f"[{email}] {msg}"))
|
||||||
|
|
||||||
|
engine = CodexAuthEngine(
|
||||||
|
email=acc_data["email"],
|
||||||
|
password=acc_data["password"],
|
||||||
|
email_service=email_service,
|
||||||
|
proxy_url=proxy_url,
|
||||||
|
callback_logger=log_cb,
|
||||||
|
email_service_id=acc_data.get("email_service_id"),
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
|
||||||
|
if result.success and result.auth_json:
|
||||||
|
# 更新数据库
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
_persist_codex_auth_result(
|
||||||
|
db,
|
||||||
|
account_id=acc_data["id"],
|
||||||
|
auth_json=result.auth_json,
|
||||||
|
workspace_id=str(result.workspace_id or "").strip(),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"更新数据库 token 失败: {e}")
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"email": acc_data["email"],
|
||||||
|
"workspace_id": result.workspace_id,
|
||||||
|
"auth_json": result.auth_json,
|
||||||
|
})
|
||||||
|
log_queue.put(("account_result", {
|
||||||
|
"email": acc_data["email"],
|
||||||
|
"success": True,
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
log_queue.put(("account_result", {
|
||||||
|
"email": acc_data["email"],
|
||||||
|
"success": False,
|
||||||
|
"error": result.error_message,
|
||||||
|
}))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_queue.put(("account_result", {
|
||||||
|
"email": acc_data["email"],
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
}))
|
||||||
|
|
||||||
|
log_queue.put(("batch_done", results))
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
thread = threading.Thread(target=run_batch, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
msg_type, msg_data = log_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
if not thread.is_alive() and log_queue.empty():
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg_type == "batch_done":
|
||||||
|
yield f"data: {json.dumps({'type': 'batch_done', 'results': msg_data}, ensure_ascii=False)}\n\n"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
yield f"data: {json.dumps({'type': msg_type, **msg_data} if isinstance(msg_data, dict) else {'type': msg_type, 'message': msg_data}, ensure_ascii=False)}\n\n"
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats/summary")
|
@router.get("/stats/summary")
|
||||||
async def get_accounts_stats():
|
async def get_accounts_stats():
|
||||||
"""获取账号统计信息"""
|
"""获取账号统计信息"""
|
||||||
|
|||||||
@@ -111,6 +111,24 @@ function initEventListeners() {
|
|||||||
// 批量删除
|
// 批量删除
|
||||||
elements.batchDeleteBtn.addEventListener('click', handleBatchDelete);
|
elements.batchDeleteBtn.addEventListener('click', handleBatchDelete);
|
||||||
|
|
||||||
|
// Codex Auth 登录
|
||||||
|
const codexAuthBtn = document.getElementById('codex-auth-login-btn');
|
||||||
|
if (codexAuthBtn) {
|
||||||
|
codexAuthBtn.addEventListener('click', handleCodexAuthLogin);
|
||||||
|
}
|
||||||
|
const closeCodexAuthModal = document.getElementById('close-codex-auth-modal');
|
||||||
|
if (closeCodexAuthModal) {
|
||||||
|
closeCodexAuthModal.addEventListener('click', () => {
|
||||||
|
document.getElementById('codex-auth-modal').classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const closeCodexAuthModalBtn = document.getElementById('close-codex-auth-modal-btn');
|
||||||
|
if (closeCodexAuthModalBtn) {
|
||||||
|
closeCodexAuthModalBtn.addEventListener('click', () => {
|
||||||
|
document.getElementById('codex-auth-modal').classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 全选(当前页)
|
// 全选(当前页)
|
||||||
elements.selectAll.addEventListener('change', (e) => {
|
elements.selectAll.addEventListener('change', (e) => {
|
||||||
const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]');
|
const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]');
|
||||||
@@ -481,7 +499,10 @@ function updateBatchButtons() {
|
|||||||
elements.batchCheckSubBtn.disabled = count === 0;
|
elements.batchCheckSubBtn.disabled = count === 0;
|
||||||
elements.exportBtn.disabled = count === 0;
|
elements.exportBtn.disabled = count === 0;
|
||||||
|
|
||||||
elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除';
|
const codexAuthBtn = document.getElementById('codex-auth-login-btn');
|
||||||
|
if (codexAuthBtn) codexAuthBtn.disabled = count === 0;
|
||||||
|
|
||||||
|
elements.batchDeleteBtn.textContent = count > 0 ? `删除 (${count})` : '删除';
|
||||||
elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token';
|
elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token';
|
||||||
elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token';
|
elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token';
|
||||||
elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传';
|
elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传';
|
||||||
@@ -724,7 +745,14 @@ async function exportAccounts(format) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`导出失败: HTTP ${response.status}`);
|
let errorMessage = `HTTP ${response.status}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||||||
|
} catch (parseError) {
|
||||||
|
// ignore non-JSON error bodies and fall back to status text
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取文件内容
|
// 获取文件内容
|
||||||
@@ -732,7 +760,7 @@ async function exportAccounts(format) {
|
|||||||
|
|
||||||
// 从 Content-Disposition 获取文件名
|
// 从 Content-Disposition 获取文件名
|
||||||
const disposition = response.headers.get('Content-Disposition');
|
const disposition = response.headers.get('Content-Disposition');
|
||||||
let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api') ? 'json' : format}`;
|
let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api' || format === 'codex_auth') ? 'json' : format}`;
|
||||||
if (disposition) {
|
if (disposition) {
|
||||||
const match = disposition.match(/filename=(.+)/);
|
const match = disposition.match(/filename=(.+)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -1266,3 +1294,188 @@ function showInboxCodeResult(code, email) {
|
|||||||
`;
|
`;
|
||||||
elements.detailModal.classList.add('active');
|
elements.detailModal.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== Codex Auth 登录 ==============
|
||||||
|
|
||||||
|
let codexAuthResults = [];
|
||||||
|
|
||||||
|
async function handleCodexAuthLogin() {
|
||||||
|
const count = getEffectiveCount();
|
||||||
|
if (count === 0) {
|
||||||
|
toast.warning('请先选择要登录的账号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await confirm(`将对选中的 ${count} 个账号执行 Codex Auth 登录(需要接收邮箱验证码),确定继续吗?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const modal = document.getElementById('codex-auth-modal');
|
||||||
|
const logsEl = document.getElementById('codex-auth-logs');
|
||||||
|
const statusEl = document.getElementById('codex-auth-status');
|
||||||
|
const downloadBtn = document.getElementById('codex-auth-download-btn');
|
||||||
|
|
||||||
|
logsEl.textContent = '';
|
||||||
|
statusEl.textContent = '正在启动 Codex Auth 登录...';
|
||||||
|
downloadBtn.style.display = 'none';
|
||||||
|
codexAuthResults = [];
|
||||||
|
modal.classList.add('active');
|
||||||
|
|
||||||
|
if (count === 1 && !selectAllPages) {
|
||||||
|
// 单账号登录
|
||||||
|
const accountId = [...selectedAccounts][0];
|
||||||
|
await codexAuthLoginSingle(accountId, logsEl, statusEl, downloadBtn);
|
||||||
|
} else {
|
||||||
|
// 批量登录
|
||||||
|
await codexAuthLoginBatch(logsEl, statusEl, downloadBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function codexAuthLoginSingle(accountId, logsEl, statusEl, downloadBtn) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/accounts/codex-auth-login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ account_id: accountId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
statusEl.textContent = '登录失败: ' + (err.detail || response.statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
if (data.type === 'log') {
|
||||||
|
logsEl.textContent += data.message + '\n';
|
||||||
|
logsEl.scrollTop = logsEl.scrollHeight;
|
||||||
|
} else if (data.type === 'result') {
|
||||||
|
if (data.success && data.auth_json) {
|
||||||
|
statusEl.textContent = 'Codex Auth 登录成功!';
|
||||||
|
codexAuthResults = [{ email: data.email, auth_json: data.auth_json }];
|
||||||
|
downloadBtn.style.display = 'inline-block';
|
||||||
|
downloadBtn.onclick = () => downloadCodexAuthResults();
|
||||||
|
loadAccounts();
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = '登录失败: ' + (data.error_message || '未知错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = '登录失败: ' + error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function codexAuthLoginBatch(logsEl, statusEl, downloadBtn) {
|
||||||
|
try {
|
||||||
|
const payload = buildBatchPayload();
|
||||||
|
const response = await fetch('/api/accounts/codex-auth-login/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
statusEl.textContent = '批量登录失败: ' + (err.detail || response.statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
if (data.type === 'log') {
|
||||||
|
logsEl.textContent += data.message + '\n';
|
||||||
|
logsEl.scrollTop = logsEl.scrollHeight;
|
||||||
|
} else if (data.type === 'progress') {
|
||||||
|
statusEl.textContent = `正在处理 ${data.current}/${data.total}: ${data.email}`;
|
||||||
|
} else if (data.type === 'account_result') {
|
||||||
|
if (data.success) {
|
||||||
|
successCount++;
|
||||||
|
logsEl.textContent += `[${data.email}] 登录成功\n`;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
logsEl.textContent += `[${data.email}] 登录失败: ${data.error || '未知错误'}\n`;
|
||||||
|
}
|
||||||
|
logsEl.scrollTop = logsEl.scrollHeight;
|
||||||
|
} else if (data.type === 'batch_done') {
|
||||||
|
codexAuthResults = data.results || [];
|
||||||
|
statusEl.textContent = `批量登录完成: 成功 ${successCount}, 失败 ${failCount}`;
|
||||||
|
if (codexAuthResults.length > 0) {
|
||||||
|
downloadBtn.style.display = 'inline-block';
|
||||||
|
downloadBtn.onclick = () => downloadCodexAuthResults();
|
||||||
|
}
|
||||||
|
loadAccounts();
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = '批量登录失败: ' + error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCodexAuthResults() {
|
||||||
|
if (codexAuthResults.length === 0) return;
|
||||||
|
|
||||||
|
if (codexAuthResults.length === 1) {
|
||||||
|
// 单个直接下载 auth.json
|
||||||
|
const item = codexAuthResults[0];
|
||||||
|
const blob = new Blob([JSON.stringify(item.auth_json, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'auth.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
|
} else {
|
||||||
|
// 多个:逐个下载(浏览器端无法打 ZIP,逐个下载)
|
||||||
|
codexAuthResults.forEach((item, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const blob = new Blob([JSON.stringify(item.auth_json, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${item.email}_auth.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
|
}, i * 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(`已下载 ${codexAuthResults.length} 个 auth.json`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -139,7 +139,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-danger" id="batch-delete-btn" disabled>
|
<button class="btn btn-danger" id="batch-delete-btn" disabled>
|
||||||
🗑️ 批量删除
|
删除
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" id="codex-auth-login-btn" disabled>
|
||||||
|
Codex Auth
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-primary" id="export-btn">
|
<button class="btn btn-primary" id="export-btn">
|
||||||
@@ -150,6 +153,7 @@
|
|||||||
<a href="#" class="dropdown-item" data-format="csv">导出 CSV</a>
|
<a href="#" class="dropdown-item" data-format="csv">导出 CSV</a>
|
||||||
<a href="#" class="dropdown-item" data-format="cpa">导出 CPA 格式</a>
|
<a href="#" class="dropdown-item" data-format="cpa">导出 CPA 格式</a>
|
||||||
<a href="#" class="dropdown-item" data-format="sub2api">导出 Sub2Api 格式</a>
|
<a href="#" class="dropdown-item" data-format="sub2api">导出 Sub2Api 格式</a>
|
||||||
|
<a href="#" class="dropdown-item" data-format="codex_auth">导出 Codex Auth 格式</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,6 +281,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Codex Auth 登录进度弹窗 -->
|
||||||
|
<div class="modal" id="codex-auth-modal">
|
||||||
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Codex Auth 登录</h3>
|
||||||
|
<button class="modal-close" id="close-codex-auth-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="codex-auth-status" style="margin-bottom: 12px; font-weight: 600;"></div>
|
||||||
|
<div id="codex-auth-logs" style="max-height: 300px; overflow-y: auto; background: var(--bg-secondary); border-radius: 6px; padding: 10px; font-size: 0.85rem; font-family: monospace; white-space: pre-wrap; line-height: 1.5;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="padding: 12px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-primary" id="codex-auth-download-btn" style="display: none;">下载 auth.json</button>
|
||||||
|
<button class="btn btn-secondary" id="close-codex-auth-modal-btn">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
||||||
<script src="/static/js/accounts.js?v={{ static_version }}"></script>
|
<script src="/static/js/accounts.js?v={{ static_version }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
204
tests/test_codex_auth_export_route.py
Normal file
204
tests/test_codex_auth_export_route.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
import src.web.routes.accounts as accounts_routes
|
||||||
|
from src.database import crud
|
||||||
|
from src.database.session import DatabaseSessionManager
|
||||||
|
from src.web.routes.accounts import BatchExportRequest
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_streaming_response_body(response) -> bytes:
|
||||||
|
chunks = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
if isinstance(chunk, bytes):
|
||||||
|
chunks.append(chunk)
|
||||||
|
else:
|
||||||
|
chunks.append(chunk.encode("utf-8"))
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fake_get_db(manager):
|
||||||
|
@contextmanager
|
||||||
|
def fake_get_db():
|
||||||
|
with manager.session_scope() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
return fake_get_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_codex_auth_single_account_uses_auth_json_filename(tmp_path, monkeypatch):
|
||||||
|
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/single.db")
|
||||||
|
manager.create_tables()
|
||||||
|
manager.migrate_tables()
|
||||||
|
|
||||||
|
with manager.session_scope() as session:
|
||||||
|
account = crud.create_account(
|
||||||
|
session,
|
||||||
|
email="single@example.com",
|
||||||
|
email_service="tempmail",
|
||||||
|
access_token="access-token",
|
||||||
|
refresh_token="refresh-token",
|
||||||
|
id_token="id-token",
|
||||||
|
account_id="acct-1",
|
||||||
|
extra_data={"codex_auth": {"generated": True}},
|
||||||
|
)
|
||||||
|
account_id = account.id
|
||||||
|
|
||||||
|
monkeypatch.setattr(accounts_routes, "get_db", _build_fake_get_db(manager))
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
accounts_routes.export_accounts_codex_auth(
|
||||||
|
BatchExportRequest(ids=[account_id]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
body = asyncio.run(_read_streaming_response_body(response))
|
||||||
|
|
||||||
|
assert response.headers["content-disposition"] == "attachment; filename=auth.json"
|
||||||
|
assert json.loads(body.decode("utf-8")) == {
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"OPENAI_API_KEY": None,
|
||||||
|
"tokens": {
|
||||||
|
"id_token": "id-token",
|
||||||
|
"access_token": "access-token",
|
||||||
|
"refresh_token": "refresh-token",
|
||||||
|
"account_id": "acct-1",
|
||||||
|
},
|
||||||
|
"last_refresh": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_codex_auth_multiple_accounts_zip_each_auth_json_under_email_directory(tmp_path, monkeypatch):
|
||||||
|
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/multi.db")
|
||||||
|
manager.create_tables()
|
||||||
|
manager.migrate_tables()
|
||||||
|
|
||||||
|
with manager.session_scope() as session:
|
||||||
|
first = crud.create_account(
|
||||||
|
session,
|
||||||
|
email="first@example.com",
|
||||||
|
email_service="tempmail",
|
||||||
|
access_token="first-access",
|
||||||
|
refresh_token="first-refresh",
|
||||||
|
id_token="first-id",
|
||||||
|
account_id="acct-first",
|
||||||
|
extra_data={"codex_auth": {"generated": True}},
|
||||||
|
)
|
||||||
|
second = crud.create_account(
|
||||||
|
session,
|
||||||
|
email="second@example.com",
|
||||||
|
email_service="tempmail",
|
||||||
|
access_token="second-access",
|
||||||
|
refresh_token="second-refresh",
|
||||||
|
id_token="second-id",
|
||||||
|
account_id="acct-second",
|
||||||
|
extra_data={"codex_auth": {"generated": True}},
|
||||||
|
)
|
||||||
|
account_ids = [first.id, second.id]
|
||||||
|
|
||||||
|
monkeypatch.setattr(accounts_routes, "get_db", _build_fake_get_db(manager))
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
accounts_routes.export_accounts_codex_auth(
|
||||||
|
BatchExportRequest(ids=account_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
body = asyncio.run(_read_streaming_response_body(response))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(body), "r") as zf:
|
||||||
|
assert sorted(zf.namelist()) == [
|
||||||
|
"first@example.com/auth.json",
|
||||||
|
"second@example.com/auth.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
first_auth = json.loads(zf.read("first@example.com/auth.json").decode("utf-8"))
|
||||||
|
second_auth = json.loads(zf.read("second@example.com/auth.json").decode("utf-8"))
|
||||||
|
|
||||||
|
assert first_auth["tokens"]["access_token"] == "first-access"
|
||||||
|
assert second_auth["tokens"]["access_token"] == "second-access"
|
||||||
|
assert response.headers["content-disposition"].startswith("attachment; filename=codex_auth_")
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_codex_auth_requires_manual_generation_first(tmp_path, monkeypatch):
|
||||||
|
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/missing-marker.db")
|
||||||
|
manager.create_tables()
|
||||||
|
manager.migrate_tables()
|
||||||
|
|
||||||
|
with manager.session_scope() as session:
|
||||||
|
account = crud.create_account(
|
||||||
|
session,
|
||||||
|
email="plain@example.com",
|
||||||
|
email_service="tempmail",
|
||||||
|
access_token="plain-access",
|
||||||
|
refresh_token="plain-refresh",
|
||||||
|
id_token="plain-id",
|
||||||
|
account_id="acct-plain",
|
||||||
|
)
|
||||||
|
account_id = account.id
|
||||||
|
|
||||||
|
monkeypatch.setattr(accounts_routes, "get_db", _build_fake_get_db(manager))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
asyncio.run(
|
||||||
|
accounts_routes.export_accounts_codex_auth(
|
||||||
|
BatchExportRequest(ids=[account_id]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert (
|
||||||
|
exc_info.value.detail
|
||||||
|
== "以下账号尚未生成 Codex Auth,请先在账号管理中点击「Codex Auth 登录」后再导出:plain@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_codex_auth_result_marks_account_generated(tmp_path):
|
||||||
|
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/persist-marker.db")
|
||||||
|
manager.create_tables()
|
||||||
|
manager.migrate_tables()
|
||||||
|
|
||||||
|
with manager.session_scope() as session:
|
||||||
|
account = crud.create_account(
|
||||||
|
session,
|
||||||
|
email="marked@example.com",
|
||||||
|
email_service="tempmail",
|
||||||
|
access_token="old-access",
|
||||||
|
refresh_token="old-refresh",
|
||||||
|
id_token="old-id",
|
||||||
|
account_id="acct-old",
|
||||||
|
extra_data={"note": "keep-me"},
|
||||||
|
)
|
||||||
|
account_id = account.id
|
||||||
|
|
||||||
|
with manager.session_scope() as session:
|
||||||
|
accounts_routes._persist_codex_auth_result(
|
||||||
|
session,
|
||||||
|
account_id=account_id,
|
||||||
|
auth_json={
|
||||||
|
"tokens": {
|
||||||
|
"access_token": "new-access",
|
||||||
|
"refresh_token": "new-refresh",
|
||||||
|
"id_token": "new-id",
|
||||||
|
"account_id": "acct-new",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
workspace_id="ws-new",
|
||||||
|
)
|
||||||
|
|
||||||
|
with manager.session_scope() as session:
|
||||||
|
account = crud.get_account_by_id(session, account_id)
|
||||||
|
assert account is not None
|
||||||
|
assert account.access_token == "new-access"
|
||||||
|
assert account.refresh_token == "new-refresh"
|
||||||
|
assert account.id_token == "new-id"
|
||||||
|
assert account.account_id == "acct-new"
|
||||||
|
assert account.workspace_id == "ws-new"
|
||||||
|
assert account.extra_data["note"] == "keep-me"
|
||||||
|
assert account.extra_data["codex_auth"]["generated"] is True
|
||||||
|
assert account.extra_data["codex_auth"]["workspace_id"] == "ws-new"
|
||||||
|
assert account.extra_data["codex_auth"]["generated_at"]
|
||||||
149
tests/test_codex_auth_flow.py
Normal file
149
tests/test_codex_auth_flow.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import src.core.codex_auth as codex_auth_module
|
||||||
|
from src.core.codex_auth import CodexAuthEngine
|
||||||
|
from src.core.register import PhaseResult, RegistrationEngine
|
||||||
|
from src.services import EmailServiceType
|
||||||
|
|
||||||
|
|
||||||
|
class DummySettings:
|
||||||
|
openai_client_id = "client-id"
|
||||||
|
openai_auth_url = "https://auth.example.test/oauth/authorize"
|
||||||
|
openai_token_url = "https://auth.example.test/oauth/token"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmailService:
|
||||||
|
service_type = EmailServiceType.TEMPMAIL
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, *, status_code=200, url="", text=""):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.url = url
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def get(self, url, **kwargs):
|
||||||
|
self.calls.append({"url": url, "kwargs": kwargs})
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
|
||||||
|
def _build_engine(monkeypatch):
|
||||||
|
monkeypatch.setattr(codex_auth_module, "get_settings", lambda: DummySettings())
|
||||||
|
return CodexAuthEngine(
|
||||||
|
email="tester@example.com",
|
||||||
|
password="Pass12345",
|
||||||
|
email_service=FakeEmailService(),
|
||||||
|
email_service_id="svc-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_auth_run_reuses_working_login_flow_without_manual_otp_send(monkeypatch):
|
||||||
|
engine = _build_engine(monkeypatch)
|
||||||
|
|
||||||
|
def fake_start_oauth(self):
|
||||||
|
self.oauth_start = SimpleNamespace(
|
||||||
|
auth_url="https://auth.example.test/oauth/authorize",
|
||||||
|
state="state-1",
|
||||||
|
code_verifier="verifier-1",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(RegistrationEngine, "_init_session", lambda self: True)
|
||||||
|
monkeypatch.setattr(RegistrationEngine, "_start_oauth", fake_start_oauth)
|
||||||
|
monkeypatch.setattr(RegistrationEngine, "_get_device_id", lambda self: "did-1")
|
||||||
|
monkeypatch.setattr(engine, "_try_reenter_login_flow", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
engine,
|
||||||
|
"_send_verification_code",
|
||||||
|
lambda: (_ for _ in ()).throw(AssertionError("unexpected manual otp send")),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(codex_auth_module.time, "time", lambda: 1_700_000_000.0)
|
||||||
|
|
||||||
|
def fake_submit_login_password_step():
|
||||||
|
seen["anchor_before_password"] = engine._otp_sent_at
|
||||||
|
return True
|
||||||
|
|
||||||
|
def fake_phase_otp_secondary(context, started_at=None):
|
||||||
|
seen["anchor_before_wait"] = context.otp_sent_at
|
||||||
|
seen["otp_wait_started_at"] = started_at
|
||||||
|
return "654321", PhaseResult(phase="otp_secondary", success=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(engine, "_submit_login_password_step", fake_submit_login_password_step)
|
||||||
|
monkeypatch.setattr(engine, "_phase_otp_secondary", fake_phase_otp_secondary)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
engine,
|
||||||
|
"_validate_verification_code_and_get_continue_url",
|
||||||
|
lambda code: (True, "https://auth.example.test/consent"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(engine, "_resolve_workspace_id", lambda consent_url: "ws-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
RegistrationEngine,
|
||||||
|
"_select_workspace",
|
||||||
|
lambda self, workspace_id: "https://auth.example.test/continue",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
RegistrationEngine,
|
||||||
|
"_follow_redirects",
|
||||||
|
lambda self, continue_url: "http://localhost:1455/auth/callback?code=code-1&state=state-1",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
RegistrationEngine,
|
||||||
|
"_handle_oauth_callback",
|
||||||
|
lambda self, callback_url: {
|
||||||
|
"id_token": "id-token",
|
||||||
|
"access_token": "access-token",
|
||||||
|
"refresh_token": "refresh-token",
|
||||||
|
"account_id": "acct-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.run()
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.workspace_id == "ws-1"
|
||||||
|
assert result.auth_json["auth_mode"] == "chatgpt"
|
||||||
|
assert result.auth_json["OPENAI_API_KEY"] is None
|
||||||
|
assert result.auth_json["tokens"] == {
|
||||||
|
"id_token": "id-token",
|
||||||
|
"access_token": "access-token",
|
||||||
|
"refresh_token": "refresh-token",
|
||||||
|
"account_id": "acct-1",
|
||||||
|
}
|
||||||
|
assert result.auth_json["last_refresh"]
|
||||||
|
assert seen["anchor_before_password"] == 1_700_000_000.0
|
||||||
|
assert seen["anchor_before_wait"] == 1_700_000_000.0
|
||||||
|
assert seen["otp_wait_started_at"] == 1_700_000_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_workspace_id_falls_back_to_cookie_path_when_consent_page_has_no_workspace(monkeypatch):
|
||||||
|
engine = _build_engine(monkeypatch)
|
||||||
|
consent_url = "https://auth.example.test/consent"
|
||||||
|
engine.oauth_start = SimpleNamespace(auth_url="https://auth.example.test/oauth/authorize")
|
||||||
|
engine.session = FakeSession(
|
||||||
|
FakeResponse(
|
||||||
|
status_code=200,
|
||||||
|
url=consent_url,
|
||||||
|
text="<html><body>consent</body></html>",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
engine,
|
||||||
|
"_extract_workspace_id_from_response",
|
||||||
|
lambda response=None, html=None, url=None: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(RegistrationEngine, "_get_workspace_id", lambda self: "ws-cookie")
|
||||||
|
|
||||||
|
workspace_id = engine._resolve_workspace_id(consent_url)
|
||||||
|
|
||||||
|
assert workspace_id == "ws-cookie"
|
||||||
|
assert engine.session.calls == [{"url": consent_url, "kwargs": {"timeout": 20}}]
|
||||||
@@ -104,7 +104,7 @@ def test_registration_task_fails_over_after_rate_limit(monkeypatch):
|
|||||||
attempts = []
|
attempts = []
|
||||||
|
|
||||||
class FakeRegistrationEngine:
|
class FakeRegistrationEngine:
|
||||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||||
self.email_service = email_service
|
self.email_service = email_service
|
||||||
self.phase_history = []
|
self.phase_history = []
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ def test_registration_task_enters_deep_cooldown_after_three_otp_timeouts(monkeyp
|
|||||||
current_time = {"value": 1000.0}
|
current_time = {"value": 1000.0}
|
||||||
|
|
||||||
class FakeRegistrationEngine:
|
class FakeRegistrationEngine:
|
||||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||||
self.email_service = email_service
|
self.email_service = email_service
|
||||||
self.phase_history = []
|
self.phase_history = []
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ def test_registration_task_success_clears_email_service_backoff(monkeypatch):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class FakeRegistrationEngine:
|
class FakeRegistrationEngine:
|
||||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||||
self.email_service = email_service
|
self.email_service = email_service
|
||||||
self.phase_history = [
|
self.phase_history = [
|
||||||
PhaseResult(
|
PhaseResult(
|
||||||
@@ -458,7 +458,7 @@ def test_registration_task_backoff_failures_do_not_get_lost_under_concurrency(mo
|
|||||||
peer_started = threading.Event()
|
peer_started = threading.Event()
|
||||||
|
|
||||||
class FakeRegistrationEngine:
|
class FakeRegistrationEngine:
|
||||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||||
self.email_service = email_service
|
self.email_service = email_service
|
||||||
self.phase_history = []
|
self.phase_history = []
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def test_run_sync_registration_task_disables_bad_proxy_and_retries(monkeypatch,
|
|||||||
saved_results = []
|
saved_results = []
|
||||||
|
|
||||||
class FakeRegistrationEngine:
|
class FakeRegistrationEngine:
|
||||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||||
self.proxy_url = proxy_url
|
self.proxy_url = proxy_url
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user