package auth import ( "fmt" "os" "strings" "time" "codex-pool/internal/proxyutil" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" "github.com/go-rod/stealth" ) // RodAuth 使用 Rod + Stealth 完成 OAuth 授权 type RodAuth struct { browser *rod.Browser headless bool proxy string proxyUser string proxyPass string profile BrowserProfile // 随机浏览器配置 } // getChromiumPath 获取 Chromium 路径 func getChromiumPath() string { // 优先使用环境变量 if path := os.Getenv("CHROME_BIN"); path != "" { if _, err := os.Stat(path); err == nil { return path } } if path := os.Getenv("CHROME_PATH"); path != "" { if _, err := os.Stat(path); err == nil { return path } } // Alpine Linux 默认路径 paths := []string{ "/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", } for _, path := range paths { if _, err := os.Stat(path); err == nil { return path } } return "" // 让 Rod 自动下载 } // NewRodAuth 创建 Rod 授权器 func NewRodAuth(headless bool, proxy string) (*RodAuth, error) { // 获取随机浏览器配置 profile := GetRandomBrowserProfile() var proxyServer string var proxyUser string var proxyPass string if proxy != "" { info, err := proxyutil.Parse(proxy) if err != nil { return nil, fmt.Errorf("代理格式错误: %v", err) } if info.URL != nil { proxy = info.URL.String() // normalized } if info.Server != nil { proxyServer = info.Server.String() } proxyUser = info.Username proxyPass = info.Password } l := launcher.New(). Headless(headless). Set("disable-blink-features", "AutomationControlled"). Set("disable-dev-shm-usage"). Set("no-sandbox"). Set("disable-gpu"). Set("disable-extensions"). Set("disable-background-networking"). Set("disable-sync"). Set("disable-translate"). Set("metrics-recording-only"). Set("no-first-run"). Set("disable-infobars"). Set("disable-automation"). // 使用随机语言和窗口大小 Set("lang", strings.Split(profile.AcceptLang, ",")[0]). Set("window-size", fmt.Sprintf("%d,%d", profile.Width, profile.Height)). // 随机 User-Agent UserDataDir(""). Set("user-agent", profile.UserAgent) // 使用系统 Chromium(如果存在) if chromiumPath := getChromiumPath(); chromiumPath != "" { l = l.Bin(chromiumPath) } if proxyServer != "" { l = l.Proxy(proxyServer) } controlURL, err := l.Launch() if err != nil { return nil, fmt.Errorf("启动浏览器失败: %v", err) } browser := rod.New().ControlURL(controlURL) if err := browser.Connect(); err != nil { return nil, fmt.Errorf("连接浏览器失败: %v", err) } return &RodAuth{ browser: browser, headless: headless, proxy: proxy, proxyUser: proxyUser, proxyPass: proxyPass, profile: profile, }, nil } // Close 关闭浏览器 func (r *RodAuth) Close() { if r.browser != nil { r.browser.Close() } } // CompleteOAuth 完成 OAuth 授权 func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string, error) { return r.CompleteOAuthLogged(authURL, email, password, teamID, nil) } // CompleteOAuthLogged 完成 OAuth 授权(带日志回调) func (r *RodAuth) CompleteOAuthLogged(authURL, email, password, teamID string, logger *AuthLogger) (string, error) { // 日志辅助函数 logStep := func(step AuthStep, format string, args ...interface{}) { if logger != nil { logger.LogStep(step, format, args...) } } logError := func(step AuthStep, format string, args ...interface{}) { if logger != nil { logger.LogError(step, format, args...) } } // Handle proxy auth (407) in headless mode. // When Fetch domain is enabled without patterns, requests will be paused and must be continued. // 只在代理需要认证时才启用 Fetch 域 if r.proxy != "" && r.proxyUser != "" { authBrowser, cancel := r.browser.WithCancel() defer cancel() restoreFetch := authBrowser.EnableDomain("", &proto.FetchEnable{HandleAuthRequests: true}) defer restoreFetch() wait := authBrowser.EachEvent( func(e *proto.FetchRequestPaused) { _ = proto.FetchContinueRequest{RequestID: e.RequestID}.Call(authBrowser) }, func(e *proto.FetchAuthRequired) { resp := &proto.FetchAuthChallengeResponse{ Response: proto.FetchAuthChallengeResponseResponseDefault, } if e.AuthChallenge != nil && e.AuthChallenge.Source == proto.FetchAuthChallengeSourceProxy { if r.proxyUser != "" { resp.Response = proto.FetchAuthChallengeResponseResponseProvideCredentials resp.Username = r.proxyUser resp.Password = r.proxyPass } else { // Fail fast if the proxy requires auth but user didn't provide credentials. resp.Response = proto.FetchAuthChallengeResponseResponseCancelAuth } } _ = proto.FetchContinueWithAuth{RequestID: e.RequestID, AuthChallengeResponse: resp}.Call(authBrowser) }, ) go wait() } page, err := stealth.Page(r.browser) if err != nil { return "", fmt.Errorf("创建页面失败: %v", err) } defer page.Close() // 设置随机窗口大小 _ = page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ Width: r.profile.Width, Height: r.profile.Height, DeviceScaleFactor: r.profile.PixelRatio, Mobile: false, }) // 注入额外的反检测脚本 antiDetectionJS := GetAntiDetectionJS(r.profile) _, _ = page.Evaluate(&rod.EvalOptions{ JS: antiDetectionJS, ByValue: true, AwaitPromise: false, ThisObj: nil, }) // 设置合理的超时时间 60 秒 page = page.Timeout(60 * time.Second) if err := page.Navigate(authURL); err != nil { logError(StepNavigate, "访问授权页失败: %v", err) return "", fmt.Errorf("访问授权URL失败: %v", err) } page.MustWaitDOMStable() // 获取当前URL info, _ := page.Info() currentURL := info.URL logStep(StepNavigate, "页面加载完成 | URL: %s", currentURL) if code := r.checkForCode(page); code != "" { logStep(StepComplete, "授权成功(快速通道)") return code, nil } // 使用10秒超时查找邮箱输入框 emailInput, err := page.Timeout(10 * time.Second).Element("input[name='email'], input[type='email'], input[name='username'], input[id='email'], input[autocomplete='email']") if err != nil { info, _ := page.Info() logError(StepInputEmail, "未找到邮箱输入框 | URL: %s", info.URL) return "", fmt.Errorf("未找到邮箱输入框") } logStep(StepInputEmail, "邮箱已填写") emailInput.MustSelectAllText().MustInput(email) time.Sleep(200 * time.Millisecond) // 点击提交按钮 if btn, _ := page.Timeout(2 * time.Second).Element("button[type='submit'], div._ctas_1alro_13 button, button[name='action']"); btn != nil { btn.MustClick() } // 等待页面跳转(等待 URL 变化或密码框出现,最多 10 秒) passwordSelector := "input[name='current-password'], input[autocomplete='current-password'], input[type='password'], input[name='password'], input[id='password']" var passwordFound bool for i := 0; i < 20; i++ { time.Sleep(500 * time.Millisecond) if code := r.checkForCode(page); code != "" { logStep(StepComplete, "授权成功") return code, nil } info, _ = page.Info() // 检查是否有密码输入框(页面已跳转) if pwdInput, _ := page.Timeout(100 * time.Millisecond).Element(passwordSelector); pwdInput != nil { passwordFound = true break } // 如果 URL 已经变化(包含 password),也跳出 if strings.Contains(info.URL, "password") { break } } // 获取当前URL用于调试 info, _ = page.Info() logStep(StepInputPassword, "查找密码框 | URL: %s", info.URL) // 使用10秒超时查找密码输入框(优先使用 current-password) var passwordInput *rod.Element if passwordFound { passwordInput, err = page.Timeout(2 * time.Second).Element(passwordSelector) } else { passwordInput, err = page.Timeout(10 * time.Second).Element(passwordSelector) } if err != nil { info, _ := page.Info() logError(StepInputPassword, "未找到密码输入框 | URL: %s", info.URL) return "", fmt.Errorf("未找到密码输入框") } logStep(StepInputPassword, "密码已填写") passwordInput.MustSelectAllText().MustInput(password) time.Sleep(200 * time.Millisecond) logStep(StepSubmitPassword, "正在登录...") if btn, _ := page.Timeout(2 * time.Second).Element("button[type='submit'], div._ctas_1alro_13 button, button[name='action']"); btn != nil { btn.MustClick() } // 等待授权回调(最多20秒) for i := 0; i < 40; i++ { time.Sleep(500 * time.Millisecond) if code := r.checkForCode(page); code != "" { logStep(StepComplete, "授权成功") return code, nil } info, _ := page.Info() currentURL := info.URL if strings.Contains(currentURL, "consent") { logStep(StepConsent, "处理授权同意... | URL: %s", currentURL) // 同意页面的确认按钮(第二个按钮) if btn, _ := page.Timeout(500 * time.Millisecond).Element("div._ctas_1alro_13 div:nth-child(2) button, button[type='submit'], div._ctas_1alro_13 button"); btn != nil { btn.Click(proto.InputMouseButtonLeft, 1) } } if strings.Contains(currentURL, "authorize") && teamID != "" { logStep(StepSelectWorkspace, "选择工作区...") wsSelector := fmt.Sprintf("[data-workspace-id='%s'], [data-account-id='%s']", teamID, teamID) if wsBtn, _ := page.Timeout(500 * time.Millisecond).Element(wsSelector); wsBtn != nil { wsBtn.Click(proto.InputMouseButtonLeft, 1) } } } logError(StepWaitCallback, "授权超时") return "", fmt.Errorf("授权超时") } // checkForCode 检查 URL 中是否包含 code func (r *RodAuth) checkForCode(page *rod.Page) string { info, err := page.Info() if err != nil { return "" } if strings.Contains(info.URL, "code=") { return ExtractCodeFromCallbackURL(info.URL) } return "" } // CompleteWithRod 使用 Rod + Stealth 完成 S2A 授权 func CompleteWithRod(authURL, email, password, teamID string, headless bool, proxy string) (string, error) { return CompleteWithRodLogged(authURL, email, password, teamID, headless, proxy, nil) } // CompleteWithRodLogged 使用 Rod + Stealth 完成 S2A 授权(带日志回调) func CompleteWithRodLogged(authURL, email, password, teamID string, headless bool, proxy string, logger *AuthLogger) (string, error) { // 日志辅助函数 logStep := func(step AuthStep, format string, args ...interface{}) { if logger != nil { logger.LogStep(step, format, args...) } } logError := func(step AuthStep, format string, args ...interface{}) { if logger != nil { logger.LogError(step, format, args...) } } logStep(StepBrowserStart, "正在启动 Rod 浏览器...") auth, err := NewRodAuth(headless, proxy) if err != nil { logError(StepBrowserStart, "启动失败: %v", err) return "", err } defer auth.Close() logStep(StepBrowserStart, "浏览器启动成功") return auth.CompleteOAuthLogged(authURL, email, password, teamID, logger) } // CompleteWithBrowser 使用 Rod 完成 S2A 授权 (别名) func CompleteWithBrowser(authURL, email, password, teamID string, headless bool, proxy string) (string, error) { return CompleteWithRod(authURL, email, password, teamID, headless, proxy) }