Merge pull request #93 from haq426-163/copilot/codex-auth-f4d0327

feat: add Codex auth login and export flow
This commit is contained in:
演变
2026-03-25 23:53:33 +08:00
committed by GitHub
11 changed files with 1265 additions and 13 deletions

View File

@@ -48,6 +48,7 @@
- 单个账号导出为独立 `.json` 文件
- 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件
- Sub2API 格式所有账号合并为单个 JSON
- Codex Auth 格式需先在账号管理中手动执行 `Codex Auth 登录` 成功后才能导出
- 上传目标(直连不走代理):
- **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url`
- **Sub2API**:支持多服务配置,标准 sub2api-data 格式

View File

@@ -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
View 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

View File

@@ -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(

View File

@@ -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():
"""获取账号统计信息"""

View File

@@ -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`);
}

View File

@@ -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">&times;</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;">

View 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"]

View 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}}]

View File

@@ -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 = []

View File

@@ -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):