mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-04 07:56:33 +08:00
add 2fa
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
package ring
|
package ring
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||||
@@ -19,27 +21,72 @@ func Init() {
|
|||||||
|
|
||||||
func apiRing(w http.ResponseWriter, r *http.Request) {
|
func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
refreshToken := query.Get("refresh_token")
|
var ringAPI *ring.RingRestClient
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Check auth method
|
||||||
|
if email := query.Get("email"); email != "" {
|
||||||
|
// Email/Password Flow
|
||||||
|
password := query.Get("password")
|
||||||
|
code := query.Get("code")
|
||||||
|
|
||||||
|
ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{
|
||||||
|
Email: email,
|
||||||
|
Password: password,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
ringAPI, err := ring.NewRingRestClient(ring.RefreshTokenAuth{RefreshToken: refreshToken}, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch devices
|
||||||
devices, err := ringAPI.FetchRingDevices()
|
devices, err := ringAPI.FetchRingDevices()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create clean query with only required parameters
|
||||||
|
cleanQuery := url.Values{}
|
||||||
|
cleanQuery.Set("refresh_token", ringAPI.RefreshToken)
|
||||||
|
|
||||||
var items []*api.Source
|
var items []*api.Source
|
||||||
|
|
||||||
for _, camera := range devices.AllCameras {
|
for _, camera := range devices.AllCameras {
|
||||||
query.Set("device_id", camera.DeviceID)
|
cleanQuery.Set("device_id", camera.DeviceID)
|
||||||
|
|
||||||
items = append(items, &api.Source{
|
items = append(items, &api.Source{
|
||||||
Name: camera.Description, URL: "ring:?" + query.Encode(),
|
Name: camera.Description,
|
||||||
|
URL: "ring:?" + cleanQuery.Encode(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
193
pkg/ring/api.go
193
pkg/ring/api.go
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ type RefreshTokenAuth struct {
|
|||||||
RefreshToken string
|
RefreshToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmailAuth struct {
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
// AuthConfig represents the decoded refresh token data
|
// AuthConfig represents the decoded refresh token data
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
RT string `json:"rt"` // Refresh Token
|
RT string `json:"rt"` // Refresh Token
|
||||||
@@ -32,6 +38,14 @@ type AuthTokenResponse struct {
|
|||||||
TokenType string `json:"token_type"` // Always "Bearer"
|
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
|
// SocketTicketRequest represents the request to get a socket ticket
|
||||||
type SocketTicketResponse struct {
|
type SocketTicketResponse struct {
|
||||||
Ticket string `json:"ticket"`
|
Ticket string `json:"ticket"`
|
||||||
@@ -44,13 +58,38 @@ type RingRestClient struct {
|
|||||||
authConfig *AuthConfig
|
authConfig *AuthConfig
|
||||||
hardwareID string
|
hardwareID string
|
||||||
authToken *AuthTokenResponse
|
authToken *AuthTokenResponse
|
||||||
auth RefreshTokenAuth
|
Using2FA bool
|
||||||
onTokenRefresh func(string) // Callback when refresh token is updated
|
PromptFor2FA string
|
||||||
|
RefreshToken string
|
||||||
|
auth interface{} // EmailAuth or RefreshTokenAuth
|
||||||
|
onTokenRefresh func(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CameraKind represents the different types of Ring cameras
|
// CameraKind represents the different types of Ring cameras
|
||||||
type CameraKind string
|
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 (
|
const (
|
||||||
Doorbot CameraKind = "doorbot"
|
Doorbot CameraKind = "doorbot"
|
||||||
Doorbell CameraKind = "doorbell"
|
Doorbell CameraKind = "doorbell"
|
||||||
@@ -86,33 +125,11 @@ const (
|
|||||||
OnvifCamera CameraKind = "onvif_camera"
|
OnvifCamera CameraKind = "onvif_camera"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RingDeviceType represents different types of Ring devices
|
|
||||||
type RingDeviceType string
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
IntercomHandsetAudio RingDeviceType = "intercom_handset_audio"
|
IntercomHandsetAudio RingDeviceType = "intercom_handset_audio"
|
||||||
OnvifCameraType RingDeviceType = "onvif_camera"
|
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 (
|
const (
|
||||||
clientAPIBaseURL = "https://api.ring.com/clients_api/"
|
clientAPIBaseURL = "https://api.ring.com/clients_api/"
|
||||||
deviceAPIBaseURL = "https://api.ring.com/devices/v1/"
|
deviceAPIBaseURL = "https://api.ring.com/devices/v1/"
|
||||||
@@ -125,24 +142,34 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewRingRestClient creates a new Ring client instance
|
// NewRingRestClient creates a new Ring client instance
|
||||||
func NewRingRestClient(auth RefreshTokenAuth, onTokenRefresh func(string)) (*RingRestClient, error) {
|
func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) {
|
||||||
client := &RingRestClient{
|
client := &RingRestClient{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{Timeout: defaultTimeout},
|
||||||
Timeout: defaultTimeout,
|
|
||||||
},
|
|
||||||
auth: auth,
|
|
||||||
onTokenRefresh: onTokenRefresh,
|
onTokenRefresh: onTokenRefresh,
|
||||||
hardwareID: generateHardwareID(),
|
hardwareID: generateHardwareID(),
|
||||||
|
auth: auth,
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if refresh token is provided
|
switch a := auth.(type) {
|
||||||
if auth.RefreshToken == "" {
|
case RefreshTokenAuth:
|
||||||
|
if a.RefreshToken == "" {
|
||||||
return nil, fmt.Errorf("refresh token is required")
|
return nil, fmt.Errorf("refresh token is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config, err := parseAuthConfig(auth.RefreshToken); err == nil {
|
config, err := parseAuthConfig(a.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
client.authConfig = config
|
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
|
||||||
@@ -289,6 +316,108 @@ func (c *RingRestClient) ensureAuth() error {
|
|||||||
return nil
|
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
|
// Helper functions for auth config encoding/decoding
|
||||||
func parseAuthConfig(refreshToken string) (*AuthConfig, error) {
|
func parseAuthConfig(refreshToken string) (*AuthConfig, error) {
|
||||||
decoded, err := base64.StdEncoding.DecodeString(refreshToken)
|
decoded, err := base64.StdEncoding.DecodeString(refreshToken)
|
||||||
|
36
www/add.html
36
www/add.html
@@ -220,7 +220,16 @@
|
|||||||
|
|
||||||
<button id="ring">Ring</button>
|
<button id="ring">Ring</button>
|
||||||
<div class="module">
|
<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="text" name="refresh_token" placeholder="refresh_token">
|
||||||
<input type="submit" value="Login">
|
<input type="submit" value="Login">
|
||||||
</form>
|
</form>
|
||||||
@@ -231,15 +240,32 @@
|
|||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('ring-form').addEventListener('submit', async ev => {
|
async function handleRingAuth(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
const query = new URLSearchParams(new FormData(ev.target));
|
const query = new URLSearchParams(new FormData(ev.target));
|
||||||
const url = new URL('api/ring?' + query.toString(), location.href);
|
const url = new URL('api/ring?' + query.toString(), location.href);
|
||||||
|
|
||||||
const r = await fetch(url, {cache: 'no-cache'});
|
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>
|
</script>
|
||||||
|
|
||||||
<button id="gopro">GoPro</button>
|
<button id="gopro">GoPro</button>
|
||||||
|
Reference in New Issue
Block a user