From 2c5f1e0417b01d495a70b8d1f709afb9636a01bb Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 24 Jan 2025 19:37:17 +0100 Subject: [PATCH] add 2fa --- internal/ring/init.go | 87 +++++++++++++---- pkg/ring/api.go | 219 +++++++++++++++++++++++++++++++++--------- www/add.html | 36 ++++++- 3 files changed, 272 insertions(+), 70 deletions(-) diff --git a/internal/ring/init.go b/internal/ring/init.go index 24a91ac6..521c137a 100644 --- a/internal/ring/init.go +++ b/internal/ring/init.go @@ -1,7 +1,9 @@ package ring import ( + "encoding/json" "net/http" + "net/url" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" @@ -18,30 +20,75 @@ func Init() { } func apiRing(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - refreshToken := query.Get("refresh_token") + query := r.URL.Query() + var ringAPI *ring.RingRestClient + var err error - ringAPI, err := ring.NewRingRestClient(ring.RefreshTokenAuth{RefreshToken: refreshToken}, nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + // Check auth method + if email := query.Get("email"); email != "" { + // Email/Password Flow + password := query.Get("password") + code := query.Get("code") - devices, err := ringAPI.FetchRingDevices() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ + Email: email, + Password: password, + }, nil) - var items []*api.Source + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - for _, camera := range devices.AllCameras { - query.Set("device_id", camera.DeviceID) + // Try authentication (this will trigger 2FA if needed) + if _, err = ringAPI.GetAuth(code); err != nil { + if ringAPI.Using2FA { + // Return 2FA prompt + json.NewEncoder(w).Encode(map[string]interface{}{ + "needs_2fa": true, + "prompt": ringAPI.PromptFor2FA, + }) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + // Refresh Token Flow + refreshToken := query.Get("refresh_token") + if refreshToken == "" { + http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest) + return + } - items = append(items, &api.Source{ - Name: camera.Description, URL: "ring:?" + query.Encode(), - }) - } + ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{ + RefreshToken: refreshToken, + }, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } - api.ResponseSources(w, items) + // Fetch devices + devices, err := ringAPI.FetchRingDevices() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Create clean query with only required parameters + cleanQuery := url.Values{} + cleanQuery.Set("refresh_token", ringAPI.RefreshToken) + + var items []*api.Source + for _, camera := range devices.AllCameras { + cleanQuery.Set("device_id", camera.DeviceID) + items = append(items, &api.Source{ + Name: camera.Description, + URL: "ring:?" + cleanQuery.Encode(), + }) + } + + api.ResponseSources(w, items) } diff --git a/pkg/ring/api.go b/pkg/ring/api.go index faebf6b9..e025e031 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "reflect" + "strings" "time" ) @@ -17,6 +18,11 @@ type RefreshTokenAuth struct { RefreshToken string } +type EmailAuth struct { + Email string + Password string +} + // AuthConfig represents the decoded refresh token data type AuthConfig struct { RT string `json:"rt"` // Refresh Token @@ -32,6 +38,14 @@ type AuthTokenResponse struct { TokenType string `json:"token_type"` // Always "Bearer" } +type Auth2faResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + TSVState string `json:"tsv_state"` + Phone string `json:"phone"` + NextTimeInSecs int `json:"next_time_in_secs"` +} + // SocketTicketRequest represents the request to get a socket ticket type SocketTicketResponse struct { Ticket string `json:"ticket"` @@ -40,17 +54,42 @@ type SocketTicketResponse struct { // RingRestClient handles authentication and requests to Ring API type RingRestClient struct { - httpClient *http.Client - authConfig *AuthConfig - hardwareID string - authToken *AuthTokenResponse - auth RefreshTokenAuth - onTokenRefresh func(string) // Callback when refresh token is updated + httpClient *http.Client + authConfig *AuthConfig + hardwareID string + authToken *AuthTokenResponse + Using2FA bool + PromptFor2FA string + RefreshToken string + auth interface{} // EmailAuth or RefreshTokenAuth + onTokenRefresh func(string) } // CameraKind represents the different types of Ring cameras type CameraKind string +// CameraData contains common fields for all camera types +type CameraData struct { + ID float64 `json:"id"` + Description string `json:"description"` + DeviceID string `json:"device_id"` + Kind string `json:"kind"` + LocationID string `json:"location_id"` +} + +// RingDeviceType represents different types of Ring devices +type RingDeviceType string + +// RingDevicesResponse represents the response from the Ring API +type RingDevicesResponse struct { + Doorbots []CameraData `json:"doorbots"` + AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` + StickupCams []CameraData `json:"stickup_cams"` + AllCameras []CameraData `json:"all_cameras"` + Chimes []CameraData `json:"chimes"` + Other []map[string]interface{} `json:"other"` +} + const ( Doorbot CameraKind = "doorbot" Doorbell CameraKind = "doorbell" @@ -86,33 +125,11 @@ const ( OnvifCamera CameraKind = "onvif_camera" ) -// RingDeviceType represents different types of Ring devices -type RingDeviceType string - const ( IntercomHandsetAudio RingDeviceType = "intercom_handset_audio" OnvifCameraType RingDeviceType = "onvif_camera" ) -// CameraData contains common fields for all camera types -type CameraData struct { - ID float64 `json:"id"` - Description string `json:"description"` - DeviceID string `json:"device_id"` - Kind string `json:"kind"` - LocationID string `json:"location_id"` -} - -// RingDevicesResponse represents the response from the Ring API -type RingDevicesResponse struct { - Doorbots []CameraData `json:"doorbots"` - AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` - StickupCams []CameraData `json:"stickup_cams"` - AllCameras []CameraData `json:"all_cameras"` - Chimes []CameraData `json:"chimes"` - Other []map[string]interface{} `json:"other"` -} - const ( clientAPIBaseURL = "https://api.ring.com/clients_api/" deviceAPIBaseURL = "https://api.ring.com/devices/v1/" @@ -125,27 +142,37 @@ const ( ) // NewRingRestClient creates a new Ring client instance -func NewRingRestClient(auth RefreshTokenAuth, onTokenRefresh func(string)) (*RingRestClient, error) { - client := &RingRestClient{ - httpClient: &http.Client{ - Timeout: defaultTimeout, - }, - auth: auth, - onTokenRefresh: onTokenRefresh, - hardwareID: generateHardwareID(), - } +func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { + client := &RingRestClient{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + } - // check if refresh token is provided - if auth.RefreshToken == "" { - return nil, fmt.Errorf("refresh token is required") - } + switch a := auth.(type) { + case RefreshTokenAuth: + if a.RefreshToken == "" { + return nil, fmt.Errorf("refresh token is required") + } + + config, err := parseAuthConfig(a.RefreshToken) + if err != nil { + return nil, fmt.Errorf("failed to parse refresh token: %w", err) + } - if config, err := parseAuthConfig(auth.RefreshToken); err == nil { client.authConfig = config - client.hardwareID = config.HID - } + client.hardwareID = config.HID + 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") + } - return client, nil + return client, nil } // Request makes an authenticated request to the Ring API @@ -289,6 +316,108 @@ func (c *RingRestClient) ensureAuth() error { return nil } +// getAuth makes an authentication request to the Ring API +func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { + var grantData map[string]string + + if c.authConfig != nil && twoFactorAuthCode == "" { + grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + } else { + authEmail, ok := c.auth.(EmailAuth) + if !ok { + return nil, fmt.Errorf("invalid auth type for email authentication") + } + grantData = map[string]string{ + "grant_type": "password", + "username": authEmail.Email, + "password": authEmail.Password, + } + } + + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + body, err := json.Marshal(grantData) + if err != nil { + return nil, fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + if twoFactorAuthCode != "" { + req.Header.Set("2fa-code", twoFactorAuthCode) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Handle 2FA Responses + if resp.StatusCode == http.StatusPreconditionFailed || + (resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) { + + var tfaResp Auth2faResponse + if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil { + return nil, err + } + + c.Using2FA = true + if resp.StatusCode == http.StatusBadRequest { + c.PromptFor2FA = "Invalid 2fa code entered. Please try again." + return nil, fmt.Errorf("invalid 2FA code") + } + + if tfaResp.TSVState != "" { + prompt := "from your authenticator app" + if tfaResp.TSVState != "totp" { + prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState) + } + c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt) + } else { + c.PromptFor2FA = "Please enter the code sent to your text/email" + } + + return nil, fmt.Errorf("2FA required") + } + + // Handle errors + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return nil, fmt.Errorf("failed to decode auth response: %w", err) + } + + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + c.RefreshToken = encodeAuthConfig(c.authConfig) + if c.onTokenRefresh != nil { + c.onTokenRefresh(c.RefreshToken) + } + + return c.authToken, nil +} + // Helper functions for auth config encoding/decoding func parseAuthConfig(refreshToken string) (*AuthConfig, error) { decoded, err := base64.StdEncoding.DecodeString(refreshToken) diff --git a/www/add.html b/www/add.html index 1190f07e..7dae63d4 100644 --- a/www/add.html +++ b/www/add.html @@ -220,7 +220,16 @@
-
+ + + + + +
+
@@ -231,15 +240,32 @@ ev.target.nextElementSibling.style.display = 'block'; }); - document.getElementById('ring-form').addEventListener('submit', async ev => { + async function handleRingAuth(ev) { ev.preventDefault(); - const query = new URLSearchParams(new FormData(ev.target)); const url = new URL('api/ring?' + query.toString(), location.href); const r = await fetch(url, {cache: 'no-cache'}); - await getSources('ring-table', r); - }); + const data = await r.json(); + + if (data.needs_2fa) { + document.getElementById('tfa-field').style.display = 'block'; + document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code'; + return; + } + + if (!r.ok) { + const table = document.getElementById('ring-table'); + table.innerText = data.error || 'Unknown error'; + return; + } + + const table = document.getElementById('ring-table'); + drawTable(table, data); + } + + document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth); + document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);