From f4d0327f67e2e59e5c0d313a9aa2fade77cdc68d Mon Sep 17 00:00:00 2001 From: Solo Date: Wed, 25 Mar 2026 16:31:03 +0800 Subject: [PATCH] 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 --- README.md | 1 + src/config/constants.py | 5 + src/core/codex_auth.py | 213 +++++++++ src/core/openai/oauth.py | 13 +- src/web/routes/accounts.py | 441 +++++++++++++++++- static/js/accounts.js | 219 ++++++++- templates/accounts.html | 24 +- tests/test_codex_auth_export_route.py | 204 ++++++++ tests/test_codex_auth_flow.py | 149 ++++++ ...est_registration_email_service_failover.py | 8 +- tests/test_registration_proxy_failover.py | 2 +- 11 files changed, 1266 insertions(+), 13 deletions(-) create mode 100644 src/core/codex_auth.py create mode 100644 tests/test_codex_auth_export_route.py create mode 100644 tests/test_codex_auth_flow.py diff --git a/README.md b/README.md index 60c0a5f..9ed9aba 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ - 单个账号导出为独立 `.json` 文件 - 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件 - Sub2API 格式所有账号合并为单个 JSON + - Codex Auth 格式需先在账号管理中手动执行 `Codex Auth 登录` 成功后才能导出 - 上传目标(直连不走代理): - **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url` - **Sub2API**:支持多服务配置,标准 sub2api-data 格式 diff --git a/src/config/constants.py b/src/config/constants.py index c0c4f91..d6b3be7 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -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", diff --git a/src/core/codex_auth.py b/src/core/codex_auth.py new file mode 100644 index 0000000..276ee3b --- /dev/null +++ b/src/core/codex_auth.py @@ -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 diff --git a/src/core/openai/oauth.py b/src/core/openai/oauth.py index e8dc0fa..5a2c618 100644 --- a/src/core/openai/oauth.py +++ b/src/core/openai/oauth.py @@ -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( diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 7b4aa6f..85cb290 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -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 @@ -29,6 +31,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: @@ -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") async def get_accounts_stats(): """获取账号统计信息""" diff --git a/static/js/accounts.js b/static/js/accounts.js index 10f83d4..fcdb6bf 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -111,6 +111,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]'); @@ -481,7 +499,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})` : '☁️ 上传'; @@ -724,7 +745,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); } // 获取文件内容 @@ -732,7 +760,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) { @@ -1266,3 +1294,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`); +} diff --git a/templates/accounts.html b/templates/accounts.html index ed25960..f2e804a 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -139,7 +139,10 @@ + @@ -277,6 +281,24 @@ + + + diff --git a/tests/test_codex_auth_export_route.py b/tests/test_codex_auth_export_route.py new file mode 100644 index 0000000..479c3b5 --- /dev/null +++ b/tests/test_codex_auth_export_route.py @@ -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"] diff --git a/tests/test_codex_auth_flow.py b/tests/test_codex_auth_flow.py new file mode 100644 index 0000000..599ee9c --- /dev/null +++ b/tests/test_codex_auth_flow.py @@ -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="consent", + ) + ) + + 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}}] diff --git a/tests/test_registration_email_service_failover.py b/tests/test_registration_email_service_failover.py index 020b63f..334b41f 100644 --- a/tests/test_registration_email_service_failover.py +++ b/tests/test_registration_email_service_failover.py @@ -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 = [] diff --git a/tests/test_registration_proxy_failover.py b/tests/test_registration_proxy_failover.py index 575699d..4bcbb20 100644 --- a/tests/test_registration_proxy_failover.py +++ b/tests/test_registration_proxy_failover.py @@ -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):