mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-06-26 01:31:47 +08:00
Merge pull request #93 from haq426-163/copilot/codex-auth-f4d0327
feat: add Codex auth login and export flow
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
- 单个账号导出为独立 `.json` 文件
|
||||
- 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件
|
||||
- Sub2API 格式所有账号合并为单个 JSON
|
||||
- Codex Auth 格式需先在账号管理中手动执行 `Codex Auth 登录` 成功后才能导出
|
||||
- 上传目标(直连不走代理):
|
||||
- **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url`
|
||||
- **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_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_ENDPOINTS = {
|
||||
"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,
|
||||
scope: str = OAUTH_SCOPE,
|
||||
client_id: str = OAUTH_CLIENT_ID
|
||||
client_id: str = OAUTH_CLIENT_ID,
|
||||
originator: Optional[str] = None
|
||||
) -> OAuthStart:
|
||||
"""
|
||||
生成 OAuth 授权 URL
|
||||
@@ -199,6 +200,7 @@ def generate_oauth_url(
|
||||
redirect_uri: 回调地址
|
||||
scope: 权限范围
|
||||
client_id: OpenAI Client ID
|
||||
originator: 来源标识(如 codex_cli_rs)
|
||||
|
||||
Returns:
|
||||
OAuthStart 对象,包含授权 URL 和必要参数
|
||||
@@ -219,6 +221,8 @@ def generate_oauth_url(
|
||||
"id_token_add_organizations": "true",
|
||||
"codex_cli_simplified_flow": "true",
|
||||
}
|
||||
if originator:
|
||||
params["originator"] = originator
|
||||
auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
|
||||
return OAuthStart(
|
||||
auth_url=auth_url,
|
||||
@@ -321,7 +325,8 @@ class OAuthManager:
|
||||
token_url: str = OAUTH_TOKEN_URL,
|
||||
redirect_uri: str = OAUTH_REDIRECT_URI,
|
||||
scope: str = OAUTH_SCOPE,
|
||||
proxy_url: Optional[str] = None
|
||||
proxy_url: Optional[str] = None,
|
||||
originator: Optional[str] = None
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.auth_url = auth_url
|
||||
@@ -329,13 +334,15 @@ class OAuthManager:
|
||||
self.redirect_uri = redirect_uri
|
||||
self.scope = scope
|
||||
self.proxy_url = proxy_url
|
||||
self.originator = originator
|
||||
|
||||
def start_oauth(self) -> OAuthStart:
|
||||
"""开始 OAuth 流程"""
|
||||
return generate_oauth_url(
|
||||
redirect_uri=self.redirect_uri,
|
||||
scope=self.scope,
|
||||
client_id=self.client_id
|
||||
client_id=self.client_id,
|
||||
originator=self.originator
|
||||
)
|
||||
|
||||
def handle_callback(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
账号管理 API 路由
|
||||
"""
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import zipfile
|
||||
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.responses import StreamingResponse
|
||||
@@ -30,6 +32,98 @@ logger = logging.getLogger(__name__)
|
||||
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]:
|
||||
"""获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置"""
|
||||
if request_proxy:
|
||||
@@ -542,6 +636,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")
|
||||
async def get_accounts_stats():
|
||||
"""获取账号统计信息"""
|
||||
|
||||
@@ -112,6 +112,24 @@ function initEventListeners() {
|
||||
// 批量删除
|
||||
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) => {
|
||||
const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]');
|
||||
@@ -489,7 +507,10 @@ function updateBatchButtons() {
|
||||
elements.batchCheckSubBtn.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.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token';
|
||||
elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传';
|
||||
@@ -732,7 +753,14 @@ async function exportAccounts(format) {
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 获取文件内容
|
||||
@@ -740,7 +768,7 @@ async function exportAccounts(format) {
|
||||
|
||||
// 从 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) {
|
||||
const match = disposition.match(/filename=(.+)/);
|
||||
if (match) {
|
||||
@@ -1390,3 +1418,188 @@ function showInboxCodeResult(code, email) {
|
||||
`;
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-primary" id="export-btn">
|
||||
@@ -151,6 +154,7 @@
|
||||
<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="sub2api">导出 Sub2Api 格式</a>
|
||||
<a href="#" class="dropdown-item" data-format="codex_auth">导出 Codex Auth 格式</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,6 +283,23 @@
|
||||
</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>
|
||||
<!-- NEWAPI 服务选择模态框 -->
|
||||
<div class="modal" id="newapi-service-modal">
|
||||
<div class="modal-content" style="max-width: 480px;">
|
||||
|
||||
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 = []
|
||||
|
||||
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.phase_history = []
|
||||
|
||||
@@ -240,7 +240,7 @@ def test_registration_task_enters_deep_cooldown_after_three_otp_timeouts(monkeyp
|
||||
current_time = {"value": 1000.0}
|
||||
|
||||
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.phase_history = []
|
||||
|
||||
@@ -350,7 +350,7 @@ def test_registration_task_success_clears_email_service_backoff(monkeypatch):
|
||||
pass
|
||||
|
||||
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.phase_history = [
|
||||
PhaseResult(
|
||||
@@ -458,7 +458,7 @@ def test_registration_task_backoff_failures_do_not_get_lost_under_concurrency(mo
|
||||
peer_started = threading.Event()
|
||||
|
||||
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.phase_history = []
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_run_sync_registration_task_disables_bad_proxy_and_retries(monkeypatch,
|
||||
saved_results = []
|
||||
|
||||
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
|
||||
|
||||
def run(self):
|
||||
|
||||
Reference in New Issue
Block a user