Files
codexautopool/backend/internal/auth/codex_api.go

693 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"codex-pool/internal/mail"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
// FixedAuthURL 固定的授权 URL用于测试绕过 S2A
const FixedAuthURL = "https://auth.openai.com/oauth/authorize?client_id=app_EMoamEEZ73f0CkXaXp7hrann&code_challenge=fEepJO0_NJiqP-_FC_HLH-aqZsq68JeFtNvYY6q3qbQ&code_challenge_method=S256&codex_cli_simplified_flow=true&id_token_add_organizations=true&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&response_type=code&scope=openid+profile+email+offline_access&state=da3ec35b7368c91193d27c90e3ecd0c4fa45bebd430bcc6b5c236461d2742e93"
// UseFixedAuthURL 是否使用固定的授权 URL设为 true 可绕过 S2A 进行测试)
var UseFixedAuthURL = false
// 常量 CodexClientID, CodexRedirectURI, CodexScope 已在 s2a.go 中定义
// CodexAPIAuth 纯 API 授权 (无浏览器) - 基于 get_code.go 的实现
type CodexAPIAuth struct {
client *http.Client
email string
password string
workspaceID string
authURL string // S2A 生成的授权 URL
sessionID string // S2A 会话 ID
deviceID string
sid string
sentinelToken string
solvedPow string
userAgent string
proxyURL string
logger *AuthLogger
}
// utlsRoundTripper - 模拟 Chrome TLS 指纹
type utlsRoundTripper struct {
proxyURL *url.URL
}
func (rt *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
var conn net.Conn
var err error
targetHost := req.URL.Host
if !strings.Contains(targetHost, ":") {
if req.URL.Scheme == "https" {
targetHost += ":443"
} else {
targetHost += ":80"
}
}
// 通过代理连接
if rt.proxyURL != nil {
proxyHost := rt.proxyURL.Host
conn, err = net.DialTimeout("tcp", proxyHost, 30*time.Second)
if err != nil {
return nil, fmt.Errorf("proxy dial error: %w", err)
}
// 构建 CONNECT 请求,支持代理认证
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", targetHost, targetHost)
// 添加代理认证头
if rt.proxyURL.User != nil {
username := rt.proxyURL.User.Username()
password, _ := rt.proxyURL.User.Password()
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", auth)
}
connectReq += "\r\n"
_, err = conn.Write([]byte(connectReq))
if err != nil {
conn.Close()
return nil, fmt.Errorf("proxy connect write error: %w", err)
}
// 读取代理响应
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
conn.Close()
return nil, fmt.Errorf("proxy connect read error: %w", err)
}
response := string(buf[:n])
if !strings.Contains(response, "200") {
conn.Close()
return nil, fmt.Errorf("proxy connect failed: %s", response)
}
} else {
conn, err = net.DialTimeout("tcp", targetHost, 30*time.Second)
if err != nil {
return nil, fmt.Errorf("dial error: %w", err)
}
}
// 使用 uTLS 进行 TLS 握手,模拟 Chrome
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: req.URL.Hostname(),
InsecureSkipVerify: true,
}, utls.HelloChrome_Auto)
err = tlsConn.Handshake()
if err != nil {
conn.Close()
return nil, fmt.Errorf("tls handshake error: %w", err)
}
// 使用 HTTP/2 或 HTTP/1.1
alpn := tlsConn.ConnectionState().NegotiatedProtocol
if alpn == "h2" {
// HTTP/2
t2 := &http2.Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
return tlsConn, nil
},
}
return t2.RoundTrip(req)
}
// HTTP/1.1
t1 := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
}
return t1.RoundTrip(req)
}
// NewCodexAPIAuth 创建 CodexAuth 实例
// authURL 和 sessionID 由 S2A 生成
func NewCodexAPIAuth(email, password, workspaceID, authURL, sessionID, proxy string, logger *AuthLogger) (*CodexAPIAuth, error) {
var proxyURL *url.URL
if proxy != "" {
var err error
proxyURL, err = url.Parse(proxy)
if err != nil {
return nil, fmt.Errorf("解析代理地址失败: %v", err)
}
}
jar, _ := cookiejar.New(nil)
client := &http.Client{
Transport: &utlsRoundTripper{proxyURL: proxyURL},
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // 禁用自动重定向,手动跟随
},
Timeout: 30 * time.Second,
}
return &CodexAPIAuth{
client: client,
email: email,
password: password,
workspaceID: workspaceID,
authURL: authURL,
sessionID: sessionID,
deviceID: generateUUID(),
sid: generateUUID(),
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36",
proxyURL: proxy,
logger: logger,
}, nil
}
// generateUUID 生成 UUID v4
func generateUUID() string {
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// fnv1a32 FNV-1a 32-bit hash
func fnv1a32(data []byte) uint32 {
h := uint32(2166136261)
for _, b := range data {
h ^= uint32(b)
h *= 16777619
}
h ^= (h >> 16)
h *= 2246822507
h ^= (h >> 13)
h *= 3266489909
h ^= (h >> 16)
return h
}
// getParseTime 生成 JS Date().toString() 格式的时间字符串
func getParseTime() string {
loc, _ := time.LoadLocation("Asia/Shanghai")
now := time.Now().In(loc)
days := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
months := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
return fmt.Sprintf("%s %s %02d %d %02d:%02d:%02d GMT+0800 (中国标准时间)",
days[now.Weekday()], months[now.Month()-1], now.Day(), now.Year(),
now.Hour(), now.Minute(), now.Second())
}
// getConfig 生成 PoW 配置数组
func (c *CodexAPIAuth) getConfig() []interface{} {
return []interface{}{
rand.Intn(1000) + 2500,
getParseTime(),
4294967296,
0,
c.userAgent,
"chrome-extension://pgojnojmmhpofjgdmaebadhbocahppod/assets/aW5qZWN0X2hhc2g/aW5qZ",
nil,
"zh-CN",
"zh-CN",
0,
"canSharefunction canShare() { [native code] }",
fmt.Sprintf("_reactListening%d", rand.Intn(9000000)+1000000),
"onhashchange",
float64(time.Now().UnixNano()) / 1e6,
c.sid,
"",
24,
time.Now().UnixMilli() - int64(rand.Intn(40000)+10000),
}
}
// solvePow 解决 PoW 挑战
func (c *CodexAPIAuth) solvePow(seed, difficulty string, cfg []interface{}, maxIterations int) string {
startTime := time.Now()
seedBytes := []byte(seed)
for i := 0; i < maxIterations; i++ {
cfg[3] = i
cfg[9] = 0
jsonStr, _ := json.Marshal(cfg)
encoded := base64.StdEncoding.EncodeToString(jsonStr)
combined := append(seedBytes, []byte(encoded)...)
h := fnv1a32(combined)
hexHash := fmt.Sprintf("%08x", h)
if hexHash[:len(difficulty)] <= difficulty {
elapsed := time.Since(startTime).Seconds()
c.logStep(StepNavigate, "[PoW] Solved in %.2fs (iteration %d, difficulty=%s)", elapsed, i, difficulty)
return encoded + "~S"
}
}
return ""
}
// getRequirementsToken 生成初始 token
func (c *CodexAPIAuth) getRequirementsToken() string {
cfg := c.getConfig()
cfg[3] = 0
cfg[9] = 0
jsonStr, _ := json.Marshal(cfg)
encoded := base64.StdEncoding.EncodeToString(jsonStr)
return "gAAAAAC" + encoded + "~S"
}
// doRequest 执行 HTTP 请求
func (c *CodexAPIAuth) doRequest(method, urlStr string, body interface{}, headers map[string]string) (*http.Response, []byte, error) {
var bodyReader io.Reader
if body != nil {
jsonBody, _ := json.Marshal(body)
bodyReader = bytes.NewReader(jsonBody)
}
req, err := http.NewRequest(method, urlStr, bodyReader)
if err != nil {
return nil, nil, err
}
// 设置默认头
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="110", "Google Chrome";v="110"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
req.Header.Set("sec-fetch-dest", "empty")
req.Header.Set("sec-fetch-mode", "cors")
req.Header.Set("sec-fetch-site", "same-origin")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return resp, respBody, nil
}
// callSentinelReq 调用 Sentinel 获取 token
func (c *CodexAPIAuth) callSentinelReq(flow string) bool {
initToken := c.getRequirementsToken()
payload := map[string]string{
"p": initToken,
"id": c.deviceID,
"flow": flow,
}
headers := map[string]string{"Content-Type": "application/json"}
resp, body, err := c.doRequest("POST", "https://sentinel.openai.com/backend-api/sentinel/req", payload, headers)
if err != nil || resp.StatusCode != 200 {
c.logError(StepNavigate, "Sentinel 请求失败: %v (status: %d)", err, resp.StatusCode)
return false
}
var data map[string]interface{}
json.Unmarshal(body, &data)
if token, ok := data["token"].(string); ok {
c.sentinelToken = token
}
if powReq, ok := data["proofofwork"].(map[string]interface{}); ok {
if required, ok := powReq["required"].(bool); ok && required {
seed, _ := powReq["seed"].(string)
difficulty, _ := powReq["difficulty"].(string)
c.logStep(StepNavigate, "[Sentinel] flow=%s, PoW required, difficulty=%s", flow, difficulty)
config := c.getConfig()
solved := c.solvePow(seed, difficulty, config, 5000000)
if solved != "" {
c.solvedPow = "gAAAAAB" + solved
} else {
c.logError(StepNavigate, "PoW 求解失败")
return false
}
} else {
c.logStep(StepNavigate, "[Sentinel] flow=%s, PoW not required", flow)
c.solvedPow = initToken
}
} else {
c.logStep(StepNavigate, "[Sentinel] flow=%s, no PoW in response", flow)
c.solvedPow = initToken
}
return true
}
// getSentinelHeader 构建 sentinel header
func (c *CodexAPIAuth) getSentinelHeader(flow string) string {
sentinelObj := map[string]string{
"p": c.solvedPow,
"id": c.deviceID,
"flow": flow,
}
if c.sentinelToken != "" {
sentinelObj["c"] = c.sentinelToken
}
header, _ := json.Marshal(sentinelObj)
return string(header)
}
// logStep 记录日志
func (c *CodexAPIAuth) logStep(step AuthStep, format string, args ...interface{}) {
if c.logger != nil {
c.logger.LogStep(step, format, args...)
}
}
// logError 记录错误
func (c *CodexAPIAuth) logError(step AuthStep, format string, args ...interface{}) {
if c.logger != nil {
c.logger.LogError(step, format, args...)
}
}
// GetSessionID 获取 S2A 会话 ID用于后续入库
func (c *CodexAPIAuth) GetSessionID() string {
return c.sessionID
}
// ObtainAuthorizationCode 获取授权码
func (c *CodexAPIAuth) ObtainAuthorizationCode() (string, error) {
c.logStep(StepNavigate, "开始 Codex API 授权流程...")
// 选择使用固定 URL 还是 S2A 生成的 URL
authURL := c.authURL
if UseFixedAuthURL {
authURL = FixedAuthURL
c.logStep(StepNavigate, "使用固定授权 URL测试模式")
} else if authURL == "" {
return "", fmt.Errorf("authURL 未设置,请先通过 S2A 生成授权 URL")
}
headers := map[string]string{
"Origin": "https://auth.openai.com",
"Referer": "https://auth.openai.com/log-in",
"Content-Type": "application/json",
}
// 访问授权页面并手动跟随重定向
c.logStep(StepNavigate, "访问授权页面...")
resp, _, err := c.doRequest("GET", authURL, nil, headers)
if err != nil {
c.logError(StepNavigate, "访问授权页失败: %v", err)
return "", fmt.Errorf("访问授权页失败: %v", err)
}
// 手动跟随重定向
currentURL := authURL
for resp.StatusCode >= 300 && resp.StatusCode < 400 {
location := resp.Header.Get("Location")
if location == "" {
break
}
if !strings.HasPrefix(location, "http") {
parsedURL, _ := url.Parse(currentURL)
location = parsedURL.Scheme + "://" + parsedURL.Host + location
}
currentURL = location
resp, _, err = c.doRequest("GET", currentURL, nil, headers)
if err != nil {
break
}
}
headers["Referer"] = currentURL
// 提交邮箱
c.logStep(StepInputEmail, "提交邮箱: %s", c.email)
if !c.callSentinelReq("login_web_init") {
return "", fmt.Errorf("Sentinel 请求失败")
}
authHeaders := make(map[string]string)
for k, v := range headers {
authHeaders[k] = v
}
authHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("authorize_continue")
emailPayload := map[string]interface{}{
"username": map[string]string{
"kind": "email",
"value": c.email,
},
}
resp, body, err := c.doRequest("POST", "https://auth.openai.com/api/accounts/authorize/continue", emailPayload, authHeaders)
if err != nil || resp.StatusCode != 200 {
c.logError(StepInputEmail, "提交邮箱失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
return "", fmt.Errorf("提交邮箱失败: %d", resp.StatusCode)
}
var data map[string]interface{}
json.Unmarshal(body, &data)
// 检查是否需要密码验证
pageType := ""
if page, ok := data["page"].(map[string]interface{}); ok {
if pt, ok := page["type"].(string); ok {
pageType = pt
}
}
c.logStep(StepInputPassword, "验证密码...")
if pageType == "password" || strings.Contains(string(body), "password") {
// 5. 验证密码
if !c.callSentinelReq("authorize_continue__auto") {
return "", fmt.Errorf("Sentinel 请求失败")
}
verifyHeaders := make(map[string]string)
for k, v := range headers {
verifyHeaders[k] = v
}
verifyHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("password_verify")
passwordPayload := map[string]string{
"password": c.password,
}
resp, body, err = c.doRequest("POST", "https://auth.openai.com/api/accounts/password/verify", passwordPayload, verifyHeaders)
if err != nil || resp.StatusCode != 200 {
c.logError(StepInputPassword, "密码验证失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
return "", fmt.Errorf("密码验证失败: %d", resp.StatusCode)
}
c.logStep(StepInputPassword, "密码验证成功")
// 解析密码验证响应
json.Unmarshal(body, &data)
// 检查下一步是什么
nextPageType := ""
if page, ok := data["page"].(map[string]interface{}); ok {
if pt, ok := page["type"].(string); ok {
nextPageType = pt
}
}
// 如果需要邮箱验证,尝试获取验证码并完成验证
if nextPageType == "email_otp_verification" {
c.logStep(StepInputPassword, "账号需要邮箱验证,正在获取验证码...")
// 等待获取验证码 (最多60秒)
// 邮件标题格式: "Your ChatGPT code is 016547"
otpCode, err := mail.GetVerificationCode(c.email, 60*time.Second)
if err != nil {
c.logError(StepInputPassword, "获取验证码失败: %v", err)
return "", fmt.Errorf("获取验证码失败: %v", err)
}
c.logStep(StepInputPassword, "获取到验证码: %s", otpCode)
// 提交验证码到 /api/accounts/email-otp/validate
// 先获取 Sentinel token (可能需要 PoW)
if !c.callSentinelReq("email_otp_verification__auto") {
// 如果失败,尝试 password_verify__auto
if !c.callSentinelReq("password_verify__auto") {
return "", fmt.Errorf("Sentinel 请求失败")
}
}
verifyOtpHeaders := make(map[string]string)
for k, v := range headers {
verifyOtpHeaders[k] = v
}
verifyOtpHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("email_otp_validate")
// 请求体格式: {"code":"016547"}
otpPayload := map[string]string{
"code": otpCode,
}
resp, body, err = c.doRequest("POST", "https://auth.openai.com/api/accounts/email-otp/validate", otpPayload, verifyOtpHeaders)
if err != nil || resp.StatusCode != 200 {
c.logError(StepInputPassword, "验证码验证失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
return "", fmt.Errorf("验证码验证失败: %d", resp.StatusCode)
}
c.logStep(StepInputPassword, "邮箱验证成功")
// 重新解析响应,检查下一步
json.Unmarshal(body, &data)
}
} else {
c.logStep(StepInputPassword, "跳过密码验证步骤 (服务器未要求)")
}
// 6. 选择工作区
c.logStep(StepSelectWorkspace, "选择工作区: %s", c.workspaceID)
// 根据前面的流程选择正确的 Sentinel 请求
if !c.callSentinelReq("email_otp_validate__auto") {
// 如果 email_otp_validate__auto 失败,尝试 password_verify__auto
if !c.callSentinelReq("password_verify__auto") {
return "", fmt.Errorf("Sentinel 请求失败")
}
}
// 选择工作区时带上 Sentinel Header
workspaceHeaders := make(map[string]string)
for k, v := range headers {
workspaceHeaders[k] = v
}
workspaceHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("workspace_select")
workspacePayload := map[string]string{
"workspace_id": c.workspaceID,
}
resp, body, err = c.doRequest("POST", "https://auth.openai.com/api/accounts/workspace/select", workspacePayload, workspaceHeaders)
if err != nil || resp.StatusCode != 200 {
c.logError(StepSelectWorkspace, "选择工作区失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
return "", fmt.Errorf("选择工作区失败: %d", resp.StatusCode)
}
json.Unmarshal(body, &data)
continueURL, ok := data["continue_url"].(string)
if !ok || continueURL == "" {
c.logError(StepSelectWorkspace, "未获取到 continue_url, 响应: %s", string(body[:min(500, len(body))]))
return "", fmt.Errorf("未获取到 continue_url")
}
// 7. 跟随重定向获取授权码
c.logStep(StepWaitCallback, "跟随重定向...")
for i := 0; i < 10; i++ {
resp, _, err = c.doRequest("GET", continueURL, nil, headers)
if err != nil {
break
}
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
location := resp.Header.Get("Location")
if strings.Contains(location, "localhost:1455") {
code := ExtractCodeFromCallbackURL(location)
if code != "" {
c.logStep(StepComplete, "授权成功,获取到授权码")
return code, nil
}
}
continueURL = location
} else {
break
}
}
c.logError(StepWaitCallback, "未能获取授权码")
return "", fmt.Errorf("未能获取授权码")
}
// ExchangeCodeForTokens 用授权码换取 tokens
func (c *CodexAPIAuth) ExchangeCodeForTokens(code, codeVerifier string) (*CodexTokens, error) {
payload := map[string]string{
"grant_type": "authorization_code",
"client_id": CodexClientID,
"code_verifier": codeVerifier,
"code": code,
"redirect_uri": CodexRedirectURI,
}
headers := map[string]string{"Content-Type": "application/json"}
resp, body, err := c.doRequest("POST", "https://auth.openai.com/oauth/token", payload, headers)
if err != nil {
return nil, fmt.Errorf("token 交换失败: %v", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("token 交换失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
}
var tokens CodexTokens
if err := json.Unmarshal(body, &tokens); err != nil {
return nil, fmt.Errorf("解析 token 失败: %v", err)
}
if tokens.ExpiresIn > 0 {
expiredAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
tokens.ExpiredAt = expiredAt.Format(time.RFC3339)
}
return &tokens, nil
}
// min 返回较小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
// CompleteWithCodexAPI 使用纯 API 方式完成授权
// authURL 和 sessionID 由 S2A 生成
func CompleteWithCodexAPI(email, password, workspaceID, authURL, sessionID, proxy string, logger *AuthLogger) (string, error) {
if logger != nil {
if proxy != "" {
logger.LogStep(StepBrowserStart, "使用 CodexAuth API 模式 (代理: %s)", proxy)
} else {
logger.LogStep(StepBrowserStart, "使用 CodexAuth API 模式 (无代理)")
}
}
auth, err := NewCodexAPIAuth(email, password, workspaceID, authURL, sessionID, proxy, logger)
if err != nil {
if logger != nil {
logger.LogError(StepBrowserStart, "创建 CodexAuth 失败: %v", err)
}
return "", err
}
code, err := auth.ObtainAuthorizationCode()
if err != nil {
return "", err
}
return code, nil
}