From f6de161c7d6a8870c3bcf75683df94e1c574c363 Mon Sep 17 00:00:00 2001 From: "Pedro S. Lopez" Date: Sun, 27 Feb 2022 22:02:49 -0400 Subject: [PATCH] Auth Strategies (#1257) * auth strategies * default to no auth * rename base auth strategy * rename base strategy cont. * refactor auth strategy methods and LocalAuth * activate old session options even if is falsy value * move restartOnAuthFail to LegacyAuthStrategy option * add link to guide item * update example/shell * types --- .gitignore | 5 +- .npmignore | 2 + example.js | 13 +-- index.d.ts | 80 ++++++++++++------ index.js | 6 ++ shell.js | 6 +- src/Client.js | 106 ++++++++---------------- src/authStrategies/BaseAuthStrategy.js | 24 ++++++ src/authStrategies/LegacySessionAuth.js | 72 ++++++++++++++++ src/authStrategies/LocalAuth.js | 54 ++++++++++++ src/authStrategies/NoAuth.js | 12 +++ src/util/Constants.js | 2 - 12 files changed, 266 insertions(+), 116 deletions(-) create mode 100644 src/authStrategies/BaseAuthStrategy.js create mode 100644 src/authStrategies/LegacySessionAuth.js create mode 100644 src/authStrategies/LocalAuth.js create mode 100644 src/authStrategies/NoAuth.js diff --git a/.gitignore b/.gitignore index 73c67dc..79f6467 100644 --- a/.gitignore +++ b/.gitignore @@ -70,7 +70,4 @@ typings/ # Test sessions *session.json - -# user data -WWebJS/ -userDataDir/ \ No newline at end of file +.wwebjs_auth/ \ No newline at end of file diff --git a/.npmignore b/.npmignore index c68d77a..a5131c9 100644 --- a/.npmignore +++ b/.npmignore @@ -12,6 +12,8 @@ yarn-debug.log* yarn-error.log* *session.json +.wwebjs_auth/ + .env tools/ tests/ diff --git a/example.js b/example.js index bc3388e..021efb3 100644 --- a/example.js +++ b/example.js @@ -1,17 +1,10 @@ -const { Client, Location, List, Buttons } = require('./index'); +const { Client, Location, List, Buttons, LocalAuth } = require('./index'); -const client = new Client({ - clientId: 'example', +const client = new Client({ + authStrategy: new LocalAuth(), puppeteer: { headless: false } }); -// You also could connect to an existing instance of a browser -// { -// puppeteer: { -// browserWSEndpoint: `ws://localhost:3000` -// } -// } - client.initialize(); client.on('qr', (qr) => { diff --git a/index.d.ts b/index.d.ts index c3d1ab1..bdd7f54 100644 --- a/index.d.ts +++ b/index.d.ts @@ -157,8 +157,7 @@ declare namespace WAWebJS { /** Emitted when authentication is successful */ on(event: 'authenticated', listener: ( /** - * Object containing session information. Can be used to restore the session - * @deprecated + * Object containing session information, when using LegacySessionAuth. Can be used to restore the session */ session?: ClientSession ) => void): this @@ -297,35 +296,23 @@ declare namespace WAWebJS { /** Options for initializing the whatsapp client */ export interface ClientOptions { /** Timeout for authentication selector in puppeteer - * @default 45000 */ + * @default 0 */ authTimeoutMs?: number, /** Puppeteer launch options. View docs here: https://github.com/puppeteer/puppeteer/ */ puppeteer?: puppeteer.LaunchOptions & puppeteer.BrowserLaunchArgumentOptions & puppeteer.BrowserConnectOptions - /** Refresh interval for qr code (how much time to wait before checking if the qr code has changed) - * @default 20000 */ - qrRefreshIntervalMs?: number - /** Timeout for qr code selector in puppeteer - * @default 45000 */ - qrTimeoutMs?: number, - /** How many times should the qrcode be refreshed before giving up + /** Determines how to save and restore sessions. Will use LegacySessionAuth if options.session is set. Otherwise, NoAuth will be used. */ + authStrategy?: AuthStrategy, + /** How many times should the qrcode be refreshed before giving up * @default 0 (disabled) */ qrMaxRetries?: number, - /** Restart client with a new session (i.e. use null 'session' var) if authentication fails - * @default false */ - restartOnAuthFail?: boolean - /** - * Enable authentication via a `session` option. - * @deprecated Will be removed in a future release - */ - useDeprecatedSessionAuth?: boolean /** - * WhatsApp session to restore. If not set, will start a new session - * @deprecated Set `useDeprecatedSessionAuth: true` to enable. This auth method is not supported by MultiDevice and will be removed in a future release. + * @deprecated This option should be set directly on the LegacySessionAuth + */ + restartOnAuthFail?: boolean + /** + * @deprecated Only here for backwards-compatibility. You should move to using LocalAuth, or set the authStrategy to LegacySessionAuth explicitly. */ session?: ClientSession - /** Client id to distinguish instances if you are using multiple, otherwise keep empty if you are using only one instance - * @default '' */ - clientId: string /** If another whatsapp web session is detected (another browser), take over the session in the current browser * @default false */ takeoverOnConflict?: boolean, @@ -338,14 +325,53 @@ declare namespace WAWebJS { /** Ffmpeg path to use when formating videos to webp while sending stickers * @default 'ffmpeg' */ ffmpegPath?: string - /** Path to place session objects in - @default './WWebJS' */ - dataPath?: string + } + + /** + * Base class which all authentication strategies extend + */ + export abstract class AuthStrategy { + setup: (client: Client) => void; + beforeBrowserInitialized: () => Promise; + afterBrowserInitialized: () => Promise; + onAuthenticationNeeded: () => Promise<{ + failed?: boolean; + restart?: boolean; + failureEventPayload?: any + }>; + getAuthEventPayload: () => Promise; + logout: () => Promise; + } + + /** + * No session restoring functionality + * Will need to authenticate via QR code every time + */ + export class NoAuth extends AuthStrategy {} + + /** + * Local directory-based authentication + */ + export class LocalAuth extends AuthStrategy { + constructor(options?: { + clientId?: string, + dataPath?: string + }) + } + + /** + * Legacy session auth strategy + * Not compatible with multi-device accounts. + */ + export class LegacySessionAuth extends AuthStrategy { + constructor(options?: { + session?: ClientSession, + restartOnAuth?: boolean, + }) } /** * Represents a WhatsApp client session - * @deprecated */ export interface ClientSession { WABrowserId: string, diff --git a/index.js b/index.js index 3d2b64f..5a149e4 100644 --- a/index.js +++ b/index.js @@ -21,5 +21,11 @@ module.exports = { ProductMetadata: require('./src/structures/ProductMetadata'), List: require('./src/structures/List'), Buttons: require('./src/structures/Buttons'), + + // Auth Strategies + NoAuth: require('./src/authStrategies/NoAuth'), + LocalAuth: require('./src/authStrategies/LocalAuth'), + LegacySessionAuth: require('./src/authStrategies/LegacySessionAuth'), + ...Constants }; diff --git a/shell.js b/shell.js index 4f94c35..fd593a1 100644 --- a/shell.js +++ b/shell.js @@ -2,17 +2,17 @@ * ==== wwebjs-shell ==== * Used for quickly testing library features * - * Running `npm run shell` will start WhatsApp Web in headless mode + * Running `npm run shell` will start WhatsApp Web with headless=false * and then drop you into Node REPL with `client` in its context. */ const repl = require('repl'); -const { Client } = require('./index'); +const { Client, LocalAuth } = require('./index'); const client = new Client({ puppeteer: { headless: false }, - clientId: 'shell' + authStrategy: new LocalAuth() }); console.log('Initializing...'); diff --git a/src/Client.js b/src/Client.js index 5f908e0..30d9ea3 100644 --- a/src/Client.js +++ b/src/Client.js @@ -1,7 +1,5 @@ 'use strict'; -const path = require('path'); -const fs = require('fs'); const EventEmitter = require('events'); const puppeteer = require('puppeteer'); const moduleRaid = require('@pedroslopez/moduleraid/moduleraid'); @@ -13,23 +11,24 @@ const { ExposeStore, LoadUtils } = require('./util/Injected'); const ChatFactory = require('./factories/ChatFactory'); const ContactFactory = require('./factories/ContactFactory'); const { ClientInfo, Message, MessageMedia, Contact, Location, GroupNotification, Label, Call, Buttons, List } = require('./structures'); +const LegacySessionAuth = require('./authStrategies/LegacySessionAuth'); +const NoAuth = require('./authStrategies/NoAuth'); + /** * Starting point for interacting with the WhatsApp Web API * @extends {EventEmitter} * @param {object} options - Client options + * @param {AuthStrategy} options.authStrategy - Determines how to save and restore sessions. Will use LegacySessionAuth if options.session is set. Otherwise, NoAuth will be used. * @param {number} options.authTimeoutMs - Timeout for authentication selector in puppeteer * @param {object} options.puppeteer - Puppeteer launch options. View docs here: https://github.com/puppeteer/puppeteer/ * @param {number} options.qrMaxRetries - How many times should the qrcode be refreshed before giving up - * @param {string} options.restartOnAuthFail - Restart client with a new session (i.e. use null 'session' var) if authentication fails - * @param {boolean} options.useDeprecatedSessionAuth - Enable JSON-based authentication. This is deprecated due to not being supported by MultiDevice, and will be removed in a future version. - * @param {object} options.session - This is deprecated due to not being supported by MultiDevice, and will be removed in a future version. + * @param {string} options.restartOnAuthFail - @deprecated This option should be set directly on the LegacySessionAuth. + * @param {object} options.session - @deprecated Only here for backwards-compatibility. You should move to using LocalAuth, or set the authStrategy to LegacySessionAuth explicitly. * @param {number} options.takeoverOnConflict - If another whatsapp web session is detected (another browser), take over the session in the current browser * @param {number} options.takeoverTimeoutMs - How much time to wait before taking over the session - * @param {string} options.dataPath - Change the default path for saving session files, default is: "./WWebJS/" * @param {string} options.userAgent - User agent to use in puppeteer * @param {string} options.ffmpegPath - Ffmpeg path to use when formating videos to webp while sending stickers * @param {boolean} options.bypassCSP - Sets bypassing of page's Content-Security-Policy. - * @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance * * @fires Client#qr * @fires Client#authenticated @@ -52,20 +51,28 @@ class Client extends EventEmitter { super(); this.options = Util.mergeDefault(DefaultOptions, options); + + if(!this.options.authStrategy) { + if(Object.prototype.hasOwnProperty.call(this.options, 'session')) { + process.emitWarning( + 'options.session is deprecated and will be removed in a future release due to incompatibility with multi-device. ' + + 'Use the LocalAuth authStrategy, don\'t pass in a session as an option, or suppress this warning by using the LegacySessionAuth strategy explicitly (see https://wwebjs.dev/guide/authentication.html#legacysessionauth-strategy).', + 'DeprecationWarning' + ); - this.id = this.options.clientId; - - // eslint-disable-next-line no-useless-escape - const foldernameRegex = /^(?!.{256,})(?!(aux|clock\$|con|nul|prn|com[1-9]|lpt[1-9])(?:$|\.))[^ ][ \.\w-$()+=[\];#@~,&']+[^\. ]$/i; - if (this.id && !foldernameRegex.test(this.id)) throw Error('Invalid client ID. Make sure you abide by the folder naming rules of your operating system.'); - - if (!this.options.useDeprecatedSessionAuth) { - this.dataDir = this.options.puppeteer.userDataDir; - const dirPath = path.join(process.cwd(), this.options.dataPath, this.id ? 'session-' + this.id : 'session'); - if (!this.dataDir) this.dataDir = dirPath; - fs.mkdirSync(this.dataDir, { recursive: true }); + this.authStrategy = new LegacySessionAuth({ + session: this.options.session, + restartOnAuthFail: this.options.restartOnAuthFail + }); + } else { + this.authStrategy = new NoAuth(); + } + } else { + this.authStrategy = this.options.authStrategy; } + this.authStrategy.setup(this); + this.pupBrowser = null; this.pupPage = null; @@ -78,10 +85,9 @@ class Client extends EventEmitter { async initialize() { let [browser, page] = [null, null]; - const puppeteerOpts = { - ...this.options.puppeteer, - userDataDir: this.options.useDeprecatedSessionAuth ? undefined : this.dataDir - }; + await this.authStrategy.beforeBrowserInitialized(); + + const puppeteerOpts = this.options.puppeteer; if (puppeteerOpts && puppeteerOpts.browserWSEndpoint) { browser = await puppeteer.connect(puppeteerOpts); page = await browser.newPage(); @@ -91,27 +97,12 @@ class Client extends EventEmitter { } await page.setUserAgent(this.options.userAgent); + if (this.options.bypassCSP) await page.setBypassCSP(true); this.pupBrowser = browser; this.pupPage = page; - if (this.options.useDeprecatedSessionAuth && this.options.session) { - await page.evaluateOnNewDocument(session => { - if (document.referrer === 'https://whatsapp.com/') { - localStorage.clear(); - localStorage.setItem('WABrowserId', session.WABrowserId); - localStorage.setItem('WASecretBundle', session.WASecretBundle); - localStorage.setItem('WAToken1', session.WAToken1); - localStorage.setItem('WAToken2', session.WAToken2); - } - - localStorage.setItem('remember-me', 'true'); - }, this.options.session); - } - - if (this.options.bypassCSP) { - await page.setBypassCSP(true); - } + await this.authStrategy.afterBrowserInitialized(); await page.goto(WhatsWebURL, { waitUntil: 'load', @@ -141,18 +132,17 @@ class Client extends EventEmitter { // Scan-qrcode selector was found. Needs authentication if (needAuthentication) { - if(this.options.session) { + const { failed, failureEventPayload, restart } = await this.authStrategy.onAuthenticationNeeded(); + if(failed) { /** * Emitted when there has been an error while trying to restore an existing session * @event Client#auth_failure * @param {string} message - * @deprecated */ - this.emit(Events.AUTHENTICATION_FAILURE, 'Unable to log in. Are the session details valid?'); + this.emit(Events.AUTHENTICATION_FAILURE, failureEventPayload); await this.destroy(); - if (this.options.restartOnAuthFail) { + if (restart) { // session restore failed so try again but without session to force new authentication - this.options.session = null; return this.initialize(); } return; @@ -224,20 +214,7 @@ class Client extends EventEmitter { } await page.evaluate(ExposeStore, moduleRaid.toString()); - let authEventPayload = undefined; - if (this.options.useDeprecatedSessionAuth) { - // Get session tokens - const localStorage = JSON.parse(await page.evaluate(() => { - return JSON.stringify(window.localStorage); - })); - - authEventPayload = { - WABrowserId: localStorage.WABrowserId, - WASecretBundle: localStorage.WASecretBundle, - WAToken1: localStorage.WAToken1, - WAToken2: localStorage.WAToken2 - }; - } + const authEventPayload = await this.authStrategy.getAuthEventPayload(); /** * Emitted when authentication is successful @@ -248,23 +225,14 @@ class Client extends EventEmitter { // Check window.Store Injection await page.waitForFunction('window.Store != undefined'); - const isMD = await page.evaluate(() => { - return window.Store.Features.features.MD_BACKEND; - }); - await page.evaluate(async () => { // safely unregister service workers const registrations = await navigator.serviceWorker.getRegistrations(); for (let registration of registrations) { registration.unregister(); } - }); - if (this.options.useDeprecatedSessionAuth && isMD) { - throw new Error('Authenticating via JSON session is not supported for MultiDevice-enabled WhatsApp accounts.'); - } - //Load util functions (serializers, helper functions) await page.evaluate(LoadUtils); @@ -518,9 +486,7 @@ class Client extends EventEmitter { return window.Store.AppState.logout(); }); - if (this.dataDir) { - return (fs.rmSync ? fs.rmSync : fs.rmdirSync).call(this.dataDir, { recursive: true }); - } + await this.authStrategy.logout(); } /** diff --git a/src/authStrategies/BaseAuthStrategy.js b/src/authStrategies/BaseAuthStrategy.js new file mode 100644 index 0000000..0c7a7c9 --- /dev/null +++ b/src/authStrategies/BaseAuthStrategy.js @@ -0,0 +1,24 @@ +'use strict'; + +/** + * Base class which all authentication strategies extend + */ +class BaseAuthStrategy { + constructor() {} + setup(client) { + this.client = client; + } + async beforeBrowserInitialized() {} + async afterBrowserInitialized() {} + async onAuthenticationNeeded() { + return { + failed: false, + restart: false, + failureEventPayload: undefined + }; + } + async getAuthEventPayload() {} + async logout() {} +} + +module.exports = BaseAuthStrategy; \ No newline at end of file diff --git a/src/authStrategies/LegacySessionAuth.js b/src/authStrategies/LegacySessionAuth.js new file mode 100644 index 0000000..58332ac --- /dev/null +++ b/src/authStrategies/LegacySessionAuth.js @@ -0,0 +1,72 @@ +'use strict'; + +const BaseAuthStrategy = require('./BaseAuthStrategy'); + +/** + * Legacy session auth strategy + * Not compatible with multi-device accounts. + * @param {object} options - options + * @param {string} options.restartOnAuthFail - Restart client with a new session (i.e. use null 'session' var) if authentication fails + * @param {object} options.session - Whatsapp session to restore. If not set, will start a new session + * @param {string} options.session.WABrowserId + * @param {string} options.session.WASecretBundle + * @param {string} options.session.WAToken1 + * @param {string} options.session.WAToken2 + */ +class LegacySessionAuth extends BaseAuthStrategy { + constructor({ session, restartOnAuthFail }) { + super(); + this.session = session; + this.restartOnAuthFail = restartOnAuthFail; + } + + async afterBrowserInitialized() { + if(this.session) { + await this.client.pupPage.evaluateOnNewDocument(session => { + if (document.referrer === 'https://whatsapp.com/') { + localStorage.clear(); + localStorage.setItem('WABrowserId', session.WABrowserId); + localStorage.setItem('WASecretBundle', session.WASecretBundle); + localStorage.setItem('WAToken1', session.WAToken1); + localStorage.setItem('WAToken2', session.WAToken2); + } + + localStorage.setItem('remember-me', 'true'); + }, this.session); + } + } + + async onAuthenticationNeeded() { + if(this.session) { + this.session = null; + return { + failed: true, + restart: this.restartOnAuthFail, + failureEventPayload: 'Unable to log in. Are the session details valid?' + }; + } + + return { failed: false }; + } + + async getAuthEventPayload() { + const isMD = await this.client.pupPage.evaluate(() => { + return window.Store.Features.features.MD_BACKEND; + }); + + if(isMD) throw new Error('Authenticating via JSON session is not supported for MultiDevice-enabled WhatsApp accounts.'); + + const localStorage = JSON.parse(await this.client.pupPage.evaluate(() => { + return JSON.stringify(window.localStorage); + })); + + return { + WABrowserId: localStorage.WABrowserId, + WASecretBundle: localStorage.WASecretBundle, + WAToken1: localStorage.WAToken1, + WAToken2: localStorage.WAToken2 + }; + } +} + +module.exports = LegacySessionAuth; \ No newline at end of file diff --git a/src/authStrategies/LocalAuth.js b/src/authStrategies/LocalAuth.js new file mode 100644 index 0000000..1f9770d --- /dev/null +++ b/src/authStrategies/LocalAuth.js @@ -0,0 +1,54 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const BaseAuthStrategy = require('./BaseAuthStrategy'); + +/** + * Local directory-based authentication + * @param {object} options - options + * @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/" +*/ +class LocalAuth extends BaseAuthStrategy { + constructor({ clientId, dataPath }={}) { + super(); + + const idRegex = /^[-_\w]+$/i; + if(clientId && !idRegex.test(clientId)) { + throw new Error('Invalid clientId. Only alphanumeric characters, underscores and hyphens are allowed.'); + } + + this.dataPath = path.resolve(dataPath || './.wwebjs_auth/'); + this.clientId = clientId; + } + + async beforeBrowserInitialized() { + const puppeteerOpts = this.client.options.puppeteer; + + if(puppeteerOpts.userDataDir) { + throw new Error('LocalAuth is not compatible with a user-supplied userDataDir.'); + } + + const sessionDirName = this.clientId ? `session-${this.clientId}` : 'session'; + const dirPath = path.join(this.dataPath, sessionDirName); + + fs.mkdirSync(dirPath, { recursive: true }); + + this.client.options.puppeteer = { + ...puppeteerOpts, + userDataDir: dirPath + }; + + this.userDataDir = dirPath; + } + + async logout() { + if (this.userDataDir) { + return (fs.rmSync ? fs.rmSync : fs.rmdirSync).call(this.userDataDir, { recursive: true }); + } + } + +} + +module.exports = LocalAuth; \ No newline at end of file diff --git a/src/authStrategies/NoAuth.js b/src/authStrategies/NoAuth.js new file mode 100644 index 0000000..1e11a2c --- /dev/null +++ b/src/authStrategies/NoAuth.js @@ -0,0 +1,12 @@ +'use strict'; + +const BaseAuthStrategy = require('./BaseAuthStrategy'); + +/** + * No session restoring functionality + * Will need to authenticate via QR code every time +*/ +class NoAuth extends BaseAuthStrategy { } + + +module.exports = NoAuth; \ No newline at end of file diff --git a/src/util/Constants.js b/src/util/Constants.js index 1b27ca5..83a030f 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -7,8 +7,6 @@ exports.DefaultOptions = { headless: true, defaultViewport: null }, - dataPath: './WWebJS/', - useDeprecatedSessionAuth: false, authTimeoutMs: 0, qrMaxRetries: 0, takeoverOnConflict: false,