feat(s2a_service): Add pure API authorization mode without browser
- Add S2A_API_MODE configuration option to enable browser-less authorization - Implement S2AApiAuthorizer class using curl_cffi for browser fingerprint simulation - Add Sentinel PoW (Proof of Work) solver with FNV-1a hashing algorithm - Implement OAuth flow via direct API calls instead of browser automation - Add s2a_api_authorize() function to handle email/password authentication - Support proxy configuration for API requests - Add requirements token generation for API authentication - Update browser_automation.py to check S2A_API_MODE and route to API or browser flow - Update config.py to load S2A_API_MODE from configuration - Add api_mode option to config.toml.example with documentation - Improves performance and stability by eliminating browser overhead while maintaining compatibility with existing browser-based flow
This commit is contained in:
476
s2a_service.py
476
s2a_service.py
@@ -6,12 +6,24 @@
|
||||
# - 会话标识: S2A 使用 session_id
|
||||
# - 授权流程: S2A 生成授权 URL -> 用户授权 -> 提交 code 换取 token -> 创建账号
|
||||
# - 账号入库: S2A 可一步完成 (create-from-oauth) 或分步完成 (exchange + add_account)
|
||||
#
|
||||
# 新增: 纯 API 授权模式 (无需浏览器)
|
||||
# - 使用 curl_cffi 模拟浏览器指纹
|
||||
# - 支持 Sentinel PoW 验证
|
||||
# - 直接通过 API 完成 OAuth 流程
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from typing import Optional, Tuple, Dict, List, Any
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import random
|
||||
import base64
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from config import (
|
||||
S2A_API_BASE,
|
||||
@@ -23,6 +35,8 @@ from config import (
|
||||
S2A_GROUP_NAMES,
|
||||
REQUEST_TIMEOUT,
|
||||
USER_AGENT,
|
||||
get_next_proxy,
|
||||
format_proxy_url,
|
||||
)
|
||||
from logger import log
|
||||
|
||||
@@ -49,6 +63,349 @@ def create_session_with_retry() -> requests.Session:
|
||||
http_session = create_session_with_retry()
|
||||
|
||||
|
||||
# ==================== PoW Solver (Sentinel 验证) ====================
|
||||
|
||||
def _fnv1a_32(data: bytes) -> int:
|
||||
"""FNV-1a 32-bit hash"""
|
||||
h = 2166136261
|
||||
for byte in data:
|
||||
h ^= byte
|
||||
h = (h * 16777619) & 0xFFFFFFFF
|
||||
h ^= (h >> 16)
|
||||
h = (h * 2246822507) & 0xFFFFFFFF
|
||||
h ^= (h >> 13)
|
||||
h = (h * 3266489909) & 0xFFFFFFFF
|
||||
h ^= (h >> 16)
|
||||
return h
|
||||
|
||||
|
||||
def _get_parse_time() -> str:
|
||||
"""生成 JS Date().toString() 格式的时间戳"""
|
||||
now = datetime.now(timezone(timedelta(hours=8)))
|
||||
return now.strftime("%a %b %d %Y %H:%M:%S") + " GMT+0800 (中国标准时间)"
|
||||
|
||||
|
||||
def _get_pow_config(user_agent: str, sid: str = None) -> list:
|
||||
"""生成 PoW 配置数组"""
|
||||
if not sid:
|
||||
sid = str(uuid.uuid4())
|
||||
return [
|
||||
random.randint(2500, 3500),
|
||||
_get_parse_time(),
|
||||
4294967296,
|
||||
0,
|
||||
user_agent,
|
||||
"chrome-extension://pgojnojmmhpofjgdmaebadhbocahppod/assets/aW5qZWN0X2hhc2g/aW5qZ",
|
||||
None,
|
||||
"zh-CN",
|
||||
"zh-CN",
|
||||
0,
|
||||
f"canShare−function canShare() {{ [native code] }}",
|
||||
f"_reactListening{random.randint(1000000, 9999999)}",
|
||||
"onhashchange",
|
||||
time.perf_counter() * 1000,
|
||||
sid,
|
||||
"",
|
||||
24,
|
||||
int(time.time() * 1000 - random.randint(10000, 50000))
|
||||
]
|
||||
|
||||
|
||||
def _solve_pow(seed: str, difficulty: str, config: list, max_iterations: int = 5000000) -> Optional[str]:
|
||||
"""CPU 求解 PoW"""
|
||||
start_time = time.perf_counter()
|
||||
seed_bytes = seed.encode()
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
config[3] = iteration
|
||||
config[9] = 0
|
||||
|
||||
json_str = json.dumps(config, separators=(',', ':'))
|
||||
encoded = base64.b64encode(json_str.encode())
|
||||
|
||||
h = _fnv1a_32(seed_bytes + encoded)
|
||||
hex_hash = f"{h:08x}"
|
||||
|
||||
if hex_hash[:len(difficulty)] <= difficulty:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
log.debug(f"[PoW] 求解完成: {elapsed:.2f}s (迭代 {iteration:,}, 难度={difficulty})")
|
||||
return f"{encoded.decode()}~S"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_requirements_token(user_agent: str, sid: str = None) -> str:
|
||||
"""生成 requirements token"""
|
||||
if not sid:
|
||||
sid = str(uuid.uuid4())
|
||||
config = _get_pow_config(user_agent, sid)
|
||||
config[3] = 0
|
||||
config[9] = 0
|
||||
json_str = json.dumps(config, separators=(',', ':'))
|
||||
encoded = base64.b64encode(json_str.encode()).decode()
|
||||
return f"gAAAAAC{encoded}~S"
|
||||
|
||||
|
||||
# ==================== S2A API 授权器 ====================
|
||||
|
||||
class S2AApiAuthorizer:
|
||||
"""S2A 纯 API 授权器 - 无需浏览器"""
|
||||
|
||||
def __init__(self, email: str, password: str, proxy: str = None):
|
||||
self.email = email
|
||||
self.password = password
|
||||
|
||||
# 尝试导入 curl_cffi,如果失败则使用 requests
|
||||
try:
|
||||
from curl_cffi import requests as cffi_requests
|
||||
self.session = cffi_requests.Session(impersonate="chrome110")
|
||||
self._use_cffi = True
|
||||
except ImportError:
|
||||
log.warning("curl_cffi 未安装,使用 requests (可能被检测)")
|
||||
self.session = requests.Session()
|
||||
self._use_cffi = False
|
||||
|
||||
if proxy:
|
||||
self.session.proxies = {"http": proxy, "https": proxy}
|
||||
|
||||
self.ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"
|
||||
self.sid = str(uuid.uuid4())
|
||||
self.device_id = str(uuid.uuid4())
|
||||
self.sentinel_token = None
|
||||
self.solved_pow = None
|
||||
|
||||
self.session.headers.update({
|
||||
"User-Agent": self.ua,
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144", "Microsoft Edge";v="144"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
})
|
||||
|
||||
def _call_sentinel_req(self, flow: str) -> Optional[dict]:
|
||||
"""调用 sentinel 获取 token 和处理 PoW"""
|
||||
init_token = _get_requirements_token(self.ua, self.sid)
|
||||
payload = {"p": init_token, "id": self.device_id, "flow": flow}
|
||||
|
||||
try:
|
||||
resp = self.session.post(
|
||||
"https://sentinel.openai.com/backend-api/sentinel/req",
|
||||
json=payload,
|
||||
timeout=15
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.warning(f"Sentinel 请求失败: {resp.status_code}")
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
self.sentinel_token = data.get('token')
|
||||
pow_req = data.get('proofofwork', {})
|
||||
|
||||
if pow_req.get('required'):
|
||||
seed = pow_req.get('seed', '')
|
||||
difficulty = pow_req.get('difficulty', '')
|
||||
config = _get_pow_config(self.ua, self.sid)
|
||||
solved = _solve_pow(seed, difficulty, config)
|
||||
if solved:
|
||||
self.solved_pow = f"gAAAAAB{solved}"
|
||||
else:
|
||||
log.error("PoW 求解失败")
|
||||
return None
|
||||
else:
|
||||
self.solved_pow = init_token
|
||||
return data
|
||||
except Exception as e:
|
||||
log.error(f"Sentinel 异常: {e}")
|
||||
return None
|
||||
|
||||
def _get_sentinel_header(self, header_flow: str) -> str:
|
||||
"""构建 sentinel header"""
|
||||
sentinel_obj = {"p": self.solved_pow, "id": self.device_id, "flow": header_flow}
|
||||
if self.sentinel_token:
|
||||
sentinel_obj["c"] = self.sentinel_token
|
||||
return json.dumps(sentinel_obj)
|
||||
|
||||
def get_authorization_code(self, auth_url: str) -> Optional[str]:
|
||||
"""执行 OAuth 流程,返回 authorization code
|
||||
|
||||
Args:
|
||||
auth_url: S2A 生成的授权 URL
|
||||
|
||||
Returns:
|
||||
str: 授权码 或 None
|
||||
"""
|
||||
log.step("开始 API 授权流程...")
|
||||
|
||||
headers = {
|
||||
"Origin": "https://auth.openai.com",
|
||||
"Referer": "https://auth.openai.com/log-in",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
# 1. 访问授权端点
|
||||
log.step("访问授权端点...")
|
||||
resp = self.session.get(auth_url, allow_redirects=True)
|
||||
headers["Referer"] = resp.url
|
||||
|
||||
# 2. 提交邮箱
|
||||
log.step("提交邮箱...")
|
||||
if not self._call_sentinel_req("login_web_init"):
|
||||
return None
|
||||
|
||||
auth_headers = headers.copy()
|
||||
auth_headers["OpenAI-Sentinel-Token"] = self._get_sentinel_header("authorize_continue")
|
||||
resp = self.session.post(
|
||||
"https://auth.openai.com/api/accounts/authorize/continue",
|
||||
json={"username": {"kind": "email", "value": self.email}},
|
||||
headers=auth_headers
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.error(f"邮箱提交失败: {resp.status_code} - {resp.text[:200]}")
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
page_type = data.get("page", {}).get("type", "")
|
||||
|
||||
# 3. 验证密码
|
||||
if page_type == "password" or "password" in str(data):
|
||||
log.step("验证密码...")
|
||||
if not self._call_sentinel_req("authorize_continue__auto"):
|
||||
return None
|
||||
|
||||
verify_headers = headers.copy()
|
||||
verify_headers["OpenAI-Sentinel-Token"] = self._get_sentinel_header("password_verify")
|
||||
resp = self.session.post(
|
||||
"https://auth.openai.com/api/accounts/password/verify",
|
||||
json={"username": self.email, "password": self.password},
|
||||
headers=verify_headers
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.error(f"密码验证失败: {resp.status_code} - {resp.text[:200]}")
|
||||
return None
|
||||
|
||||
# 4. 获取 continue_url (无需选择 workspace,S2A 授权链接已包含)
|
||||
data = resp.json()
|
||||
continue_url = data.get("continue_url")
|
||||
|
||||
# 如果没有 continue_url,可能需要额外的 sentinel 调用
|
||||
if not continue_url:
|
||||
log.step("获取重定向 URL...")
|
||||
if not self._call_sentinel_req("password_verify__auto"):
|
||||
return None
|
||||
|
||||
# 尝试再次获取
|
||||
resp = self.session.post(
|
||||
"https://auth.openai.com/api/accounts/authorize/continue",
|
||||
json={},
|
||||
headers=auth_headers
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
continue_url = data.get("continue_url")
|
||||
|
||||
if not continue_url:
|
||||
log.error(f"无法获取 continue_url: {data}")
|
||||
return None
|
||||
|
||||
# 5. 跟踪重定向直到获取 code
|
||||
log.step("跟踪重定向...")
|
||||
for _ in range(10):
|
||||
resp = self.session.get(continue_url, allow_redirects=False)
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
location = resp.headers.get('Location', '')
|
||||
if "localhost:1455" in location:
|
||||
parsed = urlparse(location)
|
||||
query = parse_qs(parsed.query)
|
||||
code = query.get('code', [None])[0]
|
||||
if code:
|
||||
log.success("成功获取授权码")
|
||||
return code
|
||||
continue_url = location
|
||||
else:
|
||||
break
|
||||
|
||||
log.error("无法获取授权码")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"API 授权异常: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def s2a_api_authorize(
|
||||
email: str,
|
||||
password: str,
|
||||
proxy: str = None
|
||||
) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
||||
"""S2A 纯 API 授权 (无需浏览器)
|
||||
|
||||
使用 OpenAI 认证 API 直接完成授权流程,无需浏览器自动化。
|
||||
|
||||
Args:
|
||||
email: 账号邮箱
|
||||
password: 账号密码
|
||||
proxy: 代理地址 (可选,格式: http://host:port 或 socks5://user:pass@host:port)
|
||||
|
||||
Returns:
|
||||
tuple: (是否成功, 账号数据或None)
|
||||
"""
|
||||
if not S2A_API_BASE or (not S2A_ADMIN_KEY and not S2A_ADMIN_TOKEN):
|
||||
log.error("S2A 未配置")
|
||||
return False, None
|
||||
|
||||
# 使用配置的代理
|
||||
if not proxy:
|
||||
proxy_config = get_next_proxy()
|
||||
if proxy_config:
|
||||
proxy = format_proxy_url(proxy_config)
|
||||
|
||||
log.info(f"开始 S2A API 授权: {email}", icon="code")
|
||||
if proxy:
|
||||
log.debug(f"使用代理: {proxy[:30]}...")
|
||||
|
||||
try:
|
||||
# 1. 生成授权 URL
|
||||
auth_url, session_id = s2a_generate_auth_url()
|
||||
if not auth_url or not session_id:
|
||||
log.error("无法获取 S2A 授权 URL")
|
||||
return False, None
|
||||
|
||||
log.debug(f"授权 URL: {auth_url[:80]}...")
|
||||
log.debug(f"Session ID: {session_id[:16]}...")
|
||||
|
||||
# 2. 使用 API 授权器获取 code
|
||||
authorizer = S2AApiAuthorizer(email, password, proxy)
|
||||
code = authorizer.get_authorization_code(auth_url)
|
||||
|
||||
if not code:
|
||||
log.error("API 授权失败,无法获取授权码")
|
||||
return False, None
|
||||
|
||||
log.debug(f"授权码: {code[:20]}...")
|
||||
|
||||
# 3. 提交授权码创建账号
|
||||
log.step("提交授权码到 S2A...")
|
||||
result = s2a_create_account_from_oauth(code, session_id, name=email)
|
||||
|
||||
if result:
|
||||
log.success(f"S2A API 授权成功: {email}")
|
||||
return True, result
|
||||
else:
|
||||
log.error("S2A 账号入库失败")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"S2A API 授权异常: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
def build_s2a_headers() -> Dict[str, str]:
|
||||
"""构建 S2A API 请求的 Headers
|
||||
|
||||
@@ -1211,3 +1568,122 @@ def format_keys_usage(keys: List[Dict[str, Any]], period_text: str = "今日") -
|
||||
lines.append(f" 费用: {fmt_cost(total_cost)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ==================== 批量 API 授权 ====================
|
||||
|
||||
def s2a_batch_api_authorize(
|
||||
accounts: List[Dict[str, str]],
|
||||
proxy: str = None,
|
||||
progress_callback: Optional[callable] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""批量使用 API 模式授权账号到 S2A
|
||||
|
||||
无需浏览器,直接通过 OpenAI 认证 API 完成授权。
|
||||
|
||||
Args:
|
||||
accounts: 账号列表 [{"email": "xxx", "password": "xxx"}, ...]
|
||||
proxy: 代理地址 (可选)
|
||||
progress_callback: 进度回调函数 (current, total, email, status, message)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"success": int,
|
||||
"failed": int,
|
||||
"total": int,
|
||||
"details": [{"email": "xxx", "status": "success/failed", "message": "xxx"}, ...]
|
||||
}
|
||||
"""
|
||||
results = {
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"total": len(accounts),
|
||||
"details": []
|
||||
}
|
||||
|
||||
if not S2A_API_BASE or (not S2A_ADMIN_KEY and not S2A_ADMIN_TOKEN):
|
||||
log.error("S2A 未配置")
|
||||
return results
|
||||
|
||||
# 使用配置的代理
|
||||
if not proxy:
|
||||
proxy_config = get_next_proxy()
|
||||
if proxy_config:
|
||||
proxy = format_proxy_url(proxy_config)
|
||||
|
||||
log.info(f"开始批量 API 授权: {len(accounts)} 个账号")
|
||||
|
||||
for i, acc in enumerate(accounts):
|
||||
email = acc.get("email", "")
|
||||
password = acc.get("password", "")
|
||||
|
||||
if not email or not password:
|
||||
results["failed"] += 1
|
||||
results["details"].append({
|
||||
"email": email or "unknown",
|
||||
"status": "failed",
|
||||
"message": "缺少邮箱或密码"
|
||||
})
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, len(accounts), email, "failed", "缺少邮箱或密码")
|
||||
continue
|
||||
|
||||
try:
|
||||
success, result = s2a_api_authorize(email, password, proxy)
|
||||
|
||||
if success:
|
||||
results["success"] += 1
|
||||
account_id = result.get("id", "") if result else ""
|
||||
results["details"].append({
|
||||
"email": email,
|
||||
"status": "success",
|
||||
"message": f"ID: {account_id}"
|
||||
})
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, len(accounts), email, "success", f"ID: {account_id}")
|
||||
else:
|
||||
results["failed"] += 1
|
||||
results["details"].append({
|
||||
"email": email,
|
||||
"status": "failed",
|
||||
"message": "授权失败"
|
||||
})
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, len(accounts), email, "failed", "授权失败")
|
||||
|
||||
except Exception as e:
|
||||
results["failed"] += 1
|
||||
results["details"].append({
|
||||
"email": email,
|
||||
"status": "failed",
|
||||
"message": str(e)
|
||||
})
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, len(accounts), email, "failed", str(e))
|
||||
|
||||
log.success(f"批量授权完成: 成功 {results['success']}, 失败 {results['failed']}")
|
||||
return results
|
||||
|
||||
|
||||
def s2a_api_authorize_single(
|
||||
email: str,
|
||||
password: str,
|
||||
proxy: str = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""单个账号 API 授权 (简化返回值)
|
||||
|
||||
Args:
|
||||
email: 账号邮箱
|
||||
password: 账号密码
|
||||
proxy: 代理地址 (可选)
|
||||
|
||||
Returns:
|
||||
tuple: (是否成功, 消息)
|
||||
"""
|
||||
success, result = s2a_api_authorize(email, password, proxy)
|
||||
|
||||
if success:
|
||||
account_id = result.get("id", "") if result else ""
|
||||
return True, f"授权成功 (ID: {account_id})"
|
||||
else:
|
||||
return False, "授权失败"
|
||||
|
||||
Reference in New Issue
Block a user