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

378 lines
11 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 (
"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)
}