This commit is contained in:
seydx
2025-01-24 19:37:17 +01:00
parent c9682ca64d
commit 2c5f1e0417
3 changed files with 272 additions and 70 deletions

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -220,7 +220,16 @@
<button id="ring">Ring</button>
<div class="module">
<form id="ring-form" style="margin-bottom: 10px">
<form id="ring-credentials-form" style="margin-bottom: 10px">
<input type="email" name="email" placeholder="email">
<input type="password" name="password" placeholder="password">
<div id="tfa-field" style="display: none">
<input type="text" name="code" placeholder="2FA code">
<div id="tfa-prompt"></div>
</div>
<input type="submit" value="Login">
</form>
<form id="ring-token-form" style="margin-bottom: 10px">
<input type="text" name="refresh_token" placeholder="refresh_token">
<input type="submit" value="Login">
</form>
@@ -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);
</script>
<button id="gopro">GoPro</button>