diff --git a/auth.go b/auth.go index fbb3199..3be2c6b 100644 --- a/auth.go +++ b/auth.go @@ -14,6 +14,7 @@ import ( "math/rand" "net/http" "net/url" + "os" "regexp" "sort" "strings" @@ -37,11 +38,23 @@ func NewAuthenticator(opts ClientOptions) Authenticator { if opts.Domain == "" { opts.Domain = "garmin.com" } + + // Enhanced transport with better TLS settings and compression baseTransport := &http.Transport{ TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, + MinVersion: tls.VersionTLS12, + PreferServerCipherSuites: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + }, }, - Proxy: http.ProxyFromEnvironment, + Proxy: http.ProxyFromEnvironment, + DisableCompression: false, // Enable compression + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, } jar, err := cookiejar.New(nil) @@ -57,6 +70,15 @@ func NewAuthenticator(opts ClientOptions) Authenticator { if len(via) >= 10 { return errors.New("stopped after 10 redirects") } + // Preserve headers during redirects + if len(via) > 0 { + for key, values := range via[0].Header { + if key == "Authorization" || key == "Cookie" { + continue // Let the jar handle cookies + } + req.Header[key] = values + } + } if jar != nil { for _, v := range via { if v.Response != nil { @@ -74,7 +96,7 @@ func NewAuthenticator(opts ClientOptions) Authenticator { client: client, tokenURL: opts.TokenURL, storage: opts.Storage, - userAgent: "GCMv3", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", // More realistic user agent domain: opts.Domain, } @@ -85,7 +107,37 @@ func NewAuthenticator(opts ClientOptions) Authenticator { return auth } +// Enhanced browser headers to bypass Cloudflare +func (a *GarthAuthenticator) getRealisticBrowserHeaders(referer string) http.Header { + headers := http.Header{ + "User-Agent": {a.userAgent}, + "Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"}, + "Accept-Language": {"en-US,en;q=0.9"}, + "Accept-Encoding": {"gzip, deflate, br"}, + "Cache-Control": {"max-age=0"}, + "Sec-Ch-Ua": {`"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"`}, + "Sec-Ch-Ua-Mobile": {"?0"}, + "Sec-Ch-Ua-Platform": {`"Windows"`}, + "Sec-Fetch-Dest": {"document"}, + "Sec-Fetch-Mode": {"navigate"}, + "Sec-Fetch-Site": {"none"}, + "Sec-Fetch-User": {"?1"}, + "Upgrade-Insecure-Requests": {"1"}, + "DNT": {"1"}, + } + + if referer != "" { + headers.Set("Referer", referer) + headers.Set("Sec-Fetch-Site", "same-origin") + } + + return headers +} + func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaToken string) (*Token, error) { + // Add delay to simulate human behavior + time.Sleep(time.Duration(500+rand.Intn(1000)) * time.Millisecond) + // Step 1: Get login ticket (lt) from SSO signin page authToken, tokenType, err := a.getLoginTicket(ctx) if err != nil { @@ -188,7 +240,8 @@ func (a *GarthAuthenticator) authorizeOAuth1Token(ctx context.Context, token *OA return fmt.Errorf("failed to create authorization request: %w", err) } - req.Header = a.getEnhancedBrowserHeaders(authURL) + // Use realistic browser headers + req.Header = a.getRealisticBrowserHeaders("https://connect.garmin.com") resp, err := a.client.Do(req) if err != nil { @@ -364,11 +417,13 @@ func (a *GarthAuthenticator) getLoginTicket(ctx context.Context) (string, string return "", "", fmt.Errorf("failed to create login page request: %w", err) } - req.Header.Set("User-Agent", a.userAgent) - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - req.Header.Set("Referer", fmt.Sprintf("https://sso.%s/sso/signin?id=gauth-widget&embedWidget=true&gauthHost=https://sso.%s/sso", a.domain, a.domain)) - req.Header.Set("Origin", fmt.Sprintf("https://sso.%s", a.domain)) + // Use realistic browser headers with proper referer chain + for key, values := range a.getRealisticBrowserHeaders("") { + req.Header[key] = values + } + + // Add some randomness to the request timing + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) resp, err := a.client.Do(req) if err != nil { @@ -376,6 +431,11 @@ func (a *GarthAuthenticator) getLoginTicket(ctx context.Context) (string, string } defer resp.Body.Close() + if resp.StatusCode == 403 { + // Cloudflare blocked us, try with different headers + return "", "", fmt.Errorf("blocked by Cloudflare - try using different IP or wait before retrying") + } + body, err := io.ReadAll(resp.Body) if err != nil { return "", "", fmt.Errorf("failed to read login page response: %w", err) @@ -416,6 +476,11 @@ func (a *GarthAuthenticator) authenticate(ctx context.Context, username, passwor req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", a.userAgent) + req.Header.Set("Referer", fmt.Sprintf("https://sso.%s/sso/signin", a.domain)) + req.Header.Set("Origin", fmt.Sprintf("https://sso.%s", a.domain)) + + // Add some delay to simulate typing + time.Sleep(time.Duration(800+rand.Intn(400)) * time.Millisecond) resp, err := a.client.Do(req) if err != nil { @@ -466,14 +531,12 @@ func (a *GarthAuthenticator) ExchangeToken(ctx context.Context, token *OAuth1Tok return a.exchangeOAuth1ForOAuth2Token(ctx, token) } -// Removed exchangeTicketForToken method - no longer needed - func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Header { u, _ := url.Parse(referrer) origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host) return http.Header{ - "User-Agent": {"GCMv3"}, + "User-Agent": {a.userAgent}, "Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"}, "Accept-Language": {"en-US,en;q=0.9"}, "Connection": {"keep-alive"}, @@ -490,34 +553,35 @@ func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Hea } func getCSRFToken(html string) (string, string, error) { - ltPatterns := []string{ - `name="lt"\s+value="([^"]+)"`, - `name="lt"\s+type="hidden"\s+value="([^"]+)"`, - `]*name="lt"[^>]*value="([^"]+)"[^>]*>`, + // Extract login ticket (lt) from hidden input field + re := regexp.MustCompile(``) + matches := re.FindStringSubmatch(html) + if len(matches) > 1 { + return matches[1], "lt", nil } - for _, pattern := range ltPatterns { - re := regexp.MustCompile(pattern) - matches := re.FindStringSubmatch(html) - if len(matches) > 1 { - return matches[1], "lt", nil - } + // Extract CSRF token as fallback + re = regexp.MustCompile(``) + matches = re.FindStringSubmatch(html) + if len(matches) > 1 { + return matches[1], "_csrf", nil } - csrfPatterns := []string{ - `name="_csrf"\s+value="([^"]+)"`, - `"csrfToken":"([^"]+)"`, + // Try alternative CSRF token pattern + re = regexp.MustCompile(`"csrfToken":"([^"]+)"`) + matches = re.FindStringSubmatch(html) + if len(matches) > 1 { + return matches[1], "_csrf", nil } - for _, pattern := range csrfPatterns { - re := regexp.MustCompile(pattern) - matches := re.FindStringSubmatch(html) - if len(matches) > 1 { - return matches[1], "_csrf", nil - } + // If we get here, we didn't find a token + // Log and save the response for debugging + log.Printf("Failed to find authentication token in HTML response") + debugFilename := fmt.Sprintf("debug/oauth1_response_%d.html", time.Now().UnixNano()) + if err := os.WriteFile(debugFilename, []byte(html), 0644); err != nil { + log.Printf("Failed to write debug file: %v", err) } - - return "", "", errors.New("no authentication token found") + return "", "", fmt.Errorf("no authentication token found in HTML response; response written to %s", debugFilename) } // RefreshToken implements token refresh functionality @@ -592,7 +656,7 @@ func (a *GarthAuthenticator) handleMFA(ctx context.Context, username, password, req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "GCMv3") + req.Header.Set("User-Agent", a.userAgent) resp, err := a.client.Do(req) if err != nil { diff --git a/cloudflare.go b/cloudflare.go new file mode 100644 index 0000000..7a3d755 --- /dev/null +++ b/cloudflare.go @@ -0,0 +1,157 @@ +package garth + +import ( + "context" + "fmt" + "io" + "math/rand" + "net/http" + "time" +) + +// CloudflareBypass provides methods to handle Cloudflare protection +type CloudflareBypass struct { + client *http.Client + userAgent string + maxRetries int +} + +// NewCloudflareBypass creates a new CloudflareBypass instance +func NewCloudflareBypass(client *http.Client) *CloudflareBypass { + return &CloudflareBypass{ + client: client, + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + maxRetries: 3, + } +} + +// MakeRequest performs a request with Cloudflare bypass techniques +func (cf *CloudflareBypass) MakeRequest(ctx context.Context, req *http.Request) (*http.Response, error) { + var resp *http.Response + var err error + + for attempt := 0; attempt < cf.maxRetries; attempt++ { + // Clone the request to avoid modifying the original + clonedReq := cf.cloneRequest(req) + + // Apply bypass headers + cf.applyBypassHeaders(clonedReq) + + // Add random delay to simulate human behavior + if attempt > 0 { + delay := time.Duration(1000+rand.Intn(2000)) * time.Millisecond + time.Sleep(delay) + } + + resp, err = cf.client.Do(clonedReq) + if err != nil { + continue + } + + // Check if we got blocked by Cloudflare + if cf.isCloudflareBlocked(resp) { + resp.Body.Close() + if attempt < cf.maxRetries-1 { + // Try different user agent on retry + cf.rotateUserAgent() + continue + } + return nil, fmt.Errorf("blocked by Cloudflare after %d attempts", cf.maxRetries) + } + + return resp, nil + } + + return nil, err +} + +// applyBypassHeaders adds headers to bypass Cloudflare +func (cf *CloudflareBypass) applyBypassHeaders(req *http.Request) { + req.Header.Set("User-Agent", cf.userAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + req.Header.Set("Cache-Control", "max-age=0") + req.Header.Set("Sec-Ch-Ua", `"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"`) + req.Header.Set("Sec-Ch-Ua-Mobile", "?0") + req.Header.Set("Sec-Ch-Ua-Platform", `"Windows"`) + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-User", "?1") + req.Header.Set("Upgrade-Insecure-Requests", "1") + req.Header.Set("DNT", "1") + + // Add some randomized headers + if rand.Float32() < 0.7 { + req.Header.Set("Pragma", "no-cache") + } + if rand.Float32() < 0.5 { + req.Header.Set("Connection", "keep-alive") + } +} + +// isCloudflareBlocked checks if the response indicates Cloudflare blocking +func (cf *CloudflareBypass) isCloudflareBlocked(resp *http.Response) bool { + if resp.StatusCode == 403 { + // Check for Cloudflare-specific headers or content + if resp.Header.Get("Server") == "cloudflare" { + return true + } + if resp.Header.Get("CF-Ray") != "" { + return true + } + + // Check response body for Cloudflare indicators + if resp.ContentLength > 0 && resp.ContentLength < 50000 { + body, err := io.ReadAll(resp.Body) + if err == nil { + bodyStr := string(body) + if contains(bodyStr, "cloudflare") || contains(bodyStr, "Attention Required") { + return true + } + } + } + } + return false +} + +// rotateUserAgent changes the user agent for retry attempts +func (cf *CloudflareBypass) rotateUserAgent() { + userAgents := []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", + } + cf.userAgent = userAgents[rand.Intn(len(userAgents))] +} + +// cloneRequest creates a copy of the HTTP request +func (cf *CloudflareBypass) cloneRequest(req *http.Request) *http.Request { + cloned := req.Clone(req.Context()) + if cloned.Header == nil { + cloned.Header = make(http.Header) + } + return cloned +} + +// contains is a case-insensitive string contains check +func contains(s, substr string) bool { + return len(s) >= len(substr) && + (s == substr || + len(s) > len(substr) && + (s[:len(substr)] == substr || + s[len(s)-len(substr):] == substr || + containsAt(s, substr))) +} + +func containsAt(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/debug/oauth1_response_1757093254543542353.html b/debug/oauth1_response_1757093254543542353.html new file mode 100644 index 0000000..5154463 Binary files /dev/null and b/debug/oauth1_response_1757093254543542353.html differ diff --git a/debug/oauth1_response_1757093255905112264.html b/debug/oauth1_response_1757093255905112264.html new file mode 100644 index 0000000..ee53a4a Binary files /dev/null and b/debug/oauth1_response_1757093255905112264.html differ diff --git a/debug/oauth1_response_1757093301799755076.html b/debug/oauth1_response_1757093301799755076.html new file mode 100644 index 0000000..561e3d9 Binary files /dev/null and b/debug/oauth1_response_1757093301799755076.html differ