mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-02-02 12:31:51 +00:00
sync
This commit is contained in:
92
internal/auth/auth.go
Normal file
92
internal/auth/auth.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/dghubble/oauth1"
|
||||
)
|
||||
|
||||
// OAuthConfig holds OAuth1 configuration for Garmin Connect
|
||||
type OAuthConfig struct {
|
||||
ConsumerKey string
|
||||
ConsumerSecret string
|
||||
}
|
||||
|
||||
// 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!"))
|
||||
}
|
||||
59
internal/auth/filestorage.go
Normal file
59
internal/auth/filestorage.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"github.com/dghubble/oauth1"
|
||||
)
|
||||
|
||||
// FileStorage implements TokenStorage using a JSON file
|
||||
type FileStorage struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// NewFileStorage creates a new FileStorage instance
|
||||
func NewFileStorage() *FileStorage {
|
||||
// Default to storing token in user's home directory
|
||||
home, _ := os.UserHomeDir()
|
||||
return &FileStorage{
|
||||
Path: filepath.Join(home, ".garminconnect", "token.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken retrieves token from file
|
||||
func (s *FileStorage) GetToken() (*oauth1.Token, error) {
|
||||
data, err := os.ReadFile(s.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var token oauth1.Token
|
||||
err = json.Unmarshal(data, &token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if token.Token == "" || token.TokenSecret == "" {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// SaveToken saves token to file
|
||||
func (s *FileStorage) SaveToken(token *oauth1.Token) error {
|
||||
// Create directory if it doesn't exist
|
||||
dir := filepath.Dir(s.Path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(token, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(s.Path, data, 0600)
|
||||
}
|
||||
85
internal/auth/filestorage_test.go
Normal file
85
internal/auth/filestorage_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dghubble/oauth1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFileStorage(t *testing.T) {
|
||||
// Create temp directory for tests
|
||||
tempDir, err := os.MkdirTemp("", "garmin-test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage := &FileStorage{
|
||||
Path: filepath.Join(tempDir, "token.json"),
|
||||
}
|
||||
|
||||
// Test saving and loading token
|
||||
t.Run("SaveAndLoadToken", func(t *testing.T) {
|
||||
testToken := &oauth1.Token{
|
||||
Token: "access-token",
|
||||
TokenSecret: "access-secret",
|
||||
}
|
||||
|
||||
// Save token
|
||||
err := storage.SaveToken(testToken)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Load token
|
||||
loadedToken, err := storage.GetToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testToken.Token, loadedToken.Token)
|
||||
assert.Equal(t, testToken.TokenSecret, loadedToken.TokenSecret)
|
||||
})
|
||||
|
||||
// Test missing token file
|
||||
t.Run("TokenMissing", func(t *testing.T) {
|
||||
_, err := storage.GetToken()
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
|
||||
// Test token expiration
|
||||
t.Run("TokenExpiration", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
token *oauth1.Token
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "EmptyToken",
|
||||
token: &oauth1.Token{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ValidToken",
|
||||
token: &oauth1.Token{
|
||||
Token: "valid",
|
||||
TokenSecret: "valid",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ExpiredToken",
|
||||
token: &oauth1.Token{
|
||||
Token: "expired",
|
||||
TokenSecret: "expired",
|
||||
CreatedAt: time.Now().Add(-200 * 24 * time.Hour), // 200 days ago
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
expired := storage.TokenExpired(tc.token)
|
||||
assert.Equal(t, tc.expected, expired)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
42
internal/auth/mfa.go
Normal file
42
internal/auth/mfa.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MFAHandler handles multi-factor authentication
|
||||
func MFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
// Show MFA form
|
||||
fmt.Fprintf(w, `<html>
|
||||
<body>
|
||||
<form method="POST">
|
||||
<label>MFA Code: <input type="text" name="mfa_code"></label>
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`)
|
||||
case "POST":
|
||||
// Process MFA code
|
||||
code := r.FormValue("mfa_code")
|
||||
// Validate MFA code - in a real app, this would be sent to Garmin
|
||||
if len(code) != 6 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Invalid MFA code format. Please enter a 6-digit code."))
|
||||
return
|
||||
}
|
||||
|
||||
// Store MFA verification status in session
|
||||
// In a real app, we'd store this in a session store
|
||||
w.Write([]byte("MFA verification successful! Please return to your application."))
|
||||
}
|
||||
}
|
||||
|
||||
// RequiresMFA checks if MFA is required based on Garmin response
|
||||
func RequiresMFA(err error) bool {
|
||||
// In a real implementation, we'd check the error type
|
||||
// or response from Garmin to determine if MFA is needed
|
||||
return err != nil && err.Error() == "mfa_required"
|
||||
}
|
||||
Reference in New Issue
Block a user