mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-02-15 03:41:35 +00:00
sync
This commit is contained in:
@@ -1,92 +1,148 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/dghubble/oauth1"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthConfig holds OAuth1 configuration for Garmin Connect
|
||||
type OAuthConfig struct {
|
||||
ConsumerKey string
|
||||
ConsumerSecret string
|
||||
// Authenticate handles Garmin Connect authentication with MFA support
|
||||
func (c *AuthClient) Authenticate(ctx context.Context, username, password, mfaToken string) (*Token, error) {
|
||||
// Create login form data
|
||||
data := url.Values{}
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
data.Set("embed", "false")
|
||||
data.Set("rememberme", "on")
|
||||
|
||||
// Create login request
|
||||
loginURL := fmt.Sprintf("%s%s", c.BaseURL, c.LoginPath)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create login request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)")
|
||||
|
||||
// Send login request
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check if MFA is required
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// Parse MFA response
|
||||
var mfaResponse struct {
|
||||
MFAToken string `json:"mfaToken"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mfaResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse MFA response: %w", err)
|
||||
}
|
||||
|
||||
// Validate MFA token
|
||||
if mfaToken == "" {
|
||||
return nil, errors.New("MFA required but no token provided")
|
||||
}
|
||||
|
||||
// Create MFA verification request
|
||||
mfaData := url.Values{}
|
||||
mfaData.Set("token", mfaResponse.MFAToken)
|
||||
mfaData.Set("rememberme", "on")
|
||||
mfaData.Set("mfaCode", mfaToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(mfaData.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MFA request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)")
|
||||
|
||||
// Send MFA request
|
||||
resp, err = c.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MFA request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// Handle non-200 responses
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("authentication failed: %d\n%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Parse response cookies to get tokens
|
||||
var token Token
|
||||
cookies := resp.Cookies()
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "access_token" {
|
||||
token.AccessToken = cookie.Value
|
||||
} else if cookie.Name == "refresh_token" {
|
||||
token.RefreshToken = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tokens
|
||||
if token.AccessToken == "" || token.RefreshToken == "" {
|
||||
return nil, errors.New("tokens not found in authentication response")
|
||||
}
|
||||
|
||||
// Set expiration time
|
||||
token.Expiry = time.Now().Add(time.Duration(3600) * time.Second)
|
||||
token.ExpiresIn = 3600
|
||||
token.TokenType = "Bearer"
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// TokenStorage defines the interface for storing and retrieving OAuth tokens
|
||||
type TokenStorage interface {
|
||||
GetToken() (*oauth1.Token, error)
|
||||
SaveToken(*oauth1.Token) error
|
||||
}
|
||||
|
||||
// Authenticate initiates the OAuth1 authentication flow
|
||||
func Authenticate(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage) {
|
||||
// Create OAuth1 config
|
||||
oauthConfig := oauth1.Config{
|
||||
ConsumerKey: config.ConsumerKey,
|
||||
ConsumerSecret: config.ConsumerSecret,
|
||||
CallbackURL: "http://localhost:8080/callback",
|
||||
Endpoint: oauth1.Endpoint{
|
||||
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
|
||||
AuthorizeURL: "https://connect.garmin.com/oauth-service/oauth/authorize",
|
||||
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
|
||||
},
|
||||
}
|
||||
|
||||
// Get request token
|
||||
requestToken, _, err := oauthConfig.RequestToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get request token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save request token secret temporarily (for callback)
|
||||
// In a real application, you'd store this in a session
|
||||
|
||||
// Redirect to authorization URL
|
||||
authURL, err := oauthConfig.AuthorizationURL(requestToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get authorization URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, authURL.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// Callback handles OAuth1 callback
|
||||
func Callback(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage, requestSecret string) {
|
||||
// Get request token and verifier from query params
|
||||
requestToken := r.URL.Query().Get("oauth_token")
|
||||
verifier := r.URL.Query().Get("oauth_verifier")
|
||||
|
||||
// Create OAuth1 config
|
||||
oauthConfig := oauth1.Config{
|
||||
ConsumerKey: config.ConsumerKey,
|
||||
ConsumerSecret: config.ConsumerSecret,
|
||||
Endpoint: oauth1.Endpoint{
|
||||
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
|
||||
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
|
||||
},
|
||||
}
|
||||
|
||||
// Get access token
|
||||
accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, requestSecret, verifier)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get access token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create token and save
|
||||
token := &oauth1.Token{
|
||||
Token: accessToken,
|
||||
TokenSecret: accessSecret,
|
||||
}
|
||||
|
||||
err = storage.SaveToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Authentication successful!"))
|
||||
// RefreshToken exchanges a refresh token for a new access token
|
||||
func (c *AuthClient) RefreshToken(ctx context.Context, token *Token) (*Token, error) {
|
||||
// Create token refresh data
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", token.RefreshToken)
|
||||
|
||||
// Create refresh token request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.TokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)")
|
||||
|
||||
// Send refresh token request
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token refresh failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle non-200 responses
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token refresh failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse token response
|
||||
var newToken Token
|
||||
if err := json.NewDecoder(resp.Body).Decode(&newToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
// Validate token
|
||||
if newToken.AccessToken == "" || newToken.RefreshToken == "" {
|
||||
return nil, errors.New("token response missing required fields")
|
||||
}
|
||||
|
||||
// Set expiration time
|
||||
newToken.Expiry = time.Now().Add(time.Duration(newToken.ExpiresIn) * time.Second)
|
||||
|
||||
return &newToken, nil
|
||||
}
|
||||
|
||||
309
internal/auth/auth_test.go
Normal file
309
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestTokenRefresh tests the token refresh functionality
|
||||
func TestTokenRefresh(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockResponse interface{}
|
||||
mockStatus int
|
||||
expectedToken *Token
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "successful token refresh",
|
||||
mockResponse: map[string]interface{}{
|
||||
"access_token": "new-access-token",
|
||||
"refresh_token": "new-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
mockStatus: http.StatusOK,
|
||||
expectedToken: &Token{
|
||||
AccessToken: "new-access-token",
|
||||
RefreshToken: "new-refresh-token",
|
||||
ExpiresIn: 3600,
|
||||
TokenType: "Bearer",
|
||||
Expiry: time.Now().Add(3600 * time.Second),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expired refresh token",
|
||||
mockResponse: map[string]interface{}{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Refresh token expired",
|
||||
},
|
||||
mockStatus: http.StatusBadRequest,
|
||||
expectedError: "token refresh failed with status 400",
|
||||
},
|
||||
{
|
||||
name: "invalid token response",
|
||||
mockResponse: map[string]interface{}{
|
||||
"invalid": "data",
|
||||
},
|
||||
mockStatus: http.StatusOK,
|
||||
expectedError: "token response missing required fields",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tt.mockStatus)
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create auth client
|
||||
client := &AuthClient{
|
||||
Client: &http.Client{},
|
||||
TokenURL: server.URL,
|
||||
}
|
||||
|
||||
// Create token to refresh
|
||||
token := &Token{
|
||||
RefreshToken: "old-refresh-token",
|
||||
}
|
||||
|
||||
// Execute test
|
||||
newToken, err := client.RefreshToken(context.Background(), token)
|
||||
|
||||
// Assert results
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
assert.Nil(t, newToken)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, newToken)
|
||||
assert.Equal(t, tt.expectedToken.AccessToken, newToken.AccessToken)
|
||||
assert.Equal(t, tt.expectedToken.RefreshToken, newToken.RefreshToken)
|
||||
assert.Equal(t, tt.expectedToken.ExpiresIn, newToken.ExpiresIn)
|
||||
assert.WithinDuration(t, tt.expectedToken.Expiry, newToken.Expiry, 5*time.Second)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFAAuthentication tests MFA authentication flow
|
||||
func TestMFAAuthentication(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
mfaToken string
|
||||
mockResponses []mockResponse // Multiple responses for MFA flow
|
||||
expectedToken *Token
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "successful MFA authentication",
|
||||
username: "user@example.com",
|
||||
password: "password123",
|
||||
mfaToken: "123456",
|
||||
mockResponses: []mockResponse{
|
||||
{
|
||||
status: http.StatusUnauthorized,
|
||||
body: map[string]interface{}{
|
||||
"mfaToken": "mfa-challenge-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: http.StatusOK,
|
||||
body: map[string]interface{}{},
|
||||
cookies: map[string]string{
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedToken: &Token{
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresIn: 3600,
|
||||
TokenType: "Bearer",
|
||||
Expiry: time.Now().Add(3600 * time.Second),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid MFA code",
|
||||
username: "user@example.com",
|
||||
password: "password123",
|
||||
mfaToken: "wrong-code",
|
||||
mockResponses: []mockResponse{
|
||||
{
|
||||
status: http.StatusUnauthorized,
|
||||
body: map[string]interface{}{
|
||||
"mfaToken": "mfa-challenge-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: http.StatusUnauthorized,
|
||||
body: map[string]interface{}{
|
||||
"error": "Invalid MFA token",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "authentication failed: 401",
|
||||
},
|
||||
{
|
||||
name: "MFA required but not provided",
|
||||
username: "user@example.com",
|
||||
password: "password123",
|
||||
mfaToken: "",
|
||||
mockResponses: []mockResponse{
|
||||
{
|
||||
status: http.StatusUnauthorized,
|
||||
body: map[string]interface{}{
|
||||
"mfaToken": "mfa-challenge-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "MFA required but no token provided",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server with state
|
||||
currentResponse := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if currentResponse < len(tt.mockResponses) {
|
||||
response := tt.mockResponses[currentResponse]
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Set additional headers if specified
|
||||
for key, value := range response.headers {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
// Set cookies if specified
|
||||
for name, value := range response.cookies {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
w.WriteHeader(response.status)
|
||||
json.NewEncoder(w).Encode(response.body)
|
||||
currentResponse++
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create auth client
|
||||
client := &AuthClient{
|
||||
Client: &http.Client{},
|
||||
BaseURL: server.URL,
|
||||
TokenURL: fmt.Sprintf("%s/oauth/token", server.URL),
|
||||
LoginPath: "/sso/login",
|
||||
}
|
||||
|
||||
// Execute test
|
||||
token, err := client.Authenticate(context.Background(), tt.username, tt.password, tt.mfaToken)
|
||||
|
||||
// Assert results
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
assert.Nil(t, token)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, token)
|
||||
assert.Equal(t, tt.expectedToken.AccessToken, token.AccessToken)
|
||||
assert.Equal(t, tt.expectedToken.RefreshToken, token.RefreshToken)
|
||||
assert.Equal(t, tt.expectedToken.ExpiresIn, token.ExpiresIn)
|
||||
assert.WithinDuration(t, tt.expectedToken.Expiry, token.Expiry, 5*time.Second)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkTokenRefresh measures the performance of token refresh
|
||||
func BenchmarkTokenRefresh(b *testing.B) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "benchmark-access-token",
|
||||
"refresh_token": "benchmark-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create auth client
|
||||
client := &AuthClient{
|
||||
Client: &http.Client{},
|
||||
TokenURL: server.URL,
|
||||
}
|
||||
|
||||
// Create token to refresh
|
||||
token := &Token{
|
||||
RefreshToken: "benchmark-refresh-token",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = client.RefreshToken(context.Background(), token)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMFAAuthentication measures the performance of MFA authentication
|
||||
func BenchmarkMFAAuthentication(b *testing.B) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/sso/login" {
|
||||
// First request returns MFA challenge
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"mfaToken": "mfa-challenge-token",
|
||||
})
|
||||
} else if r.URL.Path == "/oauth/token" {
|
||||
// Second request returns tokens
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "benchmark-access-token",
|
||||
"refresh_token": "benchmark-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create auth client
|
||||
client := &AuthClient{
|
||||
Client: &http.Client{},
|
||||
BaseURL: server.URL,
|
||||
TokenURL: fmt.Sprintf("%s/oauth/token", server.URL),
|
||||
LoginPath: "/sso/login",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = client.Authenticate(context.Background(), "benchmark@example.com", "benchmark-password", "123456")
|
||||
}
|
||||
}
|
||||
|
||||
type mockResponse struct {
|
||||
status int
|
||||
body interface{}
|
||||
headers map[string]string
|
||||
cookies map[string]string
|
||||
}
|
||||
26
internal/auth/client.go
Normal file
26
internal/auth/client.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthClient handles authentication with Garmin Connect
|
||||
type AuthClient struct {
|
||||
BaseURL string
|
||||
LoginPath string
|
||||
TokenURL string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// NewAuthClient creates a new authentication client
|
||||
func NewAuthClient() *AuthClient {
|
||||
return &AuthClient{
|
||||
BaseURL: "https://connect.garmin.com",
|
||||
LoginPath: "/signin",
|
||||
TokenURL: "https://connect.garmin.com/oauth/token",
|
||||
Client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
12
internal/auth/types.go
Normal file
12
internal/auth/types.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// Token represents OAuth2 tokens
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
}
|
||||
Reference in New Issue
Block a user