ring: implement session management and caching

This commit is contained in:
seydx
2025-05-08 17:38:09 +02:00
parent 124556f4db
commit 2eef7bdbd3
+199 -23
View File
@@ -11,9 +11,13 @@ import (
"net/http" "net/http"
"reflect" "reflect"
"strings" "strings"
"sync"
"time" "time"
) )
var clientCache = map[string]*RingRestClient{}
var cacheMutex sync.Mutex
type RefreshTokenAuth struct { type RefreshTokenAuth struct {
RefreshToken string RefreshToken string
} }
@@ -52,17 +56,35 @@ type SocketTicketResponse struct {
ResponseTimestamp int64 `json:"response_timestamp"` ResponseTimestamp int64 `json:"response_timestamp"`
} }
// SessionResponse repesents the response from the session endpoint
type SessionResponse struct {
Profile struct {
ID int64 `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
} `json:"profile"`
}
// RingRestClient handles authentication and requests to Ring API // RingRestClient handles authentication and requests to Ring API
type RingRestClient struct { type RingRestClient struct {
httpClient *http.Client httpClient *http.Client
authConfig *AuthConfig authConfig *AuthConfig
hardwareID string hardwareID string
authToken *AuthTokenResponse authToken *AuthTokenResponse
tokenExpiry time.Time
Using2FA bool Using2FA bool
PromptFor2FA string PromptFor2FA string
RefreshToken string RefreshToken string
auth interface{} // EmailAuth or RefreshTokenAuth auth interface{} // EmailAuth or RefreshTokenAuth
onTokenRefresh func(string) onTokenRefresh func(string)
authMutex sync.Mutex
session *SessionResponse
sessionExpiry time.Time
sessionMutex sync.Mutex
// Cache-Schlüssel für diese Instanz
cacheKey string
} }
// CameraKind represents the different types of Ring cameras // CameraKind represents the different types of Ring cameras
@@ -139,23 +161,50 @@ const (
apiVersion = 11 apiVersion = 11
defaultTimeout = 20 * time.Second defaultTimeout = 20 * time.Second
maxRetries = 3 maxRetries = 3
sessionValidTime = 12 * time.Hour
) )
// NewRingRestClient creates a new Ring client instance // NewRingRestClient creates a new Ring client instance with caching
func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) {
client := &RingRestClient{ var cacheKey string
httpClient: &http.Client{Timeout: defaultTimeout},
onTokenRefresh: onTokenRefresh, // Create cache key based on auth data
hardwareID: generateHardwareID(),
auth: auth,
}
switch a := auth.(type) { switch a := auth.(type) {
case RefreshTokenAuth: case RefreshTokenAuth:
if a.RefreshToken == "" { if a.RefreshToken == "" {
return nil, fmt.Errorf("refresh token is required") return nil, fmt.Errorf("refresh token is required")
} }
cacheKey = "refresh:" + a.RefreshToken
case EmailAuth:
if a.Email == "" || a.Password == "" {
return nil, fmt.Errorf("email and password are required")
}
cacheKey = "email:" + a.Email + ":" + a.Password
default:
return nil, fmt.Errorf("invalid auth type")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
if cachedClient, ok := clientCache[cacheKey]; ok {
// Check if token is not nil and not expired
if cachedClient.authToken != nil && time.Now().Before(cachedClient.tokenExpiry) {
cachedClient.onTokenRefresh = onTokenRefresh
return cachedClient, nil
}
}
client := &RingRestClient{
httpClient: &http.Client{Timeout: defaultTimeout},
onTokenRefresh: onTokenRefresh,
hardwareID: generateHardwareID(),
auth: auth,
cacheKey: cacheKey,
}
switch a := auth.(type) {
case RefreshTokenAuth:
config, err := parseAuthConfig(a.RefreshToken) config, err := parseAuthConfig(a.RefreshToken)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse refresh token: %w", err) return nil, fmt.Errorf("failed to parse refresh token: %w", err)
@@ -164,22 +213,18 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest
client.authConfig = config client.authConfig = config
client.hardwareID = config.HID client.hardwareID = config.HID
client.RefreshToken = a.RefreshToken client.RefreshToken = a.RefreshToken
case EmailAuth:
if a.Email == "" || a.Password == "" {
return nil, fmt.Errorf("email and password are required")
}
default:
return nil, fmt.Errorf("invalid auth type")
} }
clientCache[cacheKey] = client
return client, nil return client, nil
} }
// Request makes an authenticated request to the Ring API // Request makes an authenticated request to the Ring API
func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) {
// Ensure we have a valid auth token // Ensure we have a valid session
if err := c.ensureAuth(); err != nil { if err := c.ensureSession(); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err) return nil, fmt.Errorf("session validation failed: %w", err)
} }
var bodyReader io.Reader var bodyReader io.Reader
@@ -226,17 +271,54 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte,
// Handle 401 by refreshing auth and retrying // Handle 401 by refreshing auth and retrying
if resp.StatusCode == http.StatusUnauthorized { if resp.StatusCode == http.StatusUnauthorized {
c.authToken = nil // Force token refresh // Reset token to force refresh
c.authMutex.Lock()
c.authToken = nil
c.tokenExpiry = time.Time{} // Reset token expiry
c.authMutex.Unlock()
if attempt == maxRetries { if attempt == maxRetries {
return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) return nil, fmt.Errorf("authentication failed after %d retries", maxRetries)
} }
if err := c.ensureAuth(); err != nil {
return nil, fmt.Errorf("failed to refresh authentication: %w", err) // By 401 with Auth AND Session start over
c.sessionMutex.Lock()
c.session = nil
c.sessionExpiry = time.Time{} // Reset session expiry
c.sessionMutex.Unlock()
if err := c.ensureSession(); err != nil {
return nil, fmt.Errorf("failed to refresh session: %w", err)
} }
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
continue continue
} }
// Handle 404 error with hardware_id reference - session issue
if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) {
var errorBody map[string]interface{}
if err := json.Unmarshal(responseBody, &errorBody); err == nil {
if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) {
// Session with hardware_id not found, refresh session
c.sessionMutex.Lock()
c.session = nil
c.sessionExpiry = time.Time{} // Reset session expiry
c.sessionMutex.Unlock()
if attempt == maxRetries {
return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries)
}
if err := c.ensureSession(); err != nil {
return nil, fmt.Errorf("failed to refresh session: %w", err)
}
continue
}
}
}
// Handle other error status codes // Handle other error status codes
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody))
@@ -248,9 +330,82 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte,
return responseBody, nil return responseBody, nil
} }
// ensureAuth ensures we have a valid auth token // ensureSession makes sure we have a valid session
func (c *RingRestClient) ensureSession() error {
c.sessionMutex.Lock()
defer c.sessionMutex.Unlock()
// If session is still valid, use it
if c.session != nil && time.Now().Before(c.sessionExpiry) {
return nil
}
// Make sure we have a valid auth token
if err := c.ensureAuth(); err != nil {
return fmt.Errorf("authentication failed while creating session: %w", err)
}
sessionPayload := map[string]interface{}{
"device": map[string]interface{}{
"hardware_id": c.hardwareID,
"metadata": map[string]interface{}{
"api_version": apiVersion,
"device_model": "ring-client-go",
},
"os": "android",
},
}
body, err := json.Marshal(sessionPayload)
if err != nil {
return fmt.Errorf("failed to marshal session request: %w", err)
}
req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
req.Header.Set("hardware_id", c.hardwareID)
req.Header.Set("User-Agent", "android:com.ringapp")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody))
}
var sessionResp SessionResponse
if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil {
return fmt.Errorf("failed to decode session response: %w", err)
}
c.session = &sessionResp
c.sessionExpiry = time.Now().Add(sessionValidTime)
// Aktualisiere den gecachten Client
cacheMutex.Lock()
clientCache[c.cacheKey] = c
cacheMutex.Unlock()
return nil
}
// ensureAuth ensures we have a valid auth token with expiration tracking
func (c *RingRestClient) ensureAuth() error { func (c *RingRestClient) ensureAuth() error {
if c.authToken != nil { c.authMutex.Lock()
defer c.authMutex.Unlock()
// If token exists and is not expired, use it
if c.authToken != nil && time.Now().Before(c.tokenExpiry) {
return nil return nil
} }
@@ -306,12 +461,24 @@ func (c *RingRestClient) ensureAuth() error {
RT: authResp.RefreshToken, RT: authResp.RefreshToken,
HID: c.hardwareID, HID: c.hardwareID,
} }
// Set token expiry (1 minute before actual expiry)
expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second
c.tokenExpiry = time.Now().Add(expiresIn)
// Encode and notify about new refresh token // Encode and notify about new refresh token
if c.onTokenRefresh != nil { if c.onTokenRefresh != nil {
newRefreshToken := encodeAuthConfig(c.authConfig) newRefreshToken := encodeAuthConfig(c.authConfig)
c.onTokenRefresh(newRefreshToken) c.onTokenRefresh(newRefreshToken)
} }
// Refreshn the token in the client
c.RefreshToken = encodeAuthConfig(c.authConfig)
// Refresh the cached client
cacheMutex.Lock()
clientCache[c.cacheKey] = c
cacheMutex.Unlock()
return nil return nil
} }
@@ -404,16 +571,25 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse,
return nil, fmt.Errorf("failed to decode auth response: %w", err) return nil, fmt.Errorf("failed to decode auth response: %w", err)
} }
// Refresh token and expiry
c.authToken = &authResp c.authToken = &authResp
c.authConfig = &AuthConfig{ c.authConfig = &AuthConfig{
RT: authResp.RefreshToken, RT: authResp.RefreshToken,
HID: c.hardwareID, HID: c.hardwareID,
} }
// Set token expiry (1 minute before actual expiry)
expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second
c.tokenExpiry = time.Now().Add(expiresIn)
c.RefreshToken = encodeAuthConfig(c.authConfig) c.RefreshToken = encodeAuthConfig(c.authConfig)
if c.onTokenRefresh != nil { if c.onTokenRefresh != nil {
c.onTokenRefresh(c.RefreshToken) c.onTokenRefresh(c.RefreshToken)
} }
// Refresh the cached client
cacheMutex.Lock()
clientCache[c.cacheKey] = c
cacheMutex.Unlock()
return c.authToken, nil return c.authToken, nil
} }
@@ -542,4 +718,4 @@ func interfaceSlice(slice interface{}) []CameraData {
} }
} }
return ret return ret
} }