mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-01-25 16:42:32 +00:00
296 lines
8.6 KiB
Go
296 lines
8.6 KiB
Go
package garth
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
)
|
|
|
|
// Session represents the authentication session with OAuth1 and OAuth2 tokens
|
|
type Session struct {
|
|
OAuth1Token string `json:"oauth1_token"`
|
|
OAuth1Secret string `json:"oauth1_secret"`
|
|
OAuth2Token string `json:"oauth2_token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// GarthAuthenticator handles Garmin Connect authentication
|
|
type GarthAuthenticator struct {
|
|
HTTPClient *resty.Client
|
|
BaseURL string
|
|
SessionPath string
|
|
MFAPrompter MFAPrompter
|
|
}
|
|
|
|
// NewAuthenticator creates a new authenticator instance
|
|
func NewAuthenticator(baseURL, sessionPath string) *GarthAuthenticator {
|
|
client := resty.New()
|
|
|
|
return &GarthAuthenticator{
|
|
HTTPClient: client,
|
|
BaseURL: baseURL,
|
|
SessionPath: sessionPath,
|
|
MFAPrompter: DefaultConsolePrompter{},
|
|
}
|
|
}
|
|
|
|
// setCloudflareHeaders adds headers required to bypass Cloudflare protection
|
|
func (g *GarthAuthenticator) setCloudflareHeaders() {
|
|
g.HTTPClient.SetHeader("Accept", "application/json")
|
|
g.HTTPClient.SetHeader("User-Agent", "garmin-connect-client")
|
|
}
|
|
|
|
// Login authenticates with Garmin Connect using username and password
|
|
func (g *GarthAuthenticator) Login(username, password string) (*Session, error) {
|
|
g.setCloudflareHeaders()
|
|
|
|
// Step 1: Get request token
|
|
requestToken, requestSecret, err := g.getRequestToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get request token: %w", err)
|
|
}
|
|
|
|
// Step 2: Authenticate with username/password to get verifier
|
|
verifier, err := g.authenticate(username, password, requestToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("authentication failed: %w", err)
|
|
}
|
|
|
|
// Step 3: Exchange request token for access token
|
|
oauth1Token, oauth1Secret, err := g.getAccessToken(requestToken, requestSecret, verifier)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
}
|
|
|
|
// Step 4: Exchange OAuth1 token for OAuth2 token
|
|
oauth2Token, err := g.getOAuth2Token(oauth1Token, oauth1Secret)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get OAuth2 token: %w", err)
|
|
}
|
|
|
|
session := &Session{
|
|
OAuth1Token: oauth1Token,
|
|
OAuth1Secret: oauth1Secret,
|
|
OAuth2Token: oauth2Token,
|
|
ExpiresAt: time.Now().Add(8 * time.Hour), // Tokens typically expire in 8 hours
|
|
}
|
|
|
|
// Save session if path is provided
|
|
if g.SessionPath != "" {
|
|
if err := session.Save(g.SessionPath); err != nil {
|
|
return session, fmt.Errorf("failed to save session: %w", err)
|
|
}
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// getRequestToken obtains OAuth1 request token
|
|
func (g *GarthAuthenticator) getRequestToken() (token, secret string, err error) {
|
|
resp, err := g.HTTPClient.R().
|
|
SetHeader("Accept", "text/html").
|
|
Post(g.BaseURL + "/oauth-service/oauth/request_token")
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("request token request failed: %w", err)
|
|
}
|
|
|
|
// Parse token and secret from response body
|
|
values, err := url.ParseQuery(resp.String())
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to parse request token response: %w", err)
|
|
}
|
|
|
|
token = values.Get("oauth_token")
|
|
secret = values.Get("oauth_token_secret")
|
|
if token == "" || secret == "" {
|
|
return "", "", errors.New("request token response missing oauth_token or oauth_token_secret")
|
|
}
|
|
return token, secret, nil
|
|
}
|
|
|
|
// authenticate handles username/password authentication and MFA
|
|
func (g *GarthAuthenticator) authenticate(username, password, requestToken string) (verifier string, err error) {
|
|
// Step 1: Submit credentials
|
|
loginResp, err := g.HTTPClient.R().
|
|
SetFormData(map[string]string{
|
|
"username": username,
|
|
"password": password,
|
|
"embed": "false",
|
|
"_eventId": "submit",
|
|
"displayName": "Service",
|
|
}).
|
|
SetQueryParam("ticket", requestToken).
|
|
Post(g.BaseURL + "/sso/signin")
|
|
if err != nil {
|
|
return "", fmt.Errorf("login request failed: %w", err)
|
|
}
|
|
|
|
// Step 2: Check for MFA requirement
|
|
if strings.Contains(loginResp.String(), "mfa-required") {
|
|
// Extract MFA context from HTML
|
|
mfaContext := ""
|
|
if re := regexp.MustCompile(`name="mfaContext" value="([^"]+)"`); re.Match(loginResp.Body()) {
|
|
matches := re.FindStringSubmatch(string(loginResp.Body()))
|
|
if len(matches) > 1 {
|
|
mfaContext = matches[1]
|
|
}
|
|
}
|
|
|
|
if mfaContext == "" {
|
|
return "", errors.New("MFA required but no context found")
|
|
}
|
|
|
|
// Step 3: Prompt for MFA code
|
|
mfaCode, err := g.MFAPrompter.GetMFACode(context.Background())
|
|
if err != nil {
|
|
return "", fmt.Errorf("MFA prompt failed: %w", err)
|
|
}
|
|
|
|
// Step 4: Submit MFA code
|
|
mfaResp, err := g.HTTPClient.R().
|
|
SetFormData(map[string]string{
|
|
"mfaContext": mfaContext,
|
|
"code": mfaCode,
|
|
"verify": "Verify",
|
|
"embed": "false",
|
|
}).
|
|
Post(g.BaseURL + "/sso/verifyMFA")
|
|
if err != nil {
|
|
return "", fmt.Errorf("MFA submission failed: %w", err)
|
|
}
|
|
|
|
// Step 5: Extract verifier from response
|
|
return extractVerifierFromResponse(mfaResp.String())
|
|
}
|
|
|
|
// Step 3: Extract verifier from response
|
|
return extractVerifierFromResponse(loginResp.String())
|
|
}
|
|
|
|
// extractVerifierFromResponse parses verifier from HTML response
|
|
func extractVerifierFromResponse(html string) (string, error) {
|
|
// Parse verifier from HTML
|
|
if re := regexp.MustCompile(`name="oauth_verifier" value="([^"]+)"`); re.MatchString(html) {
|
|
matches := re.FindStringSubmatch(html)
|
|
if len(matches) > 1 {
|
|
return matches[1], nil
|
|
}
|
|
}
|
|
return "", errors.New("verifier not found in response")
|
|
}
|
|
|
|
// MFAPrompter defines interface for getting MFA codes
|
|
type MFAPrompter interface {
|
|
GetMFACode(ctx context.Context) (string, error)
|
|
}
|
|
|
|
// DefaultConsolePrompter is the default console-based MFA prompter
|
|
type DefaultConsolePrompter struct{}
|
|
|
|
// GetMFACode prompts user for MFA code via console
|
|
func (d DefaultConsolePrompter) GetMFACode(ctx context.Context) (string, error) {
|
|
fmt.Print("Enter Garmin MFA code: ")
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
if scanner.Scan() {
|
|
return scanner.Text(), nil
|
|
}
|
|
return "", scanner.Err()
|
|
}
|
|
|
|
// getAccessToken exchanges request token for access token
|
|
func (g *GarthAuthenticator) getAccessToken(token, secret, verifier string) (accessToken, accessSecret string, err error) {
|
|
resp, err := g.HTTPClient.R().
|
|
SetQueryParam("oauth_token", token).
|
|
SetQueryParam("oauth_verifier", verifier).
|
|
Post(g.BaseURL + "/oauth-service/oauth/access_token")
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("access token request failed: %w", err)
|
|
}
|
|
|
|
values, err := url.ParseQuery(resp.String())
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to parse access token response: %w", err)
|
|
}
|
|
|
|
accessToken = values.Get("oauth_token")
|
|
accessSecret = values.Get("oauth_token_secret")
|
|
if accessToken == "" || accessSecret == "" {
|
|
return "", "", errors.New("access token response missing oauth_token or oauth_token_secret")
|
|
}
|
|
return accessToken, accessSecret, nil
|
|
}
|
|
|
|
// getOAuth2Token exchanges OAuth1 token for OAuth2 token
|
|
func (g *GarthAuthenticator) getOAuth2Token(token, secret string) (oauth2Token string, err error) {
|
|
resp, err := g.HTTPClient.R().
|
|
SetFormData(map[string]string{
|
|
"token": token,
|
|
"token_secret": secret,
|
|
}).
|
|
Post(g.BaseURL + "/oauth-service/oauth/exchange/user/2.0")
|
|
if err != nil {
|
|
return "", fmt.Errorf("OAuth2 token exchange failed: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode() != 200 {
|
|
return "", fmt.Errorf("OAuth2 token exchange failed with status %d", resp.StatusCode())
|
|
}
|
|
|
|
return strings.TrimSpace(resp.String()), nil
|
|
}
|
|
|
|
// RefreshToken refreshes the OAuth2 token using the stored OAuth1 tokens
|
|
func (g *GarthAuthenticator) RefreshToken(oauth1Token, oauth1Secret string) (string, error) {
|
|
return g.getOAuth2Token(oauth1Token, oauth1Secret)
|
|
}
|
|
|
|
// Save persists the session to the specified path
|
|
func (s *Session) Save(path string) error {
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal session: %w", err)
|
|
}
|
|
|
|
// Ensure directory exists
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create session directory: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
return fmt.Errorf("failed to write session file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsExpired checks if the session is expired
|
|
func (s *Session) IsExpired() bool {
|
|
return time.Now().After(s.ExpiresAt)
|
|
}
|
|
|
|
// LoadSession reads a session from the specified path
|
|
func LoadSession(path string) (*Session, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read session file: %w", err)
|
|
}
|
|
|
|
var session Session
|
|
if err := json.Unmarshal(data, &session); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal session data: %w", err)
|
|
}
|
|
|
|
return &session, nil
|
|
}
|