Skip to content

Commit 8e3cb7f

Browse files
committed
feat: 2fa support
#26 #39
1 parent 4e78cb5 commit 8e3cb7f

File tree

6 files changed

+92
-15
lines changed

6 files changed

+92
-15
lines changed

api/api.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@ import { RingCamera } from './ring-camera'
1212
import { EMPTY, merge, Subject } from 'rxjs'
1313
import { debounceTime, switchMap, throttleTime } from 'rxjs/operators'
1414

15-
export type RingAlarmOptions = {
15+
export interface RingAlarmOptions {
1616
locationIds?: string[]
1717
cameraStatusPollingSeconds?: number
1818
cameraDingsPollingSeconds?: number
19-
} & RingAuth
19+
}
2020

2121
export class RingApi {
2222
public readonly restClient = new RingRestClient(this.options)
2323

2424
private locations = this.fetchAndBuildLocations()
2525

26-
constructor(public readonly options: RingAlarmOptions) {}
26+
constructor(public readonly options: RingAlarmOptions & RingAuth) {}
2727

2828
async fetchRingDevices() {
2929
const {
@@ -40,6 +40,16 @@ export class RingApi {
4040
beams_bridges: BeamBridge[]
4141
}>({ url: clientApi('ring_devices') })
4242

43+
if (this.restClient.using2fa && this.restClient.refreshToken) {
44+
console.error(
45+
'Your Ring account is configured to use 2-factor authentication (2fa).'
46+
)
47+
console.error(
48+
`Please change your Ring configuration to include "refreshToken": "${this.restClient.refreshToken}"`
49+
)
50+
process.exit(1)
51+
}
52+
4353
return {
4454
doorbots,
4555
authorizedDoorbots,

api/rest-client.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import axios, { AxiosRequestConfig, ResponseType } from 'axios'
2-
import { delay, generateRandomId, logError, logInfo } from './util'
2+
import {
3+
delay,
4+
generateRandomId,
5+
logError,
6+
logInfo,
7+
requestInput
8+
} from './util'
39
import * as querystring from 'querystring'
410
import { AuthTokenResponse, SessionResponse } from './ring-types'
511

@@ -63,14 +69,15 @@ export type RingAuth = EmailAuth | RefreshTokenAuth
6369

6470
export class RingRestClient {
6571
// prettier-ignore
66-
private refreshToken = ('refreshToken' in this.authOptions ? this.authOptions.refreshToken : undefined)
72+
public refreshToken = ('refreshToken' in this.authOptions ? this.authOptions.refreshToken : undefined)
6773
private authPromise = this.getAuthToken()
6874
private sessionPromise = this.getSession()
75+
public using2fa = false
6976

7077
constructor(private authOptions: RingAuth) {}
7178

72-
private getGrantData() {
73-
if (this.refreshToken) {
79+
private getGrantData(twoFactorAuthCode?: string) {
80+
if (this.refreshToken && !twoFactorAuthCode) {
7481
return {
7582
grant_type: 'refresh_token',
7683
refresh_token: this.refreshToken
@@ -91,8 +98,16 @@ export class RingRestClient {
9198
)
9299
}
93100

94-
private async getAuthToken(): Promise<AuthTokenResponse> {
95-
const grantData = this.getGrantData()
101+
private async getAuthToken(
102+
twoFactorAuthCode?: string
103+
): Promise<AuthTokenResponse> {
104+
const grantData = this.getGrantData(twoFactorAuthCode),
105+
twoFactorAuthHeaders = twoFactorAuthCode
106+
? {
107+
'2fa-code': twoFactorAuthCode,
108+
'2fa-Support': 'true'
109+
}
110+
: {}
96111

97112
try {
98113
const response = await requestWithRetry<AuthTokenResponse>({
@@ -104,7 +119,8 @@ export class RingRestClient {
104119
},
105120
method: 'POST',
106121
headers: {
107-
'content-type': 'application/json'
122+
'content-type': 'application/json',
123+
...twoFactorAuthHeaders
108124
}
109125
})
110126

@@ -118,8 +134,30 @@ export class RingRestClient {
118134
return this.getAuthToken()
119135
}
120136

121-
const errorMessage =
122-
'Failed to fetch oauth token from Ring. Verify that your email and password are correct.'
137+
const response = requestError.response || {},
138+
responseData = response.data || {},
139+
responseError =
140+
typeof responseData.error === 'string' ? responseData.error : ''
141+
142+
if (
143+
response.status === 412 || // need 2fa code
144+
(response.status === 400 &&
145+
responseError.startsWith('Verification Code')) // invalid 2fa code entered
146+
) {
147+
const code = await requestInput(
148+
'Ring 2fa enabled. Please enter code from text message: '
149+
)
150+
this.using2fa = true
151+
return this.getAuthToken(code)
152+
}
153+
154+
const authTypeMessage =
155+
'refreshToken' in this.authOptions
156+
? 'refresh token is'
157+
: 'email and password are',
158+
errorMessage =
159+
`Failed to fetch oauth token from Ring. Verify that your ${authTypeMessage} correct. ` +
160+
responseError
123161
logError(requestError.response)
124162
logError(errorMessage)
125163
throw new Error(errorMessage)

api/util.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import debug = require('debug')
22
import { red } from 'colors'
33
import { randomBytes } from 'crypto'
4+
import { createInterface } from 'readline'
45

56
const logger = debug('ring-alarm')
67

@@ -32,3 +33,17 @@ export function generateRandomId() {
3233
id.substr(20, 12)
3334
)
3435
}
36+
37+
export async function requestInput(question: string) {
38+
const lineReader = createInterface({
39+
input: process.stdin,
40+
output: process.stdout
41+
}),
42+
answer = await new Promise<string>(resolve => {
43+
lineReader.question(question, resolve)
44+
})
45+
46+
lineReader.close()
47+
48+
return answer.trim()
49+
}

examples/example.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ async function example() {
88
// Replace with your ring email/password
99
email: env.RING_EMAIL!,
1010
password: env.RING_PASS!,
11-
cameraDingsPollingSeconds: 1,
12-
locationIds: [env.RING_LOCATION_ID!] // Remove if you want all locations
11+
// Refresh token is used when 2fa is on
12+
refreshToken: env.RING_REFRESH_TOKEN!,
13+
// Listen for dings and motion events
14+
cameraDingsPollingSeconds: 1
1315
}),
1416
locations = await ringApi.getLocations(),
1517
cameras = await ringApi.getCameras()

homebridge/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ and third party devices that connect to the Ring Alarm System.
2727
"platform": "RingAlarm",
2828
"email": "some.one@website.com",
2929
"password": "abc123!#",
30+
31+
// For 2fa accounts only. See below for details
32+
"refreshToken": "TOKEN GENERATED FOR 2fa ACCOUNTS",
3033

3134
// Optional. DO NOT INCLUDE UNLESS NEEDED. See below for details
3235
"locationIds": ["488e4800-fcde-4493-969b-d1a06f683102", "4bbed7a7-06df-4f18-b3af-291c89854d60"],
@@ -86,6 +89,14 @@ light/siren status do not update in real time and need to be requested periodica
8689
`cameraDingsPollingSeconds`: How frequently to poll for new events from your cameras. These include motion and
8790
doorbell presses. Defaults to every `1` second.
8891

92+
### 2-Factor Authentication (2fa)
93+
94+
If you have 2fa turned on for your Ring account, start by running the homebridge plugin with your email and password in `config.json`.
95+
You will be prompted to enter the 2fa code that you received via text message. Type the code into your terminal, and then
96+
press enter. Homebridge will exit with an error, but a message will log out your `refreshToken`. Copy this refresh token
97+
and in your homebridge `config.json` add `"refreshToken": "REFERSH TOKEN LOGGED AFTER ENTERING YOUR 2fa CODE"` to the RingPlatform
98+
config section. You can delete `email` and `password` as these will no longer be used.
99+
89100
### Camera Setup
90101

91102
This plugin will connect all of your Ring cameras to homebridge, but they require a little extra work to get set up.

homebridge/ring-alarm-platform.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { RingAlarmPlatformConfig } from './config'
1313
import { Beam } from './beam'
1414
import { MultiLevelSwitch } from './multi-level-switch'
1515
import { Camera } from './camera'
16+
import { RingAuth } from '../api/rest-client'
1617

1718
const pluginName = 'homebridge-ring-alarm',
1819
platformName = 'RingAlarm',
@@ -61,7 +62,7 @@ export class RingAlarmPlatform {
6162

6263
constructor(
6364
public log: HAP.Log,
64-
public config: RingAlarmPlatformConfig,
65+
public config: RingAlarmPlatformConfig & RingAuth,
6566
public api: HAP.Platform
6667
) {
6768
if (!config) {

0 commit comments

Comments
 (0)