Initial project import
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
"""手动 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()
|
||||
Reference in New Issue
Block a user