From 8f0b05c6be2f72752db2b1b65dd6494b0553f52d Mon Sep 17 00:00:00 2001 From: bropat Date: Mon, 22 Nov 2021 17:51:29 +0100 Subject: [PATCH] Implemented captcha authentication mechanism (API v2) Fixed issue #69 --- README.md | 5 ++ package-lock.json | 4 +- package.json | 2 +- src/eufysecurity.ts | 56 +++++++++++---------- src/http/api.ts | 110 ++++++++++++++++------------------------- src/http/interfaces.ts | 2 + src/http/models.ts | 19 +++++++ src/http/types.ts | 3 +- src/interfaces.ts | 1 + 9 files changed, 105 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 08f25e8f..645b2945 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ Please use GitHub issues for this. ## Changelog +### 1.4.0 (2021-11-22) + +* (bropat) Implemented captcha authentication mechanism (API v2) +* (bropat) Fixed issue #69 + ### 1.3.0 (2021-11-20) * (bropat) Implemented new encrypted authentication mechanism (API v2) diff --git a/package-lock.json b/package-lock.json index 22258317..c20d9756 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "eufy-security-client", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@cospired/i18n-iso-languages": "^3.1.1", diff --git a/package.json b/package.json index 4f327812..eaf75337 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eufy-security-client", - "version": "1.3.0", + "version": "1.4.0", "description": "Client to comunicate with Eufy-Security devices", "author": { "name": "bropat", diff --git a/src/eufysecurity.ts b/src/eufysecurity.ts index 674cd730..3ad830f0 100644 --- a/src/eufysecurity.ts +++ b/src/eufysecurity.ts @@ -131,6 +131,7 @@ export class EufySecurity extends TypedEmitter { this.api.on("devices", (devices: FullDevices) => this.handleDevices(devices)); this.api.on("close", () => this.onAPIClose()); this.api.on("connect", () => this.onAPIConnect()); + this.api.on("captcha request", (id: string, captcha: string) => this.onCaptchaRequest(id, captcha)); if (this.persistentData.login_hash && this.persistentData.login_hash != "") { this.log.debug("Load previous login_hash:", this.persistentData.login_hash); @@ -508,32 +509,33 @@ export class EufySecurity extends TypedEmitter { this.pushService.open(); } - public async connect(verifyCode?:string|null): Promise { - if (verifyCode) { - return await this.api.addTrustDevice(verifyCode); - } else { - let retries = 0; - while (true) { - switch (await this.api.authenticate()) { - case AuthResult.SEND_VERIFY_CODE: - this.saveAPIBase(); - this.emit("tfa request"); - return false; - case AuthResult.RENEW: - this.log.debug("Renew token"); - break; - case AuthResult.ERROR: - this.log.error("Token error"); - return false; - case AuthResult.OK: - return true; - } - if (retries > 2) { - this.log.error("Max connect attempts reached, interrupt"); + public async connect(verifyCodeOrCaptcha?: string | null, captchaId?: string | null): Promise { + let retries = 0; + while (true) { + switch (await this.api.authenticate(verifyCodeOrCaptcha, captchaId)) { + case AuthResult.CAPTCHA_NEEDED: return false; - } else { - retries += 1; - } + case AuthResult.SEND_VERIFY_CODE: + this.saveAPIBase(); + this.emit("tfa request"); + return false; + case AuthResult.RENEW: + this.log.debug("Renew token"); + break; + case AuthResult.ERROR: + this.log.error("Token error"); + return false; + case AuthResult.OK: + if (verifyCodeOrCaptcha && !captchaId) { + return await this.api.addTrustDevice(verifyCodeOrCaptcha); + } + return true; + } + if (retries > 2) { + this.log.error("Max connect attempts reached, interrupt"); + return false; + } else { + retries += 1; } } } @@ -1249,4 +1251,8 @@ export class EufySecurity extends TypedEmitter { } } + private onCaptchaRequest(id: string, captcha: string): void { + this.emit("captcha request", id, captcha); + } + } \ No newline at end of file diff --git a/src/http/api.ts b/src/http/api.ts index 48fbfb4b..198d24b3 100644 --- a/src/http/api.ts +++ b/src/http/api.ts @@ -5,7 +5,7 @@ import { isValid as isValidCountry } from "i18n-iso-countries"; import { isValid as isValidLanguage } from "@cospired/i18n-iso-languages"; import { createECDH, ECDH } from "crypto"; -import { ResultResponse, FullDeviceResponse, HubResponse, LoginResultResponse, TrustDevice, Cipher, Voice, EventRecordResponse, Invite, ConfirmInvite, SensorHistoryEntry, ApiResponse } from "./models" +import { ResultResponse, FullDeviceResponse, HubResponse, LoginResultResponse, TrustDevice, Cipher, Voice, EventRecordResponse, Invite, ConfirmInvite, SensorHistoryEntry, ApiResponse, CaptchaResponse, LoginRequest } from "./models" import { HTTPApiEvents, Ciphers, FullDevices, Hubs, Voices, Invites } from "./interfaces"; import { AuthResult, EventFilterType, PublicKeyType, ResponseErrorCode, StorageType, VerfyCodeTypes } from "./types"; import { ParameterHelper } from "./parameter"; @@ -97,26 +97,29 @@ export class HTTPApi extends TypedEmitter { return this.headers.language; } - public async authenticate(): Promise { + public async authenticate(verifyCodeOrCaptcha: string | null = null, captchaId: string | null = null): Promise { //Authenticate and get an access token this.log.debug("Authenticate and get an access token", { token: this.token, tokenExpiration: this.tokenExpiration }); - if (!this.token || this.tokenExpiration && (new Date()).getTime() >= this.tokenExpiration.getTime()) { + if (!this.token || (this.tokenExpiration && (new Date()).getTime() >= this.tokenExpiration.getTime()) || verifyCodeOrCaptcha) { try { this.ecdh.generateKeys(); - const response: ApiResponse = await this.request("post", "v2/passport/login", { - /* TODO: Implement authentification with captcha. Example below: - answer: "xEoS", - captcha_id: "X1GOffz3mBa6xkVU4S3K", - */ + const data: LoginRequest = { client_secret_info: { public_key: this.ecdh.getPublicKey("hex") }, enc: 0, email: this.username, password: encryptPassword(this.password, this.ecdh.computeSecret(Buffer.from(this.serverPublicKey, "hex"))), - time_zone: -new Date().getTimezoneOffset()*60*1000, + time_zone: new Date().getTimezoneOffset() !== 0 ? -new Date().getTimezoneOffset() * 60 * 1000 : 0, transaction: `${new Date().getTime()}` - }).catch(error => { + }; + if (verifyCodeOrCaptcha && !captchaId) { + data.verify_code = verifyCodeOrCaptcha; + } else if (verifyCodeOrCaptcha && captchaId) { + data.captcha_id = captchaId; + data.answer = verifyCodeOrCaptcha; + } + const response: ApiResponse = await this.request("post", "v2/passport/login", data).catch(error => { this.log.error("Error:", error); return error; }); @@ -139,7 +142,6 @@ export class HTTPApi extends TypedEmitter { if (dataresult.domain) { if (`https://${dataresult.domain}` != this.apiBase) { this.apiBase = `https://${dataresult.domain}`; - //axios.defaults.baseURL = this.apiBase; this.log.info(`Switching to another API_BASE (${this.apiBase}) and get new token.`); this.invalidateToken(); return AuthResult.RENEW; @@ -150,7 +152,7 @@ export class HTTPApi extends TypedEmitter { this.connected = true; return AuthResult.OK; } else if (result.code == ResponseErrorCode.CODE_NEED_VERIFY_CODE) { - this.log.debug(`${this.constructor.name}.authenticate(): Send verification code...`); + this.log.debug(`Send verification code...`); const dataresult: LoginResultResponse = result.data; this.token = dataresult.auth_token @@ -158,8 +160,13 @@ export class HTTPApi extends TypedEmitter { this.log.debug("Token data", { token: this.token, tokenExpiration: this.tokenExpiration }); await this.sendVerifyCode(VerfyCodeTypes.TYPE_EMAIL); - + this.emit("tfa request"); return AuthResult.SEND_VERIFY_CODE; + } else if (result.code == ResponseErrorCode.LOGIN_NEED_CAPTCHA || result.code == ResponseErrorCode.LOGIN_CAPTCHA_ERROR) { + const dataresult: CaptchaResponse = result.data; + this.log.debug("Captcha verification received", { captchaId: dataresult.captcha_id, item: dataresult.item }); + this.emit("captcha request", dataresult.captcha_id, dataresult.item); + return AuthResult.CAPTCHA_NEEDED; } else { this.log.error("Response code not ok", {code: result.code, msg: result.msg }); } @@ -170,9 +177,10 @@ export class HTTPApi extends TypedEmitter { this.log.error("Generic Error:", error); } return AuthResult.ERROR; + } else if (!this.connected) { + this.emit("connect"); + this.connected = true; } - this.emit("connect"); - this.connected = true; return AuthResult.OK; } @@ -234,57 +242,29 @@ export class HTTPApi extends TypedEmitter { public async addTrustDevice(verifyCode: string): Promise { try { - const response = await this.request("post", "v2/passport/login", { - client_secret_info: { - public_key: this.ecdh.getPublicKey("hex") - }, - enc: 0, - email: this.username, - password: encryptPassword(this.password, this.ecdh.computeSecret(Buffer.from(this.serverPublicKey, "hex"))), - time_zone: -new Date().getTimezoneOffset()*60*1000, + const response = await this.request("post", "v1/app/trust_device/add", { verify_code: `${verifyCode}`, transaction: `${new Date().getTime()}` }).catch(error => { this.log.error("Error:", error); return error; }); - this.log.debug("Response login:", response.data); + this.log.debug("Response trust device:", response.data); if (response.status == 200) { const result: ResultResponse = response.data; if (result.code == ResponseErrorCode.CODE_WHATEVER_ERROR) { - const response2 = await this.request("post", "v1/app/trust_device/add", { - verify_code: `${verifyCode}`, - transaction: `${new Date().getTime()}` - }).catch(error => { - this.log.error("Error:", error); - return error; - }); - this.log.debug("Response trust device:", response.data); - - if (response2.status == 200) { - const result: ResultResponse = response2.data; - if (result.code == ResponseErrorCode.CODE_WHATEVER_ERROR) { - this.log.info(`2FA authentication successfully done. Device trusted.`); - const trusted_devices = await this.listTrustDevice(); - trusted_devices.forEach((trusted_device: TrustDevice) => { - if (trusted_device.is_current_device === 1) { - this.tokenExpiration = this.trustedTokenExpiration; - this.log.debug("This device is trusted. Token expiration extended:", { tokenExpiration: this.tokenExpiration}); - } - }); - this.emit("connect"); - this.connected = true; - return true; - } else { - this.log.error("Response code not ok", {code: result.code, msg: result.msg }); + this.log.info(`2FA authentication successfully done. Device trusted.`); + const trusted_devices = await this.listTrustDevice(); + trusted_devices.forEach((trusted_device: TrustDevice) => { + if (trusted_device.is_current_device === 1) { + this.tokenExpiration = this.trustedTokenExpiration; + this.log.debug("This device is trusted. Token expiration extended:", { tokenExpiration: this.tokenExpiration}); } - } else if (response2.status == 401) { - this.invalidateToken(); - this.log.error("Status return code 401, invalidate token", { status: response.status, statusText: response.statusText }); - } else { - this.log.error("Status return code not 200", { status: response.status, statusText: response.statusText }); - } + }); + this.emit("connect"); + this.connected = true; + return true; } else { this.log.error("Response code not ok", {code: result.code, msg: result.msg }); } @@ -308,7 +288,7 @@ export class HTTPApi extends TypedEmitter { orderby: "", page: 0, station_sn: "", - time_zone: -new Date().getTimezoneOffset()*60*1000, + time_zone: new Date().getTimezoneOffset() !== 0 ? -new Date().getTimezoneOffset() * 60 * 1000 : 0, transaction: `${new Date().getTime()}` }).catch(error => { this.log.error("Stations - Error:", error); @@ -322,8 +302,8 @@ export class HTTPApi extends TypedEmitter { const dataresult: Array = result.data; if (dataresult) { dataresult.forEach(element => { - this.log.debug(`${this.constructor.name}.updateDeviceInfo(): stations - element: ${JSON.stringify(element)}`); - this.log.debug(`${this.constructor.name}.updateDeviceInfo(): stations - device_type: ${element.device_type}`); + this.log.debug(`Stations - element: ${JSON.stringify(element)}`); + this.log.debug(`Stations - device_type: ${element.device_type}`); this.hubs[element.station_sn] = element; }); } else { @@ -350,7 +330,7 @@ export class HTTPApi extends TypedEmitter { orderby: "", page: 0, station_sn: "", - time_zone: -new Date().getTimezoneOffset()*60*1000, + time_zone: new Date().getTimezoneOffset() !== 0 ? -new Date().getTimezoneOffset() * 60 * 1000 : 0, transaction: `${new Date().getTime()}` }).catch(error => { this.log.error("Devices - Error:", error); @@ -471,9 +451,6 @@ export class HTTPApi extends TypedEmitter { } else { this.log.error("Response code not ok", {code: result.code, msg: result.msg }); } - } else if (response.status == 401) { - this.invalidateToken(); - this.log.error("Status return code 401, invalidate token", { status: response.status, statusText: response.statusText }); } else { this.log.error("Status return code not 200", { status: response.status, statusText: response.statusText }); } @@ -504,9 +481,6 @@ export class HTTPApi extends TypedEmitter { } else { this.log.error("Response code not ok", {code: result.code, msg: result.msg }); } - } else if (response.status == 401) { - this.invalidateToken(); - this.log.error("Status return code 401, invalidate token", { status: response.status, statusText: response.statusText }); } else { this.log.error("Status return code not 200", { status: response.status, statusText: response.statusText }); } @@ -717,15 +691,15 @@ export class HTTPApi extends TypedEmitter { } public async getVideoEvents(startTime: Date, endTime: Date, filter?: EventFilterType, maxResults?: number): Promise> { - return this._getEvents("getVideoEvents", "event/app/get_all_video_record", startTime, endTime, filter, maxResults); + return this._getEvents("getVideoEvents", "v1/event/app/get_all_video_record", startTime, endTime, filter, maxResults); } public async getAlarmEvents(startTime: Date, endTime: Date, filter?: EventFilterType, maxResults?: number): Promise> { - return this._getEvents("getAlarmEvents", "event/app/get_all_alarm_record", startTime, endTime, filter, maxResults); + return this._getEvents("getAlarmEvents", "v1/event/app/get_all_alarm_record", startTime, endTime, filter, maxResults); } public async getHistoryEvents(startTime: Date, endTime: Date, filter?: EventFilterType, maxResults?: number): Promise> { - return this._getEvents("getHistoryEvents", "event/app/get_all_history_record", startTime, endTime, filter, maxResults); + return this._getEvents("getHistoryEvents", "v1/event/app/get_all_history_record", startTime, endTime, filter, maxResults); } public async getAllVideoEvents(filter?: EventFilterType, maxResults?: number): Promise> { diff --git a/src/http/interfaces.ts b/src/http/interfaces.ts index 7b549307..4e228188 100644 --- a/src/http/interfaces.ts +++ b/src/http/interfaces.ts @@ -133,6 +133,8 @@ export interface HTTPApiEvents { "hubs": (hubs: Hubs) => void; "connect": () => void; "close": () => void; + "tfa request": () => void; + "captcha request": (id: string, captcha: string) => void; } export interface StationEvents { diff --git a/src/http/models.ts b/src/http/models.ts index c2bc36cd..e4b335b3 100644 --- a/src/http/models.ts +++ b/src/http/models.ts @@ -39,6 +39,25 @@ export interface LoginResultResponse { trust_list: Array; } +export interface CaptchaResponse { + captcha_id: string; + item: string; +} + +export interface LoginRequest { + client_secret_info: { + public_key: string; + }, + enc: number; + email: string; + password: string; + time_zone: number; + verify_code?: string; + captcha_id?: string; + answer?: string + transaction: string; +} + export interface HubResponse { readonly [index: string]: unknown; station_id: number; diff --git a/src/http/types.ts b/src/http/types.ts index 57697192..7ef25b83 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -179,7 +179,8 @@ export enum AuthResult { ERROR = -1, OK = 0, RENEW = 2, - SEND_VERIFY_CODE = 3 + SEND_VERIFY_CODE = 3, + CAPTCHA_NEEDED = 4 } export enum StorageType { diff --git a/src/interfaces.ts b/src/interfaces.ts index c07ae55c..878921b5 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -68,6 +68,7 @@ export interface EufySecurityEvents { "connect": () => void; "close": () => void; "tfa request": () => void; + "captcha request": (id: string, captcha: string) => void; "cloud livestream start": (station: Station, device: Device, url: string) => void; "cloud livestream stop": (station: Station, device: Device) => void; } \ No newline at end of file