Skip to content

Commit

Permalink
Implemented captcha authentication mechanism (API v2)
Browse files Browse the repository at this point in the history
Fixed issue #69
  • Loading branch information
bropat committed Nov 22, 2021
1 parent 82c0e62 commit 8f0b05c
Show file tree
Hide file tree
Showing 9 changed files with 105 additions and 97 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
56 changes: 31 additions & 25 deletions src/eufysecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
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);
Expand Down Expand Up @@ -508,32 +509,33 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
this.pushService.open();
}

public async connect(verifyCode?:string|null): Promise<boolean> {
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<boolean> {
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;
}
}
}
Expand Down Expand Up @@ -1249,4 +1251,8 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
}
}

private onCaptchaRequest(id: string, captcha: string): void {
this.emit("captcha request", id, captcha);
}

}
110 changes: 42 additions & 68 deletions src/http/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -97,26 +97,29 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
return this.headers.language;
}

public async authenticate(): Promise<AuthResult> {
public async authenticate(verifyCodeOrCaptcha: string | null = null, captchaId: string | null = null): Promise<AuthResult> {
//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;
});
Expand All @@ -139,7 +142,6 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
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;
Expand All @@ -150,16 +152,21 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
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
this.tokenExpiration = new Date(dataresult.token_expires_at * 1000);

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 });
}
Expand All @@ -170,9 +177,10 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
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;
}

Expand Down Expand Up @@ -234,57 +242,29 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {

public async addTrustDevice(verifyCode: string): Promise<boolean> {
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 });
}
Expand All @@ -308,7 +288,7 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
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);
Expand All @@ -322,8 +302,8 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
const dataresult: Array<HubResponse> = 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 {
Expand All @@ -350,7 +330,7 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
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);
Expand Down Expand Up @@ -471,9 +451,6 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
} 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 });
}
Expand Down Expand Up @@ -504,9 +481,6 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
} 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 });
}
Expand Down Expand Up @@ -717,15 +691,15 @@ export class HTTPApi extends TypedEmitter<HTTPApiEvents> {
}

public async getVideoEvents(startTime: Date, endTime: Date, filter?: EventFilterType, maxResults?: number): Promise<Array<EventRecordResponse>> {
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<Array<EventRecordResponse>> {
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<Array<EventRecordResponse>> {
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<Array<EventRecordResponse>> {
Expand Down
2 changes: 2 additions & 0 deletions src/http/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions src/http/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ export interface LoginResultResponse {
trust_list: Array<TrustDevice>;
}

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;
Expand Down
3 changes: 2 additions & 1 deletion src/http/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

0 comments on commit 8f0b05c

Please sign in to comment.