268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""手动 OAuth 桥服务
|
||
|
||
让用户用自己浏览器走一遍 OpenAI 的标准 OAuth + PKCE 授权码流程:
|
||
1. 后端生成 code_verifier / code_challenge / state,构造 authorize URL。
|
||
2. 用户在浏览器登录,浏览器最终被 OpenAI 重定向到 platform.openai.com 的
|
||
callback 地址;用户从地址栏或 devtools 抓出 code,回填到前端。
|
||
3. 后端拿之前存好的 code_verifier + 回填的 code 调用 /api/accounts/oauth/token
|
||
得到 {access_token, refresh_token, id_token}。
|
||
|
||
得到的 refresh_token 跟 account_service 自动刷新机制用的 client_id 是同一个
|
||
(app_2SKx67EdpoN0G6j64rFvigXD),所以落盘后能直接进入 keepalive 周期。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import secrets
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from typing import Any
|
||
from urllib.parse import parse_qs, urlencode, urlparse
|
||
|
||
from curl_cffi import requests
|
||
|
||
from services.openai_oauth import (
|
||
auth_base,
|
||
common_headers,
|
||
platform_auth0_client,
|
||
platform_base,
|
||
platform_oauth_audience,
|
||
platform_oauth_client_id,
|
||
platform_oauth_redirect_uri,
|
||
sec_ch_ua,
|
||
user_agent,
|
||
)
|
||
from services.proxy_service import proxy_settings
|
||
|
||
|
||
class OAuthLoginError(Exception):
|
||
"""OAuth 桥流程中的可预期错误,会被 API 层翻译成 400。"""
|
||
|
||
|
||
class OAuthLoginService:
|
||
"""维护 PKCE 临时会话,并完成 code → token 的兑换。"""
|
||
|
||
_SESSION_TTL_SECONDS = 10 * 60 # 用户点开浏览器 + 拿 code 给的时间上限
|
||
_MAX_SESSIONS = 64 # 防止异常累积;超过容量时清理最老的
|
||
|
||
def __init__(self) -> None:
|
||
self._lock = threading.Lock()
|
||
self._sessions: dict[str, dict[str, Any]] = {}
|
||
|
||
@staticmethod
|
||
def _generate_pkce() -> tuple[str, str]:
|
||
"""生成 PKCE code_verifier 与对应的 code_challenge(S256)。"""
|
||
from utils.pkce import generate_pkce
|
||
return generate_pkce()
|
||
|
||
def _purge_expired_locked(self) -> None:
|
||
"""清理过期或溢出容量的会话,必须在持锁状态下调用。"""
|
||
now = time.time()
|
||
expired = [sid for sid, item in self._sessions.items() if now - item["created_at"] > self._SESSION_TTL_SECONDS]
|
||
for sid in expired:
|
||
self._sessions.pop(sid, None)
|
||
if len(self._sessions) > self._MAX_SESSIONS:
|
||
ordered = sorted(self._sessions.items(), key=lambda kv: kv[1]["created_at"])
|
||
for sid, _ in ordered[: len(self._sessions) - self._MAX_SESSIONS]:
|
||
self._sessions.pop(sid, None)
|
||
|
||
def start(self, email_hint: str = "") -> dict[str, str]:
|
||
"""登记一个新的 PKCE 会话,返回 session_id 与可让用户打开的 authorize_url。
|
||
|
||
state 形如 "<session_id>.<nonce>",让 callback URL 自带 session_id,
|
||
finish 时即便前端 React 状态被覆盖也能从 URL 恢复正确的 verifier。
|
||
"""
|
||
verifier, challenge = self._generate_pkce()
|
||
nonce = secrets.token_urlsafe(32)
|
||
device_id = str(uuid.uuid4())
|
||
session_id = uuid.uuid4().hex
|
||
state = f"{session_id}.{secrets.token_urlsafe(16)}"
|
||
|
||
params = {
|
||
"issuer": auth_base,
|
||
"client_id": platform_oauth_client_id,
|
||
"audience": platform_oauth_audience,
|
||
"redirect_uri": platform_oauth_redirect_uri,
|
||
"device_id": device_id,
|
||
"screen_hint": "login_or_signup",
|
||
"max_age": "0",
|
||
"scope": "openid profile email offline_access",
|
||
"response_type": "code",
|
||
"response_mode": "query",
|
||
"state": state,
|
||
"nonce": nonce,
|
||
"code_challenge": challenge,
|
||
"code_challenge_method": "S256",
|
||
"auth0Client": platform_auth0_client,
|
||
}
|
||
email_hint = str(email_hint or "").strip()
|
||
if email_hint:
|
||
params["login_hint"] = email_hint
|
||
|
||
authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
|
||
|
||
with self._lock:
|
||
self._purge_expired_locked()
|
||
self._sessions[session_id] = {
|
||
"code_verifier": verifier,
|
||
"state": state,
|
||
"created_at": time.time(),
|
||
"redirect_uri": platform_oauth_redirect_uri,
|
||
}
|
||
|
||
return {
|
||
"session_id": session_id,
|
||
"authorize_url": authorize_url,
|
||
"expires_in": str(self._SESSION_TTL_SECONDS),
|
||
"redirect_uri_prefix": platform_oauth_redirect_uri,
|
||
}
|
||
|
||
@staticmethod
|
||
def _extract_code_from_callback(value: str) -> tuple[str, str]:
|
||
"""从 callback URL 或 raw code 中提取 (code, state)。
|
||
|
||
既允许用户粘贴整段 platform.openai.com/auth/callback?code=...&state=... 的 URL,
|
||
也允许只粘 code 本身。
|
||
"""
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return "", ""
|
||
if raw.startswith("http://") or raw.startswith("https://"):
|
||
try:
|
||
parsed = parse_qs(urlparse(raw).query)
|
||
except Exception as exc:
|
||
raise OAuthLoginError(f"无法解析 callback URL: {exc}") from exc
|
||
code = str((parsed.get("code") or [""])[0]).strip()
|
||
state = str((parsed.get("state") or [""])[0]).strip()
|
||
if not code:
|
||
err = str((parsed.get("error_description") or parsed.get("error") or [""])[0]).strip()
|
||
raise OAuthLoginError(err or "callback URL 中没有 code 参数")
|
||
return code, state
|
||
# 用户可能直接粘了 code 字符串
|
||
return raw, ""
|
||
|
||
def finish(self, session_id: str, callback: str) -> dict[str, str]:
|
||
"""用 session_id 配对的 code_verifier 把 callback 里的 code 换成 token 三件套。
|
||
|
||
- 优先用 callback URL 自带 state 里的 session_id(更可靠),
|
||
找不到才用前端传来的 session_id;
|
||
- 失败时不立刻销毁 session(OAuth code 错配换 token 失败通常不会消耗 code),
|
||
只有成功兑换才 pop,便于用户用同一 verifier 重试。
|
||
"""
|
||
body_sid = str(session_id or "").strip()
|
||
code, state = self._extract_code_from_callback(callback)
|
||
if not code:
|
||
raise OAuthLoginError("缺少 code 或 callback URL")
|
||
|
||
# state 里嵌的 session_id 优先级最高
|
||
state_sid = state.split(".", 1)[0] if state else ""
|
||
candidate_sids = [sid for sid in (state_sid, body_sid) if sid]
|
||
if not candidate_sids:
|
||
raise OAuthLoginError("既未提供 session_id,callback URL 中也未携带 state")
|
||
|
||
with self._lock:
|
||
self._purge_expired_locked()
|
||
session = None
|
||
picked_sid = ""
|
||
for sid in candidate_sids:
|
||
cur = self._sessions.get(sid)
|
||
if cur is not None:
|
||
session = cur
|
||
picked_sid = sid
|
||
break
|
||
if session is None:
|
||
raise OAuthLoginError(
|
||
"OAuth 会话已过期或不存在,请回到导入对话框点\"重新生成\"再走一次"
|
||
)
|
||
|
||
if state and session.get("state") and state != session["state"]:
|
||
raise OAuthLoginError(
|
||
"state 不匹配。常见原因:你点过两次\"打开授权页面\",但浏览器里登录的还是前一次的窗口。请点\"重新生成\"重来。"
|
||
)
|
||
|
||
tokens = self._exchange_code(
|
||
code,
|
||
session["code_verifier"],
|
||
session.get("redirect_uri") or platform_oauth_redirect_uri,
|
||
)
|
||
# 仅在成功兑换之后才消耗 session
|
||
with self._lock:
|
||
self._sessions.pop(picked_sid, None)
|
||
return tokens
|
||
|
||
@staticmethod
|
||
def _exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict[str, str]:
|
||
"""调用 /api/accounts/oauth/token 用 code+verifier 换 token 三件套。"""
|
||
kwargs = proxy_settings.build_session_kwargs(impersonate="chrome", verify=False)
|
||
session = requests.Session(**kwargs)
|
||
try:
|
||
response = session.post(
|
||
f"{auth_base}/api/accounts/oauth/token",
|
||
headers={
|
||
**common_headers,
|
||
"referer": f"{platform_base}/",
|
||
"origin": platform_base,
|
||
"auth0-client": platform_auth0_client,
|
||
"sec-ch-ua": sec_ch_ua,
|
||
"user-agent": user_agent,
|
||
},
|
||
json={
|
||
"client_id": platform_oauth_client_id,
|
||
"code_verifier": code_verifier,
|
||
"grant_type": "authorization_code",
|
||
"code": code,
|
||
"redirect_uri": redirect_uri,
|
||
},
|
||
timeout=60,
|
||
)
|
||
except Exception as exc:
|
||
raise OAuthLoginError(f"换 token 网络异常: {exc}") from exc
|
||
finally:
|
||
session.close()
|
||
|
||
try:
|
||
data = response.json() if response.text else {}
|
||
except Exception:
|
||
data = {}
|
||
|
||
if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
|
||
detail = ""
|
||
if isinstance(data, dict):
|
||
detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
|
||
if not detail:
|
||
try:
|
||
detail = str(response.text or "")[:300]
|
||
except Exception:
|
||
detail = ""
|
||
# 打到 docker logs 方便排错——OAuth 换 token 的失败原因往往只有这里能看到
|
||
print(
|
||
f"[oauth-login] /api/accounts/oauth/token rejected: "
|
||
f"status={response.status_code} detail={detail!r} "
|
||
f"raw_body={(getattr(response, 'text', '') or '')[:500]!r}",
|
||
flush=True,
|
||
)
|
||
raise OAuthLoginError(
|
||
f"OpenAI 拒绝换 token (HTTP {response.status_code}){': ' + detail if detail else ''}"
|
||
)
|
||
|
||
access_token = str(data.get("access_token") or "").strip()
|
||
refresh_token = str(data.get("refresh_token") or "").strip()
|
||
id_token = str(data.get("id_token") or "").strip()
|
||
|
||
if not access_token:
|
||
raise OAuthLoginError("OpenAI 返回的 access_token 为空")
|
||
if not refresh_token:
|
||
# scope 含 offline_access 时正常会下发 refresh_token;这里给出明确提示
|
||
raise OAuthLoginError(
|
||
"OpenAI 没有返回 refresh_token(可能 scope 未包含 offline_access 或 code 已使用过)"
|
||
)
|
||
|
||
return {
|
||
"access_token": access_token,
|
||
"refresh_token": refresh_token,
|
||
"id_token": id_token,
|
||
}
|
||
|
||
|
||
oauth_login_service = OAuthLoginService()
|