diff --git a/index.d.ts b/index.d.ts index 710d404..a99c508 100644 --- a/index.d.ts +++ b/index.d.ts @@ -265,6 +265,9 @@ declare namespace WAWebJS { /** Emitted when the client has initialized and is ready to receive messages */ on(event: 'ready', listener: () => void): this + + /** Emitted when the RemoteAuth session is saved successfully on the external Database */ + on(event: 'remote_session_saved', listener: () => void): this } /** Current connection information */ @@ -354,6 +357,9 @@ declare namespace WAWebJS { failureEventPayload?: any }>; getAuthEventPayload: () => Promise; + afterAuthReady: () => Promise; + disconnect: () => Promise; + destroy: () => Promise; logout: () => Promise; } @@ -374,6 +380,30 @@ declare namespace WAWebJS { dataPath?: string }) } + + /** + * Remote-based authentication + */ + export class RemoteAuth extends AuthStrategy { + public clientId?: string; + public dataPath?: string; + constructor(options?: { + store: Store, + clientId?: string, + dataPath?: string, + backupSyncIntervalMs: number + }) + } + + /** + * Remote store interface + */ + export interface Store { + sessionExists: ({session: string}) => Promise | boolean, + delete: ({session: string}) => Promise | any, + save: ({session: string}) => Promise | any, + extract: ({session: string, path: string}) => Promise | any, + } /** * Legacy session auth strategy @@ -476,6 +506,7 @@ declare namespace WAWebJS { DISCONNECTED = 'disconnected', STATE_CHANGED = 'change_state', BATTERY_CHANGED = 'change_battery', + REMOTE_SESSION_SAVED = 'remote_session_saved' } /** Group notification types */ diff --git a/index.js b/index.js index 5a149e4..b498064 100644 --- a/index.js +++ b/index.js @@ -25,6 +25,7 @@ module.exports = { // Auth Strategies NoAuth: require('./src/authStrategies/NoAuth'), LocalAuth: require('./src/authStrategies/LocalAuth'), + RemoteAuth: require('./src/authStrategies/RemoteAuth'), LegacySessionAuth: require('./src/authStrategies/LegacySessionAuth'), ...Constants diff --git a/package.json b/package.json index e33dfe4..13f41c0 100644 --- a/package.json +++ b/package.json @@ -51,5 +51,10 @@ }, "engines": { "node": ">=12.0.0" + }, + "optionalDependencies": { + "archiver": "^5.3.1", + "fs-extra": "^10.1.0", + "unzipper": "^0.10.11" } } diff --git a/src/Client.js b/src/Client.js index 2368ef2..b36eb96 100644 --- a/src/Client.js +++ b/src/Client.js @@ -419,7 +419,7 @@ class Client extends EventEmitter { this.emit(Events.MEDIA_UPLOADED, message); }); - await page.exposeFunction('onAppStateChangedEvent', (state) => { + await page.exposeFunction('onAppStateChangedEvent', async (state) => { /** * Emitted when the connection state changes @@ -446,6 +446,7 @@ class Client extends EventEmitter { * @event Client#disconnected * @param {WAState|"NAVIGATION"} reason reason that caused the disconnect */ + await this.authStrategy.disconnect(); this.emit(Events.DISCONNECTED, state); this.destroy(); } @@ -548,11 +549,13 @@ class Client extends EventEmitter { * @event Client#ready */ this.emit(Events.READY); + this.authStrategy.afterAuthReady(); // Disconnect when navigating away when in PAIRING state (detect logout) this.pupPage.on('framenavigated', async () => { const appState = await this.getState(); if(!appState || appState === WAState.PAIRING) { + await this.authStrategy.disconnect(); this.emit(Events.DISCONNECTED, 'NAVIGATION'); await this.destroy(); } @@ -564,6 +567,7 @@ class Client extends EventEmitter { */ async destroy() { await this.pupBrowser.close(); + await this.authStrategy.destroy(); } /** diff --git a/src/authStrategies/BaseAuthStrategy.js b/src/authStrategies/BaseAuthStrategy.js index 0c7a7c9..0344026 100644 --- a/src/authStrategies/BaseAuthStrategy.js +++ b/src/authStrategies/BaseAuthStrategy.js @@ -18,6 +18,9 @@ class BaseAuthStrategy { }; } async getAuthEventPayload() {} + async afterAuthReady() {} + async disconnect() {} + async destroy() {} async logout() {} } diff --git a/src/authStrategies/RemoteAuth.js b/src/authStrategies/RemoteAuth.js new file mode 100644 index 0000000..1645ccd --- /dev/null +++ b/src/authStrategies/RemoteAuth.js @@ -0,0 +1,201 @@ +'use strict'; + +/* Require Optional Dependencies */ +try { + var fs = require('fs-extra'); + var unzipper = require('unzipper'); + var archiver = require('archiver'); +} catch { + throw new Error('Optional Dependencies [fs-extra, unzipper, archiver] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag'); +} + +const path = require('path'); +const { Events } = require('./../util/Constants'); +const BaseAuthStrategy = require('./BaseAuthStrategy'); + +/** + * Remote-based authentication + * @param {object} options - options + * @param {object} options.store - Remote database store instance + * @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance + * @param {string} options.dataPath - Change the default path for saving session files, default is: "./.wwebjs_auth/" + * @param {number} options.backupSyncIntervalMs - Sets the time interval for periodic session backups. Accepts values starting from 60000ms {1 minute} + */ +class RemoteAuth extends BaseAuthStrategy { + constructor({ clientId, dataPath, store, backupSyncIntervalMs } = {}) { + super(); + + const idRegex = /^[-_\w]+$/i; + if (clientId && !idRegex.test(clientId)) { + throw new Error('Invalid clientId. Only alphanumeric characters, underscores and hyphens are allowed.'); + } + if (!backupSyncIntervalMs || backupSyncIntervalMs < 60000) { + throw new Error('Invalid backupSyncIntervalMs. Accepts values starting from 60000ms {1 minute}.'); + } + if(!store) throw new Error('Remote database store is required.'); + + this.store = store; + this.clientId = clientId; + this.backupSyncIntervalMs = backupSyncIntervalMs; + this.dataPath = path.resolve(dataPath || './.wwebjs_auth/'); + this.tempDir = `${this.dataPath}/wwebjs_temp_session`; + this.requiredDirs = ['Default', 'IndexedDB', 'Local Storage']; /* => Required Files & Dirs in WWebJS to restore session */ + } + + async beforeBrowserInitialized() { + const puppeteerOpts = this.client.options.puppeteer; + const sessionDirName = this.clientId ? `RemoteAuth-${this.clientId}` : 'RemoteAuth'; + const dirPath = path.join(this.dataPath, sessionDirName); + + if (puppeteerOpts.userDataDir && puppeteerOpts.userDataDir !== dirPath) { + throw new Error('RemoteAuth is not compatible with a user-supplied userDataDir.'); + } + + this.userDataDir = dirPath; + this.sessionName = sessionDirName; + + await this.extractRemoteSession(); + + this.client.options.puppeteer = { + ...puppeteerOpts, + userDataDir: dirPath + }; + } + + async logout() { + await this.disconnect(); + } + + async destroy() { + clearInterval(this.backupSync); + } + + async disconnect() { + await this.deleteRemoteSession(); + + let pathExists = await this.isValidPath(this.userDataDir); + if (pathExists) { + await fs.promises.rm(this.userDataDir, { + recursive: true, + force: true + }).catch(() => {}); + } + clearInterval(this.backupSync); + } + + async afterAuthReady() { + const sessionExists = await this.store.sessionExists({session: this.sessionName}); + if(!sessionExists) { + await this.delay(60000); /* Initial delay sync required for session to be stable enough to recover */ + await this.storeRemoteSession({emit: true}); + } + var self = this; + this.backupSync = setInterval(async function () { + await self.storeRemoteSession(); + }, this.backupSyncIntervalMs); + } + + async storeRemoteSession(options) { + /* Compress & Store Session */ + const pathExists = await this.isValidPath(this.userDataDir); + if (pathExists) { + await this.compressSession(); + await this.store.save({session: this.sessionName}); + await fs.promises.unlink(`${this.sessionName}.zip`); + await fs.promises.rm(`${this.tempDir}`, { + recursive: true, + force: true + }).catch(() => {}); + if(options && options.emit) this.client.emit(Events.REMOTE_SESSION_SAVED); + } + } + + async extractRemoteSession() { + const pathExists = await this.isValidPath(this.userDataDir); + const compressedSessionPath = `${this.sessionName}.zip`; + const sessionExists = await this.store.sessionExists({session: this.sessionName}); + if (pathExists) { + await fs.promises.rm(this.userDataDir, { + recursive: true, + force: true + }).catch(() => {}); + } + if (sessionExists) { + await this.store.extract({session: this.sessionName, path: compressedSessionPath}); + await this.unCompressSession(compressedSessionPath); + } else { + fs.mkdirSync(this.userDataDir, { recursive: true }); + } + } + + async deleteRemoteSession() { + const sessionExists = await this.store.sessionExists({session: this.sessionName}); + if (sessionExists) await this.store.delete({session: this.sessionName}); + } + + async compressSession() { + const archive = archiver('zip'); + const stream = fs.createWriteStream(`${this.sessionName}.zip`); + + await fs.copy(this.userDataDir, this.tempDir).catch(() => {}); + await this.deleteMetadata(); + return new Promise((resolve, reject) => { + archive + .directory(this.tempDir, false) + .on('error', err => reject(err)) + .pipe(stream); + + stream.on('close', () => resolve()); + archive.finalize(); + }); + } + + async unCompressSession(compressedSessionPath) { + var stream = fs.createReadStream(compressedSessionPath); + await new Promise((resolve, reject) => { + stream.pipe(unzipper.Extract({ + path: this.userDataDir + })) + .on('error', err => reject(err)) + .on('finish', () => resolve()); + }); + await fs.promises.unlink(compressedSessionPath); + } + + async deleteMetadata() { + const sessionDirs = [this.tempDir, path.join(this.tempDir, 'Default')]; + for (const dir of sessionDirs) { + const sessionFiles = await fs.promises.readdir(dir); + for (const element of sessionFiles) { + if (!this.requiredDirs.includes(element)) { + const dirElement = path.join(dir, element); + const stats = await fs.promises.lstat(dirElement); + + if (stats.isDirectory()) { + await fs.promises.rm(dirElement, { + recursive: true, + force: true + }); + } else { + await fs.promises.unlink(dirElement); + } + } + } + } + } + + async isValidPath(path) { + try { + await fs.promises.access(path); + return true; + } catch { + return false; + } + } + + async delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +module.exports = RemoteAuth; \ No newline at end of file diff --git a/src/util/Constants.js b/src/util/Constants.js index a85b8d8..d062c7a 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -51,7 +51,8 @@ exports.Events = { DISCONNECTED: 'disconnected', STATE_CHANGED: 'change_state', BATTERY_CHANGED: 'change_battery', - INCOMING_CALL: 'incoming_call' + INCOMING_CALL: 'incoming_call', + REMOTE_SESSION_SAVED: 'remote_session_saved' }; /**