From 8580b81410f00af9f36be1ed9a6cec24cb62b0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Tue, 5 Mar 2024 17:57:19 +0100 Subject: [PATCH 01/56] PB-29988 - Update the alarm in the class StartLoopAuthSessionCheckService to use the property periodInMinutes --- .../auth/startLoopAuthSessionCheckService.js | 4 +- .../startLoopAuthSessionCheckService.test.js | 14 +- test/mocks/mockAlarms.js | 135 +++++++++++++++++- 3 files changed, 140 insertions(+), 13 deletions(-) diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js index 9e175e6a..7b7aa2d5 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js @@ -42,6 +42,8 @@ class StartLoopAuthSessionCheckService { async scheduleAuthSessionCheck() { // Create an alarm to check the auth session await browser.alarms.create(AUTH_SESSION_CHECK_ALARM, { + // this `periodInMinutes` is set to ensure that after going back from sleep mode the alarms still triggers + periodInMinutes: 1, when: Date.now() + CHECK_IS_AUTHENTICATED_INTERVAL_PERIOD }); browser.alarms.onAlarm.addListener(this.checkAuthStatus); @@ -66,8 +68,6 @@ class StartLoopAuthSessionCheckService { if (alarm.name === AUTH_SESSION_CHECK_ALARM) { if (!await this.gpgAuth.isAuthenticated()) { self.dispatchEvent(new Event('passbolt.auth.after-logout')); - } else { - await this.scheduleAuthSessionCheck(); } } } diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js index 85585bcc..00bdd33e 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js @@ -25,7 +25,7 @@ beforeEach(() => { describe("StartLoopAuthSessionCheckService", () => { it("should trigger a check authentication and clear alarm on logout", async() => { - expect.assertions(11); + expect.assertions(12); // Data mocked const gpgAuth = new GpgAuth(); const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(gpgAuth); @@ -43,21 +43,21 @@ describe("StartLoopAuthSessionCheckService", () => { expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(0); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(60000); - await Promise.resolve(); - expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(2); + expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(1); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(60000); + expect(spyIsAuthenticated).toHaveBeenCalledTimes(2); + self.dispatchEvent(new Event('passbolt.auth.after-logout')); - expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(2); - expect(spyIsAuthenticated).toHaveBeenCalledTimes(1); + expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); + expect(spyIsAuthenticated).toHaveBeenCalledTimes(2); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyAlarmRemoveListener).toHaveBeenCalledWith(startLoopAuthSessionCheckService.checkAuthStatus); }); - it("should send logout event if not authenticated anymore", async() => { expect.assertions(11); // Data mocked diff --git a/test/mocks/mockAlarms.js b/test/mocks/mockAlarms.js index 9fd4f7b2..94a1e847 100644 --- a/test/mocks/mockAlarms.js +++ b/test/mocks/mockAlarms.js @@ -14,32 +14,123 @@ /** * Mock class to be used in replacement of chrome.alarms - * At the moment, only triggers an alarm once. */ class MockAlarms { constructor() { this._registeredAlarms = {}; this._timeouts = {}; + this._intervals = {}; this.onAlarm = new OnAlarmEvent(); this.onAlarm.triggerAlarm = this.onAlarm.triggerAlarm.bind(this); } + /** + * Register a new alarm by mocking the mecanism with setInterval and setTimeout. + * @param {string} alarmName the name of the alarm passed as the callback parameter when the alarm triggers + * @param {object} options the options to define when the alarm triggers and at which frequency + * @return {Promise} a promise is return to simulate the chrome.alarm API + */ async create(alarmName, options) { + if (!options.periodInMinutes && options.when) { // is a single alarm call + this._createTimeout(alarmName, options); + } else if (options.periodInMinutes && options.when) { // is a repeated alarm starting at a given timestamp + this._createDelayedInterval(alarmName, options); + } else if (options.periodInMinutes && options.delayInMinutes) { // is a repeated alarm start after a delay + this._createDelayedInterval(alarmName, options); + } else if (options.periodInMinutes) { // is a repeated alarm where counter start immediately + this._createInterval(alarmName, options); + } + } + + /** + * Creates an alarm that triggers only once using a setTimeout under the hood. + * @param {string} alarmName the name of the alarm passed as the callback parameter when the alarm triggers + * @param {object} options the options to define when the alarm triggers and at which frequency + * @private + */ + _createTimeout(alarmName, options) { + let scheduledTime = options.when; + if (!scheduledTime && options.delayInMinutes) { + scheduledTime = Date.now() + options.delayInMinutes * 60_000; + } + const alarm = { name: alarmName, - periodInMinutes: options.periodInMinutes, - scheduledTime: options.when || Date.now() + options.delayInMinutes * 1000 * 60, + scheduledTime: scheduledTime, }; + const triggerDelay = alarm.scheduledTime - Date.now(); + this._registeredAlarms[alarmName] = alarm; - const timeout = setTimeout(() => this.onAlarm.triggerAlarm(alarm), alarm.scheduledTime - Date.now()); + const timeout = setTimeout(() => this.onAlarm.triggerAlarm(alarm), triggerDelay); this._timeouts[alarmName] = timeout; } + /** + * Creates a repeating alarm that triggers every `options.periodInMinutes` minute using a setInterval. + * @param {string} alarmName the name of the alarm passed as the callback parameter when the alarm triggers + * @param {object} options the options to define when the alarm triggers and at which frequency + * @private + */ + _createInterval(alarmName, options) { + const alarm = { + name: alarmName, + periodInMinutes: options.periodInMinutes, + }; + + const triggerDelay = options.periodInMinutes * 60_000; + + this._registeredAlarms[alarmName] = alarm; + const timeout = setInterval(() => this.onAlarm.triggerAlarm(alarm), triggerDelay); + this._interval[alarmName] = timeout; + } + + /** + * Creates a repeating alarm that triggers every `options.periodInMinutes` minute after a given delay or starting from the given `options.when` timestamp. + * It uses first a setTimeout as the call is delay then a setInterval takes the relay to do the repeating part. + * @param {string} alarmName the name of the alarm passed as the callback parameter when the alarm triggers + * @param {object} options the options to define when the alarm triggers and at which frequency + * @private + */ + _createDelayedInterval(alarmName, options) { + let scheduledTime = options.when; + if (!scheduledTime && options.delayInMinutes) { + scheduledTime = Date.now() + options.delayInMinutes * 60_000 + } + + const alarm = { + name: alarmName, + periodInMinutes: options.periodInMinutes, + scheduledTime: scheduledTime, + }; + + this._registeredAlarms[alarmName] = alarm; + const periodInMinutes = options.periodInMinutes; + + const firstTrigger = setTimeout(() => { + this.onAlarm.triggerAlarm(alarm); + + // after the first delayed trigger of the series for this alarm we create a regular interval trigger + const interval = setInterval(() => this.onAlarm.triggerAlarm(alarm), periodInMinutes * 60_000); + this._intervals[alarmName] = interval; + }, alarm.scheduledTime - Date.now()); + + this._timeouts[alarmName] = firstTrigger; + } + + /** + * Returns the alarm configuration given a name or null if none found. + * @param {string} alarmName + * @returns {Promise} + */ async get(alarmName) { return this._registeredAlarms[alarmName] || null; } + /** + * Returns all the registered alarm configurations. + * @returns {Promise} + */ async getAll() { const keys = Object.keys(this._registeredAlarms); if (keys.length === 0) { @@ -54,11 +145,30 @@ class MockAlarms { return alarms; } + /** + * Clears all the timeouts and intervals bounded to an alarm given its name. + * @param {string} alarmName + * @returns {Promise} + */ async clear(alarmName) { delete this._registeredAlarms[alarmName]; + clearTimeout(this._timeouts[alarmName]); + clearInterval(this._intervals[alarmName]); } + /** + * Clears all the timeouts and intervals registered. + * @returns {Promise} + */ async clearAll() { + for (const key in Object.keys(this._timeouts)) { + clearTimeout(this._timeouts[key]); + } + + for (const key in Object.keys(this._intervals)) { + clearInterval(this._intervals[key]); + } + this._registeredAlarms = {}; } } @@ -71,12 +181,21 @@ class OnAlarmEvent { this.hasListener = this.hasListener.bind(this); this.triggerAlarm = this.triggerAlarm.bind(this); } + + /** + * Add listerner to be called on chrome.onAlarm triggers + * @param {func} callback + */ addListener(callback) { //Remove duplicate listener for desktop app to avoid infinite loop this.removeListener(callback); this._listeners.push(callback); } + /** + * Remove a callback from chrome.onAlarm + * @param {func} callback + */ removeListener(callback) { if (!this.hasListener(callback)) { return; @@ -85,11 +204,19 @@ class OnAlarmEvent { this._listeners.splice(index, 1); } + /** + * Returns true if the callback is attach as a listener + * @param {func} callback + */ hasListener(callback) { const index = this._listeners.indexOf(callback); return index > -1; } + /** + * Triggers the given alarm and run all the attached callbacks + * @param {chrome.alarm} alarm + */ triggerAlarm(alarm) { for (let i = 0; i < this._listeners.length; i++) { const callback = this._listeners[i]; From 8393f754f3949c7e687fcb52917ae4447c10ae2c Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Wed, 13 Mar 2024 14:41:58 +0100 Subject: [PATCH 02/56] PB-30335 Use timeout instead alarms for service worker --- .../service/tab/tabService.test.js | 10 +- .../service/worker/workerService.js | 121 +++++++----------- .../service/worker/workerService.test.js | 35 ++--- test/mocks/mockWebExtensionPolyfill.js | 1 + 4 files changed, 63 insertions(+), 104 deletions(-) diff --git a/src/all/background_page/service/tab/tabService.test.js b/src/all/background_page/service/tab/tabService.test.js index 713b9d55..46a77a72 100644 --- a/src/all/background_page/service/tab/tabService.test.js +++ b/src/all/background_page/service/tab/tabService.test.js @@ -203,7 +203,7 @@ describe("TabService", () => { }); it("Should exec if worker is on main frame and waiting connection", async() => { - expect.assertions(6); + expect.assertions(5); jest.useFakeTimers(); jest.clearAllTimers(); // data mocked @@ -213,8 +213,7 @@ describe("TabService", () => { tabId: worker.tabId, frameId: 0 }; - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + jest.spyOn(global, "setTimeout"); // mock function mockWorker.mockImplementationOnce(() => worker); jest.spyOn(WorkersSessionStorage, "getWorkerById").mockImplementationOnce(() => worker); @@ -224,9 +223,8 @@ describe("TabService", () => { // expectations expect(WorkersSessionStorage.getWorkerOnMainFrame).toHaveBeenCalledWith(frameDetails.tabId); // Called 1 times during the execution - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); - // start the alarm + expect(global.setTimeout).toHaveBeenCalledTimes(1); + // start the timeout jest.advanceTimersByTime(50); await Promise.resolve(); await Promise.resolve(); diff --git a/src/all/background_page/service/worker/workerService.js b/src/all/background_page/service/worker/workerService.js index 3cd25afb..b46bd1a1 100644 --- a/src/all/background_page/service/worker/workerService.js +++ b/src/all/background_page/service/worker/workerService.js @@ -11,19 +11,18 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 4.0.0 */ -import {v4 as uuid} from "uuid"; import PortManager from "../../sdk/port/portManager"; import WorkersSessionStorage from "../sessionStorage/workersSessionStorage"; import BrowserTabService from "../ui/browserTab.service"; import WorkerEntity from "../../model/entity/worker/workerEntity"; import WebNavigationService from "../webNavigation/webNavigationService"; -const WORKER_EXIST_ALARM = "WorkerExistFlush"; -const WORKER_EXIST_ALARM_TIME_CHECKING = 100; -const WORKER_CHECK_STATUS_ALARM = 'workerId-'; -const WORKER_CHECK_STATUS_ALARM_TIME_CHECKING = 50; +const WORKER_EXIST_TIME_CHECKING = 100; +const WORKER_CHECK_STATUS_TIME_CHECKING = 50; class WorkerService { + static timeoutByWorkerID = {}; + /** * * Get the worker according to the application name and tab id @@ -51,94 +50,66 @@ class WorkerService { * Wait until a worker exists * @param {string} applicationName The application name * @param {number} tabId The tab identifier on which the worker runs - * @param {int} timeout The timeout after which the promise fails if the worker is not found + * @param {int} numberOfRetry The number of retry before rejecting the promise * @return {Promise} */ - static waitExists(applicationName, tabId, timeout = 10000) { - const timeoutMs = Date.now() + timeout; - const alarmName = `${WORKER_EXIST_ALARM}-${uuid()}`; - return new Promise((resolve, reject) => { - const handleWorkerExist = async alarm => { - if (alarm.name === alarmName) { - try { - this.clearAlarm(alarmName, handleWorkerExist); - await this.get(applicationName, tabId); - resolve(); - } catch (error) { - if (alarm.scheduledTime >= timeoutMs) { - reject(error); - } else { - this.createAlarm(alarmName, WORKER_EXIST_ALARM_TIME_CHECKING, handleWorkerExist); - } - } + static async waitExists(applicationName, tabId, numberOfRetry = 50) { + // Handle worker exist and check 50 times (5 seconds) + const handleWorkerExist = async(resolve, reject, numberOfRetry) => { + try { + await this.get(applicationName, tabId); + resolve(); + } catch (error) { + if (numberOfRetry <= 0) { + reject(error); + } else { + // Use timeout cause alarm are fired at a minimum of 30 seconds + setTimeout(handleWorkerExist, WORKER_EXIST_TIME_CHECKING, resolve, reject, numberOfRetry - 1); } - }; - this.createAlarm(alarmName, WORKER_EXIST_ALARM_TIME_CHECKING, handleWorkerExist); + } + }; + + return new Promise((resolve, reject) => { + handleWorkerExist(resolve, reject, numberOfRetry); }); } /** - * Clear and create alarm to execute a navigation for worker which are waiting for connection + * Clear and use a timeout to execute a navigation for worker which are waiting for connection * @params {WorkerEntity} The worker entity * @returns {Promise} */ static async checkAndExecNavigationForWorkerWaitingConnection(workerEntity) { - const alarmName = `${WORKER_CHECK_STATUS_ALARM}${workerEntity.id}`; - await this.clearAlarm(alarmName, this.execNavigationForWorkerWaitingConnection); - await this.createAlarm(alarmName, WORKER_CHECK_STATUS_ALARM_TIME_CHECKING, this.execNavigationForWorkerWaitingConnection); - } - - /** - * Create alarm - * @param {string} alarmName The alarm name - * @param {number} timeInMs The time when the alarm fire - * @param {function} handleAlarmEvent The function on alarm listener - */ - static createAlarm(alarmName, timeInMs, handleAlarmEvent) { - // Create an alarm to check if the worker exist - browser.alarms.create(alarmName, { - when: Date.now() + timeInMs - }); - browser.alarms.onAlarm.addListener(handleAlarmEvent); - } - - /** - * Clear the alarm and listener configured. - * @param {string} alarmName The alarm name - * @param {function} handleAlarmEvent The function on alarm listener - */ - static clearAlarm(alarmName, handleAlarmEvent) { - browser.alarms.onAlarm.removeListener(handleAlarmEvent); - browser.alarms.clear(alarmName); + // Clear timeout to take only the last event of the worker to check + clearTimeout(this.timeoutByWorkerID[workerEntity.id]); + // Use timeout cause alarm are fired at a minimum of 30 seconds + this.timeoutByWorkerID[workerEntity.id] = setTimeout(this.execNavigationForWorkerWaitingConnection, WORKER_CHECK_STATUS_TIME_CHECKING, workerEntity.id); } /** * Exec a navigation for worker block in waiting connection status * @private - * @param {Object} alarm + * @param {string} workerId * @return {Promise} */ - static async execNavigationForWorkerWaitingConnection(alarm) { - if (alarm.name.startsWith(WORKER_CHECK_STATUS_ALARM)) { - const workerId = alarm.name.substring(WORKER_CHECK_STATUS_ALARM.length); - const worker = await WorkersSessionStorage.getWorkerById(workerId); - if (!worker) { - console.debug("No worker has been found"); - return; - } - const workerEntity = new WorkerEntity(worker); - if (workerEntity.isWaitingConnection) { - // Get the tab information by tab id to have the last url in case of redirection - const tab = await BrowserTabService.getById(workerEntity.tabId); - // Execute the process of a web navigation to detect pagemod and script to insert - const frameDetails = { - // Mapping the tab info as a frame details to be compliant with webNavigation API - frameId: 0, - tabId: worker.tabId, - url: tab.url - }; - await WebNavigationService.exec(frameDetails); - } + static async execNavigationForWorkerWaitingConnection(workerId) { + const worker = await WorkersSessionStorage.getWorkerById(workerId); + if (!worker) { + console.debug("No worker has been found"); + return; + } + const workerEntity = new WorkerEntity(worker); + if (workerEntity.isWaitingConnection) { + // Get the tab information by tab id to have the last url in case of redirection + const tab = await BrowserTabService.getById(workerEntity.tabId); + // Execute the process of a web navigation to detect pagemod and script to insert + const frameDetails = { + // Mapping the tab info as a frame details to be compliant with webNavigation API + frameId: 0, + tabId: worker.tabId, + url: tab.url + }; + await WebNavigationService.exec(frameDetails); } } } diff --git a/src/all/background_page/service/worker/workerService.test.js b/src/all/background_page/service/worker/workerService.test.js index e12ca9ee..161577e4 100644 --- a/src/all/background_page/service/worker/workerService.test.js +++ b/src/all/background_page/service/worker/workerService.test.js @@ -72,7 +72,7 @@ describe("WorkerService", () => { describe("WorkerService::waitExists", () => { it("should wait for the worker exists", async() => { - expect.assertions(10); + expect.assertions(5); // data mocked const worker = readWorker({name: "QuickAccess"}); const port = { @@ -86,34 +86,27 @@ describe("WorkerService", () => { // mock functions jest.spyOn(PortManager, "getPortById").mockImplementation(() => port); const spy = jest.spyOn(WorkerService, "waitExists"); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + jest.spyOn(global, "setTimeout"); expect(spy).not.toHaveBeenCalled(); WorkerService.waitExists("QuickAccess", worker.tabId); expect(spy).toHaveBeenCalledTimes(1); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(0); jest.advanceTimersByTime(101); await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); expect(spy).toHaveBeenCalledTimes(1); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(2); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.setTimeout).toHaveBeenCalledTimes(1); jest.advanceTimersByTime(100); - expect(spy).toHaveBeenCalledTimes(1); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(2); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(2); + expect(global.setTimeout).toHaveBeenCalledTimes(1); }); - it("should raise an error if the worker not exists until timeout", async() => { - expect.assertions(8); + it("should raise an error if the worker not exists until timeout", () => { + expect.assertions(4); const spy = jest.spyOn(WorkerService, "waitExists"); jest.spyOn(WorkerService, "get").mockImplementation(() => { throw new Error("Could not find worker ID QuickAccess for tab 1."); }); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + jest.spyOn(global, "setTimeout"); expect(spy).not.toHaveBeenCalled(); @@ -122,13 +115,9 @@ describe("WorkerService", () => { }); expect(spy).toHaveBeenCalledTimes(1); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(20000); - expect(spy).toHaveBeenCalledTimes(1); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(100); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(100); + jest.advanceTimersByTime(6000); + expect(global.setTimeout).toHaveBeenCalledTimes(50); }); }); @@ -149,7 +138,7 @@ describe("WorkerService", () => { }; const entity = new WorkerEntity(dto); await WorkerService.checkAndExecNavigationForWorkerWaitingConnection(entity); - // start the alarm + jest.advanceTimersByTime(50); await Promise.resolve(); await Promise.resolve(); @@ -167,7 +156,7 @@ describe("WorkerService", () => { const entity = new WorkerEntity(dto); await WorkerService.checkAndExecNavigationForWorkerWaitingConnection(entity); - // start the alarm + jest.advanceTimersByTime(50); expect(entity.toDto()).toEqual(dto); expect(WebNavigationService.exec).not.toHaveBeenCalled(); @@ -182,7 +171,7 @@ describe("WorkerService", () => { const entity = new WorkerEntity(dto); await WorkerService.checkAndExecNavigationForWorkerWaitingConnection(entity); - // start the alarm + jest.advanceTimersByTime(50); expect(entity.toDto()).toEqual(dto); expect(WebNavigationService.exec).not.toHaveBeenCalled(); diff --git a/test/mocks/mockWebExtensionPolyfill.js b/test/mocks/mockWebExtensionPolyfill.js index 8c64e667..26d63c1f 100644 --- a/test/mocks/mockWebExtensionPolyfill.js +++ b/test/mocks/mockWebExtensionPolyfill.js @@ -60,6 +60,7 @@ jest.mock("webextension-polyfill", () => { onUpdated: new MockEventListener(), onRemoved: new MockEventListener(), reload: jest.fn(), + sendMessage: jest.fn(), }, /* * Windows is not mocked by jest-webextension-mock v3.8.9 From a007cc062ac0dd482e003cb287d7737234e51f85 Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Wed, 13 Mar 2024 15:07:13 +0100 Subject: [PATCH 03/56] PB-30336 Use timeout instead alarms for promise timeout service --- .../service/tab/tabService.test.js | 24 +++++----- .../utils/promise/promiseTimeoutService.js | 42 ++--------------- .../promise/promiseTimeoutService.test.js | 46 ++++++++----------- 3 files changed, 35 insertions(+), 77 deletions(-) diff --git a/src/all/background_page/service/tab/tabService.test.js b/src/all/background_page/service/tab/tabService.test.js index 713b9d55..c86e9af2 100644 --- a/src/all/background_page/service/tab/tabService.test.js +++ b/src/all/background_page/service/tab/tabService.test.js @@ -75,8 +75,8 @@ describe("TabService", () => { const port = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId, url: frameDetails.url}); const portWrapper = new Port(port); jest.spyOn(portWrapper, "request").mockImplementationOnce(() => Promise.resolve()); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); // mock function mockWorker.mockImplementationOnce(() => worker); mockGetPort.mockImplementationOnce(() => portWrapper); @@ -90,8 +90,8 @@ describe("TabService", () => { expect(portWrapper.request).toHaveBeenCalledWith('passbolt.port.check'); expect(WebNavigationService.exec).not.toHaveBeenCalled(); // Called 1 times during the execution - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.setTimeout).toHaveBeenCalledTimes(1); + expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); it("Should exec if no worker on main frame", async() => { @@ -156,8 +156,8 @@ describe("TabService", () => { const port = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId, url: frameDetails.url}); const portWrapper = new Port(port); jest.spyOn(portWrapper, "request").mockImplementationOnce(() => Promise.reject()); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); // mock function mockWorker.mockImplementationOnce(() => worker); mockGetPort.mockImplementationOnce(() => portWrapper); @@ -168,12 +168,12 @@ describe("TabService", () => { expect(PortManager.getPortById).toHaveBeenCalledWith(worker.id); expect(WebNavigationService.exec).toHaveBeenCalledWith(frameDetails); // Called 1 times during the execution - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.setTimeout).toHaveBeenCalledTimes(1); + expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); it("Should exec if worker is on main frame and port is not responding before timeout", async() => { - expect.assertions(4); + expect.assertions(5); // data mocked const worker = readWorker(); const frameDetails = { @@ -184,7 +184,8 @@ describe("TabService", () => { const port = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId, url: frameDetails.url}); const portWrapper = new Port(port); jest.spyOn(portWrapper, "request").mockImplementationOnce(() => new Promise(() => null)); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); + jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); // mock function mockWorker.mockImplementationOnce(() => worker); mockGetPort.mockImplementationOnce(() => portWrapper); @@ -195,7 +196,8 @@ describe("TabService", () => { jest.runAllTimers(); await promise; // Called 1 times after the timeout - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.setTimeout).toHaveBeenCalledTimes(1); + expect(global.clearTimeout).toHaveBeenCalledTimes(0); // expectations expect(WorkersSessionStorage.getWorkerOnMainFrame).toHaveBeenCalledWith(frameDetails.tabId); expect(PortManager.getPortById).toHaveBeenCalledWith(worker.id); diff --git a/src/all/background_page/utils/promise/promiseTimeoutService.js b/src/all/background_page/utils/promise/promiseTimeoutService.js index c002bc17..1d7c7913 100644 --- a/src/all/background_page/utils/promise/promiseTimeoutService.js +++ b/src/all/background_page/utils/promise/promiseTimeoutService.js @@ -11,8 +11,6 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 4.1.2 */ -import {v4 as uuidv4} from "uuid"; - const PROMISE_TIMEOUT = 500; class PromiseTimeoutService { /** @@ -23,47 +21,13 @@ class PromiseTimeoutService { */ static exec(promise, timeout = PROMISE_TIMEOUT) { return new Promise((resolve, reject) => { - const alarmName = uuidv4(); - // Handle promise timeout - const handlePromiseTimeout = alarm => { - if (alarm.name === alarmName) { - this.clearPromiseTimeoutAlarm(alarmName, handlePromiseTimeout); - reject(); - } - }; // Schedule promise timeout - this.schedulePromiseTimeout(alarmName, handlePromiseTimeout, timeout); + const timeoutId = setTimeout(reject, timeout); // Clear promise timeout alarm - const clearTimeout = () => this.clearPromiseTimeoutAlarm(alarmName, handlePromiseTimeout); + const clearTimeoutWithId = () => clearTimeout(timeoutId); // return the promise resolved else reject and finally clear timeout - promise.then(resolve).catch(reject).finally(clearTimeout); - }); - } - - /** - * Schedule an alarm to reject the promise - * @param {string} alarmName The alarm name - * @param {function} handlePromiseTimeout The function on alarm listener - * @param {number} timeout The timeout in ms - * @private - */ - static schedulePromiseTimeout(alarmName, handlePromiseTimeout, timeout) { - // Create an alarm to reject the promise after a given time - browser.alarms.create(alarmName, { - when: Date.now() + timeout + promise.then(resolve).catch(reject).finally(clearTimeoutWithId); }); - browser.alarms.onAlarm.addListener(handlePromiseTimeout); - } - - /** - * Clear the alarm and listener configured for rejecting the promise. - * @param {string} alarmName The alarm name - * @param {function} handlePromiseTimeout The function on alarm listener - * @private - */ - static clearPromiseTimeoutAlarm(alarmName, handlePromiseTimeout) { - browser.alarms.onAlarm.removeListener(handlePromiseTimeout); - browser.alarms.clear(alarmName); } } diff --git a/src/all/background_page/utils/promise/promiseTimeoutService.test.js b/src/all/background_page/utils/promise/promiseTimeoutService.test.js index 934d330c..27a28b19 100644 --- a/src/all/background_page/utils/promise/promiseTimeoutService.test.js +++ b/src/all/background_page/utils/promise/promiseTimeoutService.test.js @@ -21,84 +21,76 @@ describe("PromiseTimeoutService", () => { describe("PromiseTimeoutService::exec", () => { it("Should exec PromiseTimeout with a promise resolved before timeout", async() => { - expect.assertions(5); + expect.assertions(3); // data mocked const promise = new Promise(resolve => resolve("DONE")); // mock functions - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); - const spyOnScheduleAlarm = jest.spyOn(PromiseTimeoutService, "schedulePromiseTimeout"); + jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); // process const PromiseTimeoutResult = await PromiseTimeoutService.exec(promise, 1000); // expectations - expect(spyOnScheduleAlarm).toHaveBeenCalledWith(expect.any(String), expect.any(Function), 1000); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(0); + expect(global.setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); expect(PromiseTimeoutResult).toStrictEqual("DONE"); // Need to resolve the result and the finally await Promise.resolve(); await Promise.resolve(); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); it("Should exec PromiseTimeout with a promise rejected before timeout", async() => { - expect.assertions(5); + expect.assertions(3); // data mocked const promise = new Promise((resolve, reject) => reject("REJECT")); // mock functions - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); - const spyOnScheduleAlarm = jest.spyOn(PromiseTimeoutService, "schedulePromiseTimeout"); + jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); // process try { await PromiseTimeoutService.exec(promise); } catch (error) { // expectations - expect(spyOnScheduleAlarm).toHaveBeenCalledWith(expect.any(String), expect.any(Function), 500); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(0); + expect(global.setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); // Need to resolve the finally await Promise.resolve(); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.clearTimeout).toHaveBeenCalledTimes(1); expect(error).toStrictEqual("REJECT"); } }); it("Should exec PromiseTimeout with a promise throwing error before timeout", async() => { - expect.assertions(4); + expect.assertions(3); // data mocked const promise = new Promise(() => { throw new Error("REJECT"); }); // mock functions - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); // process try { await PromiseTimeoutService.exec(promise); } catch (error) { // expectations - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(0); + expect(global.setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); // Need to resolve the finally await Promise.resolve(); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.clearTimeout).toHaveBeenCalledTimes(1); expect(error.message).toStrictEqual("REJECT"); } }); it("Should exec PromiseTimeout with a promise not resolved before timeout", async() => { - expect.assertions(2); + expect.assertions(1); // data mocked const promise = new Promise(() => null); // mock functions - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); // process try { await PromiseTimeoutService.exec(promise); } catch (error) { // expectations - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.clearTimeout).toHaveBeenCalledTimes(0); } }); }); From 8da9ab5876a9718da6dd736ce58acbeb593f3aab Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Thu, 14 Mar 2024 09:02:03 +0100 Subject: [PATCH 04/56] PB-30341 Remove alarms for toolbar controller --- .../controller/toolbarController.js | 54 +------------------ .../controller/toolbarController.test.js | 31 ----------- 2 files changed, 2 insertions(+), 83 deletions(-) diff --git a/src/all/background_page/controller/toolbarController.js b/src/all/background_page/controller/toolbarController.js index 185f52c7..375c9105 100644 --- a/src/all/background_page/controller/toolbarController.js +++ b/src/all/background_page/controller/toolbarController.js @@ -18,8 +18,6 @@ import {TabController as tabsController} from "./tabsController"; import GetLegacyAccountService from "../service/account/getLegacyAccountService"; import BuildApiClientOptionsService from "../service/account/buildApiClientOptionsService"; -const UPDATE_SUGGESTED_RESOURCE_BADGE_FLUSH_ALARM = "UpdateSuggestedResourceBadgeCacheFlush"; - class ToolbarController { constructor() { // Initially, set the browser extension icon as inactive @@ -40,7 +38,6 @@ class ToolbarController { this.handleSuggestedResourcesOnUpdatedTabBound = this.handleSuggestedResourcesOnUpdatedTab.bind(this); this.handleSuggestedResourcesOnActivatedTabBound = this.handleSuggestedResourcesOnActivatedTab.bind(this); this.handleSuggestedResourcesOnFocusedWindowBound = this.handleSuggestedResourcesOnFocusedWindow.bind(this); - this.handleFlushEvent = this.handleFlushEvent.bind(this); } /** @@ -148,23 +145,8 @@ class ToolbarController { * @private */ async updateSuggestedResourcesBadge() { - let currentTab; - - try { - this.clearAlarm(); - const tabs = await browser.tabs.query({'active': true, 'lastFocusedWindow': true}); - currentTab = tabs[0]; - } catch (error) { - /* - * With chrome (seen from 91), retrieving the current tab can generate error (Tab is busy). - * Loop until there is no error. - */ - if (browser.runtime.lastError) { - this.createAlarm(); - return; - } - throw error; - } + const tabs = await browser.tabs.query({'active': true, 'lastFocusedWindow': true}); + const currentTab = tabs[0]; const tabUrl = currentTab?.url; let suggestedResourcesCount = 0; @@ -201,38 +183,6 @@ class ToolbarController { isUrlPassboltExtension(tabUrl) { return tabUrl.startsWith(browser.runtime.getURL("/")); } - - /** - * Create alarm to flush the resource - * @private - */ - createAlarm() { - // Create an alarm to restart the update suggested resource badge - browser.alarms.create(UPDATE_SUGGESTED_RESOURCE_BADGE_FLUSH_ALARM, { - when: Date.now() + 50 - }); - browser.alarms.onAlarm.addListener(this.handleFlushEvent); - } - - /** - * Clear the alarm and listener configured for flushing the resource if any. - * @private - */ - clearAlarm() { - browser.alarms.onAlarm.removeListener(this.handleFlushEvent); - browser.alarms.clear(UPDATE_SUGGESTED_RESOURCE_BADGE_FLUSH_ALARM); - } - - /** - * Flush the current stored resource when the ResourceInProgressCacheFlush alarm triggers. - * @param {Alarm} alarm - * @private - */ - handleFlushEvent(alarm) { - if (alarm.name === UPDATE_SUGGESTED_RESOURCE_BADGE_FLUSH_ALARM) { - this.updateSuggestedResourcesBadge(); - } - } } // Exports the Toolbar controller object. diff --git a/src/all/background_page/controller/toolbarController.test.js b/src/all/background_page/controller/toolbarController.test.js index 2a5b5dc4..3f3b8176 100644 --- a/src/all/background_page/controller/toolbarController.test.js +++ b/src/all/background_page/controller/toolbarController.test.js @@ -176,35 +176,4 @@ describe("ToolbarController", () => { expect(browserExtensionIconServiceSetCountMock).toHaveBeenLastCalledWith(0); }); }); - - it("should do restart the update suggested resource badge after an error", async() => { - expect.assertions(7); - const toolbarController = new ToolbarController(); - jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => { - browser.runtime.lastError = 'Tab url not present'; - throw new Error(); - }); - const spy = jest.spyOn(toolbarController, "updateSuggestedResourcesBadge"); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); - const timeoutDelay = 50; - - expect(spy).not.toHaveBeenCalled(); - - await toolbarController.updateSuggestedResourcesBadge(); - expect(spy).toHaveBeenCalledTimes(1); - //Called 1 times during the ::set - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); - - jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => { - browser.runtime.lastError = null; - return [null]; - }); - jest.advanceTimersByTime(timeoutDelay); - expect(spy).toHaveBeenCalledTimes(2); - //Called 1 time - expect(spyOnAlarmClear).toHaveBeenCalledTimes(2); - expect(spyOnAlarmClear).toHaveBeenCalledWith("UpdateSuggestedResourceBadgeCacheFlush"); - }); }); From fb50481dd72eabd696564f6ec7abbb35159920ed Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Thu, 14 Mar 2024 10:17:26 +0100 Subject: [PATCH 05/56] PB-30342 Use timeout instead of alarm for the resourceInprogressCacheService to flush the resource not consumed --- .../cache/resourceInProgressCache.service.js | 43 +++---------------- .../resourceInProgressCache.service.test.js | 13 +++--- 2 files changed, 11 insertions(+), 45 deletions(-) diff --git a/src/all/background_page/service/cache/resourceInProgressCache.service.js b/src/all/background_page/service/cache/resourceInProgressCache.service.js index 2bcc6a81..752dfe84 100644 --- a/src/all/background_page/service/cache/resourceInProgressCache.service.js +++ b/src/all/background_page/service/cache/resourceInProgressCache.service.js @@ -17,7 +17,6 @@ import ExternalResourceEntity from "../../model/entity/resource/external/externa /** Default validity timeout of the cache */ const VALIDITY_TIMEOUT_IN_MS = 6000; -const RESOURCE_IN_PROGRESS_CACHE_FLUSH_ALARM = "ResourceInProgressCacheFlush"; const RESOURCE_IN_PROGRESS_STORAGE_KEY = "resourceInProgress"; /** @@ -31,6 +30,7 @@ class ResourceInProgressCacheService { */ constructor() { this.bindCallbacks(); + this.timeoutId = null; } /** @@ -38,7 +38,6 @@ class ResourceInProgressCacheService { */ bindCallbacks() { this.reset = this.reset.bind(this); - this.handleFlushEvent = this.handleFlushEvent.bind(this); } /** @@ -64,52 +63,20 @@ class ResourceInProgressCacheService { this.reset(); await browser.storage.session.set({[RESOURCE_IN_PROGRESS_STORAGE_KEY]: resource.toDto()}); - - this.scheduleStorageFlush(timeoutInMs); + // Set a timeout to clean the cache if not consumed + this.timeoutId = setTimeout(this.reset, timeoutInMs); // Invalid the cache if the user is logged out self.addEventListener("passbolt.auth.after-logout", this.reset); } - /** - * Schedule an alarm to flush the resource - * @param timeInMs - * @private - */ - scheduleStorageFlush(timeInMs) { - // Create an alarm to invalid the cache after a given time - browser.alarms.create(RESOURCE_IN_PROGRESS_CACHE_FLUSH_ALARM, { - when: Date.now() + timeInMs - }); - browser.alarms.onAlarm.addListener(this.handleFlushEvent); - } - - /** - * Clear the alarm and listener configured for flushing the resource if any. - * @private - */ - clearAlarm() { - browser.alarms.onAlarm.removeListener(this.handleFlushEvent); - browser.alarms.clear(RESOURCE_IN_PROGRESS_CACHE_FLUSH_ALARM); - } - - /** - * Flush the current stored resource when the ResourceInProgressCacheFlush alarm triggers. - * @param {Alarm} alarm - * @private - */ - async handleFlushEvent(alarm) { - if (alarm.name === RESOURCE_IN_PROGRESS_CACHE_FLUSH_ALARM) { - this.reset(); - } - } - /** * Resets the cache */ reset() { browser.storage.session.remove(RESOURCE_IN_PROGRESS_STORAGE_KEY); - this.clearAlarm(); + // Clear the timeout + clearTimeout(this.timeoutId); self.removeEventListener("passbolt.auth.after-logout", this.reset); } } diff --git a/src/all/background_page/service/cache/resourceInProgressCache.service.test.js b/src/all/background_page/service/cache/resourceInProgressCache.service.test.js index 5eb08d50..9eae7e44 100644 --- a/src/all/background_page/service/cache/resourceInProgressCache.service.test.js +++ b/src/all/background_page/service/cache/resourceInProgressCache.service.test.js @@ -49,10 +49,10 @@ describe("ResourceInProgressCache service", () => { }); it("should do a reset after a period of time", async() => { - expect.assertions(7); + expect.assertions(6); const spy = jest.spyOn(ResourceInProgressCacheService, "reset"); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); const timeoutDelay = 5000; const fakeResource = new ExternalResourceEntity(fakeResourceDto); @@ -61,14 +61,13 @@ describe("ResourceInProgressCache service", () => { await ResourceInProgressCacheService.set(fakeResource, timeoutDelay); expect(spy).toHaveBeenCalledTimes(1); //Called 1 times during the ::set - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(global.setTimeout).toHaveBeenCalledTimes(1); + expect(global.clearTimeout).toHaveBeenCalledTimes(1); jest.advanceTimersByTime(timeoutDelay); expect(spy).toHaveBeenCalledTimes(2); //Called 2 time after the reset - expect(spyOnAlarmClear).toHaveBeenCalledTimes(2); - expect(spyOnAlarmClear).toHaveBeenCalledWith("ResourceInProgressCacheFlush"); + expect(global.clearTimeout).toHaveBeenCalledTimes(2); }); it("should do a reset after having consumed the cached resource", async() => { From ac5547437f2a7ed428bc34f6cd1382474fbb0c72 Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Thu, 14 Mar 2024 16:41:51 +0000 Subject: [PATCH 06/56] PB-30347 Bump styleguide version to 4.7.0-alpha.3 --- package-lock.json | 14 +++++++------- package.json | 2 +- .../saveUserPassphrasePoliciesController.test.js | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 612ac090..abb78771 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "locutus": "~2.0.9", "openpgp": "^5.11.1", "papaparse": "^5.2.0", - "passbolt-styleguide": "^4.6.1", + "passbolt-styleguide": "^4.7.0-alpha.3", "react": "17.0.2", "react-dom": "17.0.2", "secrets-passbolt": "github:passbolt/secrets.js#v2.0.1", @@ -15185,9 +15185,9 @@ } }, "node_modules/passbolt-styleguide": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.6.1.tgz", - "integrity": "sha512-bkSrBF1+MyW3luCFsou0AApzhoY93erRPVKEKk1HFTtYzPDRxXYINKJ8U0Rn7Jm+IvQ7HoaryHyhh8qt/iDnWA==", + "version": "4.7.0-alpha.3", + "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0-alpha.3.tgz", + "integrity": "sha512-2vKvda7iaG7+t31v24ZU6UZgXZtVU/4GvhTo6N0SJ1oD+oDCg6cHUr8xXIsaMfcMDBGOgPCrX5G+HNAbYQ3mDg==", "dependencies": { "@testing-library/dom": "^8.11.3", "debounce-promise": "^3.1.2", @@ -31136,9 +31136,9 @@ } }, "passbolt-styleguide": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.6.1.tgz", - "integrity": "sha512-bkSrBF1+MyW3luCFsou0AApzhoY93erRPVKEKk1HFTtYzPDRxXYINKJ8U0Rn7Jm+IvQ7HoaryHyhh8qt/iDnWA==", + "version": "4.7.0-alpha.3", + "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0-alpha.3.tgz", + "integrity": "sha512-2vKvda7iaG7+t31v24ZU6UZgXZtVU/4GvhTo6N0SJ1oD+oDCg6cHUr8xXIsaMfcMDBGOgPCrX5G+HNAbYQ3mDg==", "requires": { "@testing-library/dom": "^8.11.3", "debounce-promise": "^3.1.2", diff --git a/package.json b/package.json index 7e7b2808..6cc1867e 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "locutus": "~2.0.9", "openpgp": "^5.11.1", "papaparse": "^5.2.0", - "passbolt-styleguide": "^4.6.1", + "passbolt-styleguide": "^4.7.0-alpha.3", "react": "17.0.2", "react-dom": "17.0.2", "secrets-passbolt": "github:passbolt/secrets.js#v2.0.1", diff --git a/src/all/background_page/controller/userPassphrasePolicies/saveUserPassphrasePoliciesController.test.js b/src/all/background_page/controller/userPassphrasePolicies/saveUserPassphrasePoliciesController.test.js index 9f7c1208..f85f82f5 100644 --- a/src/all/background_page/controller/userPassphrasePolicies/saveUserPassphrasePoliciesController.test.js +++ b/src/all/background_page/controller/userPassphrasePolicies/saveUserPassphrasePoliciesController.test.js @@ -62,7 +62,7 @@ describe("SaveUserPassphrasePoliciesController", () => { const dto = defaultUserPassphrasePoliciesEntityDto(); const controller = new SaveUserPassphrasePoliciesController(null, null, apiClientOptions); - expect(() => controller.exec(dto)).rejects.toBeInstanceOf(PassboltApiFetchError); + await expect(() => controller.exec(dto)).rejects.toBeInstanceOf(PassboltApiFetchError); }); it("Should return the default value if something goes when requesting the API", async() => { @@ -71,6 +71,6 @@ describe("SaveUserPassphrasePoliciesController", () => { const dto = defaultUserPassphrasePoliciesEntityDto(); const controller = new SaveUserPassphrasePoliciesController(null, null, apiClientOptions); - expect(() => controller.exec(dto)).rejects.toBeInstanceOf(PassboltServiceUnavailableError); + await expect(() => controller.exec(dto)).rejects.toBeInstanceOf(PassboltServiceUnavailableError); }); }); From fa475356ac11c43f2a2aa42a1ca82ba8ae5b5637 Mon Sep 17 00:00:00 2001 From: Cedric Alfonsi Date: Thu, 14 Mar 2024 17:57:04 +0100 Subject: [PATCH 07/56] PB-30375 - Improve CI unit test performance by running them in band Signed-off-by: Cedric Alfonsi --- .gitlab-ci/jobs/test.yml | 2 +- package.json | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci/jobs/test.yml b/.gitlab-ci/jobs/test.yml index 58572143..a8f7ace2 100644 --- a/.gitlab-ci/jobs/test.yml +++ b/.gitlab-ci/jobs/test.yml @@ -5,7 +5,7 @@ tester: extends: .rules script: - npm ci - - npm run test:coverage + - npm run test:ci:coverage artifacts: when: always reports: diff --git a/package.json b/package.json index 6cc1867e..8376dd14 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,8 @@ "lint:eslint-fix": "eslint -c .eslintrc.json --ext js --ext jsx --fix src", "i18n:externalize": "i18next -c ./i18next-parser.config.js", "test": "npm run test:unit", - "test:unit": "jest --no-cache ./src/all/ ./src/chrome-mv3/ --maxWorkers=4", - "test:coverage": "jest --no-cache ./src/all/ ./src/chrome-mv3/ --maxWorkers=4 --coverage" + "test:unit": "jest --no-cache ./src/all/ ./src/chrome-mv3/", + "test:coverage": "jest --no-cache ./src/all/ ./src/chrome-mv3/ --coverage", + "test:ci:coverage": "npm run test:coverage -- --runInBand" } } From 3ce93be13eb1b3d89cea0d90f2215b413a924edc Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Thu, 14 Mar 2024 18:52:32 +0000 Subject: [PATCH 08/56] PB-30272 Add message service in the app content script in order to reconnect... --- src/all/webAccessibleResources/js/app/App.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/all/webAccessibleResources/js/app/App.js b/src/all/webAccessibleResources/js/app/App.js index 84dc5f22..9572e5e8 100644 --- a/src/all/webAccessibleResources/js/app/App.js +++ b/src/all/webAccessibleResources/js/app/App.js @@ -15,12 +15,19 @@ import React from "react"; import ReactDOM from "react-dom"; import ExtApp from "passbolt-styleguide/src/react-extension/ExtApp"; import Port from "../lib/port"; +import MessageService from "../../../contentScripts/js/service/messageService"; +import MessageEventHandler from "../../../contentScripts/js/message/messageEventHandler"; +import ConnectPortController from "../../../contentScripts/js/controller/connectPortController"; async function main() { const query = new URLSearchParams(window.location.search); const portname = query.get('passbolt'); const port = new Port(portname); await port.connect(); + // Message listener + const messageService = new MessageService(); + const messageEventHandler = new MessageEventHandler(messageService); + messageEventHandler.listen("passbolt.port.connect", ConnectPortController, port); const storage = browser.storage; const domContainer = document.createElement("div"); document.body.appendChild(domContainer); From f3e2cebaf0ece16bc7dcf0b0f91ffe8fbfe3106b Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Thu, 14 Mar 2024 18:54:55 +0000 Subject: [PATCH 09/56] PB-30274 Add message service in the browser integration content script in... --- src/all/contentScripts/js/app/BrowserIntegration.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/all/contentScripts/js/app/BrowserIntegration.js b/src/all/contentScripts/js/app/BrowserIntegration.js index b92f2f9d..1e89f073 100644 --- a/src/all/contentScripts/js/app/BrowserIntegration.js +++ b/src/all/contentScripts/js/app/BrowserIntegration.js @@ -13,6 +13,9 @@ */ import {BrowserIntegrationBootstrap} from "passbolt-styleguide/src/react-web-integration/BrowserIntegrationBootstrap.js"; import Port from "../../../webAccessibleResources/js/lib/port"; +import MessageService from "../service/messageService"; +import ConnectPortController from "../controller/connectPortController"; +import MessageEventHandler from "../message/messageEventHandler"; async function main() { // Make the port object as a global variable to use it directly (TODO the port could be use in props) @@ -20,6 +23,10 @@ async function main() { // Emit a success if the port is still connected port.on("passbolt.port.check", requestId => self.port.emit(requestId, "SUCCESS")); await self.port.connect(); + // Message listener + const messageService = new MessageService(); + const messageEventHandler = new MessageEventHandler(messageService); + messageEventHandler.listen("passbolt.port.connect", ConnectPortController, port); BrowserIntegrationBootstrap.init(); } From c70d208cd050956080a7eeed9d026ea01eec3062 Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Fri, 15 Mar 2024 06:21:09 +0000 Subject: [PATCH 10/56] PB-30273 On the post logout event the service worker should reconnect port that needs to receive the post logout message --- src/all/background_page/index.js | 3 +- .../service/auth/postLogoutService.js | 47 +++++++ .../service/auth/postLogoutService.test.js | 115 ++++++++++++++++++ .../sessionStorage/workersSessionStorage.js | 11 ++ .../workersSessionStorage.test.js | 5 +- src/chrome-mv3/index.js | 3 +- 6 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/all/background_page/service/auth/postLogoutService.js create mode 100644 src/all/background_page/service/auth/postLogoutService.test.js diff --git a/src/all/background_page/index.js b/src/all/background_page/index.js index 1988efc8..fd17ca7d 100644 --- a/src/all/background_page/index.js +++ b/src/all/background_page/index.js @@ -14,6 +14,7 @@ import GpgAuth from "./model/gpgauth"; import Log from "./model/log"; import StartLoopAuthSessionCheckService from "./service/auth/startLoopAuthSessionCheckService"; import OnExtensionUpdateAvailableController from "./controller/extension/onExtensionUpdateAvailableController"; +import PostLogoutService from "./service/auth/postLogoutService"; const main = async() => { /** @@ -58,7 +59,7 @@ main(); /** * Add listener on passbolt logout */ -self.addEventListener("passbolt.auth.after-logout", LocalStorageService.flush); +self.addEventListener("passbolt.auth.after-logout", PostLogoutService.exec); /** * On installed the extension, add first install in the url tab of setup or recover diff --git a/src/all/background_page/service/auth/postLogoutService.js b/src/all/background_page/service/auth/postLogoutService.js new file mode 100644 index 00000000..6b6faf98 --- /dev/null +++ b/src/all/background_page/service/auth/postLogoutService.js @@ -0,0 +1,47 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import WorkersSessionStorage from "../sessionStorage/workersSessionStorage"; +import AppPagemod from "../../pagemod/appPagemod"; +import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; +import PortManager from "../../sdk/port/portManager"; +import LocalStorageService from "../localStorage/localStorageService"; +import BrowserTabService from "../ui/browserTab.service"; + +class PostLogoutService { + /** + * Execute all processes after a logout + */ + static async exec() { + const workers = await WorkersSessionStorage.getWorkersByNames([AppPagemod.appName, WebIntegrationPagemod.appName]); + PostLogoutService.sendLogoutEventForWorkerDisconnected(workers); + LocalStorageService.flush(); + } + + /** + * Send logout event on workers disconnected port + * @param workers + * @return {Promise} + */ + static async sendLogoutEventForWorkerDisconnected(workers) { + for (const worker of workers) { + if (!PortManager.isPortExist(worker.id)) { + await BrowserTabService.sendMessage(worker, "passbolt.port.connect", worker.id); + const port = PortManager.getPortById(worker.id); + port.emit('passbolt.auth.after-logout'); + } + } + } +} + +export default PostLogoutService; diff --git a/src/all/background_page/service/auth/postLogoutService.test.js b/src/all/background_page/service/auth/postLogoutService.test.js new file mode 100644 index 00000000..8dd1c90f --- /dev/null +++ b/src/all/background_page/service/auth/postLogoutService.test.js @@ -0,0 +1,115 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + + +import PostLogoutService from "./postLogoutService"; +import WorkersSessionStorage from "../sessionStorage/workersSessionStorage"; +import {readWorker} from "../../model/entity/worker/workerEntity.test.data"; +import WorkerEntity from "../../model/entity/worker/workerEntity"; +import AppPagemod from "../../pagemod/appPagemod"; +import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; +import BrowserTabService from "../ui/browserTab.service"; +import PortManager from "../../sdk/port/portManager"; +import {mockPort} from "../../sdk/port/portManager.test.data"; +import Port from "../../sdk/port"; +import LocalStorageService from "../localStorage/localStorageService"; + +describe("PostLogoutService", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("PostLogoutService:exec", () => { + it("Should send message to awake port and send post logout event", async() => { + expect.assertions(8); + // data mocked + const worker = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); + const worker2 = readWorker({name: AppPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker2)); + const worker3 = readWorker({name: WebIntegrationPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker3)); + const appPort = mockPort({name: worker2.id, tabId: worker2.tabId, frameId: worker2.frameId}); + const appPortWrapper = new Port(appPort); + const webIntegrationPort = mockPort({name: worker3.id, tabId: worker3.tabId, frameId: worker3.frameId}); + const webIntegrationPortWrapper2 = new Port(webIntegrationPort); + // function mocked + jest.spyOn(BrowserTabService, "sendMessage").mockImplementation(jest.fn()); + jest.spyOn(PortManager, "getPortById").mockImplementationOnce(() => appPortWrapper); + jest.spyOn(PortManager, "getPortById").mockImplementationOnce(() => webIntegrationPortWrapper2); + jest.spyOn(appPortWrapper, "emit"); + jest.spyOn(webIntegrationPortWrapper2, "emit"); + jest.spyOn(LocalStorageService, "flush"); + // execution + await PostLogoutService.exec(); + // Waiting all promises are resolved + await Promise.resolve(); + // expectations + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(2); + expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker2, "passbolt.port.connect", worker2.id); + expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker3, "passbolt.port.connect", worker3.id); + expect(appPortWrapper.emit).toHaveBeenCalledWith('passbolt.auth.after-logout'); + expect(appPortWrapper.emit).toHaveBeenCalledTimes(1); + expect(webIntegrationPortWrapper2.emit).toHaveBeenCalledWith('passbolt.auth.after-logout'); + expect(webIntegrationPortWrapper2.emit).toHaveBeenCalledTimes(1); + expect(LocalStorageService.flush).toHaveBeenCalled(); + }); + + it("Should not send messages if workers port are still connected", async() => { + expect.assertions(3); + // data mocked + const worker = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); + const worker2 = readWorker({name: AppPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker2)); + const worker3 = readWorker({name: WebIntegrationPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker3)); + // function mocked + jest.spyOn(BrowserTabService, "sendMessage"); + jest.spyOn(PortManager, "isPortExist").mockImplementation(() => true); + jest.spyOn(LocalStorageService, "flush"); + // execution + await PostLogoutService.exec(); + // Waiting all promises are resolved + await Promise.resolve(); + // expectations + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(0); + expect(PortManager.isPortExist).toHaveBeenCalledTimes(2); + expect(LocalStorageService.flush).toHaveBeenCalled(); + }); + + it("Should not send messages if no workers needs to receive post logout event", async() => { + expect.assertions(3); + // data mocked + const worker = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); + const worker2 = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker2)); + const worker3 = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker3)); + // function mocked + jest.spyOn(BrowserTabService, "sendMessage"); + jest.spyOn(PortManager, "isPortExist").mockImplementation(() => true); + jest.spyOn(LocalStorageService, "flush"); + // execution + await PostLogoutService.exec(); + // Waiting all promises are resolved + await Promise.resolve(); + // expectations + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(0); + expect(PortManager.isPortExist).toHaveBeenCalledTimes(0); + expect(LocalStorageService.flush).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/all/background_page/service/sessionStorage/workersSessionStorage.js b/src/all/background_page/service/sessionStorage/workersSessionStorage.js index 22a5bfbb..2f5944b8 100644 --- a/src/all/background_page/service/sessionStorage/workersSessionStorage.js +++ b/src/all/background_page/service/sessionStorage/workersSessionStorage.js @@ -57,6 +57,17 @@ class WorkersSessionStorage { return this.getWorkers().then(filterByTabId); } + /** + * Get workers from the session storage by name and tab id + * + * @param {Array} workerNames An array of worker names + * @return {Promise} worker dto object + */ + getWorkersByNames(workerNames) { + const filterByName = workers => workers.filter(worker => workerNames.includes(worker.name)); + return this.getWorkers().then(filterByName); + } + /** * Get workers from the session storage by name and tab id * diff --git a/src/all/background_page/service/sessionStorage/workersSessionStorage.test.js b/src/all/background_page/service/sessionStorage/workersSessionStorage.test.js index fe81f065..1efbc8eb 100644 --- a/src/all/background_page/service/sessionStorage/workersSessionStorage.test.js +++ b/src/all/background_page/service/sessionStorage/workersSessionStorage.test.js @@ -22,11 +22,11 @@ describe("WorkersSessionStorage", () => { describe("WorkersSessionStorage::addWorker", () => { it("Should add worker in storage session", async() => { - expect.assertions(4); + expect.assertions(5); // data mocked const workerEntity = new WorkerEntity(readWorker()); const workerEntity2 = new WorkerEntity(readWorker({name: "worker2", frameId: 2})); - const workerEntity3 = new WorkerEntity(readWorker({tabId: 2})); + const workerEntity3 = new WorkerEntity(readWorker({name: "worker3", tabId: 2})); // process await WorkersSessionStorage.addWorker(workerEntity); await WorkersSessionStorage.addWorker(workerEntity2); @@ -35,6 +35,7 @@ describe("WorkersSessionStorage", () => { expect(await WorkersSessionStorage.getWorkersByTabId(workerEntity.tabId)).toEqual([workerEntity.toDto(), workerEntity2.toDto()]); expect(await WorkersSessionStorage.getWorkerOnMainFrame(workerEntity.tabId)).toEqual(workerEntity.toDto()); expect(await WorkersSessionStorage.getWorkerById(workerEntity3.id)).toEqual(workerEntity3.toDto()); + expect(await WorkersSessionStorage.getWorkersByNames([workerEntity2.name, workerEntity.name])).toEqual([workerEntity.toDto(), workerEntity2.toDto()]); expect(await WorkersSessionStorage.getWorkersByNameAndTabId(workerEntity2.name, workerEntity2.tabId)).toEqual([workerEntity2.toDto()]); }); diff --git a/src/chrome-mv3/index.js b/src/chrome-mv3/index.js index 82f7f45b..9a769443 100644 --- a/src/chrome-mv3/index.js +++ b/src/chrome-mv3/index.js @@ -18,6 +18,7 @@ import OnExtensionInstalledController from "../all/background_page/controller/ex import TabService from "../all/background_page/service/tab/tabService"; import OnExtensionUpdateAvailableController from "../all/background_page/controller/extension/onExtensionUpdateAvailableController"; +import PostLogoutService from "../all/background_page/service/auth/postLogoutService"; /** * Load all system requirement @@ -27,7 +28,7 @@ SystemRequirementService.get(); /** * Add listener on passbolt logout */ -self.addEventListener("passbolt.auth.after-logout", LocalStorageService.flush); +self.addEventListener("passbolt.auth.after-logout", PostLogoutService.exec); /** * Add listener on startup From dd2addbade7b10fdda7e34963174e51ce81ab87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Fri, 15 Mar 2024 14:09:48 +0000 Subject: [PATCH 11/56] PB-29969 - Use a dedicated service to logout the user --- package-lock.json | 14 +-- package.json | 2 +- .../background_page/model/auth/authModel.js | 6 +- .../model/auth/authModel.test.js | 4 +- .../service/api/auth/authService.js | 36 ------ .../service/api/auth/authService.test.js | 103 ------------------ 6 files changed, 14 insertions(+), 151 deletions(-) delete mode 100644 src/all/background_page/service/api/auth/authService.test.js diff --git a/package-lock.json b/package-lock.json index abb78771..8ec500b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "locutus": "~2.0.9", "openpgp": "^5.11.1", "papaparse": "^5.2.0", - "passbolt-styleguide": "^4.7.0-alpha.3", + "passbolt-styleguide": "^4.7.0-alpha-4", "react": "17.0.2", "react-dom": "17.0.2", "secrets-passbolt": "github:passbolt/secrets.js#v2.0.1", @@ -15185,9 +15185,9 @@ } }, "node_modules/passbolt-styleguide": { - "version": "4.7.0-alpha.3", - "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0-alpha.3.tgz", - "integrity": "sha512-2vKvda7iaG7+t31v24ZU6UZgXZtVU/4GvhTo6N0SJ1oD+oDCg6cHUr8xXIsaMfcMDBGOgPCrX5G+HNAbYQ3mDg==", + "version": "4.7.0-alpha-4", + "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0-alpha-4.tgz", + "integrity": "sha512-RB8o4zfwKpXvp3IDjwJtTvzhTsyaIrvGenXl7A6NOZWlvwbPuIlYv8ndnin8izaTqq5pR1m4vVVuG1XBcraMzg==", "dependencies": { "@testing-library/dom": "^8.11.3", "debounce-promise": "^3.1.2", @@ -31136,9 +31136,9 @@ } }, "passbolt-styleguide": { - "version": "4.7.0-alpha.3", - "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0-alpha.3.tgz", - "integrity": "sha512-2vKvda7iaG7+t31v24ZU6UZgXZtVU/4GvhTo6N0SJ1oD+oDCg6cHUr8xXIsaMfcMDBGOgPCrX5G+HNAbYQ3mDg==", + "version": "4.7.0-alpha-4", + "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0-alpha-4.tgz", + "integrity": "sha512-RB8o4zfwKpXvp3IDjwJtTvzhTsyaIrvGenXl7A6NOZWlvwbPuIlYv8ndnin8izaTqq5pR1m4vVVuG1XBcraMzg==", "requires": { "@testing-library/dom": "^8.11.3", "debounce-promise": "^3.1.2", diff --git a/package.json b/package.json index 8376dd14..5bad5b5f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "locutus": "~2.0.9", "openpgp": "^5.11.1", "papaparse": "^5.2.0", - "passbolt-styleguide": "^4.7.0-alpha.3", + "passbolt-styleguide": "^4.7.0-alpha-4", "react": "17.0.2", "react-dom": "17.0.2", "secrets-passbolt": "github:passbolt/secrets.js#v2.0.1", diff --git a/src/all/background_page/model/auth/authModel.js b/src/all/background_page/model/auth/authModel.js index 9d608b5f..10f29118 100644 --- a/src/all/background_page/model/auth/authModel.js +++ b/src/all/background_page/model/auth/authModel.js @@ -13,7 +13,8 @@ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import EncryptMessageService from "../../service/crypto/encryptMessageService"; import GpgAuth from "../gpgauth"; -import AuthService from 'passbolt-styleguide/src/shared/services/api/auth/AuthService'; +import AuthService from '../../service/api/auth/authService'; +import AuthLogoutService from 'passbolt-styleguide/src/shared/services/api/auth/AuthLogoutService'; import AuthStatusLocalStorage from "../../service/local_storage/authStatusLocalStorage"; import User from "../user"; import GetDecryptedUserPrivateKeyService from "../../service/account/getDecryptedUserPrivateKeyService"; @@ -31,6 +32,7 @@ class AuthModel { */ constructor(apiClientOptions) { this.authService = new AuthService(apiClientOptions); + this.authLogoutService = new AuthLogoutService(apiClientOptions); this.legacyAuthModel = new GpgAuth(); } @@ -39,7 +41,7 @@ class AuthModel { * @returns {Promise} */ async logout() { - await this.authService.logout(); + await this.authLogoutService.logout(); await this.postLogout(); } diff --git a/src/all/background_page/model/auth/authModel.test.js b/src/all/background_page/model/auth/authModel.test.js index a845669d..268affa5 100644 --- a/src/all/background_page/model/auth/authModel.test.js +++ b/src/all/background_page/model/auth/authModel.test.js @@ -13,7 +13,7 @@ */ import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; import AuthModel from "../../model/auth/authModel"; -import AuthService from "passbolt-styleguide/src/shared/services/api/auth/AuthService"; +import AuthLogoutService from "passbolt-styleguide/src/shared/services/api/auth/AuthLogoutService"; beforeEach(async() => { jest.clearAllMocks(); @@ -27,7 +27,7 @@ describe("AuthModel", () => { const apiClientOptions = defaultApiClientOptions(); const model = new AuthModel(apiClientOptions); - const logoutServiceSpy = jest.spyOn(AuthService.prototype, "logout").mockImplementation(() => {}); + const logoutServiceSpy = jest.spyOn(AuthLogoutService.prototype, "logout").mockImplementation(() => {}); const dispatchEventSpy = jest.spyOn(self, "dispatchEvent"); await model.logout(); diff --git a/src/all/background_page/service/api/auth/authService.js b/src/all/background_page/service/api/auth/authService.js index 9e9eeb95..e826fcc5 100644 --- a/src/all/background_page/service/api/auth/authService.js +++ b/src/all/background_page/service/api/auth/authService.js @@ -47,42 +47,6 @@ class AuthService extends AbstractService { return []; } - /** - * Logout - * @returns {Promise} - */ - async logout() { - const url = this.apiClient.buildUrl(`${this.apiClient.baseUrl}/logout`, {}); - const response = await this.apiClient.sendRequest("POST", url, null, {redirect: "manual"}); - const isResponseOk = response.ok || response.status === 0; // status is 0 as there should be a redirection that is handled manually - if (!isResponseOk) { - if (response.status !== 404) { - throw new PassboltApiFetchError('An unexpected error happened during the logout process', { - code: response.status - }); - } - - return this._logoutLegacy(); - } - } - - /** - * Logout (the legacy way that uses the deprecated 'GET' method). - * @return {Promise} - * @deprecated the POST method should be used instead to avoid CSRF - * @private - */ - async _logoutLegacy() { - const url = this.apiClient.buildUrl(`${this.apiClient.baseUrl}/logout`, {}); - const response = await this.apiClient.sendRequest("GET", url, null, {redirect: "manual"}); - const isResponseOk = response.ok || response.status === 0; // status is 0 as there should be a redirection that is handled manually - if (!isResponseOk) { - throw new PassboltApiFetchError('An unexpected error happened during the legacy logout process', { - code: response.status - }); - } - } - /** * Retrieve the server key * @returns {Promise<{armored_key: string, fingerprint: string}>} diff --git a/src/all/background_page/service/api/auth/authService.test.js b/src/all/background_page/service/api/auth/authService.test.js deleted file mode 100644 index 37ed9528..00000000 --- a/src/all/background_page/service/api/auth/authService.test.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) 2023 Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) 2023 Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 4.1.0 - */ -import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; -import {enableFetchMocks} from "jest-fetch-mock"; -import AuthService from "./authService"; -import {mockApiResponse, mockApiResponseError} from "../../../../../../test/mocks/mockApiResponse"; -import PassboltApiFetchError from "passbolt-styleguide/src/shared/lib/Error/PassboltApiFetchError"; - -beforeEach(async() => { - enableFetchMocks(); - jest.clearAllMocks(); -}); - -describe("AuthService", () => { - describe("AuthService::exec", () => { - it("Should call the API on logout endpoint with a POST request", async() => { - expect.assertions(1); - - const apiClientOptions = defaultApiClientOptions(); - const service = new AuthService(apiClientOptions); - - fetch.doMockOnceIf(/auth\/logout\.json\?api-version=v2/, async req => { - expect(req.method).toStrictEqual("POST"); - return mockApiResponse({}); - }); - - await service.logout(); - }); - - it("Should call the API on logout endpoint with a POST request then a GET request if the POST endpoint is not found", async() => { - expect.assertions(2); - - const apiClientOptions = defaultApiClientOptions(); - const service = new AuthService(apiClientOptions); - - fetch.doMockOnceIf(/auth\/logout\.json\?api-version=v2/, async req => { - expect(req.method).toStrictEqual("POST"); - return mockApiResponseError(404, "Use the legacy endpoint instead"); - }); - - fetch.doMockOnceIf(/auth\/logout\.json\?api-version=v2/, async req => { - expect(req.method).toStrictEqual("GET"); - return mockApiResponse({}); - }); - - await service.logout(); - }); - - it("Should throw an exception if the POST logout endpoint exists and send an error", async() => { - expect.assertions(2); - - const apiClientOptions = defaultApiClientOptions(); - const service = new AuthService(apiClientOptions); - - fetch.doMockOnceIf(/auth\/logout\.json\?api-version=v2/, async req => { - expect(req.method).toStrictEqual("POST"); - return mockApiResponseError(500, "Something went wrong"); - }); - - try { - await service.logout(); - } catch (e) { - const expectedError = new PassboltApiFetchError('An unexpected error happened during the logout process'); - expect(e).toStrictEqual(expectedError); - } - }); - - it("Should throw an exception if the POST logout endpoint does not exists and the GET endpoint sends an error", async() => { - expect.assertions(3); - - const apiClientOptions = defaultApiClientOptions(); - const service = new AuthService(apiClientOptions); - - fetch.doMockOnceIf(/auth\/logout\.json\?api-version=v2/, async req => { - expect(req.method).toStrictEqual("POST"); - return mockApiResponseError(404, "Use the legacy endpoint instead"); - }); - - fetch.doMockOnceIf(/auth\/logout\.json\?api-version=v2/, async req => { - expect(req.method).toStrictEqual("GET"); - return mockApiResponseError(500, "Something went wrong"); - }); - - try { - await service.logout(); - } catch (e) { - const expectedError = new PassboltApiFetchError('An unexpected error happened during the legacy logout process'); - expect(e).toStrictEqual(expectedError); - } - }); - }); -}); From d024776c125d02db3b77bf846662408a72f4454f Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Tue, 19 Mar 2024 06:22:09 +0000 Subject: [PATCH 12/56] PB-29965 Use a dedicated service to verify the server --- ...ountRecoveryValidatePublicKeyController.js | 5 +- ...ecoveryValidatePublicKeyController.test.js | 2 +- .../auth/authVerifyServerKeyController.js | 65 +++++---- .../authVerifyServerKeyController.test.js | 75 +++++++++-- .../controller/auth/getServerKeyController.js | 54 ++++++++ .../auth/replaceServerKeyController.js | 59 +++++++++ .../importRecoverPrivateKeyController.js | 10 +- .../importRecoverPrivateKeyController.test.js | 40 ++---- .../recover/startRecoverController.js | 6 +- .../setup/importSetupPrivateKeyController.js | 12 +- .../importSetupPrivateKeyController.test.js | 43 ++---- .../controller/setup/startSetupController.js | 6 +- src/all/background_page/event/authEvents.js | 37 ++---- .../background_page/event/recoverEvents.js | 2 +- src/all/background_page/event/setupEvents.js | 2 +- .../background_page/model/auth/authModel.js | 43 ------ .../model/gpgAuthHeader.test.data.js | 40 ++++++ src/all/background_page/model/gpgauth.js | 115 ---------------- .../validateOrganizationPublicKeyService.js | 97 ++++++++++++++ ...OrganizationPublicKeyService.test.data.js} | 2 +- ...lidateOrganizationPublicKeyService.test.js | 69 ++++++++++ ...ccountRecoveryOrganizationPolicyService.js | 75 ----------- ...tRecoveryOrganizationPolicyService.test.js | 59 --------- .../service/api/auth/authService.js | 125 ------------------ .../api/auth/authVerifyServerKeyService.js | 79 +++++++++++ .../auth/authVerifyServerKeyService.test.js | 74 +++++++++++ .../auth/authVerifyServerChallengeService.js | 53 ++++++++ .../authVerifyServerChallengeService.test.js | 71 ++++++++++ 28 files changed, 756 insertions(+), 564 deletions(-) create mode 100644 src/all/background_page/controller/auth/getServerKeyController.js create mode 100644 src/all/background_page/controller/auth/replaceServerKeyController.js create mode 100644 src/all/background_page/model/gpgAuthHeader.test.data.js create mode 100644 src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.js rename src/all/background_page/service/{api/accountRecovery/accountRecoveryOrganizationPolicyService.test.data.js => accountRecovery/validateOrganizationPublicKeyService.test.data.js} (92%) create mode 100644 src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.test.js delete mode 100644 src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.test.js delete mode 100644 src/all/background_page/service/api/auth/authService.js create mode 100644 src/all/background_page/service/api/auth/authVerifyServerKeyService.js create mode 100644 src/all/background_page/service/api/auth/authVerifyServerKeyService.test.js create mode 100644 src/all/background_page/service/auth/authVerifyServerChallengeService.js create mode 100644 src/all/background_page/service/auth/authVerifyServerChallengeService.test.js diff --git a/src/all/background_page/controller/accountRecovery/accountRecoveryValidatePublicKeyController.js b/src/all/background_page/controller/accountRecovery/accountRecoveryValidatePublicKeyController.js index 7acfb078..56c57b3d 100644 --- a/src/all/background_page/controller/accountRecovery/accountRecoveryValidatePublicKeyController.js +++ b/src/all/background_page/controller/accountRecovery/accountRecoveryValidatePublicKeyController.js @@ -12,14 +12,15 @@ * @since 3.6.0 */ -import AccountRecoveryOrganizationPolicyService from "../../service/api/accountRecovery/accountRecoveryOrganizationPolicyService"; import AccountRecoveryModel from "../../model/accountRecovery/accountRecoveryModel"; +import ValidateOrganizationPublicKeyService from "../../service/accountRecovery/validateOrganizationPublicKeyService"; class AccountRecoveryValidatePublicKeyController { constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; this.accountRecoveryModel = new AccountRecoveryModel(apiClientOptions); + this.validateOrganizationPublicKeyService = new ValidateOrganizationPublicKeyService(apiClientOptions); } /** @@ -46,7 +47,7 @@ class AccountRecoveryValidatePublicKeyController { */ async exec(publicKeyToValidate) { const organizationPolicy = await this.accountRecoveryModel.findOrganizationPolicy(); - await AccountRecoveryOrganizationPolicyService.validatePublicKey(publicKeyToValidate, organizationPolicy.armoredKey); + await this.validateOrganizationPublicKeyService.validatePublicKey(publicKeyToValidate, organizationPolicy.armoredKey); } } diff --git a/src/all/background_page/controller/accountRecovery/accountRecoveryValidatePublicKeyController.test.js b/src/all/background_page/controller/accountRecovery/accountRecoveryValidatePublicKeyController.test.js index faa968b3..8294871e 100644 --- a/src/all/background_page/controller/accountRecovery/accountRecoveryValidatePublicKeyController.test.js +++ b/src/all/background_page/controller/accountRecovery/accountRecoveryValidatePublicKeyController.test.js @@ -63,7 +63,7 @@ describe("AccountRecoveryValidatePublicKeyController", () => { {key: pgpKeys.account_recovery_organization_alternative.public, expectedError: new Error("The key is already being used, the organization recovery key must be a new one.")}, {key: pgpKeys.betty.public, expectedError: new Error("The key is the current organization recovery key, you must provide a new one.")}, ]).describe("Should throw an error when the key cannot be validated", scenario => { - it(`Should throw an error whith the scenario: ${scenario.expectedError.message}`, async() => { + it(`Should throw an error with the scenario: ${scenario.expectedError.message}`, async() => { expect.assertions(1); mockFetch(); diff --git a/src/all/background_page/controller/auth/authVerifyServerKeyController.js b/src/all/background_page/controller/auth/authVerifyServerKeyController.js index bd613d69..f467730f 100644 --- a/src/all/background_page/controller/auth/authVerifyServerKeyController.js +++ b/src/all/background_page/controller/auth/authVerifyServerKeyController.js @@ -12,31 +12,30 @@ * @since 2.0.0 */ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; -import Keyring from "../../model/keyring"; -import GpgAuth from "../../model/gpgauth"; -import AuthModel from "../../model/auth/authModel"; -import {Uuid} from "../../utils/uuid"; import i18n from "../../sdk/i18n"; import KeyIsExpiredError from "../../error/keyIsExpiredError"; import ServerKeyChangedError from "../../error/serverKeyChangedError"; import WorkerService from "../../service/worker/workerService"; +import CompareGpgKeyService from "../../service/crypto/compareGpgKeyService"; +import GetGpgKeyInfoService from "../../service/crypto/getGpgKeyInfoService"; +import AuthVerifyServerChallengeService from "../../service/auth/authVerifyServerChallengeService"; +import AuthVerifyServerKeyService from "../../service/api/auth/authVerifyServerKeyService"; class AuthVerifyServerKeyController { /** - * AuthController Constructor + * AuthVerifyServerKeyController Constructor * * @param {Worker} worker * @param {string} requestId * @param {ApiClientOptions} apiClientOptions - * @param {string} userDomain + * @param {AccountEntity} account */ - constructor(worker, requestId, apiClientOptions, userDomain) { + constructor(worker, requestId, apiClientOptions, account) { this.worker = worker; this.requestId = requestId; - this.userDomain = userDomain; - this.keyring = new Keyring(); - this.authLegacy = new GpgAuth(this.keyring); - this.authModel = new AuthModel(apiClientOptions); + this.authVerifyServerChallengeService = new AuthVerifyServerChallengeService(apiClientOptions); + this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); + this.account = account; } /** @@ -54,28 +53,24 @@ class AuthVerifyServerKeyController { } /** - * Perform a GPGAuth verify + * Perform a GPGAuth to verify the server identity * * @returns {Promise} */ async exec() { - const serverKey = this.keyring.findPublic(Uuid.get(this.userDomain)).armoredKey; - const userFingerprint = this.keyring.findPrivate().fingerprint; - try { - await this.authModel.verify(serverKey, userFingerprint); + await this.authVerifyServerChallengeService.verifyAndValidateServerChallenge(this.account.userKeyFingerprint, this.account.serverPublicArmoredKey); } catch (error) { - await this.onVerifyError(error, serverKey); + await this.onVerifyError(error); } } /** * Whenever the verify fail * @param {Error} error The error - * @param {string} serverArmoredKey The server armored public key. * @returns {Promise} */ - async onVerifyError(error, serverArmoredKey) { + async onVerifyError(error) { if (error.message && error.message.indexOf('no user associated') !== -1) { /* * If the user has been deleted from the API, remove the authentication iframe served by the @@ -84,12 +79,12 @@ class AuthVerifyServerKeyController { (await WorkerService.get('AuthBootstrap', this.worker.tab.id)).port.emit('passbolt.auth-bootstrap.remove-iframe'); } else { try { - if (!await this.canParseServerKey(serverArmoredKey)) { + if (!await this.canParseServerKey()) { // @deprecated with v3.6.0. Fix an issue users encounter while using an armored server key with multiple keys https://github.com/passbolt/passbolt_browser_extension/issues/150 error = new ServerKeyChangedError(i18n.t('The server key cannot be parsed.')); - } else if (await this.authLegacy.serverKeyChanged()) { + } else if (await this.serverKeyChanged()) { error = new ServerKeyChangedError(i18n.t('The server key has changed.')); - } else if (this.authLegacy.isServerKeyExpired()) { + } else if (await this.serverKeyIsExpired()) { error = new KeyIsExpiredError(i18n.t('The server key is expired.')); } } catch (e) { @@ -104,17 +99,37 @@ class AuthVerifyServerKeyController { /** * Can parse the server key - * @param {string} serverArmoredKey The server armored public key. * @returns {Promise} */ - async canParseServerKey(serverArmoredKey) { + async canParseServerKey() { try { - await OpenpgpAssertion.readKeyOrFail(serverArmoredKey); + await OpenpgpAssertion.readKeyOrFail(this.account.serverPublicArmoredKey); } catch (error) { return false; } return true; } + + /** + * Check if the server key has changed + * @return {Promise} true if key has changed + */ + async serverKeyChanged() { + const remoteServerArmoredKey = (await this.authVerifyServerKeyService.getServerKey()).armored_key; + const remoteServerKey = await OpenpgpAssertion.readKeyOrFail(remoteServerArmoredKey); + const serverLocalKey = await OpenpgpAssertion.readKeyOrFail(this.account.serverPublicArmoredKey); + return !await CompareGpgKeyService.areKeysTheSame(remoteServerKey, serverLocalKey); + } + + /** + * Check if the server key is expired + * @return {Promise} true if key has expired + */ + async serverKeyIsExpired() { + const publicServerKey = await OpenpgpAssertion.readKeyOrFail(this.account.serverPublicArmoredKey); + const serverKey = await GetGpgKeyInfoService.getKeyInfo(publicServerKey); + return serverKey.isExpired; + } } export default AuthVerifyServerKeyController; diff --git a/src/all/background_page/controller/auth/authVerifyServerKeyController.test.js b/src/all/background_page/controller/auth/authVerifyServerKeyController.test.js index beca7e59..d8392165 100644 --- a/src/all/background_page/controller/auth/authVerifyServerKeyController.test.js +++ b/src/all/background_page/controller/auth/authVerifyServerKeyController.test.js @@ -12,27 +12,67 @@ * @since 3.6.1 */ -import {enableFetchMocks} from "jest-fetch-mock"; import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; import AuthVerifyServerKeyController from "./authVerifyServerKeyController"; -import storage from "../../sdk/storage"; -import MockExtension from "../../../../../test/mocks/mockExtension"; import {Uuid} from "../../utils/uuid"; import ServerKeyChangedError from "../../error/serverKeyChangedError"; +import AccountEntity from "../../model/entity/account/accountEntity"; +import {defaultAccountDto} from "../../model/entity/account/accountEntity.test.data"; +import {pgpKeys} from "../../../../../test/fixtures/pgpKeys/keys"; + +jest.mock('../../model/gpgAuthToken', () => function() { + return {token: "gpgauthv1.3.0|36|A89F6AB1-5BEE-32D8-a18B-461B810902E2|gpgauthv1.3.0", validate: () => true}; +}); beforeEach(() => { - enableFetchMocks(); + jest.resetModules(); + jest.clearAllMocks(); }); describe("AuthVerifyServerKeyController", () => { describe("AuthVerifyServerKeyController::exec", () => { + it("Should verify the server successfully.", async() => { + expect.assertions(1); + // Mock account. + const account = new AccountEntity(defaultAccountDto()); + + const controller = new AuthVerifyServerKeyController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(jest.fn()); + + const promise = controller.exec(); + await expect(promise).resolves.not.toThrow(); + }); + + it("Should throw a server key has changed error", async() => { + expect.assertions(1); + // Mock account. + const account = new AccountEntity(defaultAccountDto({server_public_armored_key: pgpKeys.expired.public})); + + const controller = new AuthVerifyServerKeyController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyServerKeyService, "getServerKey").mockImplementationOnce(() => ({armored_key: pgpKeys.server.public})); + + const promise = controller.exec(); + await expect(promise).rejects.toThrowError(new ServerKeyChangedError("Could not verify the server key. The server key has changed.")); + }); + + it("Should throw a server key expired error", async() => { + expect.assertions(1); + // Mock account. + const account = new AccountEntity(defaultAccountDto({server_public_armored_key: pgpKeys.expired.public})); + + const controller = new AuthVerifyServerKeyController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyServerKeyService, "getServerKey").mockImplementationOnce(() => ({armored_key: pgpKeys.expired.public})); + + const promise = controller.exec(); + await expect(promise).rejects.toThrowError(new ServerKeyChangedError("Could not verify the server key. The server key is expired.")); + }); + it("Should throw a server key changed error if the server key cannot be parsed.", async() => { - // Mock extension with a configured account. - const user = await MockExtension.withConfiguredAccount(); + expect.assertions(1); // Mock extension with a server public key that cannot be parsed. const publicKeys = [{ - "user_id": Uuid.get(user.settings.getDomain()), - "armored_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n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\n=qZBo\n-----END PGP PUBLIC KEY BLOCK-----\n", + "user_id": Uuid.get("https://passbolt.local"), + "armored_key": "-----BEGIN PGP PUBLIC KEY BLAOCK-----\n\n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\n=qZBo\n-----END PGP PUBLIC KEY BLOCK-----\n", "key_id": "ef1f7583", "user_ids": [ { @@ -49,13 +89,26 @@ describe("AuthVerifyServerKeyController", () => { "private": false, "revoked": false }]; - await storage.setItem('passbolt-public-gpgkeys', JSON.stringify(publicKeys)); + // Mock account. + const account = new AccountEntity(defaultAccountDto({server_public_armored_key: publicKeys[0].armored_key})); - const controller = new AuthVerifyServerKeyController(null, null, defaultApiClientOptions(), user.settings.getDomain()); + const controller = new AuthVerifyServerKeyController(null, null, defaultApiClientOptions(), account); - expect.assertions(1); const promise = controller.exec(); await expect(promise).rejects.toThrowError(new ServerKeyChangedError("Could not verify the server key. The server key cannot be parsed.")); }); + + it("Should throw a server error if the server cannot be verified", async() => { + expect.assertions(1); + // Mock account. + const account = new AccountEntity(defaultAccountDto({server_public_armored_key: pgpKeys.server.public})); + + const controller = new AuthVerifyServerKeyController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(() => { throw Error('Unknown error'); }); + jest.spyOn(controller.authVerifyServerKeyService, "getServerKey").mockImplementationOnce(() => { throw Error('Unknown error'); }); + + const promise = controller.exec(); + await expect(promise).rejects.toThrowError(new ServerKeyChangedError("Could not verify the server key. Server internal error. Check with your administrator.")); + }); }); }); diff --git a/src/all/background_page/controller/auth/getServerKeyController.js b/src/all/background_page/controller/auth/getServerKeyController.js new file mode 100644 index 00000000..79f53839 --- /dev/null +++ b/src/all/background_page/controller/auth/getServerKeyController.js @@ -0,0 +1,54 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SARL (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import AuthVerifyServerKeyService from "../../service/api/auth/authVerifyServerKeyService"; + +class GetServerKeyController { + /** + * AuthVerifyServerKeyController Constructor + * + * @param {Worker} worker + * @param {string} requestId + * @param {ApiClientOptions} apiClientOptions + */ + constructor(worker, requestId, apiClientOptions) { + this.worker = worker; + this.requestId = requestId; + this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); + } + + /** + * Controller executor. + * @returns {Promise} + */ + async _exec() { + try { + const serverKey = await this.exec(); + this.worker.port.emit(this.requestId, 'SUCCESS', serverKey); + } catch (error) { + console.error(error); + this.worker.port.emit(this.requestId, 'ERROR', error); + } + } + + /** + * Perform a GPGAuth verify + * + * @returns {Promise<{armored_key: string, fingerprint: string}>} + */ + async exec() { + return this.authVerifyServerKeyService.getServerKey(); + } +} + +export default GetServerKeyController; diff --git a/src/all/background_page/controller/auth/replaceServerKeyController.js b/src/all/background_page/controller/auth/replaceServerKeyController.js new file mode 100644 index 00000000..ab910cad --- /dev/null +++ b/src/all/background_page/controller/auth/replaceServerKeyController.js @@ -0,0 +1,59 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SARL (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import AuthVerifyServerKeyService from "../../service/api/auth/authVerifyServerKeyService"; +import Keyring from "../../model/keyring"; + +class ReplaceServerKeyController { + /** + * AuthVerifyServerKeyController Constructor + * + * @param {Worker} worker + * @param {string} requestId + * @param {ApiClientOptions} apiClientOptions + * @param {AccountEntity} account + */ + constructor(worker, requestId, apiClientOptions, account) { + this.worker = worker; + this.requestId = requestId; + this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); + this.account = account; + } + + /** + * Controller executor. + * @returns {Promise} + */ + async _exec() { + try { + const serverKey = await this.exec(); + this.worker.port.emit(this.requestId, 'SUCCESS', serverKey); + } catch (error) { + console.error(error); + this.worker.port.emit(this.requestId, 'ERROR', error); + } + } + + /** + * Perform a GPGAuth verify + * + * @returns {Promise<{armored_key: string, fingerprint: string}>} + */ + async exec() { + const keyring = new Keyring(); + const serverKeyDto = await this.authVerifyServerKeyService.getServerKey(); + await keyring.importServerPublicKey(serverKeyDto.armored_key, this.account.domain); + } +} + +export default ReplaceServerKeyController; diff --git a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js index 94c1feb9..62caca49 100644 --- a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js +++ b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js @@ -12,22 +12,23 @@ * @since 3.6.0 */ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; -import GpgAuth from "../../model/gpgauth"; import GpgKeyError from "../../error/GpgKeyError"; import i18n from "../../sdk/i18n"; +import AuthVerifyServerChallengeService from "../../service/auth/authVerifyServerChallengeService"; class ImportRecoverPrivateKeyController { /** * Constructor. * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. + * @param {ApiClientOptions} apiClientOptions the api client options * @param {AccountRecoverEntity} account The account being recovered. */ - constructor(worker, requestId, account) { + constructor(worker, requestId, apiClientOptions, account) { this.worker = worker; this.requestId = requestId; this.account = account; - this.legacyAuthModel = new GpgAuth(); + this.authVerifyServerChallengeService = new AuthVerifyServerChallengeService(apiClientOptions); } /** @@ -67,14 +68,13 @@ class ImportRecoverPrivateKeyController { * @private */ async _assertImportKeyOwnedByUser(fingerprint) { - const domain = this.account.domain; const serverPublicArmoredKey = this.account.serverPublicArmoredKey; if (!serverPublicArmoredKey) { throw new Error('The server public key should have been provided before importing a private key'); } try { - await this.legacyAuthModel.verify(domain, serverPublicArmoredKey, fingerprint); + await this.authVerifyServerChallengeService.verifyAndValidateServerChallenge(fingerprint, serverPublicArmoredKey); } catch (error) { console.error(error); // @todo Handle not controlled errors, such as timeout error... diff --git a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.test.js b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.test.js index 4a7d2b32..42722661 100644 --- a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.test.js +++ b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.test.js @@ -11,7 +11,6 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ -import {enableFetchMocks} from "jest-fetch-mock"; import ImportRecoverPrivateKeyController from "./importRecoverPrivateKeyController"; import GetGpgKeyInfoService from "../../service/crypto/getGpgKeyInfoService"; import GpgKeyError from "../../error/GpgKeyError"; @@ -23,10 +22,10 @@ import { } from "../../model/entity/account/accountRecoverEntity.test.data"; import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; +import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; beforeEach(() => { jest.clearAllMocks(); - enableFetchMocks(); }); /* @@ -44,7 +43,7 @@ describe("ImportRecoverPrivateKeyController", () => { describe("ImportRecoverPrivateKeyController::exec", () => { it("Should throw an exception if the passed DTO is not valid.", async() => { const account = new AccountRecoverEntity(withServerKeyAccountRecoverDto()); - const controller = new ImportRecoverPrivateKeyController(null, null, account); + const controller = new ImportRecoverPrivateKeyController(null, null, defaultApiClientOptions(), account); const scenarios = [ {dto: null, expectedError: Error}, @@ -73,7 +72,7 @@ describe("ImportRecoverPrivateKeyController", () => { it("Should throw an exception if the setupEntity is not initialized properly.", async() => { expect.assertions(1); const account = new AccountRecoverEntity(initialAccountRecoverDto()); - const controller = new ImportRecoverPrivateKeyController(null, null, account); + const controller = new ImportRecoverPrivateKeyController(null, null, defaultApiClientOptions(), account); try { await controller.exec(pgpKeys.ada.private); } catch (e) { @@ -85,18 +84,9 @@ describe("ImportRecoverPrivateKeyController", () => { expect.assertions(1); await MockExtension.withConfiguredAccount(); - const mockedResponse = { - status: 200, - headers: { - 'content-type': 'application/json', - 'x-gpgauth-version': '1.3.0', - 'x-gpgauth-error': true - } - }; - fetch.mockResponseOnce({}, mockedResponse); - const account = new AccountRecoverEntity(withServerKeyAccountRecoverDto()); - const controller = new ImportRecoverPrivateKeyController(null, null, account); + const controller = new ImportRecoverPrivateKeyController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(() => { throw new Error("Error"); }); try { await controller.exec(pgpKeys.ada.private); } catch (e) { @@ -105,23 +95,14 @@ describe("ImportRecoverPrivateKeyController", () => { }); it("Should set the private key and public of the setup entity.", async() => { - expect.assertions(9); + expect.assertions(10); await MockExtension.withConfiguredAccount(); - const mockedResponse = { - status: 200, - headers: { - 'content-type': 'application/json', - 'x-gpgauth-version': '1.3.0', - 'x-gpgauth-authenticated': false, - 'x-gpgauth-progress': 'stage0', - 'x-gpgauth-verify-response': "gpgauthv1.3.0|36|AAAAAAAA-AAAA-3AAA-aAAA-AAAAAAAAAAAA|gpgauthv1.3.0" - } - }; - fetch.mockResponseOnce({}, mockedResponse); const expectedKeyData = pgpKeys.ada; const account = new AccountRecoverEntity(withServerKeyAccountRecoverDto()); - const controller = new ImportRecoverPrivateKeyController(null, null, account); + const controller = new ImportRecoverPrivateKeyController(null, null, defaultApiClientOptions(), account); + + jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(jest.fn()); await controller.exec(expectedKeyData.private); @@ -142,7 +123,8 @@ describe("ImportRecoverPrivateKeyController", () => { expect(publicKeyInfo.userIds).toStrictEqual(expectedKeyData.user_ids); expect(privateKeyInfo.userIds).toStrictEqual(expectedKeyData.user_ids); - expect(fetch).toHaveBeenCalledTimes(1); + expect(controller.authVerifyServerChallengeService.verifyAndValidateServerChallenge).toHaveBeenCalledWith(expectedKeyData.fingerprint, account.serverPublicArmoredKey); + expect(controller.authVerifyServerChallengeService.verifyAndValidateServerChallenge).toHaveBeenCalledTimes(1); }, 10000); }); }); diff --git a/src/all/background_page/controller/recover/startRecoverController.js b/src/all/background_page/controller/recover/startRecoverController.js index 4b5d1fa8..f94268dc 100644 --- a/src/all/background_page/controller/recover/startRecoverController.js +++ b/src/all/background_page/controller/recover/startRecoverController.js @@ -13,10 +13,10 @@ */ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; -import AuthModel from "../../model/auth/authModel"; import SetupModel from "../../model/setup/setupModel"; import AccountRecoveryUserSettingEntity from "../../model/entity/accountRecovery/accountRecoveryUserSettingEntity"; import WorkerService from "../../service/worker/workerService"; +import AuthVerifyServerKeyService from "../../service/api/auth/authVerifyServerKeyService"; class StartRecoverController { /** @@ -31,7 +31,7 @@ class StartRecoverController { this.worker = worker; this.requestId = requestId; this.account = account; - this.authModel = new AuthModel(apiClientOptions); + this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); this.setupModel = new SetupModel(apiClientOptions); this.runtimeMemory = runtimeMemory; } @@ -71,7 +71,7 @@ class StartRecoverController { * @private */ async _findAndSetAccountServerPublicKey() { - const serverKeyDto = await this.authModel.getServerKey(); + const serverKeyDto = await this.authVerifyServerKeyService.getServerKey(); const serverKey = await OpenpgpAssertion.readKeyOrFail(serverKeyDto.armored_key); OpenpgpAssertion.assertPublicKey(serverKey); // associate the server public key to the current account. diff --git a/src/all/background_page/controller/setup/importSetupPrivateKeyController.js b/src/all/background_page/controller/setup/importSetupPrivateKeyController.js index 353e0c78..f12af127 100644 --- a/src/all/background_page/controller/setup/importSetupPrivateKeyController.js +++ b/src/all/background_page/controller/setup/importSetupPrivateKeyController.js @@ -12,23 +12,24 @@ * @since 3.6.0 */ -import GpgAuth from "../../model/gpgauth"; import i18n from "../../sdk/i18n"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import GpgKeyError from "../../error/GpgKeyError"; +import AuthVerifyServerChallengeService from "../../service/auth/authVerifyServerChallengeService"; class ImportSetupPrivateKeyController { /** * Constructor. * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. + * @param {ApiClientOptions} apiClientOptions the api client options * @param {AccountSetupEntity} account The account being setup. */ - constructor(worker, requestId, account) { + constructor(worker, requestId, apiClientOptions, account) { this.worker = worker; this.requestId = requestId; this.account = account; - this.legacyAuthModel = new GpgAuth(); + this.authVerifyServerChallengeService = new AuthVerifyServerChallengeService(apiClientOptions); } /** @@ -70,7 +71,6 @@ class ImportSetupPrivateKeyController { * @private */ async _assertImportKeyNotUsed(fingerprint) { - const domain = this.account.domain; const serverPublicArmoredKey = this.account.serverPublicArmoredKey; if (!serverPublicArmoredKey) { throw new Error('The server public key should have been provided before importing a private key'); @@ -78,10 +78,10 @@ class ImportSetupPrivateKeyController { let keyAlreadyUsed = false; try { - await this.legacyAuthModel.verify(domain, serverPublicArmoredKey, fingerprint); + await this.authVerifyServerChallengeService.verifyAndValidateServerChallenge(fingerprint, serverPublicArmoredKey); keyAlreadyUsed = true; } catch (error) { - console.log(error); + console.error(error); // @todo Handle not controlled errors, such as timeout error... } diff --git a/src/all/background_page/controller/setup/importSetupPrivateKeyController.test.js b/src/all/background_page/controller/setup/importSetupPrivateKeyController.test.js index 1a0c511a..f3bbf977 100644 --- a/src/all/background_page/controller/setup/importSetupPrivateKeyController.test.js +++ b/src/all/background_page/controller/setup/importSetupPrivateKeyController.test.js @@ -11,7 +11,6 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ -import {enableFetchMocks} from "jest-fetch-mock"; import ImportSetupPrivateKeyController from "./importSetupPrivateKeyController"; import GetGpgKeyInfoService from "../../service/crypto/getGpgKeyInfoService"; import GpgKeyError from "../../error/GpgKeyError"; @@ -23,10 +22,10 @@ import { startAccountSetupDto, withServerKeyAccountSetupDto } from "../../model/entity/account/accountSetupEntity.test.data"; +import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; beforeEach(() => { jest.clearAllMocks(); - enableFetchMocks(); }); /* @@ -44,7 +43,7 @@ describe("ImportSetupPrivateKeyController", () => { describe("GenerateKeyPairSetupController::exec", () => { it("Should throw an exception if the passed DTO is not valid.", async() => { const account = new AccountSetupEntity(withServerKeyAccountSetupDto()); - const controller = new ImportSetupPrivateKeyController(null, null, account); + const controller = new ImportSetupPrivateKeyController(null, null, defaultApiClientOptions(), account); const scenarios = [ {dto: null, expectedError: Error}, @@ -73,7 +72,7 @@ describe("ImportSetupPrivateKeyController", () => { it("Should throw an exception if the setupEntity is not initialized properly.", async() => { expect.assertions(1); const account = new AccountSetupEntity(startAccountSetupDto()); - const controller = new ImportSetupPrivateKeyController(null, null, account); + const controller = new ImportSetupPrivateKeyController(null, null, defaultApiClientOptions(), account); try { await controller.exec(pgpKeys.ada.private); } catch (e) { @@ -85,20 +84,11 @@ describe("ImportSetupPrivateKeyController", () => { expect.assertions(1); await MockExtension.withConfiguredAccount(); - const mockedResponse = { - status: 200, - headers: { - 'content-type': 'application/json', - 'x-gpgauth-version': '1.3.0', - 'x-gpgauth-authenticated': false, - 'x-gpgauth-progress': 'stage0', - 'x-gpgauth-verify-response': "gpgauthv1.3.0|36|AAAAAAAA-AAAA-3AAA-aAAA-AAAAAAAAAAAA|gpgauthv1.3.0" - } - }; - fetch.mockResponseOnce({}, mockedResponse); - const account = new AccountSetupEntity(withServerKeyAccountSetupDto()); - const controller = new ImportSetupPrivateKeyController(null, null, account); + const controller = new ImportSetupPrivateKeyController(null, null, defaultApiClientOptions(), account); + + jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(jest.fn()); + try { await controller.exec(pgpKeys.ada.private); } catch (e) { @@ -107,22 +97,14 @@ describe("ImportSetupPrivateKeyController", () => { }); it("Should set the private key and public of the setup entity.", async() => { - expect.assertions(13); + expect.assertions(14); await MockExtension.withConfiguredAccount(); - const mockedResponse = { - status: 200, - headers: { - 'content-type': 'application/json', - 'x-gpgauth-version': '1.3.0', - 'x-gpgauth-error': true - } - }; - fetch.mockResponseOnce({}, mockedResponse); - const expectedKeyData = pgpKeys.ada; const account = new AccountSetupEntity(withServerKeyAccountSetupDto()); - const controller = new ImportSetupPrivateKeyController(null, null, account); + const controller = new ImportSetupPrivateKeyController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(() => { throw new Error('User not known'); }); + await controller.exec(expectedKeyData.private); await expect(account.userKeyFingerprint).not.toBeNull(); @@ -147,7 +129,8 @@ describe("ImportSetupPrivateKeyController", () => { expect(publicKeyInfo.userIds).toStrictEqual(expectedKeyData.user_ids); expect(privateKeyInfo.userIds).toStrictEqual(expectedKeyData.user_ids); - expect(fetch).toHaveBeenCalledTimes(1); + expect(controller.authVerifyServerChallengeService.verifyAndValidateServerChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.serverPublicArmoredKey); + expect(controller.authVerifyServerChallengeService.verifyAndValidateServerChallenge).toHaveBeenCalledTimes(1); }, 10000); }); }); diff --git a/src/all/background_page/controller/setup/startSetupController.js b/src/all/background_page/controller/setup/startSetupController.js index de21ce0e..03b9f459 100644 --- a/src/all/background_page/controller/setup/startSetupController.js +++ b/src/all/background_page/controller/setup/startSetupController.js @@ -11,10 +11,10 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ -import AuthModel from "../../model/auth/authModel"; import SetupModel from "../../model/setup/setupModel"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import WorkerService from "../../service/worker/workerService"; +import AuthVerifyServerKeyService from "../../service/api/auth/authVerifyServerKeyService"; class StartSetupController { /** @@ -29,7 +29,7 @@ class StartSetupController { this.worker = worker; this.requestId = requestId; this.account = account; - this.authModel = new AuthModel(apiClientOptions); + this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); this.setupModel = new SetupModel(apiClientOptions); this.runtimeMemory = runtimeMemory; } @@ -67,7 +67,7 @@ class StartSetupController { * @private */ async _findAndSetAccountSetupServerPublicKey() { - const serverKeyDto = await this.authModel.getServerKey(); + const serverKeyDto = await this.authVerifyServerKeyService.getServerKey(); const serverPublicKey = await OpenpgpAssertion.readKeyOrFail(serverKeyDto.armored_key); OpenpgpAssertion.assertPublicKey(serverPublicKey); diff --git a/src/all/background_page/event/authEvents.js b/src/all/background_page/event/authEvents.js index 984c7399..a4aac4c7 100644 --- a/src/all/background_page/event/authEvents.js +++ b/src/all/background_page/event/authEvents.js @@ -6,16 +6,12 @@ * @copyright (c) 2019 Passbolt SA * @licence GNU Affero General Public License http://www.gnu.org/licenses/agpl-3.0.en.html */ -import Keyring from "../model/keyring"; -import User from "../model/user"; -import AuthModel from "../model/auth/authModel"; import AuthVerifyServerKeyController from "../controller/auth/authVerifyServerKeyController"; import AuthCheckStatusController from "../controller/auth/authCheckStatusController"; import AuthIsAuthenticatedController from "../controller/auth/authIsAuthenticatedController"; import AuthIsMfaRequiredController from "../controller/auth/authIsMfaRequiredController"; import CheckPassphraseController from "../controller/crypto/checkPassphraseController"; import RequestHelpCredentialsLostController from "../controller/auth/requestHelpCredentialsLostController"; -import {Config} from "../model/config"; import AuthLoginController from "../controller/auth/authLoginController"; import GetLocalSsoProviderConfiguredController from "../controller/sso/getLocalSsoProviderConfiguredController"; import SsoAuthenticationController from "../controller/sso/ssoAuthenticationController"; @@ -24,6 +20,8 @@ import UpdateLocalSsoProviderController from "../controller/sso/updateLocalSsoPr import HasSsoLoginErrorController from "../controller/sso/hasSsoLoginErrorController"; import GetQualifiedSsoLoginErrorController from "../controller/sso/getQualifiedSsoLoginErrorController"; import AuthLogoutController from "../controller/auth/authLogoutController"; +import GetServerKeyController from "../controller/auth/getServerKeyController"; +import ReplaceServerKeyController from "../controller/auth/replaceServerKeyController"; /** * Listens to the authentication events @@ -83,10 +81,7 @@ const listen = function(worker, apiClientOptions, account) { * @param requestId {uuid} The request identifier */ worker.port.on('passbolt.auth.verify-server-key', async requestId => { - const user = User.getInstance(); - const apiClientOptions = await user.getApiClientOptions(); - const userDomain = user.settings.getDomain(); - const auth = new AuthVerifyServerKeyController(worker, requestId, apiClientOptions, userDomain); + const auth = new AuthVerifyServerKeyController(worker, requestId, apiClientOptions, account); await auth._exec(); }); @@ -98,14 +93,8 @@ const listen = function(worker, apiClientOptions, account) { * @param domain {string} The server's domain */ worker.port.on('passbolt.auth.get-server-key', async requestId => { - try { - const authModel = new AuthModel(apiClientOptions); - const serverKeyDto = await authModel.getServerKey(); - worker.port.emit(requestId, 'SUCCESS', serverKeyDto); - } catch (error) { - console.error(error); - worker.port.emit(requestId, 'ERROR', error); - } + const getServerKeyController = new GetServerKeyController(worker, requestId, apiClientOptions); + await getServerKeyController._exec(); }); /* @@ -115,18 +104,8 @@ const listen = function(worker, apiClientOptions, account) { * @param requestId {uuid} The request identifier */ worker.port.on('passbolt.auth.replace-server-key', async requestId => { - const authModel = new AuthModel(apiClientOptions); - const keyring = new Keyring(); - const domain = Config.read('user.settings.trustedDomain'); - - try { - const serverKeyDto = await authModel.getServerKey(); - await keyring.importServerPublicKey(serverKeyDto.armored_key, domain); - worker.port.emit(requestId, 'SUCCESS'); - } catch (error) { - console.error(error); - worker.port.emit(requestId, 'ERROR', error); - } + const replaceServerKeyController = new ReplaceServerKeyController(worker, requestId, apiClientOptions, account); + await replaceServerKeyController._exec(); }); /* @@ -164,7 +143,7 @@ const listen = function(worker, apiClientOptions, account) { * @param requestId {uuid} The request identifier */ worker.port.on('passbolt.auth.post-login-redirect', requestId => { - let url = Config.read('user.settings.trustedDomain'); + let url = account.domain; const redirectTo = (new URL(worker.tab.url)).searchParams.get('redirect'); if (/^\/[A-Za-z0-9\-\/]*$/.test(redirectTo)) { url = `${url}${redirectTo}`; diff --git a/src/all/background_page/event/recoverEvents.js b/src/all/background_page/event/recoverEvents.js index 8534fa7a..895e7bdf 100644 --- a/src/all/background_page/event/recoverEvents.js +++ b/src/all/background_page/event/recoverEvents.js @@ -78,7 +78,7 @@ const listen = (worker, apiClientOptions, account) => { }); worker.port.on('passbolt.recover.import-key', async(requestId, armoredKey) => { - const controller = new ImportRecoverPrivateKeyController(worker, requestId, account); + const controller = new ImportRecoverPrivateKeyController(worker, requestId, apiClientOptions, account); await controller._exec(armoredKey); }); diff --git a/src/all/background_page/event/setupEvents.js b/src/all/background_page/event/setupEvents.js index 2a3fcf94..32ecb708 100644 --- a/src/all/background_page/event/setupEvents.js +++ b/src/all/background_page/event/setupEvents.js @@ -86,7 +86,7 @@ const listen = function(worker, apiClientOptions, account) { }); worker.port.on('passbolt.setup.import-key', async(requestId, armoredKey) => { - const controller = new ImportSetupPrivateKeyController(worker, requestId, account); + const controller = new ImportSetupPrivateKeyController(worker, requestId, apiClientOptions, account); await controller._exec(armoredKey); }); diff --git a/src/all/background_page/model/auth/authModel.js b/src/all/background_page/model/auth/authModel.js index 10f29118..e8b17330 100644 --- a/src/all/background_page/model/auth/authModel.js +++ b/src/all/background_page/model/auth/authModel.js @@ -10,16 +10,11 @@ * @license https://opensource.org/licenses/AGPL-3.0 AGPL License * @link https://www.passbolt.com Passbolt(tm) */ -import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; -import EncryptMessageService from "../../service/crypto/encryptMessageService"; import GpgAuth from "../gpgauth"; -import AuthService from '../../service/api/auth/authService'; import AuthLogoutService from 'passbolt-styleguide/src/shared/services/api/auth/AuthLogoutService'; import AuthStatusLocalStorage from "../../service/local_storage/authStatusLocalStorage"; import User from "../user"; import GetDecryptedUserPrivateKeyService from "../../service/account/getDecryptedUserPrivateKeyService"; -import GpgAuthToken from "../gpgAuthToken"; -import GpgAuthHeader from "../gpgAuthHeader"; import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; import StartLoopAuthSessionCheckService from "../../service/auth/startLoopAuthSessionCheckService"; @@ -31,7 +26,6 @@ class AuthModel { * @public */ constructor(apiClientOptions) { - this.authService = new AuthService(apiClientOptions); this.authLogoutService = new AuthLogoutService(apiClientOptions); this.legacyAuthModel = new GpgAuth(); } @@ -57,14 +51,6 @@ class AuthModel { self.dispatchEvent(event); } - /** - * Get server key - * @returns {Promise} The server key dto {fingerprint: string, armored_key: string} - */ - async getServerKey() { - return this.authService.getServerKey(); - } - /** * Login * @param {string} passphrase The passphrase to use to decrypt the user private key @@ -98,35 +84,6 @@ class AuthModel { const event = new Event('passbolt.auth.after-login'); self.dispatchEvent(event); } - - /** - * Verify the server identify - * - * @param {string} serverArmoredKey The public key to use to encrypt the serverToken - * @param {string} fingerprint The fingerprint to verify - * @throws {Error} If the token cannot be encrypted - * @throws {Error} if verification procedure fails - * @returns {Promise} - */ - async verify(serverArmoredKey, fingerprint) { - let encryptedToken, originalToken; - try { - originalToken = new GpgAuthToken(); - const serverKey = await OpenpgpAssertion.readKeyOrFail(serverArmoredKey); - encryptedToken = await EncryptMessageService.encrypt(originalToken.token, serverKey); - } catch (error) { - throw new Error(`Unable to encrypt the verify token. ${error.message}`); - } - - const response = await this.authService.verify(fingerprint, encryptedToken); - - // Check that the server was able to decrypt the token with our local copy - const auth = new GpgAuthHeader(response.headers, 'verify'); - const verifyToken = new GpgAuthToken(auth.headers['x-gpgauth-verify-response']); - if (verifyToken.token !== originalToken.token) { - throw new Error('The server was unable to prove it can use the advertised OpenPGP key.'); - } - } } export default AuthModel; diff --git a/src/all/background_page/model/gpgAuthHeader.test.data.js b/src/all/background_page/model/gpgAuthHeader.test.data.js new file mode 100644 index 00000000..bc6e23d7 --- /dev/null +++ b/src/all/background_page/model/gpgAuthHeader.test.data.js @@ -0,0 +1,40 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import GpgAuthToken from "./gpgAuthToken"; + +/** + * Export a default gpg headers verify complete + * @param data + * @return {{"x-gpgauth-verify-response": *, "x-gpgauth-version": string, "x-gpgauth-authenticated": string, get: (function(*): *), "x-gpgauth-progress": string, "x-gpgauth-verify-url": string, has: (function(*): boolean), "x-gpgauth-login-url": string, "x-gpgauth-logout-url": string, "x-gpgauth-pubkey-url": string}} + */ +export const defaultGpgAuthTokenVerifyHeadersDto = (data = {}) => { + const gpgAuthToken = new GpgAuthToken(data.token); + const headers = { + "x-gpgauth-authenticated": "false", + "x-gpgauth-login-url": "/auth/login", + "x-gpgauth-logout-url": "/auth/logout", + "x-gpgauth-progress": "stage0", + "x-gpgauth-pubkey-url": "/auth/verify.json", + "x-gpgauth-verify-response": gpgAuthToken.token, + "x-gpgauth-verify-url": "/auth/verify", + "x-gpgauth-version": "1.3.0" + }; + const defaultData = { + ...headers, + has: value => Boolean(headers[value]), + get: value => headers[value] + }; + return Object.assign(defaultData, data); +}; diff --git a/src/all/background_page/model/gpgauth.js b/src/all/background_page/model/gpgauth.js index 78e1fdef..27e5364d 100644 --- a/src/all/background_page/model/gpgauth.js +++ b/src/all/background_page/model/gpgauth.js @@ -13,12 +13,9 @@ */ import {OpenpgpAssertion} from "../utils/openpgp/openpgpAssertions"; import Keyring from "./keyring"; -import EncryptMessageService from "../service/crypto/encryptMessageService"; import DecryptMessageService from "../service/crypto/decryptMessageService"; -import CompareGpgKeyService from "../service/crypto/compareGpgKeyService"; import User from "./user"; import AuthStatusLocalStorage from "../service/local_storage/authStatusLocalStorage"; -import {Uuid} from "../utils/uuid"; import GpgAuthToken from "./gpgAuthToken"; import MfaAuthenticationRequiredError from "../error/mfaAuthenticationRequiredError"; import GpgAuthHeader from "./gpgAuthHeader"; @@ -28,7 +25,6 @@ import Request from "./request"; import urldecode from 'locutus/php/url/urldecode'; import stripslashes from 'locutus/php/strings/stripslashes'; -const URL_VERIFY = '/auth/verify.json?api-version=v2'; const URL_LOGIN = '/auth/login.json?api-version=v2'; /** @@ -56,117 +52,6 @@ class GpgAuth { return User.getInstance().settings.getDomain(); } - /** - * Verify the server identify - * - * @param {string} [serverUrl] optional - * @param {string} [serverArmoredKey] optional - * @param {string} [userFingerprint] optional - * @throws {Error} if domain is undefined in settings and serverUrl is not provided - * @throws {Error} if verification procedure fails - * @returns {Promise} - */ - async verify(serverUrl, serverArmoredKey, userFingerprint) { - const domain = serverUrl || this.getDomain(); - serverArmoredKey = serverArmoredKey || this.keyring.findPublic(Uuid.get(domain)).armoredKey; - const fingerprint = userFingerprint || this.keyring.findPrivate().fingerprint; - - // Encrypt a random token - let encrypted, originalToken; - try { - originalToken = new GpgAuthToken(); - const serverKey = await OpenpgpAssertion.readKeyOrFail(serverArmoredKey); - encrypted = await EncryptMessageService.encrypt(originalToken.token, serverKey); - } catch (error) { - throw new Error(`Unable to encrypt the verify token. ${error.message}`); - } - - // Prepare the request data - const data = new FormData(); - data.append('data[gpg_auth][keyid]', fingerprint); - data.append('data[gpg_auth][server_verify_token]', encrypted); - - // Send the data - const fetchOptions = { - method: 'POST', - credentials: 'include', - body: data - }; - Request.setCsrfHeader(fetchOptions, User.getInstance()); - const response = await fetch(domain + URL_VERIFY, fetchOptions); - - // If the server responded with an error build a relevant message - if (!response.ok) { - const json = await response.json(); - if (typeof json.header !== 'undefined') { - throw new Error(json.header.message); - } else { - const msg = `Server request failed (${response.status}) without providing additional information.`; - throw new Error(msg); - } - } - - // Check that the server was able to decrypt the token with our local copy - const auth = new GpgAuthHeader(response.headers, 'verify'); - const verifyToken = new GpgAuthToken(auth.headers['x-gpgauth-verify-response']); - if (verifyToken.token !== originalToken.token) { - throw new Error('The server was unable to prove it can use the advertised OpenPGP key.'); - } - } - - /** - * Check if the server key has changed - * @return {Promise} true if key has changed - */ - async serverKeyChanged() { - const remoteServerArmoredKey = (await this.getServerKey()).keydata; - const remoteServerKey = await OpenpgpAssertion.readKeyOrFail(remoteServerArmoredKey); - const serverLocalArmoredKey = this.getServerKeyFromKeyring().armoredKey; - const serverLocalKey = await OpenpgpAssertion.readKeyOrFail(serverLocalArmoredKey); - return !await CompareGpgKeyService.areKeysTheSame(remoteServerKey, serverLocalKey); - } - - /** - * Get Server key from keyring - * @returns {ExternalGpgKeyEntity} - */ - getServerKeyFromKeyring() { - return this.keyring.findPublic(Uuid.get(this.getDomain())); - } - - /** - * isServerKeyExpired - * @returns {boolean} - */ - isServerKeyExpired() { - const key = this.getServerKeyFromKeyring(); - return key.isExpired; - } - - /** - * Get Server key for GPG auth. - * - * @param {string} [serverUrl] optional domain where to get the key. - * if domain is not provided, then look in the settings. - * - * @returns {Promise.} - */ - async getServerKey(serverUrl) { - const domain = serverUrl || this.getDomain(); - const response = await fetch(domain + URL_VERIFY, { - method: 'GET', - credentials: 'include' - }); - - if (!response.ok) { - const msg = 'There was a problem when trying to communicate with the server' + ` (Code: ${response.status})`; - throw new Error(msg); - } - - const json = await response.json(); - return json.body; - } - /** * GPGAuth Login - handle stage1, stage2 and complete * diff --git a/src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.js b/src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.js new file mode 100644 index 00000000..adbed31b --- /dev/null +++ b/src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.js @@ -0,0 +1,97 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; +import GetGpgKeyInfoService from "../crypto/getGpgKeyInfoService"; +import AuthVerifyServerKeyService from "../api/auth/authVerifyServerKeyService"; +import GenerateGpgKeyPairOptionsEntity from "../../model/entity/gpgkey/generate/generateGpgKeyPairOptionsEntity"; +import Keyring from "../../model/keyring"; + +class ValidateOrganizationPublicKeyService { + /** + * Service constructor + * @param {ApiClientOptions} apiClientOptions The api client options + */ + constructor(apiClientOptions) { + this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); + this.keyring = new Keyring(); + } + + /** + * Validate the new organization account recovery public key. + * + * @param {string} publicArmoredKeyToValidate the key to check the validity as a potential new organization recovery key + * @param {string} organizationPolicyPublicArmoredKey the current organization recovery key in its armored form + * @throws {Error} If the key is not a valid openpgp key. + * @throws {Error} If the key does not use RSA as algorithm. + * @throws {Error} If the key is not public. + * @throws {Error} If the key is revoked. + * @throws {Error} If the key has an expiry date. + * @throws {Error} If the key is not at least 4096 bits. + * @throws {Error} If the key is the current server key. + * @throws {Error} If the key is used by another user. + */ + async validatePublicKey(publicArmoredKeyToValidate, organizationPolicyPublicArmoredKey) { + const publicKey = await OpenpgpAssertion.readKeyOrFail(publicArmoredKeyToValidate); + const keyInfo = await GetGpgKeyInfoService.getKeyInfo(publicKey); + + if (!keyInfo.isValid) { + throw new Error("The key should be a valid openpgp key."); + } + + if (keyInfo.algorithm !== GenerateGpgKeyPairOptionsEntity.TYPE_RSA) { + throw new Error("The key algorithm should be RSA."); + } + + if (keyInfo.private) { + throw new Error("The key should be public."); + } + + if (keyInfo.revoked) { + throw new Error("The key should not be revoked."); + } + + if (keyInfo.expires !== "Infinity") { + throw new Error("The key should not have an expiry date."); + } + + if (keyInfo.length < 4096) { + throw new Error("The key should be at least 4096 bits."); + } + + const serverKey = await this.authVerifyServerKeyService.getServerKey(); + if (serverKey.fingerprint.toUpperCase() === keyInfo.fingerprint) { + throw new Error("The key is the current server key, the organization recovery key must be a new one."); + } + + await this.keyring.sync(); + const publicKeys = this.keyring.getPublicKeysFromStorage(); + for (const id in publicKeys) { + const publicKey = publicKeys[id]; + if (publicKey.fingerprint.toUpperCase() === keyInfo.fingerprint) { + throw new Error("The key is already being used, the organization recovery key must be a new one."); + } + } + + if (!organizationPolicyPublicArmoredKey) { + return; + } + + const organizationPolicyPublicKey = await OpenpgpAssertion.readKeyOrFail(organizationPolicyPublicArmoredKey); + if (organizationPolicyPublicKey.getFingerprint().toUpperCase() === keyInfo.fingerprint) { + throw new Error("The key is the current organization recovery key, you must provide a new one."); + } + } +} + +export default ValidateOrganizationPublicKeyService; diff --git a/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.test.data.js b/src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.test.data.js similarity index 92% rename from src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.test.data.js rename to src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.test.data.js index 992192ce..60356de6 100644 --- a/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.test.data.js +++ b/src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.test.data.js @@ -11,7 +11,7 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ -const {pgpKeys} = require("../../../../../../test/fixtures/pgpKeys/keys"); +const {pgpKeys} = require("../../../../../test/fixtures/pgpKeys/keys"); exports.dummyData = { viableKey: pgpKeys.test_no_expiry_with_secret.public, diff --git a/src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.test.js b/src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.test.js new file mode 100644 index 00000000..4dc8dd4d --- /dev/null +++ b/src/all/background_page/service/accountRecovery/validateOrganizationPublicKeyService.test.js @@ -0,0 +1,69 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 3.6.0 + */ +import {enableFetchMocks} from "jest-fetch-mock"; +import each from "jest-each"; +import Keyring from "../../model/keyring"; +import ValidateOrganizationPublicKeyService from "./validateOrganizationPublicKeyService"; +import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import {mockApiResponse} from "../../../../../test/mocks/mockApiResponse"; +import {pgpKeys} from "../../../../../test/fixtures/pgpKeys/keys"; +import {dummyData} from "./validateOrganizationPublicKeyService.test.data"; + +beforeAll(() => { + enableFetchMocks(); +}); + +beforeEach(() => { + fetch.resetMocks(); + // Spy on keyring public key retrieval from local storage. + jest.spyOn(Keyring.prototype, "getPublicKeysFromStorage").mockImplementation(() => ({ + keyId1: { + fingerprint: pgpKeys.betty.fingerprint + } + })); + // Mock get server key json request. + fetch.doMockOnceIf(/auth\/verify/, () => mockApiResponse({ + fingerprint: pgpKeys.account_recovery_organization.fingerprint + })); + // Spy on the keyring synchronization + jest.spyOn(Keyring.prototype, "sync").mockImplementation(() => {}); +}); + +describe("ValidateOrganizationPublicKeyService::validatePublicKey", () => { + it("should accept a viable key", () => { + expect.assertions(1); + const service = new ValidateOrganizationPublicKeyService(defaultApiClientOptions()); + const validationPromise = service.validatePublicKey(dummyData.viableKey); + return expect(validationPromise).resolves.not.toThrow(); + }); + + each([ + {key: pgpKeys.anita.public, expectedError: new Error("The key algorithm should be RSA.")}, + {key: pgpKeys.ada.private, expectedError: new Error("The key should be public.")}, + {key: pgpKeys.revokedKey.public, expectedError: new Error("The key should not be revoked.")}, + {key: pgpKeys.expired.public, expectedError: new Error("The key should not have an expiry date.")}, + {key: pgpKeys.rsa_2048.public, expectedError: new Error("The key should be at least 4096 bits.")}, + {key: pgpKeys.validKeyWithExpirationDateDto.public, expectedError: new Error("The key should not have an expiry date.")}, + {key: pgpKeys.account_recovery_organization.public, expectedError: new Error("The key is the current server key, the organization recovery key must be a new one.")}, + {key: pgpKeys.betty.public, expectedError: new Error("The key is already being used, the organization recovery key must be a new one.")}, + {key: pgpKeys.account_recovery_organization_alternative.public, expectedError: new Error("The key is the current organization recovery key, you must provide a new one.")}, + ]).describe("Should throw an error when the key cannot be validated", scenario => { + it(`Should throw an error with the scenario: ${scenario.expectedError.message}`, async() => { + expect.assertions(1); + const service = new ValidateOrganizationPublicKeyService(defaultApiClientOptions()); + const validationPromise = service.validatePublicKey(scenario.key, pgpKeys.account_recovery_organization_alternative.public); + return expect(validationPromise).rejects.toThrow(scenario.expectedError.message); + }); + }); +}); diff --git a/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.js b/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.js index 91f3d980..77988c73 100644 --- a/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.js +++ b/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.js @@ -11,12 +11,7 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ -import {OpenpgpAssertion} from "../../../utils/openpgp/openpgpAssertions"; -import Keyring from "../../../model/keyring"; -import GpgAuth from "../../../model/gpgauth"; import AbstractService from "../abstract/abstractService"; -import GetGpgKeyInfoService from "../../crypto/getGpgKeyInfoService"; -import GenerateGpgKeyPairOptionsEntity from "../../../model/entity/gpgkey/generate/generateGpgKeyPairOptionsEntity"; const ACCOUNT_RECOVERY_ORGANIZATION_POLICY_SERVICE_RESOURCE_NAME = '/account-recovery/organization-policies'; @@ -80,76 +75,6 @@ class AccountRecoveryOrganizationPolicyService extends AbstractService { const response = await this.apiClient.create(accountRecoveryOrganizationPolicyDto); return response.body; } - - /** - * Validate the new ORK by checking that the key: - * - uses the right algorithm - * - is public - * - is not revoked - * - is not expired - * - size/length is at least 4096 - * - it's not the server key - * - it's not already used by a user - * - it's not the previous ORK - * - * @param {string} publicArmoredKeyToValidate the key to check the validity as a potential new organization recovery key - * @param {string} organizationPolicyPublicArmoredKey the current organization recovery key in its armored form - * @throws {Error} if any of the checks are wrong - */ - static async validatePublicKey(publicArmoredKeyToValidate, organizationPolicyPublicArmoredKey) { - const publicKey = await OpenpgpAssertion.readKeyOrFail(publicArmoredKeyToValidate); - const keyInfo = await GetGpgKeyInfoService.getKeyInfo(publicKey); - - if (!keyInfo.isValid) { - throw new Error("The key should be a valid openpgp key."); - } - - if (keyInfo.algorithm !== GenerateGpgKeyPairOptionsEntity.TYPE_RSA) { - throw new Error("The key algorithm should be RSA."); - } - - if (keyInfo.private) { - throw new Error("The key should be public."); - } - - if (keyInfo.revoked) { - throw new Error("The key should not be revoked."); - } - - if (keyInfo.expires !== "Infinity") { - throw new Error("The key should not have an expiry date."); - } - - if (keyInfo.length < 4096) { - throw new Error("The key should be at least 4096 bits."); - } - - const keyring = new Keyring(); - const gpgAuth = new GpgAuth(keyring); - - const serverKey = await gpgAuth.getServerKey(); - if (serverKey.fingerprint.toUpperCase() === keyInfo.fingerprint) { - throw new Error("The key is the current server key, the organization recovery key must be a new one."); - } - - await keyring.sync(); - const publicKeys = keyring.getPublicKeysFromStorage(); - for (const id in publicKeys) { - const publicKey = publicKeys[id]; - if (publicKey.fingerprint.toUpperCase() === keyInfo.fingerprint) { - throw new Error("The key is already being used, the organization recovery key must be a new one."); - } - } - - if (!organizationPolicyPublicArmoredKey) { - return; - } - - const organizationPolicyPulicKey = await OpenpgpAssertion.readKeyOrFail(organizationPolicyPublicArmoredKey); - if (organizationPolicyPulicKey.getFingerprint().toUpperCase() === keyInfo.fingerprint) { - throw new Error("The key is the current organization recovery key, you must provide a new one."); - } - } } export default AccountRecoveryOrganizationPolicyService; diff --git a/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.test.js b/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.test.js deleted file mode 100644 index c2bf0b71..00000000 --- a/src/all/background_page/service/api/accountRecovery/accountRecoveryOrganizationPolicyService.test.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 3.6.0 - */ -import AccountRecoveryOrganizationPolicyService from "./accountRecoveryOrganizationPolicyService"; -import {dummyData} from "./accountRecoveryOrganizationPolicyService.test.data"; -import Keyring from "../../../model/keyring"; -import Gpgauth from "../../../model/gpgauth"; - -jest.spyOn(Keyring.prototype, "getPublicKeysFromStorage").mockImplementation(() => ({ - keyId1: { - fingerprint: "03F60E958F4CB29723ACDF761353B5B15D9B054F" - } -})); -jest.spyOn(Keyring.prototype, "sync").mockImplementation(() => {}); - -jest.spyOn(Gpgauth.prototype, "getServerKey").mockImplementation(() => ({ - fingerprint: "7D85C136779F2F8BA48F193E1F194E8D4D8CB098" -})); - -function checkError(armored_key, errorMessage) { - const validationPromise = AccountRecoveryOrganizationPolicyService.validatePublicKey(armored_key); - return expect(validationPromise).rejects.toEqual(new Error(errorMessage)); -} - -describe("Account recovery validate public key service", () => { - it("should accept a viable key", () => { - expect.assertions(1); - const validationPromise = AccountRecoveryOrganizationPolicyService.validatePublicKey(dummyData.viableKey); - return expect(validationPromise).resolves.not.toThrow(); - }); - - it("should refuse an invalid key", async() => { - expect.assertions(6); - await checkError(dummyData.invalidKey, "The key should be a valid openpgp key."); - await checkError(dummyData.privateKey, "The key should be public."); - await checkError(dummyData.weakKey, "The key should be at least 4096 bits."); - await checkError(dummyData.expiredKey, "The key should not have an expiry date."); - await checkError(dummyData.notAKey, "The key should be a valid openpgp armored key string."); - await checkError(dummyData.existingKey, "The key is already being used, the organization recovery key must be a new one."); - }); - - it("should refuse the key if it's the same as currently used", () => { - expect.assertions(1); - const expectedError = new Error("The key is the current organization recovery key, you must provide a new one."); - - const validationPromise = AccountRecoveryOrganizationPolicyService.validatePublicKey(dummyData.viableKey, dummyData.viableKey); - return expect(validationPromise).rejects.toEqual(expectedError); - }); -}); diff --git a/src/all/background_page/service/api/auth/authService.js b/src/all/background_page/service/api/auth/authService.js deleted file mode 100644 index e826fcc5..00000000 --- a/src/all/background_page/service/api/auth/authService.js +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - */ -import PassboltBadResponseError from "../../../error/passboltBadResponseError"; -import AbstractService from "../abstract/abstractService"; -import PassboltApiFetchError from "passbolt-styleguide/src/shared/lib/Error/PassboltApiFetchError"; -import PassboltServiceUnavailableError from "../../../error/passboltServiceUnavailableError"; - -const AUTH_SERVICE_RESOURCE_NAME = 'auth'; - -class AuthService extends AbstractService { - /** - * Constructor - * - * @param {ApiClientOptions} apiClientOptions - * @public - */ - constructor(apiClientOptions) { - super(apiClientOptions, AuthService.RESOURCE_NAME); - } - - /** - * API Resource Name - * - * @returns {string} - * @public - */ - static get RESOURCE_NAME() { - return AUTH_SERVICE_RESOURCE_NAME; - } - - /** - * Return the list of supported options for the contains option in API find operations - * - * @returns {Array} list of supported option - */ - static getSupportedContainOptions() { - return []; - } - - /** - * Retrieve the server key - * @returns {Promise<{armored_key: string, fingerprint: string}>} - */ - async getServerKey() { - const url = this.apiClient.buildUrl(`${this.apiClient.baseUrl}/verify`, {}); - const response = await this.apiClient.fetchAndHandleResponse('GET', url); - return this.mapGetServerKey(response.body); - } - - /** - * Map the get server key result of the API. - * @param data - * @returns {{armored_key: string, fingerprint: string}} - */ - mapGetServerKey(data) { - const {keydata, fingerprint} = data; - return { - armored_key: keydata, - fingerprint: fingerprint - }; - } - - /** - * Verify - * @returns {Promise} - */ - async verify(fingerprint, serverVerifyToken) { - const url = this.apiClient.buildUrl(`${this.apiClient.baseUrl}/verify`, {}); - - const body = new FormData(); - body.append('data[gpg_auth][keyid]', fingerprint); - body.append('data[gpg_auth][server_verify_token]', serverVerifyToken); - - const fetchOptions = await this.apiClient.buildFetchOptions(); - fetchOptions.method = 'POST'; - fetchOptions.body = body; - // It is required to let this property unset in order to let the browser determine it by itself and set the additional variable boundary required by the API to parse the payload. - delete fetchOptions.headers['content-type']; - - let response, responseJson; - try { - response = await fetch(url.toString(), fetchOptions); - } catch (error) { - if (navigator.onLine) { - // Catch Network error such as bad certificate or server unreachable. - throw new PassboltServiceUnavailableError("Unable to reach the server, an unexpected error occurred"); - } else { - // Network connection lost. - throw new PassboltServiceUnavailableError("Unable to reach the server, you are not connected to the network"); - } - } - - try { - responseJson = await response.json(); - } catch (error) { - /* - * If the response cannot be parsed, it's not a Passbolt API response. - * It can be a for example a proxy timeout error (504). - */ - throw new PassboltBadResponseError(); - } - - if (!response.ok) { - const message = responseJson.header.message; - throw new PassboltApiFetchError(message, { - code: response.status, - body: responseJson.body - }); - } - - return response; - } -} - -export default AuthService; diff --git a/src/all/background_page/service/api/auth/authVerifyServerKeyService.js b/src/all/background_page/service/api/auth/authVerifyServerKeyService.js new file mode 100644 index 00000000..6087ceea --- /dev/null +++ b/src/all/background_page/service/api/auth/authVerifyServerKeyService.js @@ -0,0 +1,79 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import AbstractService from "../abstract/abstractService"; + +const AUTH_VERIFY_SERVER_KEY_SERVICE_RESOURCE_NAME = 'auth/verify'; + +class AuthVerifyServerKeyService extends AbstractService { + /** + * Constructor + * + * @param {ApiClientOptions} apiClientOptions + * @public + */ + constructor(apiClientOptions) { + super(apiClientOptions, AuthVerifyServerKeyService.RESOURCE_NAME); + } + + /** + * API Resource Name + * + * @returns {string} + * @public + */ + static get RESOURCE_NAME() { + return AUTH_VERIFY_SERVER_KEY_SERVICE_RESOURCE_NAME; + } + + /** + * Retrieve the server key + * @returns {Promise<{armored_key: string, fingerprint: string}>} + */ + async getServerKey() { + const response = await this.apiClient.findAll(); + return this.mapGetServerKey(response.body); + } + + /** + * Map the get server key result of the API. + * @param data + * @returns {{armored_key: string, fingerprint: string}} + */ + mapGetServerKey(data) { + const {keydata, fingerprint} = data; + return { + armored_key: keydata, + fingerprint: fingerprint + }; + } + + /** + * Verify the server identify + * @returns {Promise} + */ + async verify(fingerprint, serverVerifyToken) { + const body = new FormData(); + body.append('data[gpg_auth][keyid]', fingerprint); + body.append('data[gpg_auth][server_verify_token]', serverVerifyToken); + const fetchOptions = await this.apiClient.buildFetchOptions(); + // It is required to let this property unset in order to let the browser determine it by itself and set the additional variable boundary required by the API to parse the payload. + delete fetchOptions.headers['content-type']; + const url = this.apiClient.buildUrl(this.apiClient.baseUrl.toString()); + const response = await this.apiClient.sendRequest('POST', url, body, fetchOptions); + await this.apiClient.parseResponseJson(response); + return response; + } +} + +export default AuthVerifyServerKeyService; diff --git a/src/all/background_page/service/api/auth/authVerifyServerKeyService.test.js b/src/all/background_page/service/api/auth/authVerifyServerKeyService.test.js new file mode 100644 index 00000000..40457a14 --- /dev/null +++ b/src/all/background_page/service/api/auth/authVerifyServerKeyService.test.js @@ -0,0 +1,74 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2023 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2023 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.1.0 + */ +import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import {enableFetchMocks} from "jest-fetch-mock"; +import AuthVerifyServerKeyService from "./authVerifyServerKeyService"; +import {mockApiResponse, mockApiResponseError} from "../../../../../../test/mocks/mockApiResponse"; +import PassboltApiFetchError from "passbolt-styleguide/src/shared/lib/Error/PassboltApiFetchError"; +import {defaultAccountDto} from "../../../model/entity/account/accountEntity.test.data"; +import GpgAuthToken from "../../../model/gpgAuthToken"; +import {OpenpgpAssertion} from "../../../utils/openpgp/openpgpAssertions"; +import EncryptMessageService from "../../crypto/encryptMessageService"; +import AccountEntity from "../../../model/entity/account/accountEntity"; + +beforeEach(async() => { + enableFetchMocks(); + jest.clearAllMocks(); +}); + +describe("AuthVerifyServerKeyService", () => { + describe("AuthVerifyServerKeyService::exec", () => { + it("Should call the API on verify endpoint with a POST request", async() => { + expect.assertions(2); + + const apiClientOptions = defaultApiClientOptions(); + const account = new AccountEntity(defaultAccountDto()); + const originalToken = new GpgAuthToken(); + const serverKey = await OpenpgpAssertion.readKeyOrFail(account.serverPublicArmoredKey); + const encryptedToken = await EncryptMessageService.encrypt(originalToken.token, serverKey); + const service = new AuthVerifyServerKeyService(apiClientOptions); + + fetch.doMockOnceIf(/auth\/verify\.json\?api-version=v2/, async req => { + expect(req.headers.get('content-type') !== "application/json").toBeTruthy(); + expect(req.method).toStrictEqual("POST"); + return mockApiResponse({}); + }); + + await service.verify(account.userKeyFingerprint, encryptedToken); + }); + + it("Should throw an exception if the POST verify endpoint send an error", async() => { + expect.assertions(2); + + const apiClientOptions = defaultApiClientOptions(); + const account = new AccountEntity(defaultAccountDto()); + const originalToken = new GpgAuthToken(); + const serverKey = await OpenpgpAssertion.readKeyOrFail(account.serverPublicArmoredKey); + const encryptedToken = await EncryptMessageService.encrypt(originalToken.token, serverKey); + const service = new AuthVerifyServerKeyService(apiClientOptions); + + fetch.doMockOnceIf(/auth\/verify\.json\?api-version=v2/, async req => { + expect(req.method).toStrictEqual("POST"); + return mockApiResponseError(500, "Something went wrong"); + }); + + try { + await service.verify(account.userKeyFingerprint, encryptedToken); + } catch (e) { + const expectedError = new PassboltApiFetchError('Something went wrong'); + expect(e).toStrictEqual(expectedError); + } + }); + }); +}); diff --git a/src/all/background_page/service/auth/authVerifyServerChallengeService.js b/src/all/background_page/service/auth/authVerifyServerChallengeService.js new file mode 100644 index 00000000..13b53883 --- /dev/null +++ b/src/all/background_page/service/auth/authVerifyServerChallengeService.js @@ -0,0 +1,53 @@ +import GpgAuthToken from "../../model/gpgAuthToken"; +import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; +import EncryptMessageService from "../crypto/encryptMessageService"; +import GpgAuthHeader from "../../model/gpgAuthHeader"; +import AuthVerifyServerKeyService from "../api/auth/authVerifyServerKeyService"; + +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +class AuthVerifyServerChallengeService { + /** + * The constructor + * @param apiClientOptions + */ + constructor(apiClientOptions) { + this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); + this.gpgAuthToken = new GpgAuthToken(); + } + + /** + * Verify and validate the server challenge + * @param {string} userFingerprint The user fingerprint + * @param {string} serverPublicArmoredKey The server public armored key + * @return {Promise} + */ + async verifyAndValidateServerChallenge(userFingerprint, serverPublicArmoredKey) { + // Read the server public key + const serverKey = await OpenpgpAssertion.readKeyOrFail(serverPublicArmoredKey); + // Step 1: Encrypt the token + const encryptedToken = await EncryptMessageService.encrypt(this.gpgAuthToken.token, serverKey); + // Step 2: Send the token encrypted to the server + const response = await this.authVerifyServerKeyService.verify(userFingerprint, encryptedToken); + // Step 3: Verify and validate the response headers + const auth = new GpgAuthHeader(response.headers, 'verify'); + // Step 4: Verify and validate that the token received is the same + const verifyToken = new GpgAuthToken(auth.headers['x-gpgauth-verify-response']); + if (verifyToken.token !== this.gpgAuthToken.token) { + throw new Error('The server was unable to prove it can use the advertised OpenPGP key.'); + } + } +} + +export default AuthVerifyServerChallengeService; diff --git a/src/all/background_page/service/auth/authVerifyServerChallengeService.test.js b/src/all/background_page/service/auth/authVerifyServerChallengeService.test.js new file mode 100644 index 00000000..8a4c5321 --- /dev/null +++ b/src/all/background_page/service/auth/authVerifyServerChallengeService.test.js @@ -0,0 +1,71 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 3.6.1 + */ + +import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import AccountEntity from "../../model/entity/account/accountEntity"; +import AuthVerifyServerChallengeService from "./authVerifyServerChallengeService"; +import {defaultGpgAuthTokenVerifyHeadersDto} from "../../model/gpgAuthHeader.test.data"; +import {defaultAccountDto} from "../../model/entity/account/accountEntity.test.data"; +import {pgpKeys} from "../../../../../test/fixtures/pgpKeys/keys"; + +beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); +}); + +describe("AuthVerifyServerKeyController", () => { + describe("AuthVerifyServerKeyController::exec", () => { + it("Should verify the server successfully.", async() => { + expect.assertions(1); + // Mock account. + const account = new AccountEntity(defaultAccountDto()); + + const service = new AuthVerifyServerChallengeService(defaultApiClientOptions()); + jest.spyOn(service.authVerifyServerKeyService, "verify").mockImplementationOnce(() => ({ + headers: defaultGpgAuthTokenVerifyHeadersDto({token: service.gpgAuthToken.token}), + body: {} + })); + + const promise = service.verifyAndValidateServerChallenge(account.userKeyFingerprint, account.serverPublicArmoredKey); + await expect(promise).resolves.not.toThrow(); + }); + + it("Should throw a server error if the server token is not the same", async() => { + expect.assertions(1); + // Mock account. + const account = new AccountEntity(defaultAccountDto({server_public_armored_key: pgpKeys.server.public})); + + const service = new AuthVerifyServerChallengeService(defaultApiClientOptions()); + jest.spyOn(service.authVerifyServerKeyService, "verify").mockImplementationOnce(() => ({ + headers: defaultGpgAuthTokenVerifyHeadersDto(), + body: {} + })); + + const promise = service.verifyAndValidateServerChallenge(account.userKeyFingerprint, account.serverPublicArmoredKey); + await expect(promise).rejects.toThrowError(new Error('The server was unable to prove it can use the advertised OpenPGP key.')); + }); + + it("Should throw a server error if the server cannot be verified", async() => { + expect.assertions(1); + // Mock account. + const account = new AccountEntity(defaultAccountDto({server_public_armored_key: pgpKeys.server.public})); + + const service = new AuthVerifyServerChallengeService(defaultApiClientOptions()); + jest.spyOn(service.authVerifyServerKeyService, "verify").mockImplementationOnce(() => { throw Error('Unknown error'); }); + + const promise = service.verifyAndValidateServerChallenge(account.userKeyFingerprint, account.serverPublicArmoredKey); + await expect(promise).rejects.toThrowError(new Error('Unknown error')); + }); + }); +}); From e0986b5cdaf825d4bcf23d0556721b2d764da450 Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Wed, 20 Mar 2024 13:31:38 +0000 Subject: [PATCH 13/56] PB-29967 Use a dedicated service to do the step chalenge with the server --- .../recoverAccountController.js | 2 + .../controller/auth/authLoginController.js | 24 ++-- .../auth/authLoginController.test.js | 75 +++++++----- .../importRecoverPrivateKeyController.js | 4 +- .../controller/setup/signInSetupController.js | 12 +- .../setup/signInSetupController.test.js | 19 ++- .../sso/ssoAuthenticationController.js | 12 +- .../sso/ssoAuthenticationController.test.js | 61 +++++----- .../event/appBootstrapEvents.js | 13 +- src/all/background_page/event/authEvents.js | 2 - .../background_page/model/auth/authModel.js | 40 ------- .../model/auth/authModel.test.js | 3 +- .../model/gpgAuthHeader.test.data.js | 48 ++++++++ src/all/background_page/model/gpgauth.js | 112 ------------------ .../service/api/auth/authLoginService.js | 82 +++++++++++++ .../service/api/auth/authLoginService.test.js | 104 ++++++++++++++++ .../auth/authVerifyLoginChallengeService.js | 49 ++++++++ .../authVerifyLoginChallengeService.test.js | 59 +++++++++ .../auth/decryptUserAuthTokenService.js | 55 +++++++++ .../decryptUserAuthTokenService.test.data.js | 19 +++ .../auth/decryptUserAuthTokenService.test.js | 79 ++++++++++++ .../service/auth/postLoginService.js | 30 +++++ .../service/auth/postLoginService.test.js | 34 ++++++ 23 files changed, 700 insertions(+), 238 deletions(-) create mode 100644 src/all/background_page/service/api/auth/authLoginService.js create mode 100644 src/all/background_page/service/api/auth/authLoginService.test.js create mode 100644 src/all/background_page/service/auth/authVerifyLoginChallengeService.js create mode 100644 src/all/background_page/service/auth/authVerifyLoginChallengeService.test.js create mode 100644 src/all/background_page/service/auth/decryptUserAuthTokenService.js create mode 100644 src/all/background_page/service/auth/decryptUserAuthTokenService.test.data.js create mode 100644 src/all/background_page/service/auth/decryptUserAuthTokenService.test.js create mode 100644 src/all/background_page/service/auth/postLoginService.js create mode 100644 src/all/background_page/service/auth/postLoginService.test.js diff --git a/src/all/background_page/controller/accountRecovery/recoverAccountController.js b/src/all/background_page/controller/accountRecovery/recoverAccountController.js index 92536e68..7924b90f 100644 --- a/src/all/background_page/controller/accountRecovery/recoverAccountController.js +++ b/src/all/background_page/controller/accountRecovery/recoverAccountController.js @@ -147,6 +147,7 @@ class RecoverAccountController { OpenpgpAssertion.assertPrivateKey(recoveredPrivateKey); this.account.userPrivateArmoredKey = recoveredPrivateKey.armor(); this.account.userPublicArmoredKey = recoveredPrivateKey.toPublic().armor(); + this.account.userKeyFingerprint = recoveredPrivateKey.getFingerprint().toUpperCase(); await this.setupModel.completeRecover(this.account); } @@ -178,6 +179,7 @@ class RecoverAccountController { _updateWorkerAccount(account) { this.account.userPublicArmoredKey = account.userPublicArmoredKey; this.account.userPrivateArmoredKey = account.userPrivateArmoredKey; + this.account.userKeyFingerprint = account.userKeyFingerprint; } /** diff --git a/src/all/background_page/controller/auth/authLoginController.js b/src/all/background_page/controller/auth/authLoginController.js index 54f45f29..2b8be90b 100644 --- a/src/all/background_page/controller/auth/authLoginController.js +++ b/src/all/background_page/controller/auth/authLoginController.js @@ -11,13 +11,15 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.9.0 */ -import AuthModel from "../../model/auth/authModel"; import UserAlreadyLoggedInError from "../../error/userAlreadyLoggedInError"; import Keyring from "../../model/keyring"; import CheckPassphraseService from "../../service/crypto/checkPassphraseService"; import UpdateSsoCredentialsService from "../../service/account/updateSsoCredentialsService"; import UserRememberMeLatestChoiceLocalStorage from "../../service/local_storage/userRememberMeLatestChoiceLocalStorage"; import UserRememberMeLatestChoiceEntity from "../../model/entity/rememberMe/userRememberMeLatestChoiceEntity"; +import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; +import PostLoginService from "../../service/auth/postLoginService"; +import AuthVerifyLoginChallengeService from "../../service/auth/authVerifyLoginChallengeService"; class AuthLoginController { /** @@ -31,7 +33,7 @@ class AuthLoginController { this.worker = worker; this.requestId = requestId; this.account = account; - this.authModel = new AuthModel(apiClientOptions); + this.authVerifyLoginChallengeService = new AuthVerifyLoginChallengeService(apiClientOptions); this.updateSsoCredentialsService = new UpdateSsoCredentialsService(apiClientOptions); this.checkPassphraseService = new CheckPassphraseService(new Keyring()); this.userRememberMeLatestChoiceLocalStorage = new UserRememberMeLatestChoiceLocalStorage(account); @@ -40,9 +42,9 @@ class AuthLoginController { /** * Wrapper of exec function to run it with worker. * - * @param {uuid} requestId The request identifier * @param {string} passphrase The passphrase to decryt the private key - * @param {boolean} rememberMe whether to remember the passphrase or not + * @param {boolean} remember whether to remember the passphrase or not + * @param {boolean} shouldRefreshCurrentTab should refresh the current tab * @return {Promise} */ async _exec(passphrase, remember, shouldRefreshCurrentTab = false) { @@ -59,11 +61,9 @@ class AuthLoginController { * Attemps to sign in the current user. * * @param {string} passphrase The passphrase to decryt the private key - * @param {string} rememberMe whether to remember the passphrase + * @param {boolean} rememberMe whether to remember the passphrase * @param {boolean} shouldRefreshCurrentTab Should the controller calls for a refresh of the current running tab, default false * (bool) false|undefined if should not remember - * (integer) -1 if should remember for the session - * (integer) duration in seconds to specify a specific duration * @return {Promise} */ async exec(passphrase, rememberMe, shouldRefreshCurrentTab) { @@ -93,7 +93,15 @@ class AuthLoginController { } try { - await this.authModel.login(passphrase, rememberMe); + await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(this.account.userKeyFingerprint, this.account.userPrivateArmoredKey, passphrase); + /* + * Post login operations + * MFA may not be complete yet, so no need to preload things here + */ + if (rememberMe) { + await PassphraseStorageService.set(passphrase, -1); + } + await PostLoginService.postLogin(); await this.registerRememberMeOption(rememberMe); } catch (error) { if (!(error instanceof UserAlreadyLoggedInError)) { diff --git a/src/all/background_page/controller/auth/authLoginController.test.js b/src/all/background_page/controller/auth/authLoginController.test.js index 29e7f6f4..09b57ab0 100644 --- a/src/all/background_page/controller/auth/authLoginController.test.js +++ b/src/all/background_page/controller/auth/authLoginController.test.js @@ -25,14 +25,9 @@ import {anonymousOrganizationSettings} from "../../model/entity/organizationSett import {mockApiResponse} from "../../../../../test/mocks/mockApiResponse"; import {defaultEmptySettings, withAzureSsoSettings} from "../sso/getCurrentSsoSettingsController.test.data"; import {clientSsoKit} from "../../model/entity/sso/ssoKitClientPart.test.data"; - -const mockLogin = jest.fn(); -jest.mock("../../model/auth/authModel", () => ({ - __esModule: true, - default: jest.fn(() => ({ - login: mockLogin - })) -})); +import PostLoginService from "../../service/auth/postLoginService"; +import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; +import each from "jest-each"; beforeEach(async() => { enableFetchMocks(); @@ -55,27 +50,39 @@ describe("AuthLoginController", () => { fetch.doMockOnceIf(new RegExp('/sso/settings/current.json'), () => mockApiResponse(ssoSettings)); }; - it("Should sign-in the user.", async() => { - mockOrganisationSettings(false); - - const account = new AccountEntity(defaultAccountDto()); - const controller = new AuthLoginController(null, null, defaultApiClientOptions(), account); - - const scenarios = [{ - passphrase: passphrase, - rememberMe: true - }, - { - passphrase: passphrase, - rememberMe: false - }]; - expect.assertions(scenarios.length); - - for (let i = 0; i < scenarios.length; i++) { - const scenario = scenarios[i]; - await controller.exec(scenario.passphrase, scenario.rememberMe); - expect(mockLogin).toHaveBeenCalledWith(scenario.passphrase, scenario.rememberMe); - } + each([ + {scenario: 'remember me true', passphrase: passphrase, rememberMe: true, shouldRefreshCurrentTab: true}, + {scenario: 'remember me true, no tab refresh', passphrase: passphrase, rememberMe: true, shouldRefreshCurrentTab: false}, + {scenario: 'remember me false', passphrase: passphrase, rememberMe: false, shouldRefreshCurrentTab: true}, + {scenario: 'remember me false, no tab refresh', passphrase: passphrase, rememberMe: false, shouldRefreshCurrentTab: false}, + ]).describe("Should sign-in the user.", test => { + it(`Sign in with ${test.scenario}`, async() => { + mockOrganisationSettings(false); + + const account = new AccountEntity(defaultAccountDto()); + const controller = new AuthLoginController({tab: {id: 1}}, null, defaultApiClientOptions(), account); + + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + jest.spyOn(PassphraseStorageService, "set"); + jest.spyOn(PostLoginService, "postLogin"); + jest.spyOn(browser.tabs, "update"); + + expect.assertions(4); + + await controller.exec(test.passphrase, test.rememberMe, test.shouldRefreshCurrentTab); + expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, test.passphrase); + if (test.rememberMe) { + expect(PassphraseStorageService.set).toHaveBeenCalledWith(test.passphrase, -1); + } else { + expect(PassphraseStorageService.set).not.toHaveBeenCalled(); + } + if (test.shouldRefreshCurrentTab) { + expect(browser.tabs.update).toHaveBeenCalledWith(1, {url: account.domain}); + } else { + expect(browser.tabs.update).not.toHaveBeenCalled(); + } + expect(PostLoginService.postLogin).toHaveBeenCalledWith(); + }); }); it("Should throw an exception if the passphrase is not a valid.", async() => { @@ -106,6 +113,8 @@ describe("AuthLoginController", () => { const account = new AccountEntity(defaultAccountDto()); const controller = new AuthLoginController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + await controller.exec(passphrase, true); expect(GenerateSsoKitService.generate).not.toHaveBeenCalled(); }); @@ -119,6 +128,8 @@ describe("AuthLoginController", () => { const account = new AccountEntity(defaultAccountDto()); const controller = new AuthLoginController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + await controller.exec(passphrase, true); expect(GenerateSsoKitService.generate).not.toHaveBeenCalled(); }); @@ -133,6 +144,8 @@ describe("AuthLoginController", () => { const account = new AccountEntity(defaultAccountDto()); const controller = new AuthLoginController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + await controller.exec(passphrase, true); expect(GenerateSsoKitService.generate).toHaveBeenCalledTimes(1); expect(GenerateSsoKitService.generate).toHaveBeenCalledWith(passphrase, ssoSettingsDto.provider); @@ -146,6 +159,8 @@ describe("AuthLoginController", () => { const account = new AccountEntity(defaultAccountDto()); const controller = new AuthLoginController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + await controller.exec(passphrase, true); expect(SsoDataStorage.flush).toHaveBeenCalledTimes(1); }); @@ -157,6 +172,8 @@ describe("AuthLoginController", () => { const account = new AccountEntity(defaultAccountDto()); const controller = new AuthLoginController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + await controller.exec(passphrase, true); expect(SsoDataStorage.flush).toHaveBeenCalledTimes(1); }); diff --git a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js index 62caca49..c3993342 100644 --- a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js +++ b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js @@ -54,9 +54,11 @@ class ImportRecoverPrivateKeyController { async exec(armoredKey) { const privateKey = await OpenpgpAssertion.readKeyOrFail(armoredKey); OpenpgpAssertion.assertPrivateKey(privateKey); - await this._assertImportKeyOwnedByUser(privateKey.getFingerprint().toUpperCase()); + const fingerprint = privateKey.getFingerprint().toUpperCase(); + await this._assertImportKeyOwnedByUser(fingerprint); this.account.userPrivateArmoredKey = privateKey.armor(); this.account.userPublicArmoredKey = privateKey.toPublic().armor(); + this.account.userKeyFingerprint = fingerprint; } /** diff --git a/src/all/background_page/controller/setup/signInSetupController.js b/src/all/background_page/controller/setup/signInSetupController.js index 39da0c17..00666b38 100644 --- a/src/all/background_page/controller/setup/signInSetupController.js +++ b/src/all/background_page/controller/setup/signInSetupController.js @@ -11,10 +11,12 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ -import AuthModel from "../../model/auth/authModel"; import CheckPassphraseService from "../../service/crypto/checkPassphraseService"; import Keyring from "../../model/keyring"; import UpdateSsoCredentialsService from "../../service/account/updateSsoCredentialsService"; +import AuthVerifyLoginChallengeService from "../../service/auth/authVerifyLoginChallengeService"; +import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; +import PostLoginService from "../../service/auth/postLoginService"; class SignInSetupController { /** @@ -29,7 +31,7 @@ class SignInSetupController { this.worker = worker; this.requestId = requestId; this.account = account; - this.authModel = new AuthModel(apiClientOptions); + this.authVerifyLoginChallengeService = new AuthVerifyLoginChallengeService(apiClientOptions); this.runtimeMemory = runtimeMemory; this.updateSsoCredentialsService = new UpdateSsoCredentialsService(apiClientOptions); this.checkPassphraseService = new CheckPassphraseService(new Keyring()); @@ -68,7 +70,11 @@ class SignInSetupController { await this.checkPassphraseService.checkPassphrase(this.runtimeMemory.passphrase); await this.updateSsoCredentialsService.forceUpdateSsoKit(this.runtimeMemory.passphrase); - await this.authModel.login(this.runtimeMemory.passphrase, rememberMe); + await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(this.account.userKeyFingerprint, this.account.userPrivateArmoredKey, this.runtimeMemory.passphrase); + if (rememberMe) { + await PassphraseStorageService.set(this.runtimeMemory.passphrase, -1); + } + await PostLoginService.postLogin(); await this.redirectToApp(); } diff --git a/src/all/background_page/controller/setup/signInSetupController.test.js b/src/all/background_page/controller/setup/signInSetupController.test.js index 9f00f58b..f1421ac2 100644 --- a/src/all/background_page/controller/setup/signInSetupController.test.js +++ b/src/all/background_page/controller/setup/signInSetupController.test.js @@ -27,6 +27,8 @@ import {mockApiResponse} from "../../../../../test/mocks/mockApiResponse"; import GenerateSsoKitService from "../../service/sso/generateSsoKitService"; import SsoDataStorage from "../../service/indexedDB_storage/ssoDataStorage"; import {withAzureSsoSettings} from "../sso/getCurrentSsoSettingsController.test.data"; +import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; +import PostLoginService from "../../service/auth/postLoginService"; beforeEach(() => { enableFetchMocks(); @@ -74,10 +76,7 @@ describe("SignInSetupController", () => { } }, 10000); - /** - * @todo: put back when a easier mock implementation of login procedure will be available - */ - it.skip("Should ask for SSO kits generation.", async() => { + it("Should ask for SSO kits generation.", async() => { const organizationSettings = anonymousOrganizationSettings(); organizationSettings.passbolt.plugins.sso = { enabled: true @@ -85,6 +84,9 @@ describe("SignInSetupController", () => { fetch.doMockOnceIf(new RegExp('/settings.json'), () => mockApiResponse(organizationSettings, {servertime: Date.now() / 1000})); fetch.doMockOnceIf(new RegExp('/sso/settings/current.json'), () => mockApiResponse(withAzureSsoSettings())); jest.spyOn(browser.cookies, "get").mockImplementationOnce(() => ({value: "csrf-token"})); + jest.spyOn(PassphraseStorageService, "set"); + jest.spyOn(PostLoginService, "postLogin"); + jest.spyOn(browser.tabs, "update"); SsoDataStorage.setMockedData(null); @@ -93,12 +95,17 @@ describe("SignInSetupController", () => { await MockExtension.withConfiguredAccount(); const account = new AccountEntity(defaultAccountDto()); const runtimeMemory = {passphrase: "ada@passbolt.com"}; - const controller = new SignInSetupController(null, null, defaultApiClientOptions(), account, runtimeMemory); + const controller = new SignInSetupController({tab: {id: 1}}, null, defaultApiClientOptions(), account, runtimeMemory); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); - expect.assertions(2); + expect.assertions(6); await controller.exec(true); + expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, runtimeMemory.passphrase); + expect(PassphraseStorageService.set).toHaveBeenCalledWith(runtimeMemory.passphrase, -1); + expect(PostLoginService.postLogin).toHaveBeenCalled(); expect(GenerateSsoKitService.generate).toHaveBeenCalledWith("ada@passbolt.com", "azure"); expect(GenerateSsoKitService.generate).toHaveBeenCalledTimes(1); + expect(browser.tabs.update).toHaveBeenCalledWith(1, {url: account.domain}); }, 10000); }); }); diff --git a/src/all/background_page/controller/sso/ssoAuthenticationController.js b/src/all/background_page/controller/sso/ssoAuthenticationController.js index 4dc6c71e..2ec932f5 100644 --- a/src/all/background_page/controller/sso/ssoAuthenticationController.js +++ b/src/all/background_page/controller/sso/ssoAuthenticationController.js @@ -15,18 +15,22 @@ import SsoDataStorage from "../../service/indexedDB_storage/ssoDataStorage"; import DecryptSsoPassphraseService from "../../service/crypto/decryptSsoPassphraseService"; import PopupHandlerService from "../../service/sso/popupHandlerService"; import SsoKitServerPartModel from "../../model/sso/ssoKitServerPartModel"; -import AuthModel from "../../model/auth/authModel"; import {QuickAccessService} from "../../service/ui/quickAccess.service"; import SsoLoginModel from "../../model/sso/ssoLoginModel"; import SsoSettingsModel from "../../model/sso/ssoSettingsModel"; import SsoSettingsChangedError from "../../error/ssoSettingsChangedError"; import QualifySsoLoginErrorService from "../../service/sso/qualifySsoLoginErrorService"; +import AuthVerifyLoginChallengeService from "../../service/auth/authVerifyLoginChallengeService"; +import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; +import PostLoginService from "../../service/auth/postLoginService"; class SsoAuthenticationController { /** * SsoAuthenticationController constructor * @param {Worker} worker * @param {string} requestId uuid + * @param {ApiClientOptions} apiClientOptions the api client options + * @param {AccountEntity} account The user account */ constructor(worker, requestId, apiClientOptions, account) { this.worker = worker; @@ -35,7 +39,7 @@ class SsoAuthenticationController { this.ssoKitServerPartModel = new SsoKitServerPartModel(apiClientOptions); this.ssoLoginModel = new SsoLoginModel(apiClientOptions); this.popupHandler = new PopupHandlerService(account.domain, worker?.tab?.id, false); - this.authModel = new AuthModel(apiClientOptions); + this.authVerifyLoginChallengeService = new AuthVerifyLoginChallengeService(apiClientOptions); this.ssoSettingsModel = new SsoSettingsModel(apiClientOptions); } @@ -84,7 +88,9 @@ class SsoAuthenticationController { const passphrase = await DecryptSsoPassphraseService.decrypt(clientPartSsoKit.secret, clientPartSsoKit.nek, serverKey, clientPartSsoKit.iv1, clientPartSsoKit.iv2); await this.popupHandler.closeHandler(); - await this.authModel.login(passphrase, true); + await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(this.account.userKeyFingerprint, this.account.userPrivateArmoredKey, passphrase); + await PassphraseStorageService.set(passphrase, -1); + await PostLoginService.postLogin(); if (isInQuickAccessMode) { await this.ensureRedirectionInQuickaccessMode(); } diff --git a/src/all/background_page/controller/sso/ssoAuthenticationController.test.js b/src/all/background_page/controller/sso/ssoAuthenticationController.test.js index 5b486f97..75904cdd 100644 --- a/src/all/background_page/controller/sso/ssoAuthenticationController.test.js +++ b/src/all/background_page/controller/sso/ssoAuthenticationController.test.js @@ -34,14 +34,8 @@ import User from "../../model/user"; import {defaultAccountDto} from "../../model/entity/account/accountEntity.test.data"; import AccountEntity from "../../model/entity/account/accountEntity"; import {QuickAccessService} from "../../service/ui/quickAccess.service"; - -const mockLogin = jest.fn(); -jest.mock("../../model/auth/authModel", () => ({ - __esModule: true, - default: jest.fn(() => ({ - login: mockLogin - })) -})); +import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; +import PostLoginService from "../../service/auth/postLoginService"; const mockGetSsoTokenFromThirdParty = jest.fn(); const mockCloseHandler = jest.fn(); @@ -77,21 +71,17 @@ const scenarios = [ each(scenarios).describe("SsoAuthenticationController", scenario => { describe(`SsoAuthenticationController::exec (with provider: '${scenario.providerId}')`, () => { it(`Should sign the user using a third party: ${scenario.providerId}`, async() => { - expect.assertions(13); + expect.assertions(14); const ssoLocalKit = clientSsoKit(); SsoDataStorage.setMockedData(ssoLocalKit); const ssoLoginToken = uuid(); const serverSsoKit = {data: generateSsoKitServerData()}; const deserializedKeyData = JSON.parse(Buffer.from(serverSsoKit.data, 'base64').toString()); - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); const severImportedKey = {algorithm: {name: "AES-GCM"}}; const userPassphrase = "this is the user passphrase"; mockGetSsoTokenFromThirdParty.mockImplementation(async() => ssoLoginToken); - mockLogin.mockImplementation(async(passphrase, rememberMe) => { - expect(passphrase).toBe(userPassphrase); - expect(rememberMe).toBe(true); - }); crypto.subtle.importKey.mockImplementation(async(keyFormat, keyInfo, algorithmName, isExtractable, capabilities) => { expect(keyFormat).toBe("jwk"); @@ -120,21 +110,28 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { fetch.doMockOnceIf(new RegExp(`/sso/keys/${ssoLocalKit.id}/${account.userId}/${ssoLoginToken}.json`), async() => mockApiResponse(serverSsoKit)); const controller = new SsoAuthenticationController(null, null, defaultApiClientOptions(), account); - return controller.exec(scenario.providerId); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + jest.spyOn(PassphraseStorageService, "set"); + jest.spyOn(PostLoginService, "postLogin"); + + await controller.exec(scenario.providerId); + + expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, userPassphrase); + expect(PassphraseStorageService.set).toHaveBeenCalledWith(userPassphrase, -1); + expect(PostLoginService.postLogin).toHaveBeenCalled(); }); it(`Should sign the user using a third party: ${scenario.providerId}`, async() => { - expect.assertions(2); + expect.assertions(5); const ssoLocalKit = clientSsoKit(); SsoDataStorage.setMockedData(ssoLocalKit); const ssoLoginToken = uuid(); const serverSsoKit = {data: generateSsoKitServerData()}; - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); const severImportedKey = {algorithm: {name: "AES-GCM"}}; const userPassphrase = "this is the user passphrase"; mockGetSsoTokenFromThirdParty.mockImplementation(async() => ssoLoginToken); - mockLogin.mockImplementation(async() => {}); crypto.subtle.importKey.mockImplementation(async() => severImportedKey); jest.spyOn(DecryptSsoPassphraseService, "decrypt").mockImplementation(async() => userPassphrase); fetch.doMockOnceIf(new RegExp(`/sso/${scenario.providerId}/login.json`), async() => mockApiResponse(scenario.loginUrlResponse)); @@ -143,8 +140,16 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const spyOnOpenInDetachedMode = jest.spyOn(QuickAccessService, "openInDetachedMode").mockImplementation(() => {}); const controller = new SsoAuthenticationController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + jest.spyOn(PassphraseStorageService, "set"); + jest.spyOn(PostLoginService, "postLogin"); + await controller.exec(scenario.providerId, true); + expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, userPassphrase); + expect(PassphraseStorageService.set).toHaveBeenCalledWith(userPassphrase, -1); + expect(PostLoginService.postLogin).toHaveBeenCalled(); + const expectedQuickAccessCallParameters = [ {name: "uiMode", value: "detached"}, {name: "feature", value: "login"} @@ -161,7 +166,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const ssoKit = clientSsoKit(); SsoDataStorage.setMockedData(ssoKit); - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); fetch.doMockOnceIf(new RegExp(`/sso/${scenario.providerId}/login.json`), async() => mockApiResponseError(400)); fetch.doMockOnceIf(new RegExp(`/sso/settings/current.json`), async() => mockApiResponse({provider: null})); @@ -180,7 +185,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const configuredProvider = scenario.providerId === "azure" ? "google" : "azure"; SsoDataStorage.setMockedData(ssoKit); - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); fetch.doMockOnceIf(new RegExp(`/sso/${scenario.providerId}/login.json`), async() => mockApiResponseError(400)); fetch.doMockOnceIf(new RegExp(`/sso/settings/current.json`), async() => mockApiResponse({provider: configuredProvider})); @@ -221,7 +226,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { describe(`SsoAuthenticationController::exec should throw an error when something wrong happens (with provider: '${scenario.providerId}')`, () => { it("Should throw an error when client sso kit can't be find.", async() => { expect.assertions(2); - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); SsoDataStorage.setMockedData(null); const controller = new SsoAuthenticationController(null, null, defaultApiClientOptions(), account); try { @@ -236,7 +241,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { expect.assertions(2); const ssoKit = clientSsoKit(); const ssoToken = uuid(); - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); SsoDataStorage.setMockedData(ssoKit); mockGetSsoTokenFromThirdParty.mockImplementation(async() => ssoToken); @@ -257,7 +262,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const ssoToken = uuid(); const ssoLocalKit = clientSsoKit(); const serverSsoKit = {data: generateSsoKitServerData({})}; - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); SsoDataStorage.setMockedData(ssoLocalKit); jest.spyOn(DecryptSsoPassphraseService, "decrypt").mockImplementation(async() => { throw new OutdatedSsoKitError(); }); @@ -280,7 +285,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const ssoToken = uuid(); const ssoLocalKit = clientSsoKit(); const serverSsoKit = {data: generateSsoKitServerData({})}; - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); SsoDataStorage.setMockedData(ssoLocalKit); jest.spyOn(DecryptSsoPassphraseService, "decrypt").mockImplementation(async() => { throw new Error(); }); @@ -302,18 +307,20 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { expect.assertions(2); const ssoToken = uuid(); const serverSsoKit = {data: generateSsoKitServerData({})}; - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); const ssoLocalKit = clientSsoKit(); SsoDataStorage.setMockedData(ssoLocalKit); jest.spyOn(DecryptSsoPassphraseService, "decrypt").mockImplementation(async() => "passphrase"); mockGetSsoTokenFromThirdParty.mockImplementation(async() => ssoToken); - mockLogin.mockImplementation(async() => { throw new InvalidMasterPasswordError(); }); fetch.doMockOnceIf(new RegExp(`/sso/${scenario.providerId}/login.json`), async() => mockApiResponse(scenario.loginUrlResponse)); fetch.doMockOnceIf(new RegExp(`/sso/keys/${ssoLocalKit.id}/${account.userId}/${ssoToken}.json`), async() => mockApiResponse(serverSsoKit)); const controller = new SsoAuthenticationController(null, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(() => { throw new InvalidMasterPasswordError(); }); + + try { await controller.exec(scenario.providerId); } catch (e) { @@ -324,7 +331,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { it("Should throw an error if CSRF token is not valid but shouldn't remove the local SSO kit.", async() => { expect.assertions(2); - const account = {userId: uuid()}; + const account = new AccountEntity(defaultAccountDto()); SsoDataStorage.setMockedData(clientSsoKit()); jest.spyOn(DecryptSsoPassphraseService, "decrypt").mockImplementation(async() => "passphrase"); diff --git a/src/all/background_page/event/appBootstrapEvents.js b/src/all/background_page/event/appBootstrapEvents.js index e14bc087..bbb574ff 100644 --- a/src/all/background_page/event/appBootstrapEvents.js +++ b/src/all/background_page/event/appBootstrapEvents.js @@ -10,10 +10,15 @@ * @license https://opensource.org/licenses/AGPL-3.0 AGPL License * @link https://www.passbolt.com Passbolt(tm) */ -import User from "../model/user"; import AuthModel from "../model/auth/authModel"; -const listen = function(worker) { +/** + * Listens to the application bootstrap events + * @param {Worker} worker The worker + * @param {ApiClientOptions} apiClientOptions The api client options + * @param {AccountAccountRecoveryEntity} account The account completing the account recovery + */ +const listen = function(worker, apiClientOptions, account) { /* * Navigate to logout * @@ -21,10 +26,8 @@ const listen = function(worker) { * @deprecated will be removed with v4. Helps to support legacy appjs logout. */ worker.port.on('passbolt.app-boostrap.navigate-to-logout', async() => { - const user = User.getInstance(); - const apiClientOptions = await user.getApiClientOptions(); const auth = new AuthModel(apiClientOptions); - const url = `${user.settings.getDomain()}/auth/logout`; + const url = `${account.domain}/auth/logout`; try { await chrome.tabs.update(worker.tab.id, {url: url}); diff --git a/src/all/background_page/event/authEvents.js b/src/all/background_page/event/authEvents.js index a4aac4c7..e88ab0e6 100644 --- a/src/all/background_page/event/authEvents.js +++ b/src/all/background_page/event/authEvents.js @@ -128,8 +128,6 @@ const listen = function(worker, apiClientOptions, account) { * @param passphrase {string} The passphrase to decryt the private key * @param remember {string} whether to remember the passphrase * (bool) false|undefined if should not remember - * (integer) -1 if should remember for the session - * (integer) duration in seconds to specify a specific duration */ worker.port.on('passbolt.auth.login', async(requestId, passphrase, remember) => { const controller = new AuthLoginController(worker, requestId, apiClientOptions, account); diff --git a/src/all/background_page/model/auth/authModel.js b/src/all/background_page/model/auth/authModel.js index e8b17330..b5dd327c 100644 --- a/src/all/background_page/model/auth/authModel.js +++ b/src/all/background_page/model/auth/authModel.js @@ -10,13 +10,8 @@ * @license https://opensource.org/licenses/AGPL-3.0 AGPL License * @link https://www.passbolt.com Passbolt(tm) */ -import GpgAuth from "../gpgauth"; import AuthLogoutService from 'passbolt-styleguide/src/shared/services/api/auth/AuthLogoutService'; import AuthStatusLocalStorage from "../../service/local_storage/authStatusLocalStorage"; -import User from "../user"; -import GetDecryptedUserPrivateKeyService from "../../service/account/getDecryptedUserPrivateKeyService"; -import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; -import StartLoopAuthSessionCheckService from "../../service/auth/startLoopAuthSessionCheckService"; class AuthModel { /** @@ -27,7 +22,6 @@ class AuthModel { */ constructor(apiClientOptions) { this.authLogoutService = new AuthLogoutService(apiClientOptions); - this.legacyAuthModel = new GpgAuth(); } /** @@ -50,40 +44,6 @@ class AuthModel { const event = new Event('passbolt.auth.after-logout'); self.dispatchEvent(event); } - - /** - * Login - * @param {string} passphrase The passphrase to use to decrypt the user private key - * @param {boolean?} rememberUntilLogout Should the passphrase remember until the user is logged out - * @returns {Promise} - */ - async login(passphrase, rememberUntilLogout) { - rememberUntilLogout = rememberUntilLogout || false; - const user = User.getInstance(); - const privateKey = await GetDecryptedUserPrivateKeyService.getKey(passphrase); - // @deprecated to be removed with v4. Prior to API v3, retrieving the CSRF token log the user out, so we need to fetch it before the login. - await user.retrieveAndStoreCsrfToken(); - await this.legacyAuthModel.login(privateKey); - /* - * Post login operations - * MFA may not be complete yet, so no need to preload things here - */ - if (rememberUntilLogout) { - await PassphraseStorageService.set(passphrase, -1); - } - await this.postLogin(); - } - - /** - * Post login - * @returns {Promise} - */ - async postLogin() { - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(this.legacyAuthModel); - await startLoopAuthSessionCheckService.exec(); - const event = new Event('passbolt.auth.after-login'); - self.dispatchEvent(event); - } } export default AuthModel; diff --git a/src/all/background_page/model/auth/authModel.test.js b/src/all/background_page/model/auth/authModel.test.js index 268affa5..6fa71870 100644 --- a/src/all/background_page/model/auth/authModel.test.js +++ b/src/all/background_page/model/auth/authModel.test.js @@ -20,10 +20,9 @@ beforeEach(async() => { }); describe("AuthModel", () => { - describe("AuthModel::exec", () => { + describe("AuthModel::logout", () => { it("Should call the AuthService to logout and dispatch a logout event", async() => { expect.assertions(3); - const apiClientOptions = defaultApiClientOptions(); const model = new AuthModel(apiClientOptions); diff --git a/src/all/background_page/model/gpgAuthHeader.test.data.js b/src/all/background_page/model/gpgAuthHeader.test.data.js index bc6e23d7..f9e1b936 100644 --- a/src/all/background_page/model/gpgAuthHeader.test.data.js +++ b/src/all/background_page/model/gpgAuthHeader.test.data.js @@ -38,3 +38,51 @@ export const defaultGpgAuthTokenVerifyHeadersDto = (data = {}) => { }; return Object.assign(defaultData, data); }; + +/** + * Export a default gpg headers login complete + * @param data + * @return {{"x-gpgauth-verify-response": *, "x-gpgauth-version": string, "x-gpgauth-authenticated": string, get: (function(*): *), "x-gpgauth-progress": string, "x-gpgauth-verify-url": string, has: (function(*): boolean), "x-gpgauth-login-url": string, "x-gpgauth-logout-url": string, "x-gpgauth-pubkey-url": string}} + */ +export const defaultGpgAuthTokenLoginStage1HeadersDto = (data = {}) => { + const headers = { + "x-gpgauth-authenticated": "false", + "x-gpgauth-login-url": "/auth/login", + "x-gpgauth-logout-url": "/auth/logout", + "x-gpgauth-progress": "stage1", + "x-gpgauth-pubkey-url": "/auth/verify.json", + "x-gpgauth-user-auth-token": data.token, + "x-gpgauth-verify-url": "/auth/verify", + "x-gpgauth-version": "1.3.0" + }; + const defaultData = { + ...headers, + has: value => Boolean(headers[value]), + get: value => headers[value] + }; + return Object.assign(defaultData, data); +}; + +/** + * Export a default gpg headers login complete + * @param data + * @return {{"x-gpgauth-refer": *, "x-gpgauth-version": string, "x-gpgauth-authenticated": string, get: (function(*): *), "x-gpgauth-progress": string, "x-gpgauth-verify-url": string, has: (function(*): boolean), "x-gpgauth-login-url": string, "x-gpgauth-logout-url": string, "x-gpgauth-pubkey-url": string}} + */ +export const defaultGpgAuthTokenLoginCompleteHeadersDto = (data = {}) => { + const headers = { + "x-gpgauth-authenticated": "true", + "x-gpgauth-login-url": "/auth/login", + "x-gpgauth-logout-url": "/auth/logout", + "x-gpgauth-progress": "complete", + "x-gpgauth-pubkey-url": "/auth/verify.json", + "x-gpgauth-refer": "/", + "x-gpgauth-verify-url": "/auth/verify", + "x-gpgauth-version": "1.3.0" + }; + const defaultData = { + ...headers, + has: value => Boolean(headers[value]), + get: value => headers[value] + }; + return Object.assign(defaultData, data); +}; diff --git a/src/all/background_page/model/gpgauth.js b/src/all/background_page/model/gpgauth.js index 27e5364d..b92860cb 100644 --- a/src/all/background_page/model/gpgauth.js +++ b/src/all/background_page/model/gpgauth.js @@ -11,21 +11,11 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 2.9.0 */ -import {OpenpgpAssertion} from "../utils/openpgp/openpgpAssertions"; import Keyring from "./keyring"; -import DecryptMessageService from "../service/crypto/decryptMessageService"; import User from "./user"; import AuthStatusLocalStorage from "../service/local_storage/authStatusLocalStorage"; -import GpgAuthToken from "./gpgAuthToken"; import MfaAuthenticationRequiredError from "../error/mfaAuthenticationRequiredError"; -import GpgAuthHeader from "./gpgAuthHeader"; -import GetGpgKeyInfoService from "../service/crypto/getGpgKeyInfoService"; import AuthService from "../service/auth"; -import Request from "./request"; -import urldecode from 'locutus/php/url/urldecode'; -import stripslashes from 'locutus/php/strings/stripslashes'; - -const URL_LOGIN = '/auth/login.json?api-version=v2'; /** * GPGAuth authentication @@ -52,108 +42,6 @@ class GpgAuth { return User.getInstance().settings.getDomain(); } - /** - * GPGAuth Login - handle stage1, stage2 and complete - * - * @param {openpgp.PrivateKey} privateKey The decrypted private key to use to decrypt the message. - * @returns {Promise} - */ - async login(privateKey) { - const privateKeyInfo = await GetGpgKeyInfoService.getKeyInfo(privateKey); - const userAuthToken = await this.stage1(privateKeyInfo); - await this.stage2(userAuthToken, privateKeyInfo); - } - - /** - * GPGAuth stage1 - get and decrypt a verification given by the server - * - * @param {ExternalGpgKeyEntity} privateKey The decrypted private key to use to decrypt the message. - * @returns {Promise.} token - */ - async stage1(privateKey) { - // Prepare request data - const url = this.getDomain() + URL_LOGIN; - const body = new FormData(); - body.append('data[gpg_auth][keyid]', privateKey.fingerprint); - const fetchOptions = { - method: 'POST', - credentials: 'include', - body: body - }; - Request.setCsrfHeader(fetchOptions, User.getInstance()); - - // Send request token to the server - const response = await fetch(url, fetchOptions); - if (!response.ok) { - return this.onResponseError(response); - } - - // Check headers - const auth = new GpgAuthHeader(response.headers, 'stage1'); - - // Try to decrypt the User Auth Token - const encryptedUserAuthToken = stripslashes(urldecode(auth.headers['x-gpgauth-user-auth-token'])); - const decryptionKey = await OpenpgpAssertion.readKeyOrFail(privateKey.armoredKey); - const encryptedMessage = await OpenpgpAssertion.readMessageOrFail(encryptedUserAuthToken); - const userAuthToken = await DecryptMessageService.decrypt(encryptedMessage, decryptionKey); - - // Validate the User Auth Token - const authToken = new GpgAuthToken(userAuthToken); - return authToken.token; - } - - /** - * Stage 2. send back the token to the server to get auth cookie - * - * @param userAuthToken {string} The user authentication token - * @param {ExternalGpgKeyEntity} privateKey decrypted private key - * @returns {Promise} - */ - async stage2(userAuthToken, privateKey) { - // Prepare request data - const url = this.getDomain() + URL_LOGIN; - const data = new FormData(); - data.append('data[gpg_auth][keyid]', privateKey.fingerprint); - data.append('data[gpg_auth][user_token_result]', userAuthToken); - - // Send it over - const fetchOptions = { - method: 'POST', - credentials: 'include', - body: data - }; - Request.setCsrfHeader(fetchOptions, User.getInstance()); - const response = await fetch(url, fetchOptions); - - // Check response status - if (!response.ok) { - await this.onResponseError(response); - } - - // Check the headers and return the redirection url - new GpgAuthHeader(response.headers, 'complete'); - } - - /** - * Handle the creation of an error when response status is no ok - * - * @param response {object} - * @returns {Promise.} throw a relevant exception - */ - async onResponseError(response) { - const error_msg = 'There was a server error. No additional information provided' + `(${response.status}`; - let json; - try { - json = await response.json(); - } catch (error) { - throw new Error(error_msg); - } - if (typeof json.header !== 'undefined') { - throw new Error(json.header.message); - } - throw new Error(error_msg); - } - /** * Check if the user is authenticated. * @param {object} [options] Optional parameters diff --git a/src/all/background_page/service/api/auth/authLoginService.js b/src/all/background_page/service/api/auth/authLoginService.js new file mode 100644 index 00000000..54c2233b --- /dev/null +++ b/src/all/background_page/service/api/auth/authLoginService.js @@ -0,0 +1,82 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import AbstractService from "../abstract/abstractService"; + +const AUTH_LOGIN_SERVICE_RESOURCE_NAME = 'auth/login'; + +class AuthLoginService extends AbstractService { + /** + * Constructor + * + * @param {ApiClientOptions} apiClientOptions + * @public + */ + constructor(apiClientOptions) { + super(apiClientOptions, AuthLoginService.RESOURCE_NAME); + } + + /** + * API Resource Name + * + * @returns {string} + * @public + */ + static get RESOURCE_NAME() { + return AUTH_LOGIN_SERVICE_RESOURCE_NAME; + } + + /** + * GPGAuth stage1 - get and decrypt a verification given by the server + * + * @param {string} fingerprint The user fingerprint + * @returns {Promise<*>} token + */ + async loginStage1(fingerprint) { + // Prepare request data + const body = new FormData(); + body.append('data[gpg_auth][keyid]', fingerprint); + const fetchOptions = await this.apiClient.buildFetchOptions(); + // It is required to let this property unset in order to let the browser determine it by itself and set the additional variable boundary required by the API to parse the payload. + delete fetchOptions.headers['content-type']; + const url = this.apiClient.buildUrl(this.apiClient.baseUrl.toString()); + // Send request token to the server + const response = await this.apiClient.sendRequest('POST', url, body, fetchOptions); + await this.apiClient.parseResponseJson(response); + return response; + } + + /** + * Stage 2. send back the token to the server to get auth cookie + * + * @param userAuthToken {string} The user authentication token + * @param {string} fingerprint The user fingerprint + * @returns {Promise<*>} + */ + async loginStage2(userAuthToken, fingerprint) { + // Prepare request data + const body = new FormData(); + body.append('data[gpg_auth][keyid]', fingerprint); + body.append('data[gpg_auth][user_token_result]', userAuthToken); + const fetchOptions = await this.apiClient.buildFetchOptions(); + // It is required to let this property unset in order to let the browser determine it by itself and set the additional variable boundary required by the API to parse the payload. + delete fetchOptions.headers['content-type']; + const url = this.apiClient.buildUrl(this.apiClient.baseUrl.toString()); + // Send request token to the server + const response = await this.apiClient.sendRequest('POST', url, body, fetchOptions); + await this.apiClient.parseResponseJson(response); + return response; + } +} + +export default AuthLoginService; diff --git a/src/all/background_page/service/api/auth/authLoginService.test.js b/src/all/background_page/service/api/auth/authLoginService.test.js new file mode 100644 index 00000000..cbdf53a7 --- /dev/null +++ b/src/all/background_page/service/api/auth/authLoginService.test.js @@ -0,0 +1,104 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import {enableFetchMocks} from "jest-fetch-mock"; +import AuthLoginService from "./authLoginService"; +import {mockApiResponse, mockApiResponseError} from "../../../../../../test/mocks/mockApiResponse"; +import PassboltApiFetchError from "passbolt-styleguide/src/shared/lib/Error/PassboltApiFetchError"; +import {defaultAccountDto} from "../../../model/entity/account/accountEntity.test.data"; +import AccountEntity from "../../../model/entity/account/accountEntity"; +import GpgAuthToken from "../../../model/gpgAuthToken"; + +beforeEach(async() => { + enableFetchMocks(); + jest.clearAllMocks(); +}); + +describe("AuthLoginService", () => { + describe("AuthLoginService::exec", () => { + it("Should call the API on login stage 1 endpoint with a POST request", async() => { + expect.assertions(2); + + const apiClientOptions = defaultApiClientOptions(); + const account = new AccountEntity(defaultAccountDto()); + const service = new AuthLoginService(apiClientOptions); + + fetch.doMockOnceIf(/auth\/login\.json\?api-version=v2/, async req => { + expect(req.headers.get('content-type') !== "application/json").toBeTruthy(); + expect(req.method).toStrictEqual("POST"); + return mockApiResponse({}); + }); + + await service.loginStage1(account.userKeyFingerprint); + }); + + it("Should call the API on login stage 2 endpoint with a POST request", async() => { + expect.assertions(2); + + const apiClientOptions = defaultApiClientOptions(); + const account = new AccountEntity(defaultAccountDto()); + const gpgAuthToken = new GpgAuthToken(); + const service = new AuthLoginService(apiClientOptions); + + fetch.doMockOnceIf(/auth\/login\.json\?api-version=v2/, async req => { + expect(req.headers.get('content-type') !== "application/json").toBeTruthy(); + expect(req.method).toStrictEqual("POST"); + return mockApiResponse({}); + }); + + await service.loginStage2(gpgAuthToken.token, account.userKeyFingerprint); + }); + + it("Should throw an exception if the POST login stage 1 send an error", async() => { + expect.assertions(2); + + const apiClientOptions = defaultApiClientOptions(); + const account = new AccountEntity(defaultAccountDto()); + const service = new AuthLoginService(apiClientOptions); + + fetch.doMockOnceIf(/auth\/login\.json\?api-version=v2/, async req => { + expect(req.method).toStrictEqual("POST"); + return mockApiResponseError(500, "Something went wrong"); + }); + + try { + await service.loginStage1(account.userKeyFingerprint); + } catch (e) { + const expectedError = new PassboltApiFetchError('Something went wrong'); + expect(e).toStrictEqual(expectedError); + } + }); + + it("Should throw an exception if the POST login stage 2 send an error", async() => { + expect.assertions(2); + + const apiClientOptions = defaultApiClientOptions(); + const account = new AccountEntity(defaultAccountDto()); + const gpgAuthToken = new GpgAuthToken(); + const service = new AuthLoginService(apiClientOptions); + + fetch.doMockOnceIf(/auth\/login\.json\?api-version=v2/, async req => { + expect(req.method).toStrictEqual("POST"); + return mockApiResponseError(500, "Something went wrong"); + }); + + try { + await service.loginStage2(gpgAuthToken.token, account.userKeyFingerprint); + } catch (e) { + const expectedError = new PassboltApiFetchError('Something went wrong'); + expect(e).toStrictEqual(expectedError); + } + }); + }); +}); diff --git a/src/all/background_page/service/auth/authVerifyLoginChallengeService.js b/src/all/background_page/service/auth/authVerifyLoginChallengeService.js new file mode 100644 index 00000000..4b9d6705 --- /dev/null +++ b/src/all/background_page/service/auth/authVerifyLoginChallengeService.js @@ -0,0 +1,49 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import GpgAuthHeader from "../../model/gpgAuthHeader"; +import AuthLoginService from "../api/auth/authLoginService"; +import DecryptUserAuthTokenService from "./decryptUserAuthTokenService"; +class AuthVerifyLoginChallengeService { + /** + * The constructor + * @param apiClientOptions + */ + constructor(apiClientOptions) { + this.authLoginService = new AuthLoginService(apiClientOptions); + } + + /** + * Verify and validate the server challenge + * @param {string} userFingerprint The user fingerprint + * @param {string} userPrivateArmoredKey The user armored key encrypted + * @param {string} passphrase The passphrase + * @return {Promise} + */ + async verifyAndValidateLoginChallenge(userFingerprint, userPrivateArmoredKey, passphrase) { + // Step 1: Get an encrypted token from the server + const responseStage1 = await this.authLoginService.loginStage1(userFingerprint); + // Step 2: Check headers and validate the step + const authStage1 = new GpgAuthHeader(responseStage1.headers, 'stage1'); + // Step 3: Decrypt the user auth token + const encryptedUserAuthToken = authStage1.headers['x-gpgauth-user-auth-token']; + const token = await DecryptUserAuthTokenService.decryptToken(encryptedUserAuthToken, userPrivateArmoredKey, passphrase); + // Step 4: Send back the token decrypted + const responseStage2 = await this.authLoginService.loginStage2(token, userFingerprint); + // Step 5: Check headers and validate the step + new GpgAuthHeader(responseStage2.headers, 'complete'); + } +} + +export default AuthVerifyLoginChallengeService; diff --git a/src/all/background_page/service/auth/authVerifyLoginChallengeService.test.js b/src/all/background_page/service/auth/authVerifyLoginChallengeService.test.js new file mode 100644 index 00000000..18ff8db7 --- /dev/null +++ b/src/all/background_page/service/auth/authVerifyLoginChallengeService.test.js @@ -0,0 +1,59 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; +import EncryptMessageService from "../../service/crypto/encryptMessageService"; +import {urlencode} from "locutus/php/url"; +import AuthVerifyLoginChallengeService from "./authVerifyLoginChallengeService"; +import { + defaultGpgAuthTokenLoginCompleteHeadersDto, + defaultGpgAuthTokenLoginStage1HeadersDto +} from "../../model/gpgAuthHeader.test.data"; +import AccountEntity from "../../model/entity/account/accountEntity"; +import {defaultAccountDto} from "../../model/entity/account/accountEntity.test.data"; +import GpgAuthToken from "../../model/gpgAuthToken"; + +beforeEach(async() => { + jest.clearAllMocks(); +}); + +describe("AuthVerifyLoginChallengeService", () => { + describe("AuthVerifyLoginChallengeService::verifyAndValidateLoginChallenge", () => { + it("Sign in with", async() => { + const account = new AccountEntity(defaultAccountDto()); + const passphrase = "ada@passbolt.com"; + const service = new AuthVerifyLoginChallengeService(defaultApiClientOptions()); + + // Mock the service with the user token + const gpgAuthToken = new GpgAuthToken(); + const encryptionKey = await OpenpgpAssertion.readKeyOrFail(account.userPublicArmoredKey); + const encryptedUserToken = await EncryptMessageService.encrypt(gpgAuthToken.token, encryptionKey); + const encodedToken = urlencode(encryptedUserToken); + jest.spyOn(service.authLoginService, "loginStage1").mockImplementation(() => ({ + headers: defaultGpgAuthTokenLoginStage1HeadersDto({token: encodedToken}), + body: {} + })); + jest.spyOn(service.authLoginService, "loginStage2").mockImplementation(() => ({ + headers: defaultGpgAuthTokenLoginCompleteHeadersDto(), + body: {} + })); + + expect.assertions(2); + + await service.verifyAndValidateLoginChallenge(account.userKeyFingerprint, account.userPrivateArmoredKey, passphrase); + expect(service.authLoginService.loginStage1).toHaveBeenCalledWith(account.userKeyFingerprint); + expect(service.authLoginService.loginStage2).toHaveBeenCalledWith(gpgAuthToken.token, account.userKeyFingerprint); + }); + }); +}); diff --git a/src/all/background_page/service/auth/decryptUserAuthTokenService.js b/src/all/background_page/service/auth/decryptUserAuthTokenService.js new file mode 100644 index 00000000..e50475e2 --- /dev/null +++ b/src/all/background_page/service/auth/decryptUserAuthTokenService.js @@ -0,0 +1,55 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2024 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2024 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; +import DecryptPrivateKeyService from "../crypto/decryptPrivateKeyService"; +import DecryptMessageService from "../crypto/decryptMessageService"; +import GpgAuthToken from "../../model/gpgAuthToken"; +import stripslashes from "locutus/php/strings/stripslashes"; +import urldecode from "locutus/php/url/urldecode"; + +class DecryptUserAuthTokenService { + /** + * Decrypt the user auth token + * + * @param {string} encryptedUserAuthToken The message to decrypt. + * @param {string} userPrivateArmoredKey The user private armored key. + * @param {string} passphrase The user passphrase. + * @returns {Promise} The token + * @throws {Error} If the token is not valid + */ + static async decryptToken(encryptedUserAuthToken, userPrivateArmoredKey, passphrase) { + if (typeof encryptedUserAuthToken != "string") { + throw new TypeError("The encrypted user auth token should be string."); + } + if (typeof userPrivateArmoredKey != "string") { + throw new TypeError("The user private armored key should be string."); + } + if (typeof passphrase != "string") { + throw new TypeError("The passphrase should be string."); + } + + const token = stripslashes(urldecode(encryptedUserAuthToken)); + // Get the private key decrypted + const key = await OpenpgpAssertion.readKeyOrFail(userPrivateArmoredKey); + const privateKey = await DecryptPrivateKeyService.decrypt(key, passphrase); + const encryptedMessage = await OpenpgpAssertion.readMessageOrFail(token); + const userAuthToken = await DecryptMessageService.decrypt(encryptedMessage, privateKey); + // Validate the User Auth Token + const authToken = new GpgAuthToken(userAuthToken); + return authToken.token; + } +} + +export default DecryptUserAuthTokenService; diff --git a/src/all/background_page/service/auth/decryptUserAuthTokenService.test.data.js b/src/all/background_page/service/auth/decryptUserAuthTokenService.test.data.js new file mode 100644 index 00000000..0d5af78d --- /dev/null +++ b/src/all/background_page/service/auth/decryptUserAuthTokenService.test.data.js @@ -0,0 +1,19 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +export const bettyEncryptedAuthToken = "-----BEGIN+PGP+MESSAGE-----%0A%0AwcFLAw0P12ReHhxtAQ%2F4%2B6pljslnohKzJAgPUo0ccmg1xBn5SrIQef%2BW5JJ6%0ABu9FxQCrY8dPq2p7hIBflMZexFU%2BHhSl%2FXEbHSbipGJLgHO9YmzWdPbXzilB%0AqKFy7iWgrssQ9RQKHMheDgMuv%2B1Dmcz1ZIbKqExd%2BOxRRI22bB7ySfGkKM3T%0A3I%2BZ3rJQXcRwXB70FpymwUdW9u9%2Bh3zFWciDhNHnpiVZdzKK77ofg3dWj1Yw%0AtvLbMOrX4KkXaaGIC%2Fwnq%2BFzh2rh4%2FGrWj8G6sHA7mjYpGc9bap3%2Fdajhwcg%0AadGggPEmlEvPlgaVTG%2B6PBZajTxSubGUFcslu%2FF7SFg7UkAaA6ri%2F%2B1rqaYS%0AYRVJcWhYR%2F6b6KlOg52ak2ALbaTt3GlWEqUDDw31L18YpV%2BRCFxif3J3MZi9%0AXCmFhMmRdVUDV7GKoCG42mx2IXOD8WSJd%2BurDQRpIgMu%2F7j7qY%2FRs5ZCMpqb%0A1gmnfEWTwtccs0juOtb8L%2FiHYfdelBXozwr5l4fUtgUGgCR6e%2F%2F3QB8b0HwC%0A529VO7ucwJD1J0WAWBRJd2wMezpaEigQ%2Fs%2BAK1kvDlR9aWHwdT872p9cTC2d%0AF5PYb1iesEg7NY0hVmB5EA%2Fe%2FKrXOJMYNCaHK6eYfzh1pt6Au48smmnBYQDK%0AQx5QqEAxQJCiJCMhh4FW7fCLe%2B1NI3X64E%2BQww0TLNJ0AanCqyOzx358xxBz%0AASXIXI1pAMfvfZm4aGRf7zpWs3p%2BYHWo%2FHtSDtMl5dKsLIZOfVIJeD4J%2B6p%2F%0AuuXtmGeRy3tKQnH27A8IagUGTK%2FrJ%2B5F%2BoTDg%2BNRPUQbged56kHjy%2FI52KS%2F%0AbYMezV4iFD97T%2FU9roY%3D%0A%3DkbK2%0A-----END+PGP+MESSAGE-----%0A"; + +export const bettyAuthToken = "gpgauthv1.3.0|36|AA04FC90-AA94-3F0F-a828-F8E485D54551|gpgauthv1.3.0"; + +export const invalidBettyEncryptedAuthToken = "-----BEGIN+PGP+MESSAGE-----%0A%0AwcFMAw0P12ReHhxtAQ%2F%2FXMS8JaA7yHacro1sBy8ZHf2vl2TwFVZp5s5%2FjzJy%0ANfimMNZy0LJgj7K1%2BXlfikofZpgp2qM4p9rqiyYT3%2Bhyi7UZqfpcIwsiWCfp%0ATUisdG0unVW%2BBpb0Trnz7cec8X0niq2LHaYgGihN8Lz7hla6B23EqIC8%2Fug%2F%0AkpnRTUWkMqUNA6aMcP5N1mQ06SJQqt5XDWOe0cmdjtYI9DQAt8kc5drOrfSe%0AUfYQ7yJCnltQIl%2FJCnRrcFIxPf4zzHWmpAiQsmEgVBwBTduOt%2F5QBhaXsTY3%0ALG4zvSaJWX9wI6EQYnGTo1IbUCZd3zFg417mN7zGXqy4iYSfxgJyOxaP219c%0A2YhXSxSwETUVJIWWdfdWIs0lH8gnaoChH0Yclns4YtyuGjTlvrl%2F4z%2FFlN3G%0AdgjjvjgOCh7ITIKc0ez%2BsX%2F7lVC%2B3lcrldAytV%2FxjUMUc6yfYH7sOkrdogqr%0ASmRwzGU7pncjAkECNBNcdNIpNH6eWbust%2B2RNyUf3uv1CZTu%2BuZH%2F%2B1dY7Bo%0A7rlFyhxfadGyvlaRpQrvFzSU8OnyCOpB%2BAlRiyIk2zMVhq2rkAwHdRaahaCX%0AeU9Wh0ripqYdGa2n7KF2cku5Yg0BvqzslGz7ZJH4cRYr44qRNA20o6X8ik7t%0ANZ2K6VjklBzF9UW8l6625FFqOUPfILKOq7rJpDx%2BfWHSdAG9V7s6GU2KOb0Q%0AAjXTYR9jqIGuLtEJ%2FBSBcsie1INVfv2CgtePhf6i8eL9InT87tMN8QM1GAXr%0A6i7i3GaPM44VTR7pIhH510WMPMpMuj4YW9ykX%2F12DMtJ%2FzkOBHyakSX9wQs%2F%0AhPDi38NEfyxcfzmeLZqW%0A%3DOI5c%0A-----END+PGP+MESSAGE-----%0A"; diff --git a/src/all/background_page/service/auth/decryptUserAuthTokenService.test.js b/src/all/background_page/service/auth/decryptUserAuthTokenService.test.js new file mode 100644 index 00000000..c872e88f --- /dev/null +++ b/src/all/background_page/service/auth/decryptUserAuthTokenService.test.js @@ -0,0 +1,79 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import DecryptUserAuthTokenService from "./decryptUserAuthTokenService"; +import {pgpKeys} from "passbolt-styleguide/test/fixture/pgpKeys/keys"; +import { + bettyAuthToken, + bettyEncryptedAuthToken, + invalidBettyEncryptedAuthToken +} from "./decryptUserAuthTokenService.test.data"; + +describe("DecryptUserAuthTokenService", () => { + describe("DecryptUserAuthTokenService::decryptToken", () => { + it("should decrypt and validate the user authentication token", async() => { + expect.assertions(1); + const gpgAuthToken = await DecryptUserAuthTokenService.decryptToken(bettyEncryptedAuthToken, pgpKeys.betty.private, pgpKeys.betty.passphrase); + expect(gpgAuthToken).toEqual(bettyAuthToken); + }); + + it("should throw if the encrypted user auth token parameter is not a valid string", async() => { + expect.assertions(1); + await expect(() => DecryptUserAuthTokenService.decryptToken(42, pgpKeys.betty.private, pgpKeys.betty.passphrase)) + .rejects.toThrow(new TypeError("The encrypted user auth token should be string.")); + }); + + it("should throw if the user private armored key parameter is not a valid string", async() => { + expect.assertions(1); + await expect(() => DecryptUserAuthTokenService.decryptToken(bettyEncryptedAuthToken, 42, pgpKeys.betty.passphrase)) + .rejects.toThrow(new TypeError("The user private armored key should be string.")); + }); + + it("should throw if the user passphrase parameter is not a valid string", async() => { + expect.assertions(1); + await expect(() => DecryptUserAuthTokenService.decryptToken(bettyEncryptedAuthToken, pgpKeys.betty.private, 42)) + .rejects.toThrow(new TypeError("The passphrase should be string.")); + }); + + it("should throw if the user private key cannot be read", async() => { + expect.assertions(1); + await expect(() => DecryptUserAuthTokenService.decryptToken(bettyEncryptedAuthToken, "not-valid-user-private-key", pgpKeys.betty.passphrase)) + .rejects.toThrow(new TypeError("The key should be a valid openpgp armored key string.")); + }); + + it("should throw if the user private key cannot be decrypted", async() => { + expect.assertions(1); + await expect(() => DecryptUserAuthTokenService.decryptToken(bettyEncryptedAuthToken, pgpKeys.betty.private, "not-valid-passphrase")) + .rejects.toThrow(new TypeError("This is not a valid passphrase")); + }); + + it("should throw if the encrypted token is not a valid gpg message", async() => { + expect.assertions(1); + await expect(() => DecryptUserAuthTokenService.decryptToken("not-valid-encrypted-message", pgpKeys.betty.private, pgpKeys.betty.passphrase)) + .rejects.toThrow(new TypeError("The message should be a valid openpgp message.")); + }); + + it("should throw if the encrypted message cannot be decrypted if the user private key", async() => { + expect.assertions(1); + await expect(() => DecryptUserAuthTokenService.decryptToken(bettyEncryptedAuthToken, pgpKeys.ada.private, pgpKeys.ada.passphrase)) + .rejects.toThrow(new TypeError("Error decrypting message: Session key decryption failed.")); + }); + + it("should throw if the token does not validate", async() => { + expect.assertions(1); + await expect(() => DecryptUserAuthTokenService.decryptToken(invalidBettyEncryptedAuthToken, pgpKeys.betty.private, pgpKeys.betty.passphrase)) + .rejects.toThrow(new TypeError("Passbolt does not support GPGAuth token nonce longer than 36 characters: A37B2216-0484-3610-a6E3-5F47B704FD0F")); + }); + }); +}); diff --git a/src/all/background_page/service/auth/postLoginService.js b/src/all/background_page/service/auth/postLoginService.js new file mode 100644 index 00000000..ba0846a8 --- /dev/null +++ b/src/all/background_page/service/auth/postLoginService.js @@ -0,0 +1,30 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; +import GpgAuth from "../../model/gpgauth"; +class PostLoginService { + /** + * Post login + * @returns {Promise} + */ + static async postLogin() { + const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(new GpgAuth()); + await startLoopAuthSessionCheckService.exec(); + const event = new Event('passbolt.auth.after-login'); + self.dispatchEvent(event); + } +} + +export default PostLoginService; diff --git a/src/all/background_page/service/auth/postLoginService.test.js b/src/all/background_page/service/auth/postLoginService.test.js new file mode 100644 index 00000000..f2a0c765 --- /dev/null +++ b/src/all/background_page/service/auth/postLoginService.test.js @@ -0,0 +1,34 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import PostLoginService from "./postLoginService"; +import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; + +beforeEach(async() => { + jest.clearAllMocks(); +}); + +describe("PostLoginService", () => { + describe("PostLoinService::postLogin", () => { + it("Should call the start loop auth session check service and dispatch a post login event", async() => { + expect.assertions(2); + jest.spyOn(StartLoopAuthSessionCheckService.prototype, "exec"); + jest.spyOn(self, "dispatchEvent"); + + await PostLoginService.postLogin(); + + expect(StartLoopAuthSessionCheckService.prototype.exec).toHaveBeenCalled(); + expect(self.dispatchEvent).toHaveBeenCalledWith(new Event('passbolt.auth.after-login')); + }); + }); +}); From c1286ee90539e47796f2142efda009e1f3922de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Wed, 20 Mar 2024 16:00:43 +0000 Subject: [PATCH 14/56] Feature/pb 29968 use a dedicated service to check the user authentication status --- .../auth/authCheckStatus.test.data.js | 28 ++++ .../auth/authCheckStatusController.js | 22 +++- .../auth/authCheckStatusController.test.js | 75 +++++++++++ .../auth/authIsAuthenticatedController.js | 27 ++-- .../authIsAuthenticatedController.test.js | 54 ++++++++ .../auth/authIsMfaRequiredController.js | 26 ++-- .../auth/authIsMfaRequiredController.test.js | 54 ++++++++ .../onExtensionUpdateAvailableController.js | 15 ++- ...ExtensionUpdateAvailableController.test.js | 34 +++-- .../informCallToActionController.js | 9 +- src/all/background_page/event/authEvents.js | 8 +- src/all/background_page/index.js | 36 ++--- src/all/background_page/model/gpgauth.js | 120 ----------------- .../pagemod/appBootstrapPagemod.js | 11 +- .../pagemod/appBootstrapPagemod.test.js | 7 +- src/all/background_page/pagemod/appPagemod.js | 7 +- .../pagemod/appPagemod.test.js | 6 +- .../pagemod/pagemodManager.test.js | 5 +- src/all/background_page/service/auth.js | 78 ----------- .../service/auth/checkAuthStatusService.js | 54 ++++++++ .../auth/checkAuthStatusService.test.js | 123 ++++++++++++++++++ .../service/auth/postLoginService.js | 3 +- .../auth/startLoopAuthSessionCheckService.js | 10 +- .../startLoopAuthSessionCheckService.test.js | 13 +- .../service/authenticationStatusService.js | 71 ++++++++++ .../authenticationStatusService.test.js | 58 +++++++++ .../local_storage/authStatusLocalStorage.js | 38 +++--- test/mocks/mockApiResponse.js | 11 ++ 28 files changed, 689 insertions(+), 314 deletions(-) create mode 100644 src/all/background_page/controller/auth/authCheckStatus.test.data.js create mode 100644 src/all/background_page/controller/auth/authCheckStatusController.test.js create mode 100644 src/all/background_page/controller/auth/authIsAuthenticatedController.test.js create mode 100644 src/all/background_page/controller/auth/authIsMfaRequiredController.test.js delete mode 100644 src/all/background_page/model/gpgauth.js delete mode 100644 src/all/background_page/service/auth.js create mode 100644 src/all/background_page/service/auth/checkAuthStatusService.js create mode 100644 src/all/background_page/service/auth/checkAuthStatusService.test.js create mode 100644 src/all/background_page/service/authenticationStatusService.js create mode 100644 src/all/background_page/service/authenticationStatusService.test.js diff --git a/src/all/background_page/controller/auth/authCheckStatus.test.data.js b/src/all/background_page/controller/auth/authCheckStatus.test.data.js new file mode 100644 index 00000000..4419f6ff --- /dev/null +++ b/src/all/background_page/controller/auth/authCheckStatus.test.data.js @@ -0,0 +1,28 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +export const userLoggedOutAuthStatus = () => ({ + isAuthenticated: false, + isMfaRequired: false, +}); + +export const userLoggedInAuthStatus = () => ({ + isAuthenticated: true, + isMfaRequired: false, +}); + +export const userRequireMfaAuthStatus = () => ({ + isAuthenticated: true, + isMfaRequired: true, +}); diff --git a/src/all/background_page/controller/auth/authCheckStatusController.js b/src/all/background_page/controller/auth/authCheckStatusController.js index 15b3df6a..b008be88 100644 --- a/src/all/background_page/controller/auth/authCheckStatusController.js +++ b/src/all/background_page/controller/auth/authCheckStatusController.js @@ -11,24 +11,36 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 2.11.0 */ -import GpgAuth from "../../model/gpgauth"; +import CheckAuthStatusService from "../../service/auth/checkAuthStatusService"; class AuthCheckStatusController { constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.auth = new GpgAuth(); + this.checkAuthStatusService = new CheckAuthStatusService(); } - async main() { + /** + * Controller executor. + * @returns {Promise} + */ + async _exec() { try { - const status = await this.auth.checkAuthStatus(); - this.worker.port.emit(this.requestId, 'SUCCESS', status); + const authStatus = await this.exec(); + this.worker.port.emit(this.requestId, 'SUCCESS', authStatus); } catch (error) { console.error(error); this.worker.port.emit(this.requestId, 'ERROR', error); } } + + /** + * Controller executor. + * @returns {Promise<{isAuthenticated: {bool}, isMfaRequired: {bool}}>} + */ + async exec() { + return await this.checkAuthStatusService.checkAuthStatus(true); + } } export default AuthCheckStatusController; diff --git a/src/all/background_page/controller/auth/authCheckStatusController.test.js b/src/all/background_page/controller/auth/authCheckStatusController.test.js new file mode 100644 index 00000000..018846ca --- /dev/null +++ b/src/all/background_page/controller/auth/authCheckStatusController.test.js @@ -0,0 +1,75 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; +import AuthService from "../../service/authenticationStatusService"; +import AuthStatusLocalStorage from "../../service/local_storage/authStatusLocalStorage"; +import AuthCheckStatusController from "./authCheckStatusController"; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("AuthCheckStatusController", () => { + it("should return the auth status matching the unauthanticated state", async() => { + expect.assertions(3); + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthService, "isAuthenticated").mockImplementation(() => false); + + const controller = new AuthCheckStatusController(); + const authStatus = await controller.exec(); + + expect(AuthStatusLocalStorage.get).not.toHaveBeenCalled(); + expect(AuthStatusLocalStorage.flush).toHaveBeenCalled(); + expect(authStatus).toStrictEqual({ + isAuthenticated: false, + isMfaRequired: false, + }); + }); + + it("expects the user to be fully authenticated", async() => { + expect.assertions(3); + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthService, "isAuthenticated").mockImplementation(() => true); + + const controller = new AuthCheckStatusController(); + const authStatus = await controller.exec(); + + expect(AuthStatusLocalStorage.get).not.toHaveBeenCalled(); + expect(AuthStatusLocalStorage.flush).toHaveBeenCalled(); + expect(authStatus).toStrictEqual({ + isAuthenticated: true, + isMfaRequired: false, + }); + }); + + it("expects the user to require MFA authentication", async() => { + expect.assertions(3); + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthService, "isAuthenticated").mockImplementation(() => { throw new MfaAuthenticationRequiredError(); }); + + const controller = new AuthCheckStatusController(); + const authStatus = await controller.exec(); + + expect(AuthStatusLocalStorage.get).not.toHaveBeenCalled(); + expect(AuthStatusLocalStorage.flush).toHaveBeenCalled(); + expect(authStatus).toStrictEqual({ + isAuthenticated: true, + isMfaRequired: true, + }); + }); +}); diff --git a/src/all/background_page/controller/auth/authIsAuthenticatedController.js b/src/all/background_page/controller/auth/authIsAuthenticatedController.js index ee92cc17..1f838ec3 100644 --- a/src/all/background_page/controller/auth/authIsAuthenticatedController.js +++ b/src/all/background_page/controller/auth/authIsAuthenticatedController.js @@ -11,34 +11,35 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 2.11.0 */ -import GpgAuth from "../../model/gpgauth"; +import CheckAuthStatusService from "../../service/auth/checkAuthStatusService"; class AuthIsAuthenticatedController { constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.auth = new GpgAuth(); + this.checkAuthStatusService = new CheckAuthStatusService(); } /** * Execute the controller. - * @param {object} options Optional parameters - * - options.requestApi {bool}, get the status from the API, default true. */ - async main(options) { - options = options || {}; - + async _exec() { try { - const isAuthenticated = await this.auth.isAuthenticated(options); - if (isAuthenticated) { - this.worker.port.emitQuiet(this.requestId, 'SUCCESS', true); - } else { - this.worker.port.emitQuiet(this.requestId, 'SUCCESS', false); - } + const isAuthenticated = await this.exec(); + this.worker.port.emitQuiet(this.requestId, 'SUCCESS', isAuthenticated); } catch (error) { this.worker.port.emitQuiet(this.requestId, 'ERROR', error); } } + + /** + * Returns true if the current user is authenticated (regardless of the MFA status) + * @returns {Promise} + */ + async exec() { + const authStatus = await this.checkAuthStatusService.checkAuthStatus(true); + return authStatus.isAuthenticated; + } } export default AuthIsAuthenticatedController; diff --git a/src/all/background_page/controller/auth/authIsAuthenticatedController.test.js b/src/all/background_page/controller/auth/authIsAuthenticatedController.test.js new file mode 100644 index 00000000..2d78b7c6 --- /dev/null +++ b/src/all/background_page/controller/auth/authIsAuthenticatedController.test.js @@ -0,0 +1,54 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import {userLoggedInAuthStatus, userLoggedOutAuthStatus, userRequireMfaAuthStatus} from "./authCheckStatus.test.data"; +import AuthIsAuthenticatedController from "./authIsAuthenticatedController"; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("AuthIsAuthenticatedController", () => { + it("should return true if the user is authenticated", async() => { + expect.assertions(1); + + const controller = new AuthIsAuthenticatedController(); + jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => userLoggedInAuthStatus()); + + const isAuthenticated = await controller.exec(); + expect(isAuthenticated).toStrictEqual(true); + }); + + it("should return true if the user requires MFA authenticate", async() => { + expect.assertions(1); + + const controller = new AuthIsAuthenticatedController(); + jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => userRequireMfaAuthStatus()); + + const isAuthenticated = await controller.exec(); + expect(isAuthenticated).toStrictEqual(true); + }); + + it("should return the isAuthenticated part of the AuthStatus", async() => { + expect.assertions(1); + + const controller = new AuthIsAuthenticatedController(); + + const authStatus = userLoggedOutAuthStatus(); + jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => authStatus); + + const isAuthenticated = await controller.exec(); + expect(isAuthenticated).toStrictEqual(authStatus.isAuthenticated); + }); +}); diff --git a/src/all/background_page/controller/auth/authIsMfaRequiredController.js b/src/all/background_page/controller/auth/authIsMfaRequiredController.js index 3b91d920..789f47ad 100644 --- a/src/all/background_page/controller/auth/authIsMfaRequiredController.js +++ b/src/all/background_page/controller/auth/authIsMfaRequiredController.js @@ -11,27 +11,35 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 2.11.0 */ -import GpgAuth from "../../model/gpgauth"; +import CheckAuthStatusService from "../../service/auth/checkAuthStatusService"; class AuthIsMfaRequiredController { constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.auth = new GpgAuth(); + this.checkAuthStatusService = new CheckAuthStatusService(); } - async main() { + /** + * Execute the controller. + */ + async _exec() { try { - const isMfaRequired = await this.auth.isMfaRequired(); - if (isMfaRequired) { - this.worker.port.emit(this.requestId, 'SUCCESS', true); - } else { - this.worker.port.emit(this.requestId, 'SUCCESS', false); - } + const isMfaRequired = await this.exec(); + this.worker.port.emit(this.requestId, 'SUCCESS', isMfaRequired); } catch (error) { this.worker.port.emit(this.requestId, 'ERROR', error); } } + + /** + * Returns true if the current user needs to answer the MFA challenge to finish sign in + * @returns {Promise} + */ + async exec() { + const authStatus = await this.checkAuthStatusService.checkAuthStatus(true); + return authStatus.isMfaRequired; + } } export default AuthIsMfaRequiredController; diff --git a/src/all/background_page/controller/auth/authIsMfaRequiredController.test.js b/src/all/background_page/controller/auth/authIsMfaRequiredController.test.js new file mode 100644 index 00000000..4a05433c --- /dev/null +++ b/src/all/background_page/controller/auth/authIsMfaRequiredController.test.js @@ -0,0 +1,54 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import {userLoggedInAuthStatus, userLoggedOutAuthStatus, userRequireMfaAuthStatus} from "./authCheckStatus.test.data"; +import AuthIsMfaRequiredController from "./authIsMfaRequiredController"; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("AuthIsMfaRequiredController", () => { + it("should return true if the user needs to authenticate with MFA", async() => { + expect.assertions(1); + + const controller = new AuthIsMfaRequiredController(); + jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => userRequireMfaAuthStatus()); + + const isMfaRequired = await controller.exec(); + expect(isMfaRequired).toStrictEqual(true); + }); + + it("should return false if the user does not need to authenticate with MFA", async() => { + expect.assertions(1); + + const controller = new AuthIsMfaRequiredController(); + jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => userLoggedInAuthStatus()); + + const isMfaRequired = await controller.exec(); + expect(isMfaRequired).toStrictEqual(false); + }); + + it("should return the MFA status part of the authentication status", async() => { + expect.assertions(1); + + const controller = new AuthIsMfaRequiredController(); + + const authStatus = userLoggedOutAuthStatus(); + jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => authStatus); + + const isMfaRequired = await controller.exec(); + expect(isMfaRequired).toStrictEqual(authStatus.isMfaRequired); + }); +}); diff --git a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js index 18b28af0..27966bb0 100644 --- a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js +++ b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js @@ -14,7 +14,8 @@ * On extension update available controller */ import User from "../../model/user"; -import GpgAuth from "../../model/gpgauth"; +import AuthenticationStatusService from "../../service/authenticationStatusService"; +import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; class OnExtensionUpdateAvailableController { /** @@ -39,10 +40,18 @@ const isUserAuthenticated = async() => { const user = User.getInstance(); // Check if user is valid if (user.isValid()) { - const auth = new GpgAuth(); try { - return await auth.isAuthenticated(); + const isAuth = await AuthenticationStatusService.isAuthenticated(); + return isAuth; } catch (error) { + if (error instanceof MfaAuthenticationRequiredError) { + /* + * The browser shouldn't update the current extension when the user is logged in. + * The main reason is to avoid a bug where the passphrase is registered in memory and then forgotten as the updates provokes a memory clean + * This would be problematic for users not knowing/remembering their passphrase and using SSO to sign in + */ + return true; + } /* * Service unavailable */ diff --git a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js index e92df513..a9b8be3e 100644 --- a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js +++ b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js @@ -12,9 +12,10 @@ * @since 4.6.0 */ -import User from "../../model/user"; -import GpgAuth from "../../model/gpgauth"; import OnExtensionUpdateAvailableController from "./onExtensionUpdateAvailableController"; +import AuthenticationStatusService from "../../service/authenticationStatusService"; +import MockExtension from "../../../../../test/mocks/mockExtension"; +import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; // Reset the modules before each test. beforeEach(() => { @@ -27,8 +28,8 @@ describe("OnExtensionInstalledController", () => { it("Should exec update if the user is not signed-in", async() => { expect.assertions(1); // mock function - jest.spyOn(User.getInstance(), "isValid").mockImplementationOnce(() => true); - jest.spyOn(GpgAuth.prototype, "isAuthenticated").mockImplementation(() => false); + MockExtension.withConfiguredAccount(); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => false); jest.spyOn(browser.runtime, "reload"); // process await OnExtensionUpdateAvailableController.exec(); @@ -39,7 +40,7 @@ describe("OnExtensionInstalledController", () => { it("Should exec update if the user is not valid", async() => { expect.assertions(1); // mock function - jest.spyOn(User.getInstance(), "isValid").mockImplementationOnce(() => false); + MockExtension.withMissingPrivateKeyAccount(); jest.spyOn(browser.runtime, "reload"); // process await OnExtensionUpdateAvailableController.exec(); @@ -50,8 +51,8 @@ describe("OnExtensionInstalledController", () => { it("Should exec update only when the user is signed-out", async() => { expect.assertions(2); // mock function - jest.spyOn(User.getInstance(), "isValid").mockImplementationOnce(() => true); - jest.spyOn(GpgAuth.prototype, "isAuthenticated").mockImplementation(() => true); + MockExtension.withConfiguredAccount(); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); jest.spyOn(browser.runtime, "reload"); // process await OnExtensionUpdateAvailableController.exec(); @@ -64,13 +65,28 @@ describe("OnExtensionInstalledController", () => { it("Should exec update if an error occurred and there is no possibility to check if the user is authenticated", async() => { expect.assertions(1); // mock function - jest.spyOn(User.getInstance(), "isValid").mockImplementationOnce(() => true); - jest.spyOn(GpgAuth.prototype, "isAuthenticated").mockImplementation(() => { throw new Error("Error"); }); + MockExtension.withConfiguredAccount(); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => { throw new Error("Error"); }); jest.spyOn(browser.runtime, "reload"); // process await OnExtensionUpdateAvailableController.exec(); // expectation expect(browser.runtime.reload).toHaveBeenCalledTimes(1); }); + + it("Should not exec update when the user is not fully signed-in", async() => { + expect.assertions(2); + // mock function + MockExtension.withConfiguredAccount(); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => { throw new MfaAuthenticationRequiredError(); }); + jest.spyOn(browser.runtime, "reload"); + // process + await OnExtensionUpdateAvailableController.exec(); + // expectation + expect(browser.runtime.reload).not.toHaveBeenCalled(); + self.dispatchEvent(new Event('passbolt.auth.after-logout')); + //can't use toHaveBeenCalledTimes(1) as the event is called multiple times due to the test + expect(browser.runtime.reload).toHaveBeenCalled(); + }); }); }); diff --git a/src/all/background_page/controller/informCallToActionController/informCallToActionController.js b/src/all/background_page/controller/informCallToActionController/informCallToActionController.js index 7f6962e1..aa6c7cc9 100644 --- a/src/all/background_page/controller/informCallToActionController/informCallToActionController.js +++ b/src/all/background_page/controller/informCallToActionController/informCallToActionController.js @@ -11,11 +11,11 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.3.0 */ -import GpgAuth from "../../model/gpgauth"; import User from "../../model/user"; import ResourceModel from "../../model/resource/resourceModel"; import {QuickAccessService} from "../../service/ui/quickAccess.service"; import WorkerService from "../../service/worker/workerService"; +import CheckAuthStatusService from "../../service/auth/checkAuthStatusService"; /** * Controller related to the in-form call-to-action @@ -30,6 +30,7 @@ class InformCallToActionController { constructor(worker, clientOptions, account) { this.worker = worker; this.resourceModel = new ResourceModel(clientOptions, account); + this.checkAuthStatusService = new CheckAuthStatusService(); } /** @@ -38,8 +39,7 @@ class InformCallToActionController { */ async checkStatus(requestId) { try { - const auth = new GpgAuth(); - const status = await auth.checkAuthStatus({requestApi: false}); + const status = await this.checkAuthStatusService.checkAuthStatus(false); this.worker.port.emit(requestId, "SUCCESS", status); } catch (error) { /* @@ -72,8 +72,7 @@ class InformCallToActionController { */ async execute(requestId) { try { - const auth = new GpgAuth(); - const status = await auth.checkAuthStatus({requestApi: false}); + const status = await this.checkAuthStatusService.checkAuthStatus(false); if (!status.isAuthenticated) { const queryParameters = [ {name: "uiMode", value: "detached"}, diff --git a/src/all/background_page/event/authEvents.js b/src/all/background_page/event/authEvents.js index e88ab0e6..6b748b6d 100644 --- a/src/all/background_page/event/authEvents.js +++ b/src/all/background_page/event/authEvents.js @@ -36,9 +36,9 @@ const listen = function(worker, apiClientOptions, account) { * @listens passbolt.auth.is-authenticated * @param requestId {uuid} The request identifier */ - worker.port.on('passbolt.auth.is-authenticated', async(requestId, options) => { + worker.port.on('passbolt.auth.is-authenticated', async requestId => { const controller = new AuthIsAuthenticatedController(worker, requestId); - controller.main(options); + controller._exec(); }); /* @@ -49,7 +49,7 @@ const listen = function(worker, apiClientOptions, account) { */ worker.port.on('passbolt.auth.is-mfa-required', async requestId => { const controller = new AuthIsMfaRequiredController(worker, requestId); - controller.main(); + controller._exec(); }); /* @@ -60,7 +60,7 @@ const listen = function(worker, apiClientOptions, account) { */ worker.port.on('passbolt.auth.check-status', async requestId => { const controller = new AuthCheckStatusController(worker, requestId); - controller.main(); + controller._exec(); }); /* diff --git a/src/all/background_page/index.js b/src/all/background_page/index.js index fd17ca7d..9b9366f9 100644 --- a/src/all/background_page/index.js +++ b/src/all/background_page/index.js @@ -10,11 +10,11 @@ import LocalStorageService from "./service/localStorage/localStorageService"; import OnExtensionInstalledController from "./controller/extension/onExtensionInstalledController"; import TabService from "./service/tab/tabService"; import User from "./model/user"; -import GpgAuth from "./model/gpgauth"; import Log from "./model/log"; import StartLoopAuthSessionCheckService from "./service/auth/startLoopAuthSessionCheckService"; import OnExtensionUpdateAvailableController from "./controller/extension/onExtensionUpdateAvailableController"; import PostLogoutService from "./service/auth/postLogoutService"; +import CheckAuthStatusService from "./service/auth/checkAuthStatusService"; const main = async() => { /** @@ -33,23 +33,25 @@ const main = async() => { const checkAndProcessIfUserAuthenticated = async() => { const user = User.getInstance(); // Check if user is valid - if (user.isValid()) { - const auth = new GpgAuth(); - try { - const isAuthenticated = await auth.isAuthenticated(); - if (isAuthenticated) { - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(auth); - await startLoopAuthSessionCheckService.exec(); - const event = new Event('passbolt.auth.after-login'); - self.dispatchEvent(event); - } - } catch (error) { - /* - * Service unavailable - * Do nothing... - */ - Log.write({level: 'debug', message: 'The Service is unavailable to check if the user is authenticated'}); + if (!user.isValid()) { + return; + } + + const checkAuthStatusService = new CheckAuthStatusService(); + try { + const authStatus = await checkAuthStatusService.checkAuthStatus(true); + if (authStatus.isAuthenticated) { + const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(); + await startLoopAuthSessionCheckService.exec(); + const event = new Event('passbolt.auth.after-login'); + self.dispatchEvent(event); } + } catch (error) { + /* + * Service unavailable + * Do nothing... + */ + Log.write({level: 'debug', message: 'The Service is unavailable to check if the user is authenticated'}); } }; diff --git a/src/all/background_page/model/gpgauth.js b/src/all/background_page/model/gpgauth.js deleted file mode 100644 index b92860cb..00000000 --- a/src/all/background_page/model/gpgauth.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) Passbolt SARL (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 2.9.0 - */ -import Keyring from "./keyring"; -import User from "./user"; -import AuthStatusLocalStorage from "../service/local_storage/authStatusLocalStorage"; -import MfaAuthenticationRequiredError from "../error/mfaAuthenticationRequiredError"; -import AuthService from "../service/auth"; - -/** - * GPGAuth authentication - * @constructor - */ -class GpgAuth { - /** - * @param {Keyring} [keyring] optional - */ - constructor(keyring) { - this.keyring = keyring ? keyring : new Keyring(); - - // Latest stored auth user status. - this.authStatus = null; - } - - /** - * Alias for User settings get domain - * - * @throw {Error} if the trusted domain is not set - * @returns {string} - */ - getDomain() { - return User.getInstance().settings.getDomain(); - } - - /** - * Check if the user is authenticated. - * @param {object} [options] Optional parameters - * - options.requestApi {bool}, get the status from the API, default true. - * @return {bool} - */ - async isAuthenticated(options) { - const authStatus = await this.checkAuthStatus(options); - return authStatus.isAuthenticated; - } - - /** - * Check if the user needs to complete the MFA. - * - * @return {bool} - */ - async isMfaRequired() { - const authStatus = await this.checkAuthStatus(); - return authStatus.isMfaRequired; - } - - /** - * Request the server and retrieve the auth status. - * @param {object} [options] Optional parameters - * - options.requestApi {bool}, get the status from the API, default true. - * @return {object} - * { - * isAuthenticated: {bool} true if the user is authenticated, false otherwise - * isMfaRequired: {bool} true if the mfa is required, false otherwise. - * } - */ - async checkAuthStatus(options) { - let isAuthenticated, isMfaRequired; - // Define options. - options = Object.assign({ - requestApi: true - }, options); - - /* - * No request to API required, return the latest stored information. - * Check in the local storage if any - */ - if (!options.requestApi) { - try { - const storedStatus = await AuthStatusLocalStorage.get(); - if (storedStatus) { - this.authStatus = storedStatus; - return this.authStatus; - } - } catch (error) { - /* - * Nothing found, check with the API - * continue... - */ - } - } - - try { - isAuthenticated = await AuthService.isAuthenticated(); - isMfaRequired = false; - } catch (error) { - if (error instanceof MfaAuthenticationRequiredError) { - isAuthenticated = true; - isMfaRequired = true; - } else { - throw error; - } - } - - this.authStatus = {isAuthenticated: isAuthenticated, isMfaRequired: isMfaRequired}; - await AuthStatusLocalStorage.set(isAuthenticated, isMfaRequired); - return this.authStatus; - } -} -// Exports the Authentication model object. -export default GpgAuth; diff --git a/src/all/background_page/pagemod/appBootstrapPagemod.js b/src/all/background_page/pagemod/appBootstrapPagemod.js index 1b5b7bb7..ab1c639f 100644 --- a/src/all/background_page/pagemod/appBootstrapPagemod.js +++ b/src/all/background_page/pagemod/appBootstrapPagemod.js @@ -13,10 +13,10 @@ */ import Pagemod from "./pagemod"; import User from "../model/user"; -import GpgAuth from "../model/gpgauth"; import {AppBootstrapEvents} from "../event/appBootstrapEvents"; import ParseAppUrlService from "../service/app/parseAppUrlService"; import {PortEvents} from "../event/portEvents"; +import CheckAuthStatusService from "../service/auth/checkAuthStatusService"; class AppBootstrap extends Pagemod { /** @@ -93,11 +93,10 @@ class AppBootstrap extends Pagemod { * Is the constraint validated * @returns {Promise} */ - assertUserAuthenticated() { - const auth = new GpgAuth(); - const onSuccess = value => value; - const onReject = () => false; - return auth.isAuthenticated().then(onSuccess, onReject); + async assertUserAuthenticated() { + const checkAuthStatusService = new CheckAuthStatusService(); + const authStatus = await checkAuthStatusService.checkAuthStatus(true); + return authStatus.isAuthenticated; } } diff --git a/src/all/background_page/pagemod/appBootstrapPagemod.test.js b/src/all/background_page/pagemod/appBootstrapPagemod.test.js index f2959de3..ab5b5bff 100644 --- a/src/all/background_page/pagemod/appBootstrapPagemod.test.js +++ b/src/all/background_page/pagemod/appBootstrapPagemod.test.js @@ -12,7 +12,6 @@ * @since 3.8.0 */ import User from "../model/user"; -import GpgAuth from "../model/gpgauth"; import UserSettings from "../model/userSettings/userSettings"; import AppBootstrap from "./appBootstrapPagemod"; import WorkersSessionStorage from "../service/sessionStorage/workersSessionStorage"; @@ -22,6 +21,8 @@ import {AppBootstrapEvents} from "../event/appBootstrapEvents"; import Pagemod from "./pagemod"; import each from "jest-each"; import {PortEvents} from "../event/portEvents"; +import CheckAuthStatusService from "../service/auth/checkAuthStatusService"; +import {userLoggedInAuthStatus, userLoggedOutAuthStatus} from "../controller/auth/authCheckStatus.test.data"; const spyAddWorker = jest.spyOn(WorkersSessionStorage, "addWorker"); jest.spyOn(ScriptExecution.prototype, "injectPortname").mockImplementation(jest.fn()); @@ -60,7 +61,7 @@ describe("AppBootstrap", () => { expect.assertions(1); // mock functions jest.spyOn(User.getInstance(), "isValid").mockImplementation(() => true); - jest.spyOn(GpgAuth.prototype, "isAuthenticated").mockImplementation(() => new Promise(resolve => resolve(true))); + jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(async() => userLoggedInAuthStatus()); jest.spyOn(UserSettings.prototype, "getDomain").mockImplementation(() => "https://passbolt.dev"); const result = await AppBootstrap.canBeAttachedTo({frameId: Pagemod.TOP_FRAME_ID, url: "https://passbolt.dev/app"}); expect(result).toBeTruthy(); @@ -91,7 +92,7 @@ describe("AppBootstrap", () => { expect.assertions(1); // mock functions jest.spyOn(User.getInstance(), "isValid").mockImplementation(() => true); - jest.spyOn(GpgAuth.prototype, "isAuthenticated").mockImplementation(() => new Promise(resolve => resolve(false))); + jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(async() => userLoggedOutAuthStatus()); jest.spyOn(UserSettings.prototype, "getDomain").mockImplementation(() => "https://passbolt"); // process const constraint = await AppBootstrap.canBeAttachedTo({frameId: 0}); diff --git a/src/all/background_page/pagemod/appPagemod.js b/src/all/background_page/pagemod/appPagemod.js index 6db58880..8b1d8f4c 100644 --- a/src/all/background_page/pagemod/appPagemod.js +++ b/src/all/background_page/pagemod/appPagemod.js @@ -13,7 +13,6 @@ */ import Pagemod from "./pagemod"; import GetLegacyAccountService from "../service/account/getLegacyAccountService"; -import GpgAuth from "../model/gpgauth"; import AppInitController from "../controller/app/appInitController"; import {AppEvents} from "../event/appEvents"; import {ConfigEvents} from "../event/configEvents"; @@ -44,6 +43,7 @@ import {MfaEvents} from "../event/mfaEvents"; import {ClipboardEvents} from "../event/clipboardEvents"; import BuildApiClientOptionsService from "../service/account/buildApiClientOptionsService"; import {RememberMeEvents} from "../event/rememberMeEvents"; +import CheckAuthStatusService from "../service/auth/checkAuthStatusService"; class App extends Pagemod { /** @@ -96,8 +96,9 @@ class App extends Pagemod { async attachEvents(port) { try { const tab = port._port.sender.tab; - const auth = new GpgAuth(); - if (!await auth.isAuthenticated() || await auth.isMfaRequired()) { + const checkAuthStatusService = new CheckAuthStatusService(); + const authStatus = await checkAuthStatusService.checkAuthStatus(true); + if (!authStatus.isAuthenticated || authStatus.isMfaRequired) { console.error('Can not attach application if user is not logged in.'); return; } diff --git a/src/all/background_page/pagemod/appPagemod.test.js b/src/all/background_page/pagemod/appPagemod.test.js index 7dfd66f8..fc309aca 100644 --- a/src/all/background_page/pagemod/appPagemod.test.js +++ b/src/all/background_page/pagemod/appPagemod.test.js @@ -37,7 +37,6 @@ import {ActionLogEvents} from "../event/actionLogEvents"; import {MultiFactorAuthenticationEvents} from "../event/multiFactorAuthenticationEvents"; import {ThemeEvents} from "../event/themeEvents"; import {MobileEvents} from "../event/mobileEvents"; -import GpgAuth from "../model/gpgauth"; import {PownedPasswordEvents} from '../event/pownedPasswordEvents'; import {MfaEvents} from "../event/mfaEvents"; import {ClipboardEvents} from "../event/clipboardEvents"; @@ -45,6 +44,8 @@ import {v4 as uuid} from "uuid"; import BuildApiClientOptionsService from "../service/account/buildApiClientOptionsService"; import {enableFetchMocks} from "jest-fetch-mock"; import {RememberMeEvents} from "../event/rememberMeEvents"; +import CheckAuthStatusService from "../service/auth/checkAuthStatusService"; +import {userLoggedInAuthStatus} from "../controller/auth/authCheckStatus.test.data"; jest.spyOn(ConfigEvents, "listen").mockImplementation(jest.fn()); jest.spyOn(AppEvents, "listen").mockImplementation(jest.fn()); @@ -99,8 +100,7 @@ describe("App", () => { // mock functions jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); - jest.spyOn(GpgAuth.prototype, "isAuthenticated").mockImplementation(() => new Promise(resolve => resolve(true))); - jest.spyOn(GpgAuth.prototype, "isMfaRequired").mockImplementation(() => new Promise(resolve => resolve(false))); + jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(async() => userLoggedInAuthStatus()); const mockedAccount = {user_id: uuid(), domain: "https://test-domain.passbolt.com"}; const mockApiClient = await BuildApiClientOptionsService.buildFromAccount(mockedAccount); jest.spyOn(GetLegacyAccountService, 'get').mockImplementation(() => mockedAccount); diff --git a/src/all/background_page/pagemod/pagemodManager.test.js b/src/all/background_page/pagemod/pagemodManager.test.js index 6ca7635b..1d7e3bf3 100644 --- a/src/all/background_page/pagemod/pagemodManager.test.js +++ b/src/all/background_page/pagemod/pagemodManager.test.js @@ -18,10 +18,11 @@ import SetupBootstrapPagemod from "./setupBootstrapPagemod"; import AuthBootstrapPagemod from "./authBootstrapPagemod"; import User from "../model/user"; import UserSettings from "../model/userSettings/userSettings"; -import GpgAuth from "../model/gpgauth"; import AppBootstrapPagemod from "./appBootstrapPagemod"; import WebIntegrationPagemod from "./webIntegrationPagemod"; import PublicWebsiteSignInPagemod from "./publicWebsiteSignInPagemod"; +import CheckAuthStatusService from "../service/auth/checkAuthStatusService"; +import {userLoggedInAuthStatus} from "../controller/auth/authCheckStatus.test.data"; jest.spyOn(pagemod.prototype, "injectFiles").mockImplementation(jest.fn()); jest.spyOn(pagemod.prototype, "attachEvents").mockImplementation(jest.fn()); @@ -91,7 +92,7 @@ describe("PagemodManager", () => { }; // mock functions jest.spyOn(User.getInstance(), "isValid").mockImplementation(() => true); - jest.spyOn(GpgAuth.prototype, "isAuthenticated").mockImplementation(() => new Promise(resolve => resolve(true))); + jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(async() => userLoggedInAuthStatus()); jest.spyOn(UserSettings.prototype, "getDomain").mockImplementation(() => "https://passbolt.dev"); // process await PagemodManager.exec(details); diff --git a/src/all/background_page/service/auth.js b/src/all/background_page/service/auth.js deleted file mode 100644 index dd443a73..00000000 --- a/src/all/background_page/service/auth.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 2.11.0 - */ -import User from "../model/user"; -import PassboltBadResponseError from "../error/passboltBadResponseError"; -import PassboltServiceUnavailableError from "../error/passboltServiceUnavailableError"; -import MfaAuthenticationRequiredError from "../error/mfaAuthenticationRequiredError"; -import NotFoundError from "../error/notFoundError"; - -class AuthService {} - -/** - * Check if the current user is authenticated. - * - * @return {Promise} - */ -AuthService.isAuthenticated = async function() { - const user = User.getInstance(); - const domain = user.settings.getDomain(); - const fetchOptions = { - method: 'GET', - credentials: 'include', - headers: { - 'Accept': 'application/json', - 'content-type': 'application/json' - } - }; - const url = `${domain}/auth/is-authenticated.json`; - let response, - responseJson; - - try { - response = await fetch(url, fetchOptions); - } catch (error) { - if (navigator.onLine) { - // Catch Network error such as bad certificate or server unreachable. - throw new PassboltServiceUnavailableError("Unable to reach the server, an unexpected error occurred"); - } else { - // Network connection lost. - throw new PassboltServiceUnavailableError("Unable to reach the server, you are not connected to the network"); - } - } - - try { - //Get response on json format - responseJson = await response.json(); - } catch (error) { - // If the response cannot be parsed, it's not a Passbolt API response. It can be a nginx error (504). - throw new PassboltBadResponseError(); - } - - if (response.ok) { - return true; - } - - // MFA required. - if (/mfa\/verify\/error\.json$/.test(response.url)) { - //Retrieve the message error details from json - throw new MfaAuthenticationRequiredError(null, responseJson.body); - } else if (response.status === 404) { - // Entry point not found. - throw new NotFoundError(); - } - - return false; -}; - -export default AuthService; diff --git a/src/all/background_page/service/auth/checkAuthStatusService.js b/src/all/background_page/service/auth/checkAuthStatusService.js new file mode 100644 index 00000000..490f3461 --- /dev/null +++ b/src/all/background_page/service/auth/checkAuthStatusService.js @@ -0,0 +1,54 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; +import AuthenticationStatusService from "../authenticationStatusService"; +import AuthStatusLocalStorage from "../local_storage/authStatusLocalStorage"; + +class CheckAuthStatusService { + /** + * Returns the authentication status of the current user. + * It first interrogates the local storage and if necessary the API afterward. + * @param {boolean} [flushCache] should the cache be flushed before or not. + * @return {Promise<{isAuthenticated: {bool}, isMfaRequired: {bool}}>} + * @throws {Error} if something wrong happened on the API + */ + async checkAuthStatus(flushCache = false) { + if (!flushCache) { + const storedStatus = await AuthStatusLocalStorage.get(); + if (storedStatus) { + return storedStatus; + } + } else { + await AuthStatusLocalStorage.flush(); + } + + let isAuthenticated, isMfaRequired; + try { + isAuthenticated = await AuthenticationStatusService.isAuthenticated(); + isMfaRequired = false; + } catch (error) { + if (!(error instanceof MfaAuthenticationRequiredError)) { + throw error; + } + isAuthenticated = true; + isMfaRequired = true; + } + + await AuthStatusLocalStorage.set(isAuthenticated, isMfaRequired); + return {isAuthenticated, isMfaRequired}; + } +} + +export default CheckAuthStatusService; diff --git a/src/all/background_page/service/auth/checkAuthStatusService.test.js b/src/all/background_page/service/auth/checkAuthStatusService.test.js new file mode 100644 index 00000000..56aad689 --- /dev/null +++ b/src/all/background_page/service/auth/checkAuthStatusService.test.js @@ -0,0 +1,123 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; +import AuthenticationStatusService from "../authenticationStatusService"; +import AuthStatusLocalStorage from "../local_storage/authStatusLocalStorage"; +import CheckAuthStatusService from "./checkAuthStatusService"; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("CheckAuthStatusService", () => { + it("expects the user not to be authenticated", async() => { + expect.assertions(3); + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => false); + + const service = new CheckAuthStatusService(); + const authStatus = await service.checkAuthStatus(); + + expect(AuthStatusLocalStorage.get).toHaveBeenCalledTimes(1); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); + expect(authStatus).toStrictEqual({ + isAuthenticated: false, + isMfaRequired: false, + }); + }); + + it("expects the user to be fully authenticated", async() => { + expect.assertions(3); + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); + + const service = new CheckAuthStatusService(); + const authStatus = await service.checkAuthStatus(); + + expect(AuthStatusLocalStorage.get).toHaveBeenCalledTimes(1); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); + expect(authStatus).toStrictEqual({ + isAuthenticated: true, + isMfaRequired: false, + }); + }); + + it("expects the user to require MFA authentication", async() => { + expect.assertions(3); + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => { throw new MfaAuthenticationRequiredError(); }); + + const service = new CheckAuthStatusService(); + const authStatus = await service.checkAuthStatus(); + + expect(AuthStatusLocalStorage.get).toHaveBeenCalledTimes(1); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); + expect(authStatus).toStrictEqual({ + isAuthenticated: true, + isMfaRequired: true, + }); + }); + + it("should flush the cache if asked for and call the API to find the authentication status", async() => { + expect.assertions(3); + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); + + const service = new CheckAuthStatusService(); + const authStatus = await service.checkAuthStatus(true); + + expect(AuthStatusLocalStorage.get).not.toHaveBeenCalled(); + expect(AuthStatusLocalStorage.flush).toHaveBeenCalledTimes(1); + expect(authStatus).toStrictEqual({ + isAuthenticated: true, + isMfaRequired: false, + }); + }); + + it("should return the authentication status from the cache", async() => { + expect.assertions(3); + const localStorageData = { + isAuthenticated: false, + isMfaRequired: false, + }; + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => localStorageData); + jest.spyOn(AuthStatusLocalStorage, "flush"); + + const service = new CheckAuthStatusService(); + const authStatus = await service.checkAuthStatus(false); + + expect(AuthStatusLocalStorage.get).toHaveBeenCalledTimes(1); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); + expect(authStatus).toStrictEqual(localStorageData); + }); + + it("should return the authentication status from the API if the cache is empty", async() => { + expect.assertions(3); + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => null); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => false); + + const service = new CheckAuthStatusService(); + const authStatus = await service.checkAuthStatus(false); + + expect(AuthStatusLocalStorage.get).toHaveBeenCalledTimes(1); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); + expect(authStatus).toStrictEqual({isAuthenticated: false, isMfaRequired: false}); + }); +}); diff --git a/src/all/background_page/service/auth/postLoginService.js b/src/all/background_page/service/auth/postLoginService.js index ba0846a8..81f84482 100644 --- a/src/all/background_page/service/auth/postLoginService.js +++ b/src/all/background_page/service/auth/postLoginService.js @@ -13,14 +13,13 @@ */ import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; -import GpgAuth from "../../model/gpgauth"; class PostLoginService { /** * Post login * @returns {Promise} */ static async postLogin() { - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(new GpgAuth()); + const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(); await startLoopAuthSessionCheckService.exec(); const event = new Event('passbolt.auth.after-login'); self.dispatchEvent(event); diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js index 7b7aa2d5..25bf6106 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js @@ -12,16 +12,17 @@ * @since 4.0.0 */ +import CheckAuthStatusService from "./checkAuthStatusService"; + const CHECK_IS_AUTHENTICATED_INTERVAL_PERIOD = 60000; const AUTH_SESSION_CHECK_ALARM = "AuthSessionCheck"; class StartLoopAuthSessionCheckService { /** * Constructor - * @param {GpgAuth} gpgAuth */ - constructor(gpgAuth) { - this.gpgAuth = gpgAuth; + constructor() { + this.checkAuthStatusService = new CheckAuthStatusService(); this.checkAuthStatus = this.checkAuthStatus.bind(this); this.clearAlarm = this.clearAlarm.bind(this); } @@ -66,7 +67,8 @@ class StartLoopAuthSessionCheckService { */ async checkAuthStatus(alarm) { if (alarm.name === AUTH_SESSION_CHECK_ALARM) { - if (!await this.gpgAuth.isAuthenticated()) { + const authStatus = await this.checkAuthStatusService.checkAuthStatus(); + if (!authStatus.isAuthenticated) { self.dispatchEvent(new Event('passbolt.auth.after-logout')); } } diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js index 00bdd33e..0eaeaa9a 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js @@ -12,7 +12,6 @@ * @since 3.3.0 */ import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; -import GpgAuth from "../../model/gpgauth"; jest.useFakeTimers(); @@ -27,12 +26,12 @@ describe("StartLoopAuthSessionCheckService", () => { it("should trigger a check authentication and clear alarm on logout", async() => { expect.assertions(12); // Data mocked - const gpgAuth = new GpgAuth(); - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(gpgAuth); + const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(); // Function mocked const spyScheduleAuthSessionCheck = jest.spyOn(startLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); const spyClearAuthSessionCheck = jest.spyOn(startLoopAuthSessionCheckService, "clearAlarm"); - const spyIsAuthenticated = jest.spyOn(gpgAuth, "isAuthenticated").mockImplementation(() => Promise.resolve(true)); + const authStatus = {isAuthenticated: true, isMfaRequired: false}; + const spyIsAuthenticated = jest.spyOn(startLoopAuthSessionCheckService.checkAuthStatusService, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); const spyAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); expect(spyScheduleAuthSessionCheck).not.toHaveBeenCalled(); @@ -61,13 +60,13 @@ describe("StartLoopAuthSessionCheckService", () => { it("should send logout event if not authenticated anymore", async() => { expect.assertions(11); // Data mocked - const gpgAuth = new GpgAuth(); - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(gpgAuth); + const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(); // Function mocked const spyScheduleAuthSessionCheck = jest.spyOn(startLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); const spyClearAuthSessionCheck = jest.spyOn(startLoopAuthSessionCheckService, "clearAlarm"); const spyDispatchEvent = jest.spyOn(self, "dispatchEvent"); - const spyIsAuthenticated = jest.spyOn(gpgAuth, "isAuthenticated").mockImplementation(() => Promise.resolve(false)); + const authStatus = {isAuthenticated: false, isMfaRequired: false}; + const spyIsAuthenticated = jest.spyOn(startLoopAuthSessionCheckService.checkAuthStatusService, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); const spyAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); // Process await startLoopAuthSessionCheckService.exec(); diff --git a/src/all/background_page/service/authenticationStatusService.js b/src/all/background_page/service/authenticationStatusService.js new file mode 100644 index 00000000..48c006f2 --- /dev/null +++ b/src/all/background_page/service/authenticationStatusService.js @@ -0,0 +1,71 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 2.11.0 + */ +import User from "../model/user"; +import MfaAuthenticationRequiredError from "../error/mfaAuthenticationRequiredError"; +import NotFoundError from "../error/notFoundError"; +import {ApiClient} from "passbolt-styleguide/src/shared/lib/apiClient/apiClient"; +import {ApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions"; + +const AUTH_RESOURCE_NAME = '/auth'; + +class AuthenticationStatusService { + /** + * Check if the current user is authenticated. + * @returns {Promise} + */ + static async isAuthenticated() { + const apiClient = new ApiClient(this.apiClientOptions); + const url = apiClient.buildUrl(`${apiClient.baseUrl.toString()}/is-authenticated`, null); + + const fetchOptions = { + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'content-type': 'application/json' + } + }; + const response = await apiClient.sendRequest('GET', url, null, fetchOptions); + + if (response.ok) { + return true; + } + + const responseJson = await response.json(); + // MFA required. + if (/mfa\/verify\/error\.json$/.test(response.url)) { + //Retrieve the message error details from json + throw new MfaAuthenticationRequiredError(null, responseJson.body); + } else if (response.status === 404) { + // Entry point not found. + throw new NotFoundError(); + } + + return false; + } + + /** + * Return a built ApiClientOptions for requesting the API. + * @returns {ApiClientOptions} + * @private + */ + static get apiClientOptions() { + const domain = User.getInstance()?.settings?.getDomain(); + + return new ApiClientOptions() + .setBaseUrl(domain) + .setResourceName(AUTH_RESOURCE_NAME); + } +} + +export default AuthenticationStatusService; diff --git a/src/all/background_page/service/authenticationStatusService.test.js b/src/all/background_page/service/authenticationStatusService.test.js new file mode 100644 index 00000000..570b3fac --- /dev/null +++ b/src/all/background_page/service/authenticationStatusService.test.js @@ -0,0 +1,58 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import AuthenticationStatusService from "./authenticationStatusService"; +import {enableFetchMocks} from "jest-fetch-mock"; +import {mockApiResponse, mockApiResponseError, mockApiRedirectResponse} from "../../../../test/mocks/mockApiResponse"; +import NotFoundError from "../error/notFoundError"; +import MockExtension from "../../../../test/mocks/mockExtension"; +import MfaAuthenticationRequiredError from "../error/mfaAuthenticationRequiredError"; + +beforeAll(() => { + enableFetchMocks(); +}); + +beforeEach(() => { + jest.clearAllMocks(); + MockExtension.withConfiguredAccount(); +}); + +describe("AuthenticationStatusService::isAuthenticated", () => { + function mockIsAuthenticated(callback) { + fetch.doMockOnceIf(/auth\/is-authenticated\.json/, callback); + } + + it("should return true if the user is fully authenticated", async() => { + expect.assertions(1); + mockIsAuthenticated(() => mockApiResponse({})); + await expect(AuthenticationStatusService.isAuthenticated()).resolves.toStrictEqual(true); + }); + + it("should return false if the user is not signed in and doesn't required MFA", async() => { + expect.assertions(1); + mockIsAuthenticated(() => mockApiResponseError(403, "User is not signed in")); + await expect(AuthenticationStatusService.isAuthenticated()).resolves.toStrictEqual(false); + }); + + it("should throw an Error if the endpoint is not found", async() => { + expect.assertions(1); + mockIsAuthenticated(() => mockApiResponseError(404, "Endpoint is not found")); + await expect(AuthenticationStatusService.isAuthenticated()).rejects.toThrowError(new NotFoundError()); + }); + + it("should throw an MfaAuthenticationRequiredError if the user miss the Mfa authentication", async() => { + expect.assertions(1); + mockIsAuthenticated(() => mockApiRedirectResponse("/mfa/verify/error.json")); + await expect(AuthenticationStatusService.isAuthenticated()).rejects.toThrowError(new MfaAuthenticationRequiredError()); + }); +}); diff --git a/src/all/background_page/service/local_storage/authStatusLocalStorage.js b/src/all/background_page/service/local_storage/authStatusLocalStorage.js index a9f4ac77..b86f5515 100644 --- a/src/all/background_page/service/local_storage/authStatusLocalStorage.js +++ b/src/all/background_page/service/local_storage/authStatusLocalStorage.js @@ -12,12 +12,17 @@ * @since 2.13.4 */ import Log from "../../model/log"; -import Lock from "../../utils/lock"; -const lock = new Lock(); const AUTH_STATUS_STORAGE_KEY = 'auth_status'; class AuthStatusLocalStorage { + /** + * Get the storage key. + */ + static get storageKey() { + return AUTH_STATUS_STORAGE_KEY; + } + /** * Flush the folders local storage * @@ -26,7 +31,7 @@ class AuthStatusLocalStorage { */ static async flush() { Log.write({level: 'debug', message: 'AuthStatusLocalStorage flushed'}); - return await browser.storage.local.remove(AuthStatusLocalStorage.AUTH_STATUS_STORAGE_KEY); + return await browser.storage.local.remove(this.storageKey); } /** @@ -37,9 +42,9 @@ class AuthStatusLocalStorage { * If storage is not set, undefined will be returned. */ static async get() { - const result = await browser.storage.local.get([AuthStatusLocalStorage.AUTH_STATUS_STORAGE_KEY]); + const result = await browser.storage.local.get([this.storageKey]); if (result) { - return result[AuthStatusLocalStorage.AUTH_STATUS_STORAGE_KEY]; + return result[this.storageKey]; } return undefined; } @@ -52,22 +57,13 @@ class AuthStatusLocalStorage { * @return {Promise} */ static async set(isAuthenticated, isMfaRequired) { - await lock.acquire(); - isAuthenticated = isAuthenticated === true ? true : false; - isMfaRequired = isMfaRequired === false ? false : true; - const status = {}; - status[AuthStatusLocalStorage.AUTH_STATUS_STORAGE_KEY] = {isAuthenticated: isAuthenticated, isMfaRequired: isMfaRequired}; - await browser.storage.local.set(status); - lock.release(); - } - - /** - * AuthStatusLocalStorage.AUTH_STATUS_STORAGE_KEY - * @returns {string} - * @constructor - */ - static get AUTH_STATUS_STORAGE_KEY() { - return AUTH_STATUS_STORAGE_KEY; + await navigator.locks.request(this.storageKey, async() => { + const auth_status = { + isAuthenticated: Boolean(isAuthenticated), + isMfaRequired: Boolean(isMfaRequired), + }; + await browser.storage.local.set({[this.storageKey]: auth_status}); + }); } } diff --git a/test/mocks/mockApiResponse.js b/test/mocks/mockApiResponse.js index 3fc34b08..c70421b7 100644 --- a/test/mocks/mockApiResponse.js +++ b/test/mocks/mockApiResponse.js @@ -19,6 +19,17 @@ */ exports.mockApiResponse = (body = {}, header = {}) => Promise.resolve(JSON.stringify({header: header, body: body})); +exports.mockApiRedirectResponse = (redirectTo, status = 302) => Promise.resolve({ + status: status, + url: redirectTo, + body: JSON.stringify({ + header: { + status: status + }, + body: {} + }) +}); + exports.mockApiResponseError = (status, errorMessage, body = {}) => Promise.resolve({ status: status, body: JSON.stringify({ From 8bf8f45197916e21ce93089515702732bd67b6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Wed, 3 Apr 2024 17:27:47 +0200 Subject: [PATCH 15/56] PB-30374 - Check if AuthService from styleguide is still used in the Bext otherwise remove it --- .../controller/auth/authCheckStatusController.test.js | 8 ++++---- src/all/background_page/model/auth/authModel.test.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/all/background_page/controller/auth/authCheckStatusController.test.js b/src/all/background_page/controller/auth/authCheckStatusController.test.js index 018846ca..e64f830d 100644 --- a/src/all/background_page/controller/auth/authCheckStatusController.test.js +++ b/src/all/background_page/controller/auth/authCheckStatusController.test.js @@ -13,7 +13,7 @@ */ import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; -import AuthService from "../../service/authenticationStatusService"; +import AuthenticationStatusService from "../../service/authenticationStatusService"; import AuthStatusLocalStorage from "../../service/local_storage/authStatusLocalStorage"; import AuthCheckStatusController from "./authCheckStatusController"; @@ -26,7 +26,7 @@ describe("AuthCheckStatusController", () => { expect.assertions(3); jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); jest.spyOn(AuthStatusLocalStorage, "flush"); - jest.spyOn(AuthService, "isAuthenticated").mockImplementation(() => false); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => false); const controller = new AuthCheckStatusController(); const authStatus = await controller.exec(); @@ -43,7 +43,7 @@ describe("AuthCheckStatusController", () => { expect.assertions(3); jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); jest.spyOn(AuthStatusLocalStorage, "flush"); - jest.spyOn(AuthService, "isAuthenticated").mockImplementation(() => true); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); const controller = new AuthCheckStatusController(); const authStatus = await controller.exec(); @@ -60,7 +60,7 @@ describe("AuthCheckStatusController", () => { expect.assertions(3); jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); jest.spyOn(AuthStatusLocalStorage, "flush"); - jest.spyOn(AuthService, "isAuthenticated").mockImplementation(() => { throw new MfaAuthenticationRequiredError(); }); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => { throw new MfaAuthenticationRequiredError(); }); const controller = new AuthCheckStatusController(); const authStatus = await controller.exec(); diff --git a/src/all/background_page/model/auth/authModel.test.js b/src/all/background_page/model/auth/authModel.test.js index 6fa71870..43f6a75a 100644 --- a/src/all/background_page/model/auth/authModel.test.js +++ b/src/all/background_page/model/auth/authModel.test.js @@ -21,7 +21,7 @@ beforeEach(async() => { describe("AuthModel", () => { describe("AuthModel::logout", () => { - it("Should call the AuthService to logout and dispatch a logout event", async() => { + it("Should call the AuthLogoutService to logout and dispatch a logout event", async() => { expect.assertions(3); const apiClientOptions = defaultApiClientOptions(); const model = new AuthModel(apiClientOptions); From 4611113fc51a9df8b87e138d9f27497598c7660f Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Mon, 8 Apr 2024 11:55:17 +0200 Subject: [PATCH 16/56] PB-23928 Handle when the extension is updated, the webIntegration should be destroy and injected again --- .../onExtensionUpdateAvailableController.js | 15 +++- ...ExtensionUpdateAvailableController.test.js | 74 +++++++++++++++- .../sessionStorage/workersSessionStorage.js | 2 +- .../service/worker/workerService.js | 24 +++++- .../service/worker/workerService.test.js | 85 ++++++++++++++----- src/all/webAccessibleResources/js/lib/port.js | 17 +++- .../js/lib/port.test.js | 20 +++++ 7 files changed, 209 insertions(+), 28 deletions(-) diff --git a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js index 27966bb0..7db182d8 100644 --- a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js +++ b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js @@ -16,6 +16,8 @@ import User from "../../model/user"; import AuthenticationStatusService from "../../service/authenticationStatusService"; import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; +import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; +import WorkerService from "../../service/worker/workerService"; class OnExtensionUpdateAvailableController { /** @@ -25,11 +27,20 @@ class OnExtensionUpdateAvailableController { static async exec() { if (await isUserAuthenticated()) { // Add listener on passbolt logout to update the extension - self.addEventListener("passbolt.auth.after-logout", () => browser.runtime.reload()); + self.addEventListener("passbolt.auth.after-logout", this.cleanAndReload); } else { - browser.runtime.reload(); + await this.cleanAndReload(); } } + + /** + * Clean and reload the new extension + * @return {Promise} + */ + static async cleanAndReload() { + await WorkerService.destroyWorkersByName([WebIntegrationPagemod.appName]); + browser.runtime.reload(); + } } /** diff --git a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js index a9b8be3e..fdb10942 100644 --- a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js +++ b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js @@ -16,6 +16,14 @@ import OnExtensionUpdateAvailableController from "./onExtensionUpdateAvailableCo import AuthenticationStatusService from "../../service/authenticationStatusService"; import MockExtension from "../../../../../test/mocks/mockExtension"; import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; +import {readWorker} from "../../model/entity/worker/workerEntity.test.data"; +import WorkersSessionStorage from "../../service/sessionStorage/workersSessionStorage"; +import WorkerEntity from "../../model/entity/worker/workerEntity"; +import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; +import Port from "../../sdk/port"; +import {mockPort} from "../../sdk/port/portManager.test.data"; +import PortManager from "../../sdk/port/portManager"; +import BrowserTabService from "../../service/ui/browserTab.service"; // Reset the modules before each test. beforeEach(() => { @@ -26,14 +34,23 @@ beforeEach(() => { describe("OnExtensionInstalledController", () => { describe("OnExtensionInstalledController::exec", () => { it("Should exec update if the user is not signed-in", async() => { - expect.assertions(1); + expect.assertions(3); + // data mocked + const worker = readWorker({name: WebIntegrationPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); + const webIntegrationPort = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId}); + const webIntegrationPortWrapper = new Port(webIntegrationPort); + PortManager.registerPort(webIntegrationPortWrapper); // mock function MockExtension.withConfiguredAccount(); jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => false); jest.spyOn(browser.runtime, "reload"); + jest.spyOn(webIntegrationPortWrapper, "emit"); // process await OnExtensionUpdateAvailableController.exec(); // expectation + expect(webIntegrationPortWrapper.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); + expect(webIntegrationPortWrapper.emit).toHaveBeenCalledTimes(1); expect(browser.runtime.reload).toHaveBeenCalledTimes(1); }); @@ -59,6 +76,53 @@ describe("OnExtensionInstalledController", () => { // expectation expect(browser.runtime.reload).not.toHaveBeenCalled(); self.dispatchEvent(new Event('passbolt.auth.after-logout')); + // Waiting all promises has been finished + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(browser.runtime.reload).toHaveBeenCalledTimes(1); + }); + + it("Should clean and exec update", async() => { + expect.assertions(8); + // data mocked + const worker = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); + const worker2 = readWorker({name: WebIntegrationPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker2)); + const worker3 = readWorker({name: WebIntegrationPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker3)); + const webIntegrationPort = mockPort({name: worker2.id, tabId: worker2.tabId, frameId: worker2.frameId}); + const webIntegrationPortWrapper = new Port(webIntegrationPort); + const webIntegrationPort2 = mockPort({name: worker3.id, tabId: worker3.tabId, frameId: worker3.frameId}); + const webIntegrationPortWrapper2 = new Port(webIntegrationPort2); + PortManager.registerPort(webIntegrationPortWrapper2); + // mock function + MockExtension.withConfiguredAccount(); + jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); + jest.spyOn(BrowserTabService, "sendMessage").mockImplementation(() => PortManager.registerPort(webIntegrationPortWrapper)); + jest.spyOn(browser.runtime, "reload"); + jest.spyOn(webIntegrationPortWrapper, "emit"); + jest.spyOn(webIntegrationPortWrapper2, "emit"); + // process + await OnExtensionUpdateAvailableController.exec(); + // expectation + expect(browser.runtime.reload).not.toHaveBeenCalled(); + self.dispatchEvent(new Event('passbolt.auth.after-logout')); + // Waiting all promises has been finished + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + // expectation + expect(webIntegrationPortWrapper.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); + expect(webIntegrationPortWrapper.emit).toHaveBeenCalledTimes(1); + expect(webIntegrationPortWrapper2.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); + expect(webIntegrationPortWrapper2.emit).toHaveBeenCalledTimes(1); + expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker2, "passbolt.port.connect", worker2.id); + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(1); expect(browser.runtime.reload).toHaveBeenCalledTimes(1); }); @@ -85,8 +149,12 @@ describe("OnExtensionInstalledController", () => { // expectation expect(browser.runtime.reload).not.toHaveBeenCalled(); self.dispatchEvent(new Event('passbolt.auth.after-logout')); - //can't use toHaveBeenCalledTimes(1) as the event is called multiple times due to the test - expect(browser.runtime.reload).toHaveBeenCalled(); + // Waiting all promises has been finished + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(browser.runtime.reload).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/all/background_page/service/sessionStorage/workersSessionStorage.js b/src/all/background_page/service/sessionStorage/workersSessionStorage.js index 2f5944b8..9904b168 100644 --- a/src/all/background_page/service/sessionStorage/workersSessionStorage.js +++ b/src/all/background_page/service/sessionStorage/workersSessionStorage.js @@ -61,7 +61,7 @@ class WorkersSessionStorage { * Get workers from the session storage by name and tab id * * @param {Array} workerNames An array of worker names - * @return {Promise} worker dto object + * @return {Promise} Array of worker dto object */ getWorkersByNames(workerNames) { const filterByName = workers => workers.filter(worker => workerNames.includes(worker.name)); diff --git a/src/all/background_page/service/worker/workerService.js b/src/all/background_page/service/worker/workerService.js index b46bd1a1..feeb1a3f 100644 --- a/src/all/background_page/service/worker/workerService.js +++ b/src/all/background_page/service/worker/workerService.js @@ -76,7 +76,7 @@ class WorkerService { /** * Clear and use a timeout to execute a navigation for worker which are waiting for connection - * @params {WorkerEntity} The worker entity + * @param {WorkerEntity} workerEntity The worker entity * @returns {Promise} */ static async checkAndExecNavigationForWorkerWaitingConnection(workerEntity) { @@ -112,6 +112,28 @@ class WorkerService { await WebNavigationService.exec(frameDetails); } } + + /** + * Send message to destroy all worker to invalidate content script + * @param {Array} workersName + * @return {Promise} + */ + static async destroyWorkersByName(workersName) { + const workers = await WorkersSessionStorage.getWorkersByNames(workersName); + for (const worker of workers) { + if (!PortManager.isPortExist(worker.id)) { + try { + await BrowserTabService.sendMessage(worker, "passbolt.port.connect", worker.id); + } catch (error) { + console.debug("Unable to reconnect the port before to update the extension"); + console.error(error); + continue; + } + } + const port = PortManager.getPortById(worker.id); + port.emit('passbolt.content-script.destroy'); + } + } } export default WorkerService; diff --git a/src/all/background_page/service/worker/workerService.test.js b/src/all/background_page/service/worker/workerService.test.js index 161577e4..617698c6 100644 --- a/src/all/background_page/service/worker/workerService.test.js +++ b/src/all/background_page/service/worker/workerService.test.js @@ -18,6 +18,8 @@ import WorkersSessionStorage from "../sessionStorage/workersSessionStorage"; import {readWorker} from "../../model/entity/worker/workerEntity.test.data"; import WorkerEntity from "../../model/entity/worker/workerEntity"; import WebNavigationService from "../webNavigation/webNavigationService"; +import {mockPort} from "../../sdk/port/portManager.test.data"; +import Port from "../../sdk/port"; describe("WorkerService", () => { beforeEach(() => { @@ -32,24 +34,18 @@ describe("WorkerService", () => { expect.assertions(3); // data mocked const worker = readWorker(); - const port = { - _name: worker.id, - _port: { - sender: { - tab: {} - } - } - }; + const port = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId}); + const portWrapper = new Port(port); // mock functions jest.spyOn(WorkersSessionStorage, 'getWorkersByNameAndTabId').mockImplementationOnce(() => [worker]); - jest.spyOn(BrowserTabService, "sendMessage").mockImplementationOnce(() => jest.fn()); - jest.spyOn(PortManager, "getPortById").mockImplementationOnce(() => port); + jest.spyOn(BrowserTabService, "sendMessage").mockImplementationOnce(() => PortManager.registerPort(portWrapper)); + jest.spyOn(PortManager, "getPortById"); // process const workerResult = await WorkerService.get("ApplicationName", 1); // expectations expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker, "passbolt.port.connect", worker.id); expect(PortManager.getPortById).toHaveBeenCalledWith(worker.id); - expect(workerResult).toStrictEqual({port: port, tab: port._port.sender.tab}); + expect(workerResult).toStrictEqual({port: portWrapper, tab: portWrapper._port.sender.tab}); }); it("Should get no worker", async() => { @@ -75,16 +71,11 @@ describe("WorkerService", () => { expect.assertions(5); // data mocked const worker = readWorker({name: "QuickAccess"}); - const port = { - _name: worker.id, - _port: { - sender: { - tab: worker.tabId - } - } - }; + const port = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId}); + const portWrapper = new Port(port); + PortManager.registerPort(portWrapper); // mock functions - jest.spyOn(PortManager, "getPortById").mockImplementation(() => port); + jest.spyOn(PortManager, "getPortById"); const spy = jest.spyOn(WorkerService, "waitExists"); jest.spyOn(global, "setTimeout"); @@ -177,4 +168,58 @@ describe("WorkerService", () => { expect(WebNavigationService.exec).not.toHaveBeenCalled(); }); }); + + describe("WorkerService::destroyWorkersByName", () => { + it("Destroy workers", async() => { + expect.assertions(6); + + const worker = readWorker(); + const worker2 = readWorker(); + const port = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId}); + const portWrapper = new Port(port); + const port2 = mockPort({name: worker2.id, tabId: worker2.tabId, frameId: worker2.frameId}); + const portWrapper2 = new Port(port2); + PortManager.registerPort(portWrapper); + + jest.spyOn(WorkersSessionStorage, "getWorkersByNames").mockImplementationOnce(() => [worker, worker2]); + jest.spyOn(BrowserTabService, "sendMessage").mockImplementationOnce(() => PortManager.registerPort(portWrapper2)); + jest.spyOn(portWrapper, "emit"); + jest.spyOn(portWrapper2, "emit"); + + await WorkerService.destroyWorkersByName([worker.name, worker2.name]); + + // expectation + expect(portWrapper.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); + expect(portWrapper.emit).toHaveBeenCalledTimes(1); + expect(portWrapper2.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); + expect(portWrapper2.emit).toHaveBeenCalledTimes(1); + expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker2, "passbolt.port.connect", worker2.id); + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(1); + }); + + it("Should continue process if worker cannot reconnect port", async() => { + expect.assertions(5); + const worker = readWorker(); + const worker2 = readWorker(); + const port = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId}); + const portWrapper = new Port(port); + const port2 = mockPort({name: worker2.id, tabId: worker2.tabId, frameId: worker2.frameId}); + const portWrapper2 = new Port(port2); + PortManager.registerPort(portWrapper2); + + jest.spyOn(WorkersSessionStorage, "getWorkersByNames").mockImplementationOnce(() => [worker, worker2]); + jest.spyOn(BrowserTabService, "sendMessage").mockImplementationOnce(() => { throw new Error("error"); }); + jest.spyOn(portWrapper, "emit"); + jest.spyOn(portWrapper2, "emit"); + + await WorkerService.destroyWorkersByName([worker.name, worker2.name]); + + // expectation + expect(portWrapper.emit).toHaveBeenCalledTimes(0); + expect(portWrapper2.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); + expect(portWrapper2.emit).toHaveBeenCalledTimes(1); + expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker, "passbolt.port.connect", worker.id); + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/all/webAccessibleResources/js/lib/port.js b/src/all/webAccessibleResources/js/lib/port.js index fae1ea5d..ae51ac24 100644 --- a/src/all/webAccessibleResources/js/lib/port.js +++ b/src/all/webAccessibleResources/js/lib/port.js @@ -38,7 +38,13 @@ class Port { connect() { let resolver; const promise = new Promise(resolve => { resolver = resolve; }); - this._port = browser.runtime.connect({name: this._name}); + try { + this._port = browser.runtime.connect({name: this._name}); + } catch (error) { + // Error happen only if there is no listener in the service worker or if the context is invalidated by an update + this.destroyContentScript(); + throw error; + } this._connected = true; this.initListener(); this.once("passbolt.port.ready", resolver); @@ -182,6 +188,15 @@ class Port { this.lock.release(); } } + + /** + * Simulate the message destroy content script on the port to clean listeners which still running after an update. + * In case of a user click manually on the button to update the extension, + * there is no process to clean the content script, so it prevent issues on a port reconnection. + */ + destroyContentScript() { + this._onMessage(JSON.stringify(["passbolt.content-script.destroy"])); + } } export default Port; diff --git a/src/all/webAccessibleResources/js/lib/port.test.js b/src/all/webAccessibleResources/js/lib/port.test.js index 0781d3cc..b5984ed2 100644 --- a/src/all/webAccessibleResources/js/lib/port.test.js +++ b/src/all/webAccessibleResources/js/lib/port.test.js @@ -40,6 +40,26 @@ describe("Port", () => { expect(error.message).toBe('The port name should be a valid string.'); } }); + + it("Should raise an error if context is invalid and send destroy message", async() => { + expect.assertions(4); + + const portname = uuidv4(); + const port = new Port(portname); + + jest.spyOn(browser.runtime, "connect").mockImplementationOnce(() => { throw new Error("context invalid"); }); + jest.spyOn(port, "destroyContentScript"); + jest.spyOn(port, "_onMessage"); + + expect(port._connected).toBeFalsy(); + try { + await port.connect(); + } catch (error) { + expect(browser.runtime.connect).toHaveBeenCalledWith({name: portname}); + expect(port.destroyContentScript).toHaveBeenCalled(); + expect(port._onMessage).toHaveBeenCalledWith(JSON.stringify(["passbolt.content-script.destroy"])); + } + }); }); describe("Port::request", () => { From a5131243fd26e1d2147ff5cfa604e190a359b514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Thu, 11 Apr 2024 07:43:00 +0000 Subject: [PATCH 17/56] PB-29989: Put the alarm listener at the top level for the StartLoopAuthSessionCheckService to check the authentication status --- src/all/background_page/index.js | 21 ++++++- .../service/auth/postLoginService.js | 3 +- .../service/auth/postLoginService.test.js | 4 +- .../auth/startLoopAuthSessionCheckService.js | 55 ++++++++++--------- .../startLoopAuthSessionCheckService.test.js | 40 +++++++------- .../utils/topLevelAlarmMapping.data.js | 22 ++++++++ src/chrome-mv3/index.js | 19 +++++++ test/mocks/mockAlarms.js | 10 ++++ 8 files changed, 122 insertions(+), 52 deletions(-) create mode 100644 src/all/background_page/utils/topLevelAlarmMapping.data.js diff --git a/src/all/background_page/index.js b/src/all/background_page/index.js index 9b9366f9..039e6783 100644 --- a/src/all/background_page/index.js +++ b/src/all/background_page/index.js @@ -15,6 +15,7 @@ import StartLoopAuthSessionCheckService from "./service/auth/startLoopAuthSessio import OnExtensionUpdateAvailableController from "./controller/extension/onExtensionUpdateAvailableController"; import PostLogoutService from "./service/auth/postLogoutService"; import CheckAuthStatusService from "./service/auth/checkAuthStatusService"; +import {topLevelAlarmMapping} from "./utils/topLevelAlarmMapping.data"; const main = async() => { /** @@ -41,8 +42,7 @@ const checkAndProcessIfUserAuthenticated = async() => { try { const authStatus = await checkAuthStatusService.checkAuthStatus(true); if (authStatus.isAuthenticated) { - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(); - await startLoopAuthSessionCheckService.exec(); + await StartLoopAuthSessionCheckService.exec(); const event = new Event('passbolt.auth.after-login'); self.dispatchEvent(event); } @@ -93,3 +93,20 @@ browser.runtime.onConnect.addListener(PortManager.onPortConnect); */ browser.tabs.onRemoved.addListener(PortManager.onTabRemoved); +/** + * Top level alarm handler to ensure alarm callbacks are still processed after the service worker awakes. + * @param {Alarm} alarm + */ +const handleTopLevelAlarms = alarm => { + topLevelAlarmMapping[alarm.name]?.(alarm); +}; + +/** + * Ensures the top-level alarm handler is not triggered twice + */ +browser.alarms.onAlarm.removeListener(handleTopLevelAlarms); + +/** + * Add a top-level alarm handler. + */ +browser.alarms.onAlarm.addListener(handleTopLevelAlarms); diff --git a/src/all/background_page/service/auth/postLoginService.js b/src/all/background_page/service/auth/postLoginService.js index 81f84482..28a6cd8f 100644 --- a/src/all/background_page/service/auth/postLoginService.js +++ b/src/all/background_page/service/auth/postLoginService.js @@ -19,8 +19,7 @@ class PostLoginService { * @returns {Promise} */ static async postLogin() { - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(); - await startLoopAuthSessionCheckService.exec(); + await StartLoopAuthSessionCheckService.exec(); const event = new Event('passbolt.auth.after-login'); self.dispatchEvent(event); } diff --git a/src/all/background_page/service/auth/postLoginService.test.js b/src/all/background_page/service/auth/postLoginService.test.js index f2a0c765..c77f68a7 100644 --- a/src/all/background_page/service/auth/postLoginService.test.js +++ b/src/all/background_page/service/auth/postLoginService.test.js @@ -22,12 +22,12 @@ describe("PostLoginService", () => { describe("PostLoinService::postLogin", () => { it("Should call the start loop auth session check service and dispatch a post login event", async() => { expect.assertions(2); - jest.spyOn(StartLoopAuthSessionCheckService.prototype, "exec"); + jest.spyOn(StartLoopAuthSessionCheckService, "exec"); jest.spyOn(self, "dispatchEvent"); await PostLoginService.postLogin(); - expect(StartLoopAuthSessionCheckService.prototype.exec).toHaveBeenCalled(); + expect(StartLoopAuthSessionCheckService.exec).toHaveBeenCalled(); expect(self.dispatchEvent).toHaveBeenCalledWith(new Event('passbolt.auth.after-login')); }); }); diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js index 25bf6106..544a82a9 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js @@ -11,67 +11,68 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 4.0.0 */ - import CheckAuthStatusService from "./checkAuthStatusService"; const CHECK_IS_AUTHENTICATED_INTERVAL_PERIOD = 60000; const AUTH_SESSION_CHECK_ALARM = "AuthSessionCheck"; class StartLoopAuthSessionCheckService { - /** - * Constructor - */ - constructor() { - this.checkAuthStatusService = new CheckAuthStatusService(); - this.checkAuthStatus = this.checkAuthStatus.bind(this); - this.clearAlarm = this.clearAlarm.bind(this); - } - /** * Exec the StartLoopAuthSessionCheckService * @return {Promise} */ - async exec() { - await this.scheduleAuthSessionCheck(); - self.addEventListener("passbolt.auth.after-logout", this.clearAlarm); + static async exec() { + await StartLoopAuthSessionCheckService.scheduleAuthSessionCheck(); + self.addEventListener("passbolt.auth.after-logout", StartLoopAuthSessionCheckService.clearAlarm); } /** * Schedule an alarm to check if the user is authenticated. + * @returns {Promise} * @private */ - async scheduleAuthSessionCheck() { - // Create an alarm to check the auth session - await browser.alarms.create(AUTH_SESSION_CHECK_ALARM, { + static async scheduleAuthSessionCheck() { + // Create an alarm to check the auth session. This alarm is managed in `handleTopLevelAlarms` + await browser.alarms.create(StartLoopAuthSessionCheckService.ALARM_NAME, { // this `periodInMinutes` is set to ensure that after going back from sleep mode the alarms still triggers periodInMinutes: 1, when: Date.now() + CHECK_IS_AUTHENTICATED_INTERVAL_PERIOD }); - browser.alarms.onAlarm.addListener(this.checkAuthStatus); } /** * Clear the alarm and listener configured for flushing the resource if any. + * @returns {Promise} * @private */ - async clearAlarm() { - browser.alarms.onAlarm.removeListener(this.checkAuthStatus); - await browser.alarms.clear(AUTH_SESSION_CHECK_ALARM); + static async clearAlarm() { + await browser.alarms.clear(StartLoopAuthSessionCheckService.ALARM_NAME); } /** * Check if the user is authenticated when the AuthSessionCheck alarm triggers. * - In the case the user is logged out, trigger a passbolt.auth.after-logout event. * @param {Alarm} alarm - * @private + * @returns {Promise} */ - async checkAuthStatus(alarm) { - if (alarm.name === AUTH_SESSION_CHECK_ALARM) { - const authStatus = await this.checkAuthStatusService.checkAuthStatus(); - if (!authStatus.isAuthenticated) { - self.dispatchEvent(new Event('passbolt.auth.after-logout')); - } + static async handleAuthStatusCheckAlarm(alarm) { + if (alarm.name !== StartLoopAuthSessionCheckService.ALARM_NAME) { + return; } + + const checkAuthStatusService = new CheckAuthStatusService(); + const authStatus = await checkAuthStatusService.checkAuthStatus(true); + if (!authStatus.isAuthenticated) { + self.dispatchEvent(new Event('passbolt.auth.after-logout')); + } + } + + /** + * Returns the alarm names that this service handles + * @return {string} + */ + static get ALARM_NAME() { + return AUTH_SESSION_CHECK_ALARM; } } diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js index 0eaeaa9a..ee3bbaf8 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js @@ -11,33 +11,36 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.3.0 */ +import CheckAuthStatusService from "./checkAuthStatusService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; jest.useFakeTimers(); // Reset the modules before each test. -beforeEach(() => { +beforeEach(async() => { jest.resetModules(); jest.clearAllMocks(); jest.clearAllTimers(); + await browser.alarms.clearAllMocks(); }); describe("StartLoopAuthSessionCheckService", () => { it("should trigger a check authentication and clear alarm on logout", async() => { - expect.assertions(12); - // Data mocked - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(); + expect.assertions(11); // Function mocked - const spyScheduleAuthSessionCheck = jest.spyOn(startLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); - const spyClearAuthSessionCheck = jest.spyOn(startLoopAuthSessionCheckService, "clearAlarm"); + const spyScheduleAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); + const spyClearAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "clearAlarm"); const authStatus = {isAuthenticated: true, isMfaRequired: false}; - const spyIsAuthenticated = jest.spyOn(startLoopAuthSessionCheckService.checkAuthStatusService, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); - const spyAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); + const spyIsAuthenticated = jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); + + //mocking top-level alarm handler + browser.alarms.onAlarm.addListener(async alarm => await StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm(alarm)); expect(spyScheduleAuthSessionCheck).not.toHaveBeenCalled(); // Process - await startLoopAuthSessionCheckService.exec(); + await StartLoopAuthSessionCheckService.exec(); + // Expectation expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(0); @@ -54,22 +57,22 @@ describe("StartLoopAuthSessionCheckService", () => { expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(2); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(1); - expect(spyAlarmRemoveListener).toHaveBeenCalledWith(startLoopAuthSessionCheckService.checkAuthStatus); }); it("should send logout event if not authenticated anymore", async() => { - expect.assertions(11); - // Data mocked - const startLoopAuthSessionCheckService = new StartLoopAuthSessionCheckService(); + expect.assertions(10); // Function mocked - const spyScheduleAuthSessionCheck = jest.spyOn(startLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); - const spyClearAuthSessionCheck = jest.spyOn(startLoopAuthSessionCheckService, "clearAlarm"); + const spyScheduleAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); + const spyClearAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "clearAlarm"); const spyDispatchEvent = jest.spyOn(self, "dispatchEvent"); const authStatus = {isAuthenticated: false, isMfaRequired: false}; - const spyIsAuthenticated = jest.spyOn(startLoopAuthSessionCheckService.checkAuthStatusService, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); - const spyAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); + const spyIsAuthenticated = jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); + + //mocking top-level alarm handler + browser.alarms.onAlarm.addListener(async alarm => await StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm(alarm)); + // Process - await startLoopAuthSessionCheckService.exec(); + await StartLoopAuthSessionCheckService.exec(); // Expectation expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(0); @@ -85,6 +88,5 @@ describe("StartLoopAuthSessionCheckService", () => { expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(1); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(1); - expect(spyAlarmRemoveListener).toHaveBeenCalledWith(startLoopAuthSessionCheckService.checkAuthStatus); }); }); diff --git a/src/all/background_page/utils/topLevelAlarmMapping.data.js b/src/all/background_page/utils/topLevelAlarmMapping.data.js new file mode 100644 index 00000000..27f0478a --- /dev/null +++ b/src/all/background_page/utils/topLevelAlarmMapping.data.js @@ -0,0 +1,22 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import StartLoopAuthSessionCheckService from "../service/auth/startLoopAuthSessionCheckService"; + +/** + * Top-level alarm callback mapping. + */ +export const topLevelAlarmMapping = { + [StartLoopAuthSessionCheckService.ALARM_NAME]: StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm, +}; diff --git a/src/chrome-mv3/index.js b/src/chrome-mv3/index.js index 9a769443..97a66425 100644 --- a/src/chrome-mv3/index.js +++ b/src/chrome-mv3/index.js @@ -19,6 +19,7 @@ import TabService from "../all/background_page/service/tab/tabService"; import OnExtensionUpdateAvailableController from "../all/background_page/controller/extension/onExtensionUpdateAvailableController"; import PostLogoutService from "../all/background_page/service/auth/postLogoutService"; +import {topLevelAlarmMapping} from "../all/background_page/utils/topLevelAlarmMapping.data"; /** * Load all system requirement @@ -59,3 +60,21 @@ browser.runtime.onConnect.addListener(PortManager.onPortConnect); * Add listener on tabs on removed */ browser.tabs.onRemoved.addListener(PortManager.onTabRemoved); + +/** + * Top level alarm handler to ensure alarm callbacks are still processed after the service worker awakes. + * @param {Alarm} alarm + */ +const handleTopLevelAlarms = alarm => { + topLevelAlarmMapping[alarm.name]?.(alarm); +}; + +/** + * Ensures the top-level alarm handler is not triggered twice + */ +browser.alarms.onAlarm.removeListener(handleTopLevelAlarms); + +/** + * Add a top-level alarm handler. + */ +browser.alarms.onAlarm.addListener(handleTopLevelAlarms); diff --git a/test/mocks/mockAlarms.js b/test/mocks/mockAlarms.js index 94a1e847..2459224a 100644 --- a/test/mocks/mockAlarms.js +++ b/test/mocks/mockAlarms.js @@ -171,6 +171,16 @@ class MockAlarms { this._registeredAlarms = {}; } + + /** + * Clean all the mocked alarm data to ensure unit tests are not running with previous tests' data + * @returns {Promise} + */ + async clearAllMocks() { + this.clearAll(); + this.onAlarm = new OnAlarmEvent(); + this.onAlarm.triggerAlarm = this.onAlarm.triggerAlarm.bind(this); + } } class OnAlarmEvent { From b4d030262c3fdd80ad25e4986551ea94ec67ea17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Wed, 20 Mar 2024 18:52:59 +0100 Subject: [PATCH 18/56] PB-30337 - Put the alarm listener at the top level for the passphraseStorageService to flush passphrase after a time duration --- src/all/background_page/index.js | 14 +-- .../alarm/globalAlarmService.js} | 15 ++- .../passphraseStorageService.js | 101 ++++++++---------- .../passphraseStorageService.test.js | 14 ++- src/chrome-mv3/index.js | 14 +-- 5 files changed, 76 insertions(+), 82 deletions(-) rename src/all/background_page/{utils/topLevelAlarmMapping.data.js => service/alarm/globalAlarmService.js} (62%) diff --git a/src/all/background_page/index.js b/src/all/background_page/index.js index 039e6783..5ddfec05 100644 --- a/src/all/background_page/index.js +++ b/src/all/background_page/index.js @@ -15,7 +15,7 @@ import StartLoopAuthSessionCheckService from "./service/auth/startLoopAuthSessio import OnExtensionUpdateAvailableController from "./controller/extension/onExtensionUpdateAvailableController"; import PostLogoutService from "./service/auth/postLogoutService"; import CheckAuthStatusService from "./service/auth/checkAuthStatusService"; -import {topLevelAlarmMapping} from "./utils/topLevelAlarmMapping.data"; +import GlobalAlarmService from "./service/alarm/globalAlarmService"; const main = async() => { /** @@ -93,20 +93,12 @@ browser.runtime.onConnect.addListener(PortManager.onPortConnect); */ browser.tabs.onRemoved.addListener(PortManager.onTabRemoved); -/** - * Top level alarm handler to ensure alarm callbacks are still processed after the service worker awakes. - * @param {Alarm} alarm - */ -const handleTopLevelAlarms = alarm => { - topLevelAlarmMapping[alarm.name]?.(alarm); -}; - /** * Ensures the top-level alarm handler is not triggered twice */ -browser.alarms.onAlarm.removeListener(handleTopLevelAlarms); +browser.alarms.onAlarm.removeListener(GlobalAlarmService.exec); /** * Add a top-level alarm handler. */ -browser.alarms.onAlarm.addListener(handleTopLevelAlarms); +browser.alarms.onAlarm.addListener(GlobalAlarmService.exec); diff --git a/src/all/background_page/utils/topLevelAlarmMapping.data.js b/src/all/background_page/service/alarm/globalAlarmService.js similarity index 62% rename from src/all/background_page/utils/topLevelAlarmMapping.data.js rename to src/all/background_page/service/alarm/globalAlarmService.js index 27f0478a..0a99c9a4 100644 --- a/src/all/background_page/utils/topLevelAlarmMapping.data.js +++ b/src/all/background_page/service/alarm/globalAlarmService.js @@ -12,11 +12,16 @@ * @since 4.7.0 */ -import StartLoopAuthSessionCheckService from "../service/auth/startLoopAuthSessionCheckService"; +import StartLoopAuthSessionCheckService from "../auth/startLoopAuthSessionCheckService"; +import PassphraseStorageService from "../session_storage/passphraseStorageService"; -/** - * Top-level alarm callback mapping. - */ -export const topLevelAlarmMapping = { +const topLevelAlarmMapping = { [StartLoopAuthSessionCheckService.ALARM_NAME]: StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm, + [PassphraseStorageService.PASSPHRASE_FLUSH_ALARM_NAME]: PassphraseStorageService.handleFlushEvent, }; + +export default class GlobalAlarmService { + static exec(alarm) { + topLevelAlarmMapping[alarm.name]?.(alarm); + } +} diff --git a/src/all/background_page/service/session_storage/passphraseStorageService.js b/src/all/background_page/service/session_storage/passphraseStorageService.js index 2a0765f0..44eac543 100644 --- a/src/all/background_page/service/session_storage/passphraseStorageService.js +++ b/src/all/background_page/service/session_storage/passphraseStorageService.js @@ -14,8 +14,6 @@ import User from "../../model/user"; import UserService from "../api/user/userService"; import Log from "../../model/log"; -import Lock from "../../utils/lock"; -const lock = new Lock(); const PASSPHRASE_FLUSH_ALARM = "PassphraseStorageFlush"; const SESSION_KEEP_ALIVE_ALARM = "SessionKeepAlive"; @@ -23,32 +21,22 @@ const PASSPHRASE_STORAGE_KEY = "passphrase"; const SESSION_CHECK_INTERNAL = 15; class PassphraseStorageService { - /** - * Constructor of the storage service - */ - constructor() { - this._handleFlushEvent = this._handleFlushEvent.bind(this); - this._handleKeepSessionAlive = this._handleKeepSessionAlive.bind(this); - } - /** * Stores the passphrase in session memory for given a duration. * @param {string} passphrase * @param {number|null} timeout duration in second before flushing passphrase * @return {Promise} */ - async set(passphrase, timeout) { - await lock.acquire(); - await browser.storage.session.set({[PASSPHRASE_STORAGE_KEY]: passphrase}); - lock.release(); + static async set(passphrase, timeout) { + await navigator.locks.request(PASSPHRASE_STORAGE_KEY, async() => { + await browser.storage.session.set({[PASSPHRASE_STORAGE_KEY]: passphrase}); + }); - await this._clearFlushAlarms(); + PassphraseStorageService._clearFlushAlarms(); if (timeout >= 0) { - const flushingTime = Date.now() + timeout * 1000; browser.alarms.create(PASSPHRASE_FLUSH_ALARM, { - when: flushingTime + when: Date.now() + timeout * 1000 }); - browser.alarms.onAlarm.addListener(this._handleFlushEvent); } const keepAliveAlarm = await browser.alarms.get(SESSION_KEEP_ALIVE_ALARM); @@ -61,81 +49,78 @@ class PassphraseStorageService { * Retrieve the passphrase from the session memory if any. * @return {Promise} */ - async get() { + static async get() { const storedData = await browser.storage.session.get(PASSPHRASE_STORAGE_KEY); return storedData?.[PASSPHRASE_STORAGE_KEY] || null; } - /** - * Flush the registered passphrase without removing any alarms. - * @returns {Promise} - */ - async flushPassphrase() { - await lock.acquire(); - await browser.storage.session.remove(PASSPHRASE_STORAGE_KEY); - lock.release(); - } - /** * Returns true if the session is set to be kept until the user logs out. * @returns {boolean} */ - isSessionKeptUntilLogOut() { + static isSessionKeptUntilLogOut() { // we assume that the event listener is present only when the session is no kept until log out. - return !browser.alarms.onAlarm.hasListener(this._handleFlushEvent); + return !browser.alarms.onAlarm.hasListener(PassphraseStorageService.handleFlushEvent); } /** * Removes the stored passphrase from the session memory and resets alarms. * @return {Promise} */ - async flush() { + static async flush() { Log.write({level: 'debug', message: 'PassphraseStorageService flushed'}); return Promise.all([ - this.flushPassphrase(), - this._clearFlushAlarms(), - this._clearKeepAliveAlarms(), + PassphraseStorageService.flushPassphrase(), + PassphraseStorageService._clearFlushAlarms(), + PassphraseStorageService._clearKeepAliveAlarms(), ]); } /** - * Removes the stored passphrase from the session memory. - * @return {Promise} + * Flush the registered passphrase without removing any alarms. + * @returns {Promise} */ - async stopSessionKeepAlive() { - this._clearKeepAliveAlarms(); + static async flushPassphrase() { + await navigator.locks.request(PASSPHRASE_STORAGE_KEY, async() => { + await browser.storage.session.remove(PASSPHRASE_STORAGE_KEY); + }); } /** * Clear all the alarms and listeners configured for flushing the passphrase if any. * @private */ - async _clearFlushAlarms() { - await browser.alarms.clear(PASSPHRASE_FLUSH_ALARM); - if (browser.alarms.onAlarm.hasListener(this._handleFlushEvent)) { - browser.alarms.onAlarm.removeListener(this._handleFlushEvent); - } + static _clearFlushAlarms() { + browser.alarms.clear(PASSPHRASE_FLUSH_ALARM); } /** * Clear all the alarms and listeners configured for keeping session alive if any. * @private */ - async _clearKeepAliveAlarms() { + static async _clearKeepAliveAlarms() { await browser.alarms.clear(SESSION_KEEP_ALIVE_ALARM); - if (browser.alarms.onAlarm.hasListener(this._handleKeepSessionAlive)) { - browser.alarms.onAlarm.removeListener(this._handleKeepSessionAlive); + if (browser.alarms.onAlarm.hasListener(PassphraseStorageService._handleKeepSessionAlive)) { + browser.alarms.onAlarm.removeListener(PassphraseStorageService._handleKeepSessionAlive); } } + /** + * Removes the stored passphrase from the session memory. + * @return {Promise} + */ + static stopSessionKeepAlive() { + this._clearKeepAliveAlarms(); + } + /** * Flush the current stored passphrase when the PassphraseStorageFlush alarm triggers. + * This is a top-level alarm callback * @param {Alarm} alarm - * @private */ - async _handleFlushEvent(alarm) { + static async handleFlushEvent(alarm) { if (alarm.name === PASSPHRASE_FLUSH_ALARM) { - await this.flush(); + await PassphraseStorageService.flush(); } } @@ -145,12 +130,12 @@ class PassphraseStorageService { * @returns {Promise} * @private */ - async _handleKeepSessionAlive(alarm) { + static async _handleKeepSessionAlive(alarm) { if (alarm.name !== SESSION_KEEP_ALIVE_ALARM) { return; } - if (await this.get() === null) { + if (await PassphraseStorageService.get() === null) { return; } @@ -163,7 +148,7 @@ class PassphraseStorageService { /** * Creates an alarm to ensure session is kept alive. */ - _keepAliveSession() { + static _keepAliveSession() { browser.alarms.create(SESSION_KEEP_ALIVE_ALARM, { delayInMinutes: SESSION_CHECK_INTERNAL, periodInMinutes: SESSION_CHECK_INTERNAL @@ -171,6 +156,14 @@ class PassphraseStorageService { browser.alarms.onAlarm.addListener(this._handleKeepSessionAlive); } + + /** + * Returns the PASSPHRASE_FLUSH_ALARM name + * @returns {string} + */ + static get PASSPHRASE_FLUSH_ALARM_NAME() { + return PASSPHRASE_FLUSH_ALARM; + } } -export default new PassphraseStorageService(); +export default PassphraseStorageService; diff --git a/src/all/background_page/service/session_storage/passphraseStorageService.test.js b/src/all/background_page/service/session_storage/passphraseStorageService.test.js index acb34c7d..a7943be4 100644 --- a/src/all/background_page/service/session_storage/passphraseStorageService.test.js +++ b/src/all/background_page/service/session_storage/passphraseStorageService.test.js @@ -70,6 +70,8 @@ describe("PassphraseStorageService", () => { const spyOnFlush = jest.spyOn(PassphraseStorageService, "flush"); const spyOnKeepAliveSession = jest.spyOn(PassphraseStorageService, "_handleKeepSessionAlive"); + browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); + await PassphraseStorageService.flush(); expect(spyOnFlush).toHaveBeenCalledTimes(1); @@ -125,6 +127,8 @@ describe("PassphraseStorageService", () => { it("should return null after the delay is passed", async() => { expect.assertions(3); + browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); + await PassphraseStorageService.flush(); const emptyPassphrase = await PassphraseStorageService.get(); @@ -150,6 +154,8 @@ describe("PassphraseStorageService", () => { const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); const spyOnAlarmListeners = jest.spyOn(browser.alarms.onAlarm, "addListener"); + browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); + await PassphraseStorageService.flush(); const passphrase = "This is a very strong passphrase"; @@ -200,6 +206,8 @@ describe("PassphraseStorageService", () => { const spyOnAlarmListeners = jest.spyOn(browser.alarms.onAlarm, "addListener"); const spyOnAlarmRemoveListeners = jest.spyOn(browser.alarms.onAlarm, "removeListener"); + browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); + await PassphraseStorageService.flush(); const passphrase = "This is a very strong passphrase"; @@ -218,7 +226,7 @@ describe("PassphraseStorageService", () => { expect(spyOnAlarmClear).toHaveBeenCalledWith("PassphraseStorageFlush"); expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); - expect(spyOnAlarmRemoveListeners).toHaveBeenCalledTimes(4); + expect(spyOnAlarmRemoveListeners).toHaveBeenCalledTimes(3); }); }); @@ -227,6 +235,8 @@ describe("PassphraseStorageService", () => { expect.assertions(1); const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); + browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); + await PassphraseStorageService.flush(); await PassphraseStorageService.stopSessionKeepAlive(); @@ -238,6 +248,8 @@ describe("PassphraseStorageService", () => { const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); const spyOnAlarmRemoveListeners = jest.spyOn(browser.alarms.onAlarm, "removeListener"); + browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); + await PassphraseStorageService.flush(); const passphrase = "This is a very strong passphrase"; diff --git a/src/chrome-mv3/index.js b/src/chrome-mv3/index.js index 97a66425..147a72b5 100644 --- a/src/chrome-mv3/index.js +++ b/src/chrome-mv3/index.js @@ -19,7 +19,7 @@ import TabService from "../all/background_page/service/tab/tabService"; import OnExtensionUpdateAvailableController from "../all/background_page/controller/extension/onExtensionUpdateAvailableController"; import PostLogoutService from "../all/background_page/service/auth/postLogoutService"; -import {topLevelAlarmMapping} from "../all/background_page/utils/topLevelAlarmMapping.data"; +import GlobalAlarmService from "../all/background_page/service/alarm/globalAlarmService"; /** * Load all system requirement @@ -61,20 +61,12 @@ browser.runtime.onConnect.addListener(PortManager.onPortConnect); */ browser.tabs.onRemoved.addListener(PortManager.onTabRemoved); -/** - * Top level alarm handler to ensure alarm callbacks are still processed after the service worker awakes. - * @param {Alarm} alarm - */ -const handleTopLevelAlarms = alarm => { - topLevelAlarmMapping[alarm.name]?.(alarm); -}; - /** * Ensures the top-level alarm handler is not triggered twice */ -browser.alarms.onAlarm.removeListener(handleTopLevelAlarms); +browser.alarms.onAlarm.removeListener(GlobalAlarmService.exec); /** * Add a top-level alarm handler. */ -browser.alarms.onAlarm.addListener(handleTopLevelAlarms); +browser.alarms.onAlarm.addListener(GlobalAlarmService.exec); From e547859c3cba5af7f1b18362162d261a6568414d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Thu, 21 Mar 2024 12:32:25 +0100 Subject: [PATCH 19/56] PB-32335 - Split PassphraseStorageService to put the KeepSessionAlive feature on its own service --- .../account/updatePrivateKeyController.js | 3 +- .../controller/auth/authLoginController.js | 6 +- .../controller/setup/signInSetupController.js | 6 +- .../sso/ssoAuthenticationController.js | 6 +- src/all/background_page/model/user.js | 7 +- .../service/alarm/globalAlarmService.js | 20 ++- .../startLoopAuthSessionCheckService.test.js | 2 +- .../localStorage/localStorageService.js | 2 + .../passphrase/getPassphraseService.js | 6 +- .../keepSessionAliveService.js | 94 +++++++++++ .../keepSessionAliveService.test.js | 152 ++++++++++++++++++ .../passphraseStorageService.js | 86 +--------- .../passphraseStorageService.test.js | 84 ++-------- test/mocks/mockAlarms.js | 7 - 14 files changed, 316 insertions(+), 165 deletions(-) create mode 100644 src/all/background_page/service/session_storage/keepSessionAliveService.js create mode 100644 src/all/background_page/service/session_storage/keepSessionAliveService.test.js diff --git a/src/all/background_page/controller/account/updatePrivateKeyController.js b/src/all/background_page/controller/account/updatePrivateKeyController.js index 274582ae..4b4f8d1d 100644 --- a/src/all/background_page/controller/account/updatePrivateKeyController.js +++ b/src/all/background_page/controller/account/updatePrivateKeyController.js @@ -19,6 +19,7 @@ import SsoDataStorage from "../../service/indexedDB_storage/ssoDataStorage"; import SsoKitServerPartModel from "../../model/sso/ssoKitServerPartModel"; import PassboltApiFetchError from "passbolt-styleguide/src/shared/lib/Error/PassboltApiFetchError"; import GenerateSsoKitService from "../../service/sso/generateSsoKitService"; +import KeepSessionAliveService from "../../service/session_storage/keepSessionAliveService"; const RECOVERY_KIT_FILENAME = "passbolt-recovery-kit.asc"; @@ -72,7 +73,7 @@ class UpdatePrivateKeyController { } await this.accountModel.updatePrivateKey(userPrivateArmoredKey); await PassphraseStorageService.flushPassphrase(); - if (PassphraseStorageService.isSessionKeptUntilLogOut()) { + if (KeepSessionAliveService.isSessionKeptUntilLogOut()) { await PassphraseStorageService.set(newPassphrase); } await FileService.saveFile(RECOVERY_KIT_FILENAME, userPrivateArmoredKey, "text/plain", this.worker.tab.id); diff --git a/src/all/background_page/controller/auth/authLoginController.js b/src/all/background_page/controller/auth/authLoginController.js index 2b8be90b..65ea93c4 100644 --- a/src/all/background_page/controller/auth/authLoginController.js +++ b/src/all/background_page/controller/auth/authLoginController.js @@ -20,6 +20,7 @@ import UserRememberMeLatestChoiceEntity from "../../model/entity/rememberMe/user import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; import PostLoginService from "../../service/auth/postLoginService"; import AuthVerifyLoginChallengeService from "../../service/auth/authVerifyLoginChallengeService"; +import KeepSessionAliveService from "../../service/session_storage/keepSessionAliveService"; class AuthLoginController { /** @@ -99,7 +100,10 @@ class AuthLoginController { * MFA may not be complete yet, so no need to preload things here */ if (rememberMe) { - await PassphraseStorageService.set(passphrase, -1); + Promise.all([ + PassphraseStorageService.set(passphrase, -1), + KeepSessionAliveService.set(), + ]); } await PostLoginService.postLogin(); await this.registerRememberMeOption(rememberMe); diff --git a/src/all/background_page/controller/setup/signInSetupController.js b/src/all/background_page/controller/setup/signInSetupController.js index 00666b38..6802ea2a 100644 --- a/src/all/background_page/controller/setup/signInSetupController.js +++ b/src/all/background_page/controller/setup/signInSetupController.js @@ -17,6 +17,7 @@ import UpdateSsoCredentialsService from "../../service/account/updateSsoCredenti import AuthVerifyLoginChallengeService from "../../service/auth/authVerifyLoginChallengeService"; import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; import PostLoginService from "../../service/auth/postLoginService"; +import KeepSessionAliveService from "../../service/session_storage/keepSessionAliveService"; class SignInSetupController { /** @@ -72,7 +73,10 @@ class SignInSetupController { await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(this.account.userKeyFingerprint, this.account.userPrivateArmoredKey, this.runtimeMemory.passphrase); if (rememberMe) { - await PassphraseStorageService.set(this.runtimeMemory.passphrase, -1); + Promise.all([ + PassphraseStorageService.set(this.runtimeMemory.passphrase, -1), + KeepSessionAliveService.set(), + ]); } await PostLoginService.postLogin(); await this.redirectToApp(); diff --git a/src/all/background_page/controller/sso/ssoAuthenticationController.js b/src/all/background_page/controller/sso/ssoAuthenticationController.js index 2ec932f5..35743d79 100644 --- a/src/all/background_page/controller/sso/ssoAuthenticationController.js +++ b/src/all/background_page/controller/sso/ssoAuthenticationController.js @@ -23,6 +23,7 @@ import QualifySsoLoginErrorService from "../../service/sso/qualifySsoLoginErrorS import AuthVerifyLoginChallengeService from "../../service/auth/authVerifyLoginChallengeService"; import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; import PostLoginService from "../../service/auth/postLoginService"; +import KeepSessionAliveService from "../../service/session_storage/keepSessionAliveService"; class SsoAuthenticationController { /** @@ -89,7 +90,10 @@ class SsoAuthenticationController { const passphrase = await DecryptSsoPassphraseService.decrypt(clientPartSsoKit.secret, clientPartSsoKit.nek, serverKey, clientPartSsoKit.iv1, clientPartSsoKit.iv2); await this.popupHandler.closeHandler(); await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(this.account.userKeyFingerprint, this.account.userPrivateArmoredKey, passphrase); - await PassphraseStorageService.set(passphrase, -1); + Promise.all([ + PassphraseStorageService.set(passphrase, -1), + KeepSessionAliveService.set(), + ]); await PostLoginService.postLogin(); if (isInQuickAccessMode) { await this.ensureRedirectionInQuickaccessMode(); diff --git a/src/all/background_page/model/user.js b/src/all/background_page/model/user.js index 9f9f5dfd..51e3e824 100644 --- a/src/all/background_page/model/user.js +++ b/src/all/background_page/model/user.js @@ -10,6 +10,7 @@ import {ApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/api import Validator from "validator"; import {ValidatorRule} from "../utils/validatorRules"; import PassphraseStorageService from "../service/session_storage/passphraseStorageService"; +import KeepSessionAliveService from "../service/session_storage/keepSessionAliveService"; /** * The class that deals with users. @@ -358,8 +359,10 @@ const UserSingleton = (function() { * Observe when the user session is terminated. * - Flush the temporary stored master password */ - self.addEventListener("passbolt.auth.after-logout", async() => - await PassphraseStorageService.flush() + self.addEventListener("passbolt.auth.after-logout", () => Promise.all([ + PassphraseStorageService.flush(), + KeepSessionAliveService.stopKeepingSessionAlive() + ]) ); } }; diff --git a/src/all/background_page/service/alarm/globalAlarmService.js b/src/all/background_page/service/alarm/globalAlarmService.js index 0a99c9a4..3be9fc43 100644 --- a/src/all/background_page/service/alarm/globalAlarmService.js +++ b/src/all/background_page/service/alarm/globalAlarmService.js @@ -13,15 +13,29 @@ */ import StartLoopAuthSessionCheckService from "../auth/startLoopAuthSessionCheckService"; +import KeepSessionAliveService from "../session_storage/keepSessionAliveService"; import PassphraseStorageService from "../session_storage/passphraseStorageService"; const topLevelAlarmMapping = { - [StartLoopAuthSessionCheckService.ALARM_NAME]: StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm, - [PassphraseStorageService.PASSPHRASE_FLUSH_ALARM_NAME]: PassphraseStorageService.handleFlushEvent, + [StartLoopAuthSessionCheckService.ALARM_NAME]: [StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm], + [PassphraseStorageService.ALARM_NAME]: [PassphraseStorageService.handleFlushEvent, KeepSessionAliveService.stopKeepingSessionAlive], }; +/** + * Top-level GlobalAlarmService. + * The role of the service is to manage alarms that need to be set on top-level. + * This is necessary to process alarms' callbacks when the service worker wakes up. + * Putting it at a top-level makes sure that the callbacks are still defined and could be called. + */ export default class GlobalAlarmService { static exec(alarm) { - topLevelAlarmMapping[alarm.name]?.(alarm); + const alarmCallbacks = topLevelAlarmMapping[alarm.name]; + if (!alarmCallbacks) { + return; + } + + for (let i = 0; i < alarmCallbacks.length; i++) { + alarmCallbacks[i](alarm); + } } } diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js index ee3bbaf8..1883a687 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js @@ -21,7 +21,7 @@ beforeEach(async() => { jest.resetModules(); jest.clearAllMocks(); jest.clearAllTimers(); - await browser.alarms.clearAllMocks(); + await browser.alarms.clearAll(); }); describe("StartLoopAuthSessionCheckService", () => { diff --git a/src/all/background_page/service/localStorage/localStorageService.js b/src/all/background_page/service/localStorage/localStorageService.js index 3214eb68..573792d5 100644 --- a/src/all/background_page/service/localStorage/localStorageService.js +++ b/src/all/background_page/service/localStorage/localStorageService.js @@ -31,6 +31,7 @@ import UserMeSessionStorageService from "../sessionStorage/userMeSessionStorageS import User from "../../model/user"; import PasswordPoliciesLocalStorage from "../local_storage/passwordPoliciesLocalStorage"; import PasswordExpirySettingsLocalStorage from "../local_storage/passwordExpirySettingsLocalStorage"; +import KeepSessionAliveService from "../session_storage/keepSessionAliveService"; /** * Flush storage data when: @@ -53,6 +54,7 @@ class LocalStorageService { PostponeUserSettingInvitationService.reset(); PassphraseStorageService.flush(); SsoKitTemporaryStorageService.flush(); + KeepSessionAliveService.stopKeepingSessionAlive(); LocalStorageService.flushAccountBasedStorages(); } diff --git a/src/all/background_page/service/passphrase/getPassphraseService.js b/src/all/background_page/service/passphrase/getPassphraseService.js index 685cd26c..c5861066 100644 --- a/src/all/background_page/service/passphrase/getPassphraseService.js +++ b/src/all/background_page/service/passphrase/getPassphraseService.js @@ -15,6 +15,7 @@ import WorkerService from "../worker/workerService"; import UserRememberMeLatestChoiceLocalStorage from "../local_storage/userRememberMeLatestChoiceLocalStorage"; import UserRememberMeLatestChoiceEntity from "../../model/entity/rememberMe/userRememberMeLatestChoiceEntity"; import {assertPassphrase} from "../../utils/assertions"; +import KeepSessionAliveService from "../session_storage/keepSessionAliveService"; export default class GetPassphraseService { constructor(account) { @@ -127,7 +128,10 @@ export default class GetPassphraseService { return; } - await PassphraseStorageService.set(passphrase, duration); + Promise.all([ + PassphraseStorageService.set(passphrase, duration), + KeepSessionAliveService.set(), + ]); const userRememberMeLatestChoiceEntity = new UserRememberMeLatestChoiceEntity({ duration: parseInt(duration, 10) }); diff --git a/src/all/background_page/service/session_storage/keepSessionAliveService.js b/src/all/background_page/service/session_storage/keepSessionAliveService.js new file mode 100644 index 00000000..448ef7a2 --- /dev/null +++ b/src/all/background_page/service/session_storage/keepSessionAliveService.js @@ -0,0 +1,94 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import User from "../../model/user"; +import UserService from "../api/user/userService"; +import PassphraseStorageService from "./passphraseStorageService"; + +const SESSION_KEEP_ALIVE_ALARM = "SessionKeepAlive"; +const SESSION_CHECK_INTERNAL = 15; + +class KeepSessionAliveService { + /** + * Set the current to be kept alive if not already. + * @return {Promise} + */ + static async set() { + const keepAliveAlarm = await browser.alarms.get(KeepSessionAliveService.ALARM_NAME); + if (!keepAliveAlarm) { + await this._keepAliveSession(); + } + } + + /** + * Returns true if the session is set to be kept until the user logs out. + * @returns {Promise} + */ + static async isSessionKeptUntilLogOut() { + return Boolean(await browser.alarms.get(KeepSessionAliveService.ALARM_NAME)); + } + + /** + * Removes the stored passphrase from the session memory. + * @return {Promise} + */ + static async stopKeepingSessionAlive() { + await browser.alarms.clear(KeepSessionAliveService.ALARM_NAME); + if (browser.alarms.onAlarm.hasListener(KeepSessionAliveService.handleKeepSessionAlive)) { + browser.alarms.onAlarm.removeListener(KeepSessionAliveService.handleKeepSessionAlive); + } + } + + /** + * Keep the current session alive + * @param {Alarm} alarm + * @returns {Promise} + */ + static async handleKeepSessionAlive(alarm) { + if (alarm.name !== KeepSessionAliveService.ALARM_NAME) { + return; + } + + if (await PassphraseStorageService.get() === null) { + return; + } + + const apiClientOptions = await User.getInstance().getApiClientOptions(); + const userService = new UserService(apiClientOptions); + userService.keepSessionAlive(); + } + + /** + * Creates an alarm to ensure session is kept alive. + * @returns {Promise} + * @private + */ + static async _keepAliveSession() { + await browser.alarms.create(KeepSessionAliveService.ALARM_NAME, { + delayInMinutes: SESSION_CHECK_INTERNAL, + periodInMinutes: SESSION_CHECK_INTERNAL + }); + + await browser.alarms.onAlarm.addListener(KeepSessionAliveService.handleKeepSessionAlive); + } + + /** + * Returns the SESSION_KEEP_ALIVE_ALARM name + * @returns {string} + */ + static get ALARM_NAME() { + return SESSION_KEEP_ALIVE_ALARM; + } +} + +export default KeepSessionAliveService; diff --git a/src/all/background_page/service/session_storage/keepSessionAliveService.test.js b/src/all/background_page/service/session_storage/keepSessionAliveService.test.js new file mode 100644 index 00000000..da9a1857 --- /dev/null +++ b/src/all/background_page/service/session_storage/keepSessionAliveService.test.js @@ -0,0 +1,152 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 3.8.0 + */ + +import MockExtension from "../../../../../test/mocks/mockExtension"; +import UserService from "../api/user/userService"; +import KeepSessionAliveService from "./keepSessionAliveService"; +import PassphraseStorageService from "./passphraseStorageService"; + +beforeEach(async() => { + await browser.alarms.clearAll(); + jest.clearAllMocks(); +}); + +describe("KeepSessionAliveService", () => { + describe("KeepSessionAliveService::set", () => { + it("should set the alarm if none has been set already", async() => { + expect.assertions(5); + const spyOnAlarmGet = jest.spyOn(browser.alarms, "get"); + const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + const spyOnAlarmAddListener = jest.spyOn(browser.alarms.onAlarm, "addListener"); + + await KeepSessionAliveService.set(); + + expect(spyOnAlarmGet).toHaveBeenCalledTimes(1); + expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); + expect(spyOnAlarmCreate).toHaveBeenCalledWith("SessionKeepAlive", { + delayInMinutes: 15, + periodInMinutes: 15 + }); + expect(spyOnAlarmAddListener).toHaveBeenCalledTimes(1); + expect(spyOnAlarmAddListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); + }); + + it("should not set the alarm if one already exist", async() => { + expect.assertions(2); + const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); + const spyOnAlarmAddListener = jest.spyOn(browser.alarms.onAlarm, "addListener"); + + await KeepSessionAliveService.set(); + await KeepSessionAliveService.set(); + + expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); + expect(spyOnAlarmAddListener).toHaveBeenCalledTimes(1); + await browser.alarms.clearAll(); + }); + }); + + describe("KeepSessionAliveService::isSessionKeptUntilLogOut", () => { + it("should return true if the alarm is set", async() => { + expect.assertions(1); + await KeepSessionAliveService.set(); + const isSessionKept = await KeepSessionAliveService.isSessionKeptUntilLogOut(); + expect(isSessionKept).toStrictEqual(true); + }); + + it("should return false if the alarm has been cleared out", async() => { + expect.assertions(1); + await KeepSessionAliveService.set(); + await browser.alarms.clearAll(); + const isSessionKept = await KeepSessionAliveService.isSessionKeptUntilLogOut(); + expect(isSessionKept).toStrictEqual(false); + }); + + it("should return false if the alarm has never been set", async() => { + expect.assertions(1); + const isSessionKept = await KeepSessionAliveService.isSessionKeptUntilLogOut(); + expect(isSessionKept).toStrictEqual(false); + }); + }); + + describe("KeepSessionAliveService::stopKeepingSessionAlive", () => { + it("should clear the alarms and remove listeners if any", async() => { + expect.assertions(4); + + const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); + const spyOnAlarmHasListener = jest.spyOn(browser.alarms.onAlarm, "hasListener").mockImplementation(() => true); + const spyOnAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); + + await KeepSessionAliveService.stopKeepingSessionAlive(); + + expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); + expect(spyOnAlarmHasListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); + expect(spyOnAlarmRemoveListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); + }); + + it("should clear the alarms and not remove the listeners if there is none", async() => { + expect.assertions(4); + + const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); + const spyOnAlarmHasListener = jest.spyOn(browser.alarms.onAlarm, "hasListener").mockImplementation(() => false); + const spyOnAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); + + await KeepSessionAliveService.stopKeepingSessionAlive(); + + expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); + expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); + expect(spyOnAlarmHasListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); + expect(spyOnAlarmRemoveListener).not.toHaveBeenCalled(); + }); + }); + + describe("KeepSessionAliveService::handleKeepSessionAlive", () => { + it("should not handle alarms that is not SessionKeepAlive", async() => { + expect.assertions(1); + const spyOnPassphraseStorage = jest.spyOn(PassphraseStorageService, "get"); + + await KeepSessionAliveService.handleKeepSessionAlive({ + name: "Not-SessionKeepAlive", + }); + + expect(spyOnPassphraseStorage).not.toHaveBeenCalled(); + }); + + it("should not try to keep session alive if no passphrase is found in memory", async() => { + expect.assertions(1); + const spyOnPassphraseStorage = jest.spyOn(PassphraseStorageService, "get"); + spyOnPassphraseStorage.mockImplementation(async() => null); + + await KeepSessionAliveService.handleKeepSessionAlive({ + name: "SessionKeepAlive", + }); + + expect(spyOnPassphraseStorage).toHaveBeenCalledTimes(1); + }); + + it("should keep the session alive if a passphrase is found in memory", async() => { + expect.assertions(2); + await MockExtension.withConfiguredAccount(); + const spyOnPassphraseStorage = jest.spyOn(PassphraseStorageService, "get").mockImplementation(async() => "what a passphrase!"); + const spyOnUserService = jest.spyOn(UserService.prototype, "keepSessionAlive").mockImplementation(() => true); + + await KeepSessionAliveService.handleKeepSessionAlive({ + name: "SessionKeepAlive", + }); + + expect(spyOnPassphraseStorage).toHaveBeenCalledTimes(1); + expect(spyOnUserService).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/all/background_page/service/session_storage/passphraseStorageService.js b/src/all/background_page/service/session_storage/passphraseStorageService.js index 44eac543..40136604 100644 --- a/src/all/background_page/service/session_storage/passphraseStorageService.js +++ b/src/all/background_page/service/session_storage/passphraseStorageService.js @@ -11,14 +11,10 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.8.0 */ -import User from "../../model/user"; -import UserService from "../api/user/userService"; import Log from "../../model/log"; const PASSPHRASE_FLUSH_ALARM = "PassphraseStorageFlush"; -const SESSION_KEEP_ALIVE_ALARM = "SessionKeepAlive"; const PASSPHRASE_STORAGE_KEY = "passphrase"; -const SESSION_CHECK_INTERNAL = 15; class PassphraseStorageService { /** @@ -34,15 +30,10 @@ class PassphraseStorageService { PassphraseStorageService._clearFlushAlarms(); if (timeout >= 0) { - browser.alarms.create(PASSPHRASE_FLUSH_ALARM, { + browser.alarms.create(PassphraseStorageService.ALARM_NAME, { when: Date.now() + timeout * 1000 }); } - - const keepAliveAlarm = await browser.alarms.get(SESSION_KEEP_ALIVE_ALARM); - if (!keepAliveAlarm) { - this._keepAliveSession(); - } } /** @@ -54,15 +45,6 @@ class PassphraseStorageService { return storedData?.[PASSPHRASE_STORAGE_KEY] || null; } - /** - * Returns true if the session is set to be kept until the user logs out. - * @returns {boolean} - */ - static isSessionKeptUntilLogOut() { - // we assume that the event listener is present only when the session is no kept until log out. - return !browser.alarms.onAlarm.hasListener(PassphraseStorageService.handleFlushEvent); - } - /** * Removes the stored passphrase from the session memory and resets alarms. * @return {Promise} @@ -72,7 +54,6 @@ class PassphraseStorageService { return Promise.all([ PassphraseStorageService.flushPassphrase(), PassphraseStorageService._clearFlushAlarms(), - PassphraseStorageService._clearKeepAliveAlarms(), ]); } @@ -80,37 +61,17 @@ class PassphraseStorageService { * Flush the registered passphrase without removing any alarms. * @returns {Promise} */ - static async flushPassphrase() { - await navigator.locks.request(PASSPHRASE_STORAGE_KEY, async() => { - await browser.storage.session.remove(PASSPHRASE_STORAGE_KEY); - }); + static flushPassphrase() { + return navigator.locks.request(PASSPHRASE_STORAGE_KEY, () => browser.storage.session.remove(PASSPHRASE_STORAGE_KEY)); } /** * Clear all the alarms and listeners configured for flushing the passphrase if any. + * @returns {Promise} * @private */ static _clearFlushAlarms() { - browser.alarms.clear(PASSPHRASE_FLUSH_ALARM); - } - - /** - * Clear all the alarms and listeners configured for keeping session alive if any. - * @private - */ - static async _clearKeepAliveAlarms() { - await browser.alarms.clear(SESSION_KEEP_ALIVE_ALARM); - if (browser.alarms.onAlarm.hasListener(PassphraseStorageService._handleKeepSessionAlive)) { - browser.alarms.onAlarm.removeListener(PassphraseStorageService._handleKeepSessionAlive); - } - } - - /** - * Removes the stored passphrase from the session memory. - * @return {Promise} - */ - static stopSessionKeepAlive() { - this._clearKeepAliveAlarms(); + return browser.alarms.clear(PassphraseStorageService.ALARM_NAME); } /** @@ -119,49 +80,16 @@ class PassphraseStorageService { * @param {Alarm} alarm */ static async handleFlushEvent(alarm) { - if (alarm.name === PASSPHRASE_FLUSH_ALARM) { + if (alarm.name === PassphraseStorageService.ALARM_NAME) { await PassphraseStorageService.flush(); } } - /** - * Keep the current session alive - * @param {Alarm} alarm - * @returns {Promise} - * @private - */ - static async _handleKeepSessionAlive(alarm) { - if (alarm.name !== SESSION_KEEP_ALIVE_ALARM) { - return; - } - - if (await PassphraseStorageService.get() === null) { - return; - } - - const user = User.getInstance(); - const apiClientOptions = await user.getApiClientOptions(); - const userService = new UserService(apiClientOptions); - userService.keepSessionAlive(); - } - - /** - * Creates an alarm to ensure session is kept alive. - */ - static _keepAliveSession() { - browser.alarms.create(SESSION_KEEP_ALIVE_ALARM, { - delayInMinutes: SESSION_CHECK_INTERNAL, - periodInMinutes: SESSION_CHECK_INTERNAL - }); - - browser.alarms.onAlarm.addListener(this._handleKeepSessionAlive); - } - /** * Returns the PASSPHRASE_FLUSH_ALARM name * @returns {string} */ - static get PASSPHRASE_FLUSH_ALARM_NAME() { + static get ALARM_NAME() { return PASSPHRASE_FLUSH_ALARM; } } diff --git a/src/all/background_page/service/session_storage/passphraseStorageService.test.js b/src/all/background_page/service/session_storage/passphraseStorageService.test.js index a7943be4..0164f4ef 100644 --- a/src/all/background_page/service/session_storage/passphraseStorageService.test.js +++ b/src/all/background_page/service/session_storage/passphraseStorageService.test.js @@ -33,11 +33,9 @@ describe("PassphraseStorageService", () => { describe("PassphraseStorageService::set", () => { it("Should register the given passphrase on the storage without time limit", async() => { - expect.assertions(7); + expect.assertions(4); const spyOnStorageSet = jest.spyOn(browser.storage.session, "set"); const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); - const spyOnKeepAliveSession = jest.spyOn(PassphraseStorageService, "_handleKeepSessionAlive").mockImplementationOnce(() => jest.fn()); await PassphraseStorageService.flush(); const passphrase = "This is a very strong passphrase"; @@ -46,29 +44,18 @@ describe("PassphraseStorageService", () => { expect(spyOnStorageSet).toHaveBeenCalledTimes(1); expect(spyOnStorageSet).toHaveBeenCalledWith({passphrase: passphrase}); - //Called 2 times at init + 1 times during the ::set - expect(spyOnAlarmClear).toHaveBeenCalledTimes(2 + 1); + //Called 2 times: at init and during the ::set + expect(spyOnAlarmClear).toHaveBeenCalledTimes(2); expect(spyOnAlarmClear).toHaveBeenCalledWith("PassphraseStorageFlush"); - - //Only keep alive session is called and not passphrase storage flush - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmCreate).toHaveBeenCalledWith("SessionKeepAlive", { - delayInMinutes: 15, - periodInMinutes: 15 - }); - - await jest.advanceTimersByTime(15 * 60 * 1000); - expect(spyOnKeepAliveSession).toHaveBeenCalledTimes(1); }); it("Should register the given passphrase on the storage with a time limit", async() => { - expect.assertions(14); + expect.assertions(11); const spyOnStorageSet = jest.spyOn(browser.storage.session, "set"); const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); const spyOnAlarmListeners = jest.spyOn(browser.alarms.onAlarm, "addListener"); const spyOnFlush = jest.spyOn(PassphraseStorageService, "flush"); - const spyOnKeepAliveSession = jest.spyOn(PassphraseStorageService, "_handleKeepSessionAlive"); browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); @@ -84,27 +71,21 @@ describe("PassphraseStorageService", () => { expect(spyOnStorageSet).toHaveBeenCalledTimes(1); expect(spyOnStorageSet).toHaveBeenCalledWith({passphrase: passphrase}); - //Called 2 times at init + 1 times during the ::set - expect(spyOnAlarmClear).toHaveBeenCalledTimes(3); + //Called 2 times: at init and during the ::set + expect(spyOnAlarmClear).toHaveBeenCalledTimes(2); expect(spyOnAlarmClear).toHaveBeenCalledWith("PassphraseStorageFlush"); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(2); - expect(spyOnAlarmListeners).toHaveBeenCalledTimes(2); + expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); + expect(spyOnAlarmListeners).toHaveBeenCalledTimes(1); const alarms = await browser.alarms.getAll(); - expect(alarms.length).toBe(2); + expect(alarms.length).toBe(1); expect(alarms[0].name).toBe("PassphraseStorageFlush"); - expect(alarms[1].name).toBe("SessionKeepAlive"); await jest.advanceTimersByTime(30 * 1000); //1 call by the init funciton and another from the alarm expect(spyOnFlush).toHaveBeenCalledTimes(2); - - //The keep alive session shoud have been called 1 time with the PassphraseStorageFlush alarm - await jest.advanceTimersByTime(15 * 60 * 1000); - expect(spyOnKeepAliveSession).toHaveBeenCalledTimes(1); - expect(spyOnKeepAliveSession).toHaveBeenCalledWith({name: "PassphraseStorageFlush", periodInMinutes: undefined, scheduledTime: expect.anything()}); }); }); @@ -161,14 +142,14 @@ describe("PassphraseStorageService", () => { const passphrase = "This is a very strong passphrase"; await PassphraseStorageService.set(passphrase, 30); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(2); - expect(spyOnAlarmListeners).toHaveBeenCalledTimes(2); + expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); + expect(spyOnAlarmListeners).toHaveBeenCalledTimes(1); const storedPassphrase = await PassphraseStorageService.get(); expect(storedPassphrase).toBe(passphrase); //Clear is called with Init and Set - const expectedClearCall = 3; + const expectedClearCall = 2; expect(spyOnAlarmClear).toHaveBeenCalledTimes(expectedClearCall); await PassphraseStorageService.flushPassphrase(); @@ -200,7 +181,7 @@ describe("PassphraseStorageService", () => { }); it("should remove the passphrase from the storage and remove the timers and listeners", async() => { - expect.assertions(7); + expect.assertions(6); const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); const spyOnAlarmListeners = jest.spyOn(browser.alarms.onAlarm, "addListener"); @@ -213,8 +194,8 @@ describe("PassphraseStorageService", () => { const passphrase = "This is a very strong passphrase"; await PassphraseStorageService.set(passphrase, 30); - expect(spyOnAlarmCreate).toHaveBeenCalledTimes(2); - expect(spyOnAlarmListeners).toHaveBeenCalledTimes(2); + expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); + expect(spyOnAlarmListeners).toHaveBeenCalledTimes(1); const storedPassphrase = await PassphraseStorageService.get(); expect(storedPassphrase).toBe(passphrase); @@ -225,40 +206,7 @@ describe("PassphraseStorageService", () => { expect(flushedPassphrase).toBeNull(); expect(spyOnAlarmClear).toHaveBeenCalledWith("PassphraseStorageFlush"); - expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); - expect(spyOnAlarmRemoveListeners).toHaveBeenCalledTimes(3); - }); - }); - - describe("PassphraseStorageService::stopSessionKeepAlive", () => { - it("should clear the 'keep session alive' alarm", async() => { - expect.assertions(1); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - - browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); - - await PassphraseStorageService.flush(); - - await PassphraseStorageService.stopSessionKeepAlive(); - expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); - }); - - it("should clear the 'keep session alive' alarm and remove listeners if any", async() => { - expect.assertions(2); - const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmRemoveListeners = jest.spyOn(browser.alarms.onAlarm, "removeListener"); - - browser.alarms.onAlarm.addListener(async alarm => await PassphraseStorageService.handleFlushEvent(alarm)); - - await PassphraseStorageService.flush(); - - const passphrase = "This is a very strong passphrase"; - await PassphraseStorageService.set(passphrase, 30); - - await PassphraseStorageService.stopSessionKeepAlive(); - - expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); - expect(spyOnAlarmRemoveListeners).toHaveBeenCalledTimes(3); + expect(spyOnAlarmRemoveListeners).toHaveBeenCalledTimes(1); }); }); }); diff --git a/test/mocks/mockAlarms.js b/test/mocks/mockAlarms.js index 2459224a..de386e85 100644 --- a/test/mocks/mockAlarms.js +++ b/test/mocks/mockAlarms.js @@ -170,14 +170,7 @@ class MockAlarms { } this._registeredAlarms = {}; - } - /** - * Clean all the mocked alarm data to ensure unit tests are not running with previous tests' data - * @returns {Promise} - */ - async clearAllMocks() { - this.clearAll(); this.onAlarm = new OnAlarmEvent(); this.onAlarm.triggerAlarm = this.onAlarm.triggerAlarm.bind(this); } From bdb6b08391c0f720db7a76eee5b68371af099296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Thu, 11 Apr 2024 09:59:21 +0200 Subject: [PATCH 20/56] PB-32335: Refactor code following peer review --- .../account/updatePrivateKeyController.js | 2 +- .../controller/auth/authLoginController.js | 4 +-- .../controller/setup/signInSetupController.js | 4 +-- .../sso/ssoAuthenticationController.js | 4 +-- src/all/background_page/model/user.js | 2 +- .../service/alarm/globalAlarmService.js | 2 +- .../localStorage/localStorageService.js | 2 +- .../passphrase/getPassphraseService.js | 4 +-- .../keepSessionAliveService.js | 11 +++---- .../keepSessionAliveService.test.js | 30 +++++++++---------- 10 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/all/background_page/controller/account/updatePrivateKeyController.js b/src/all/background_page/controller/account/updatePrivateKeyController.js index 4b4f8d1d..6833a519 100644 --- a/src/all/background_page/controller/account/updatePrivateKeyController.js +++ b/src/all/background_page/controller/account/updatePrivateKeyController.js @@ -73,7 +73,7 @@ class UpdatePrivateKeyController { } await this.accountModel.updatePrivateKey(userPrivateArmoredKey); await PassphraseStorageService.flushPassphrase(); - if (KeepSessionAliveService.isSessionKeptUntilLogOut()) { + if (KeepSessionAliveService.isStarted()) { await PassphraseStorageService.set(newPassphrase); } await FileService.saveFile(RECOVERY_KIT_FILENAME, userPrivateArmoredKey, "text/plain", this.worker.tab.id); diff --git a/src/all/background_page/controller/auth/authLoginController.js b/src/all/background_page/controller/auth/authLoginController.js index 65ea93c4..ad79da5d 100644 --- a/src/all/background_page/controller/auth/authLoginController.js +++ b/src/all/background_page/controller/auth/authLoginController.js @@ -100,9 +100,9 @@ class AuthLoginController { * MFA may not be complete yet, so no need to preload things here */ if (rememberMe) { - Promise.all([ + await Promise.all([ PassphraseStorageService.set(passphrase, -1), - KeepSessionAliveService.set(), + KeepSessionAliveService.start(), ]); } await PostLoginService.postLogin(); diff --git a/src/all/background_page/controller/setup/signInSetupController.js b/src/all/background_page/controller/setup/signInSetupController.js index 6802ea2a..2f46a67a 100644 --- a/src/all/background_page/controller/setup/signInSetupController.js +++ b/src/all/background_page/controller/setup/signInSetupController.js @@ -73,9 +73,9 @@ class SignInSetupController { await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(this.account.userKeyFingerprint, this.account.userPrivateArmoredKey, this.runtimeMemory.passphrase); if (rememberMe) { - Promise.all([ + await Promise.all([ PassphraseStorageService.set(this.runtimeMemory.passphrase, -1), - KeepSessionAliveService.set(), + KeepSessionAliveService.start(), ]); } await PostLoginService.postLogin(); diff --git a/src/all/background_page/controller/sso/ssoAuthenticationController.js b/src/all/background_page/controller/sso/ssoAuthenticationController.js index 35743d79..1d74053d 100644 --- a/src/all/background_page/controller/sso/ssoAuthenticationController.js +++ b/src/all/background_page/controller/sso/ssoAuthenticationController.js @@ -90,9 +90,9 @@ class SsoAuthenticationController { const passphrase = await DecryptSsoPassphraseService.decrypt(clientPartSsoKit.secret, clientPartSsoKit.nek, serverKey, clientPartSsoKit.iv1, clientPartSsoKit.iv2); await this.popupHandler.closeHandler(); await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(this.account.userKeyFingerprint, this.account.userPrivateArmoredKey, passphrase); - Promise.all([ + await Promise.all([ PassphraseStorageService.set(passphrase, -1), - KeepSessionAliveService.set(), + KeepSessionAliveService.start(), ]); await PostLoginService.postLogin(); if (isInQuickAccessMode) { diff --git a/src/all/background_page/model/user.js b/src/all/background_page/model/user.js index 51e3e824..41bfc687 100644 --- a/src/all/background_page/model/user.js +++ b/src/all/background_page/model/user.js @@ -361,7 +361,7 @@ const UserSingleton = (function() { */ self.addEventListener("passbolt.auth.after-logout", () => Promise.all([ PassphraseStorageService.flush(), - KeepSessionAliveService.stopKeepingSessionAlive() + KeepSessionAliveService.stop() ]) ); } diff --git a/src/all/background_page/service/alarm/globalAlarmService.js b/src/all/background_page/service/alarm/globalAlarmService.js index 3be9fc43..7861b28f 100644 --- a/src/all/background_page/service/alarm/globalAlarmService.js +++ b/src/all/background_page/service/alarm/globalAlarmService.js @@ -18,7 +18,7 @@ import PassphraseStorageService from "../session_storage/passphraseStorageServic const topLevelAlarmMapping = { [StartLoopAuthSessionCheckService.ALARM_NAME]: [StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm], - [PassphraseStorageService.ALARM_NAME]: [PassphraseStorageService.handleFlushEvent, KeepSessionAliveService.stopKeepingSessionAlive], + [PassphraseStorageService.ALARM_NAME]: [PassphraseStorageService.handleFlushEvent, KeepSessionAliveService.stop], }; /** diff --git a/src/all/background_page/service/localStorage/localStorageService.js b/src/all/background_page/service/localStorage/localStorageService.js index 573792d5..60dea175 100644 --- a/src/all/background_page/service/localStorage/localStorageService.js +++ b/src/all/background_page/service/localStorage/localStorageService.js @@ -54,7 +54,7 @@ class LocalStorageService { PostponeUserSettingInvitationService.reset(); PassphraseStorageService.flush(); SsoKitTemporaryStorageService.flush(); - KeepSessionAliveService.stopKeepingSessionAlive(); + KeepSessionAliveService.stop(); LocalStorageService.flushAccountBasedStorages(); } diff --git a/src/all/background_page/service/passphrase/getPassphraseService.js b/src/all/background_page/service/passphrase/getPassphraseService.js index c5861066..00761d8f 100644 --- a/src/all/background_page/service/passphrase/getPassphraseService.js +++ b/src/all/background_page/service/passphrase/getPassphraseService.js @@ -128,9 +128,9 @@ export default class GetPassphraseService { return; } - Promise.all([ + await Promise.all([ PassphraseStorageService.set(passphrase, duration), - KeepSessionAliveService.set(), + KeepSessionAliveService.start(), ]); const userRememberMeLatestChoiceEntity = new UserRememberMeLatestChoiceEntity({ duration: parseInt(duration, 10) diff --git a/src/all/background_page/service/session_storage/keepSessionAliveService.js b/src/all/background_page/service/session_storage/keepSessionAliveService.js index 448ef7a2..29270b68 100644 --- a/src/all/background_page/service/session_storage/keepSessionAliveService.js +++ b/src/all/background_page/service/session_storage/keepSessionAliveService.js @@ -20,10 +20,10 @@ const SESSION_CHECK_INTERNAL = 15; class KeepSessionAliveService { /** - * Set the current to be kept alive if not already. + * Keeps the user session alive. * @return {Promise} */ - static async set() { + static async start() { const keepAliveAlarm = await browser.alarms.get(KeepSessionAliveService.ALARM_NAME); if (!keepAliveAlarm) { await this._keepAliveSession(); @@ -31,10 +31,10 @@ class KeepSessionAliveService { } /** - * Returns true if the session is set to be kept until the user logs out. + * Check if this service is started. * @returns {Promise} */ - static async isSessionKeptUntilLogOut() { + static async isStarted() { return Boolean(await browser.alarms.get(KeepSessionAliveService.ALARM_NAME)); } @@ -42,7 +42,7 @@ class KeepSessionAliveService { * Removes the stored passphrase from the session memory. * @return {Promise} */ - static async stopKeepingSessionAlive() { + static async stop() { await browser.alarms.clear(KeepSessionAliveService.ALARM_NAME); if (browser.alarms.onAlarm.hasListener(KeepSessionAliveService.handleKeepSessionAlive)) { browser.alarms.onAlarm.removeListener(KeepSessionAliveService.handleKeepSessionAlive); @@ -59,6 +59,7 @@ class KeepSessionAliveService { return; } + // The session is kept alive only for the duration users wanted their passphrase to be remembered. if (await PassphraseStorageService.get() === null) { return; } diff --git a/src/all/background_page/service/session_storage/keepSessionAliveService.test.js b/src/all/background_page/service/session_storage/keepSessionAliveService.test.js index da9a1857..98a6d435 100644 --- a/src/all/background_page/service/session_storage/keepSessionAliveService.test.js +++ b/src/all/background_page/service/session_storage/keepSessionAliveService.test.js @@ -24,13 +24,13 @@ beforeEach(async() => { describe("KeepSessionAliveService", () => { describe("KeepSessionAliveService::set", () => { - it("should set the alarm if none has been set already", async() => { + it("should start the alarm if none has been set already", async() => { expect.assertions(5); const spyOnAlarmGet = jest.spyOn(browser.alarms, "get"); const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); const spyOnAlarmAddListener = jest.spyOn(browser.alarms.onAlarm, "addListener"); - await KeepSessionAliveService.set(); + await KeepSessionAliveService.start(); expect(spyOnAlarmGet).toHaveBeenCalledTimes(1); expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); @@ -42,13 +42,13 @@ describe("KeepSessionAliveService", () => { expect(spyOnAlarmAddListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); }); - it("should not set the alarm if one already exist", async() => { + it("should not start the alarm if one already exist", async() => { expect.assertions(2); const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); const spyOnAlarmAddListener = jest.spyOn(browser.alarms.onAlarm, "addListener"); - await KeepSessionAliveService.set(); - await KeepSessionAliveService.set(); + await KeepSessionAliveService.start(); + await KeepSessionAliveService.start(); expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); expect(spyOnAlarmAddListener).toHaveBeenCalledTimes(1); @@ -56,30 +56,30 @@ describe("KeepSessionAliveService", () => { }); }); - describe("KeepSessionAliveService::isSessionKeptUntilLogOut", () => { - it("should return true if the alarm is set", async() => { + describe("KeepSessionAliveService::isStarted", () => { + it("should return true if the alarm is started", async() => { expect.assertions(1); - await KeepSessionAliveService.set(); - const isSessionKept = await KeepSessionAliveService.isSessionKeptUntilLogOut(); + await KeepSessionAliveService.start(); + const isSessionKept = await KeepSessionAliveService.isStarted(); expect(isSessionKept).toStrictEqual(true); }); it("should return false if the alarm has been cleared out", async() => { expect.assertions(1); - await KeepSessionAliveService.set(); + await KeepSessionAliveService.start(); await browser.alarms.clearAll(); - const isSessionKept = await KeepSessionAliveService.isSessionKeptUntilLogOut(); + const isSessionKept = await KeepSessionAliveService.isStarted(); expect(isSessionKept).toStrictEqual(false); }); it("should return false if the alarm has never been set", async() => { expect.assertions(1); - const isSessionKept = await KeepSessionAliveService.isSessionKeptUntilLogOut(); + const isSessionKept = await KeepSessionAliveService.isStarted(); expect(isSessionKept).toStrictEqual(false); }); }); - describe("KeepSessionAliveService::stopKeepingSessionAlive", () => { + describe("KeepSessionAliveService::stop", () => { it("should clear the alarms and remove listeners if any", async() => { expect.assertions(4); @@ -87,7 +87,7 @@ describe("KeepSessionAliveService", () => { const spyOnAlarmHasListener = jest.spyOn(browser.alarms.onAlarm, "hasListener").mockImplementation(() => true); const spyOnAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); - await KeepSessionAliveService.stopKeepingSessionAlive(); + await KeepSessionAliveService.stop(); expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); @@ -102,7 +102,7 @@ describe("KeepSessionAliveService", () => { const spyOnAlarmHasListener = jest.spyOn(browser.alarms.onAlarm, "hasListener").mockImplementation(() => false); const spyOnAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); - await KeepSessionAliveService.stopKeepingSessionAlive(); + await KeepSessionAliveService.stop(); expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); From f452f82223e76958cfa17f0e4376b5ca1098d8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Wed, 3 Apr 2024 12:53:18 +0200 Subject: [PATCH 21/56] PB-29990 - Move PassphraseStorageService keep alive alarm listener in top level --- .../service/alarm/globalAlarmService.js | 15 +++++------ .../keepSessionAliveService.js | 26 +++++-------------- .../keepSessionAliveService.test.js | 22 +++------------- 3 files changed, 17 insertions(+), 46 deletions(-) diff --git a/src/all/background_page/service/alarm/globalAlarmService.js b/src/all/background_page/service/alarm/globalAlarmService.js index 7861b28f..14ef1554 100644 --- a/src/all/background_page/service/alarm/globalAlarmService.js +++ b/src/all/background_page/service/alarm/globalAlarmService.js @@ -17,8 +17,9 @@ import KeepSessionAliveService from "../session_storage/keepSessionAliveService" import PassphraseStorageService from "../session_storage/passphraseStorageService"; const topLevelAlarmMapping = { - [StartLoopAuthSessionCheckService.ALARM_NAME]: [StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm], - [PassphraseStorageService.ALARM_NAME]: [PassphraseStorageService.handleFlushEvent, KeepSessionAliveService.stop], + [StartLoopAuthSessionCheckService.ALARM_NAME]: StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm, + [PassphraseStorageService.ALARM_NAME]: PassphraseStorageService.handleFlushEvent, + [KeepSessionAliveService.ALARM_NAME]: KeepSessionAliveService.stop, }; /** @@ -29,13 +30,9 @@ const topLevelAlarmMapping = { */ export default class GlobalAlarmService { static exec(alarm) { - const alarmCallbacks = topLevelAlarmMapping[alarm.name]; - if (!alarmCallbacks) { - return; - } - - for (let i = 0; i < alarmCallbacks.length; i++) { - alarmCallbacks[i](alarm); + const alarmCallback = topLevelAlarmMapping[alarm.name]; + if (alarmCallback) { + alarmCallback(alarm); } } } diff --git a/src/all/background_page/service/session_storage/keepSessionAliveService.js b/src/all/background_page/service/session_storage/keepSessionAliveService.js index 29270b68..0df27aaf 100644 --- a/src/all/background_page/service/session_storage/keepSessionAliveService.js +++ b/src/all/background_page/service/session_storage/keepSessionAliveService.js @@ -25,9 +25,14 @@ class KeepSessionAliveService { */ static async start() { const keepAliveAlarm = await browser.alarms.get(KeepSessionAliveService.ALARM_NAME); - if (!keepAliveAlarm) { - await this._keepAliveSession(); + if (keepAliveAlarm) { + return; } + + await browser.alarms.create(KeepSessionAliveService.ALARM_NAME, { + delayInMinutes: SESSION_CHECK_INTERNAL, + periodInMinutes: SESSION_CHECK_INTERNAL + }); } /** @@ -44,9 +49,6 @@ class KeepSessionAliveService { */ static async stop() { await browser.alarms.clear(KeepSessionAliveService.ALARM_NAME); - if (browser.alarms.onAlarm.hasListener(KeepSessionAliveService.handleKeepSessionAlive)) { - browser.alarms.onAlarm.removeListener(KeepSessionAliveService.handleKeepSessionAlive); - } } /** @@ -69,20 +71,6 @@ class KeepSessionAliveService { userService.keepSessionAlive(); } - /** - * Creates an alarm to ensure session is kept alive. - * @returns {Promise} - * @private - */ - static async _keepAliveSession() { - await browser.alarms.create(KeepSessionAliveService.ALARM_NAME, { - delayInMinutes: SESSION_CHECK_INTERNAL, - periodInMinutes: SESSION_CHECK_INTERNAL - }); - - await browser.alarms.onAlarm.addListener(KeepSessionAliveService.handleKeepSessionAlive); - } - /** * Returns the SESSION_KEEP_ALIVE_ALARM name * @returns {string} diff --git a/src/all/background_page/service/session_storage/keepSessionAliveService.test.js b/src/all/background_page/service/session_storage/keepSessionAliveService.test.js index 98a6d435..8a50d2a7 100644 --- a/src/all/background_page/service/session_storage/keepSessionAliveService.test.js +++ b/src/all/background_page/service/session_storage/keepSessionAliveService.test.js @@ -25,10 +25,9 @@ beforeEach(async() => { describe("KeepSessionAliveService", () => { describe("KeepSessionAliveService::set", () => { it("should start the alarm if none has been set already", async() => { - expect.assertions(5); + expect.assertions(3); const spyOnAlarmGet = jest.spyOn(browser.alarms, "get"); const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); - const spyOnAlarmAddListener = jest.spyOn(browser.alarms.onAlarm, "addListener"); await KeepSessionAliveService.start(); @@ -38,21 +37,16 @@ describe("KeepSessionAliveService", () => { delayInMinutes: 15, periodInMinutes: 15 }); - expect(spyOnAlarmAddListener).toHaveBeenCalledTimes(1); - expect(spyOnAlarmAddListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); }); it("should not start the alarm if one already exist", async() => { - expect.assertions(2); + expect.assertions(1); const spyOnAlarmCreate = jest.spyOn(browser.alarms, "create"); - const spyOnAlarmAddListener = jest.spyOn(browser.alarms.onAlarm, "addListener"); await KeepSessionAliveService.start(); await KeepSessionAliveService.start(); expect(spyOnAlarmCreate).toHaveBeenCalledTimes(1); - expect(spyOnAlarmAddListener).toHaveBeenCalledTimes(1); - await browser.alarms.clearAll(); }); }); @@ -81,33 +75,25 @@ describe("KeepSessionAliveService", () => { describe("KeepSessionAliveService::stop", () => { it("should clear the alarms and remove listeners if any", async() => { - expect.assertions(4); + expect.assertions(2); const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmHasListener = jest.spyOn(browser.alarms.onAlarm, "hasListener").mockImplementation(() => true); - const spyOnAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); await KeepSessionAliveService.stop(); expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); - expect(spyOnAlarmHasListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); - expect(spyOnAlarmRemoveListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); }); it("should clear the alarms and not remove the listeners if there is none", async() => { - expect.assertions(4); + expect.assertions(2); const spyOnAlarmClear = jest.spyOn(browser.alarms, "clear"); - const spyOnAlarmHasListener = jest.spyOn(browser.alarms.onAlarm, "hasListener").mockImplementation(() => false); - const spyOnAlarmRemoveListener = jest.spyOn(browser.alarms.onAlarm, "removeListener"); await KeepSessionAliveService.stop(); expect(spyOnAlarmClear).toHaveBeenCalledTimes(1); expect(spyOnAlarmClear).toHaveBeenCalledWith("SessionKeepAlive"); - expect(spyOnAlarmHasListener).toHaveBeenCalledWith(KeepSessionAliveService.handleKeepSessionAlive); - expect(spyOnAlarmRemoveListener).not.toHaveBeenCalled(); }); }); From ee2d45007e083f34e2af5dd2303c7cda3dc7bb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Thu, 11 Apr 2024 10:31:31 +0200 Subject: [PATCH 22/56] PB-29990: refactor code according to peer review --- .../service/session_storage/keepSessionAliveService.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/all/background_page/service/session_storage/keepSessionAliveService.js b/src/all/background_page/service/session_storage/keepSessionAliveService.js index 0df27aaf..221fe275 100644 --- a/src/all/background_page/service/session_storage/keepSessionAliveService.js +++ b/src/all/background_page/service/session_storage/keepSessionAliveService.js @@ -24,8 +24,7 @@ class KeepSessionAliveService { * @return {Promise} */ static async start() { - const keepAliveAlarm = await browser.alarms.get(KeepSessionAliveService.ALARM_NAME); - if (keepAliveAlarm) { + if (await KeepSessionAliveService.isStarted()) { return; } From b5858550252db15c4e3a45bd0e3f27fcd72186fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Tue, 2 Apr 2024 17:25:53 +0200 Subject: [PATCH 23/56] PB-32420 - Fix double calls to PownedPassword API service --- src/all/background_page/event/secretEvents.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/all/background_page/event/secretEvents.js b/src/all/background_page/event/secretEvents.js index ced8ee2f..54bfa4d7 100644 --- a/src/all/background_page/event/secretEvents.js +++ b/src/all/background_page/event/secretEvents.js @@ -6,7 +6,6 @@ * @licence GNU Affero General Public License http://www.gnu.org/licenses/agpl-3.0.en.html */ import SecretDecryptController from "../controller/secret/secretDecryptController"; -import PownedPasswordController from '../controller/secret/pownedPasswordController'; /** * Listens the secret events @@ -25,18 +24,6 @@ const listen = function(worker, apiClientOptions, account) { const controller = new SecretDecryptController(worker, requestId, apiClientOptions, account); await controller._exec(resourceId); }); - - /* - * Check if password is powned - * - * @listens passbolt.secrets.powned-password - * @param requestId {uuid} The request identifier - * @param password {string} the password to check - */ - worker.port.on('passbolt.secrets.powned-password', async(requestId, password) => { - const controller = new PownedPasswordController(worker, requestId); - await controller._exec(password); - }); }; export const SecretEvents = {listen}; From 5d24e910c58d54648b6e09f91ade1531b9a01bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Thu, 4 Apr 2024 16:19:33 +0200 Subject: [PATCH 24/56] PB-22623 - Investigate how to start service worker in an insecure environment --- .../auth/authCheckStatusController.js | 10 +++--- .../auth/authCheckStatusController.test.js | 31 +++++++++++++++---- .../auth/authIsAuthenticatedController.js | 11 ++++--- .../informCallToActionController.js | 18 ----------- src/all/background_page/event/authEvents.js | 4 +-- .../event/informCallToActionEvents.js | 8 ++--- .../service/auth/checkAuthStatusService.js | 2 -- 7 files changed, 44 insertions(+), 40 deletions(-) diff --git a/src/all/background_page/controller/auth/authCheckStatusController.js b/src/all/background_page/controller/auth/authCheckStatusController.js index b008be88..26ac030f 100644 --- a/src/all/background_page/controller/auth/authCheckStatusController.js +++ b/src/all/background_page/controller/auth/authCheckStatusController.js @@ -22,11 +22,12 @@ class AuthCheckStatusController { /** * Controller executor. + * @param {boolean} [flushCache = true] should the cache be flushed before * @returns {Promise} */ - async _exec() { + async _exec(flushCache = true) { try { - const authStatus = await this.exec(); + const authStatus = await this.exec(flushCache); this.worker.port.emit(this.requestId, 'SUCCESS', authStatus); } catch (error) { console.error(error); @@ -36,10 +37,11 @@ class AuthCheckStatusController { /** * Controller executor. + * @param {boolean} flushCache should the cache be flushed before * @returns {Promise<{isAuthenticated: {bool}, isMfaRequired: {bool}}>} */ - async exec() { - return await this.checkAuthStatusService.checkAuthStatus(true); + async exec(flushCache) { + return await this.checkAuthStatusService.checkAuthStatus(flushCache); } } diff --git a/src/all/background_page/controller/auth/authCheckStatusController.test.js b/src/all/background_page/controller/auth/authCheckStatusController.test.js index e64f830d..c46719d0 100644 --- a/src/all/background_page/controller/auth/authCheckStatusController.test.js +++ b/src/all/background_page/controller/auth/authCheckStatusController.test.js @@ -29,10 +29,10 @@ describe("AuthCheckStatusController", () => { jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => false); const controller = new AuthCheckStatusController(); - const authStatus = await controller.exec(); + const authStatus = await controller.exec(true); expect(AuthStatusLocalStorage.get).not.toHaveBeenCalled(); - expect(AuthStatusLocalStorage.flush).toHaveBeenCalled(); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); expect(authStatus).toStrictEqual({ isAuthenticated: false, isMfaRequired: false, @@ -46,10 +46,10 @@ describe("AuthCheckStatusController", () => { jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); const controller = new AuthCheckStatusController(); - const authStatus = await controller.exec(); + const authStatus = await controller.exec(true); expect(AuthStatusLocalStorage.get).not.toHaveBeenCalled(); - expect(AuthStatusLocalStorage.flush).toHaveBeenCalled(); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); expect(authStatus).toStrictEqual({ isAuthenticated: true, isMfaRequired: false, @@ -63,13 +63,32 @@ describe("AuthCheckStatusController", () => { jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => { throw new MfaAuthenticationRequiredError(); }); const controller = new AuthCheckStatusController(); - const authStatus = await controller.exec(); + const authStatus = await controller.exec(true); expect(AuthStatusLocalStorage.get).not.toHaveBeenCalled(); - expect(AuthStatusLocalStorage.flush).toHaveBeenCalled(); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); expect(authStatus).toStrictEqual({ isAuthenticated: true, isMfaRequired: true, }); }); + + it("should return the auth status from the local storage", async() => { + expect.assertions(4); + const expectedAuthStatus = { + isAuthenticated: false, + isMfaRequired: false, + }; + jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => expectedAuthStatus); + jest.spyOn(AuthStatusLocalStorage, "flush"); + jest.spyOn(AuthService, "isAuthenticated"); + + const controller = new AuthCheckStatusController(); + const authStatus = await controller.exec(false); + + expect(AuthStatusLocalStorage.get).toHaveBeenCalledTimes(1); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); + expect(AuthService.isAuthenticated).not.toHaveBeenCalled(); + expect(authStatus).toStrictEqual(expectedAuthStatus); + }); }); diff --git a/src/all/background_page/controller/auth/authIsAuthenticatedController.js b/src/all/background_page/controller/auth/authIsAuthenticatedController.js index 1f838ec3..ab9eca87 100644 --- a/src/all/background_page/controller/auth/authIsAuthenticatedController.js +++ b/src/all/background_page/controller/auth/authIsAuthenticatedController.js @@ -22,10 +22,12 @@ class AuthIsAuthenticatedController { /** * Execute the controller. + * @param {boolean} flushCache should the auth status cache be flushed before or not. + * @returns {Promise} */ - async _exec() { + async _exec(flushCache) { try { - const isAuthenticated = await this.exec(); + const isAuthenticated = await this.exec(flushCache); this.worker.port.emitQuiet(this.requestId, 'SUCCESS', isAuthenticated); } catch (error) { this.worker.port.emitQuiet(this.requestId, 'ERROR', error); @@ -34,10 +36,11 @@ class AuthIsAuthenticatedController { /** * Returns true if the current user is authenticated (regardless of the MFA status) + * @param {boolean} flushCache should the auth status cache be flushed before or not. * @returns {Promise} */ - async exec() { - const authStatus = await this.checkAuthStatusService.checkAuthStatus(true); + async exec(flushCache) { + const authStatus = await this.checkAuthStatusService.checkAuthStatus(flushCache); return authStatus.isAuthenticated; } } diff --git a/src/all/background_page/controller/informCallToActionController/informCallToActionController.js b/src/all/background_page/controller/informCallToActionController/informCallToActionController.js index aa6c7cc9..81cd98ae 100644 --- a/src/all/background_page/controller/informCallToActionController/informCallToActionController.js +++ b/src/all/background_page/controller/informCallToActionController/informCallToActionController.js @@ -33,24 +33,6 @@ class InformCallToActionController { this.checkAuthStatusService = new CheckAuthStatusService(); } - /** - * Whenever one intends to check the status of the call-to-action (authenticated / unauthenticated mode) - * @param requestId - */ - async checkStatus(requestId) { - try { - const status = await this.checkAuthStatusService.checkAuthStatus(false); - this.worker.port.emit(requestId, "SUCCESS", status); - } catch (error) { - /* - * When we are in a logged out mode and there's some cleaning of the local storage - * the check status request the api. In case of unauthenticated user, it throws a 401 - * that we catch right here - */ - this.worker.port.emit(requestId, "SUCCESS", {isAuthenticated: false}); - } - } - /** * Whenever one intends to know the count of suggested resources * @param requestId The identifier of the request diff --git a/src/all/background_page/event/authEvents.js b/src/all/background_page/event/authEvents.js index 6b748b6d..f8724cf0 100644 --- a/src/all/background_page/event/authEvents.js +++ b/src/all/background_page/event/authEvents.js @@ -36,9 +36,9 @@ const listen = function(worker, apiClientOptions, account) { * @listens passbolt.auth.is-authenticated * @param requestId {uuid} The request identifier */ - worker.port.on('passbolt.auth.is-authenticated', async requestId => { + worker.port.on('passbolt.auth.is-authenticated', async(requestId, flushCache = true) => { const controller = new AuthIsAuthenticatedController(worker, requestId); - controller._exec(); + controller._exec(flushCache); }); /* diff --git a/src/all/background_page/event/informCallToActionEvents.js b/src/all/background_page/event/informCallToActionEvents.js index 4f1d3b18..317da9a7 100644 --- a/src/all/background_page/event/informCallToActionEvents.js +++ b/src/all/background_page/event/informCallToActionEvents.js @@ -12,7 +12,7 @@ */ import InformCallToActionController from "../controller/informCallToActionController/informCallToActionController"; import AuthenticationEventController from "../controller/auth/authenticationEventController"; - +import AuthCheckStatusController from "../controller/auth/authCheckStatusController"; /** * Listens the inform call to action events @@ -30,9 +30,9 @@ const listen = function(worker, apiClientOptions, account) { * @param requestId {uuid} The request identifier * @returns {*{isAuthenticated,isMfaRequired} */ - worker.port.on('passbolt.in-form-cta.check-status', async requestId => { - const informCallToActionController = new InformCallToActionController(worker, apiClientOptions, account); - await informCallToActionController.checkStatus(requestId); + worker.port.on('passbolt.in-form-cta.check-status', async(requestId, flushCache = false) => { + const authIsAuthenticatedController = new AuthCheckStatusController(worker, requestId); + await authIsAuthenticatedController._exec(flushCache); }); /* diff --git a/src/all/background_page/service/auth/checkAuthStatusService.js b/src/all/background_page/service/auth/checkAuthStatusService.js index 490f3461..d75daef9 100644 --- a/src/all/background_page/service/auth/checkAuthStatusService.js +++ b/src/all/background_page/service/auth/checkAuthStatusService.js @@ -30,8 +30,6 @@ class CheckAuthStatusService { if (storedStatus) { return storedStatus; } - } else { - await AuthStatusLocalStorage.flush(); } let isAuthenticated, isMfaRequired; From 017869b331abecb08981e945f18f8e50e7b19bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Thu, 11 Apr 2024 13:17:04 +0200 Subject: [PATCH 25/56] PB-30348: fix unit tests --- .../service/auth/checkAuthStatusService.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/all/background_page/service/auth/checkAuthStatusService.test.js b/src/all/background_page/service/auth/checkAuthStatusService.test.js index 56aad689..f28a5004 100644 --- a/src/all/background_page/service/auth/checkAuthStatusService.test.js +++ b/src/all/background_page/service/auth/checkAuthStatusService.test.js @@ -73,7 +73,7 @@ describe("CheckAuthStatusService", () => { }); }); - it("should flush the cache if asked for and call the API to find the authentication status", async() => { + it("should ask for an API call to find the authentication status", async() => { expect.assertions(3); jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => undefined); jest.spyOn(AuthStatusLocalStorage, "flush"); @@ -83,7 +83,7 @@ describe("CheckAuthStatusService", () => { const authStatus = await service.checkAuthStatus(true); expect(AuthStatusLocalStorage.get).not.toHaveBeenCalled(); - expect(AuthStatusLocalStorage.flush).toHaveBeenCalledTimes(1); + expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); expect(authStatus).toStrictEqual({ isAuthenticated: true, isMfaRequired: false, From 237d533e45c7e0edea18207d0b2d3386a69b23de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Thu, 11 Apr 2024 13:27:04 +0200 Subject: [PATCH 26/56] PB-30348: fix authCheckStatusController unit tests --- .../controller/auth/authCheckStatusController.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/all/background_page/controller/auth/authCheckStatusController.test.js b/src/all/background_page/controller/auth/authCheckStatusController.test.js index c46719d0..ff23a7f5 100644 --- a/src/all/background_page/controller/auth/authCheckStatusController.test.js +++ b/src/all/background_page/controller/auth/authCheckStatusController.test.js @@ -81,14 +81,14 @@ describe("AuthCheckStatusController", () => { }; jest.spyOn(AuthStatusLocalStorage, "get").mockImplementation(() => expectedAuthStatus); jest.spyOn(AuthStatusLocalStorage, "flush"); - jest.spyOn(AuthService, "isAuthenticated"); + jest.spyOn(AuthenticationStatusService, "isAuthenticated"); const controller = new AuthCheckStatusController(); const authStatus = await controller.exec(false); expect(AuthStatusLocalStorage.get).toHaveBeenCalledTimes(1); expect(AuthStatusLocalStorage.flush).not.toHaveBeenCalled(); - expect(AuthService.isAuthenticated).not.toHaveBeenCalled(); + expect(AuthenticationStatusService.isAuthenticated).not.toHaveBeenCalled(); expect(authStatus).toStrictEqual(expectedAuthStatus); }); }); From 7a40e0c74d241ed92c35607319b3f9b8ec62bce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Wed, 3 Apr 2024 16:33:46 +0200 Subject: [PATCH 27/56] PB-32291 - Cleanup legacy code and unused passbolt.auth.is-authenticated related elements --- .../auth/authIsAuthenticatedController.js | 48 ----------------- .../authIsAuthenticatedController.test.js | 54 ------------------- src/all/background_page/event/authEvents.js | 12 ----- 3 files changed, 114 deletions(-) delete mode 100644 src/all/background_page/controller/auth/authIsAuthenticatedController.js delete mode 100644 src/all/background_page/controller/auth/authIsAuthenticatedController.test.js diff --git a/src/all/background_page/controller/auth/authIsAuthenticatedController.js b/src/all/background_page/controller/auth/authIsAuthenticatedController.js deleted file mode 100644 index ab9eca87..00000000 --- a/src/all/background_page/controller/auth/authIsAuthenticatedController.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 2.11.0 - */ -import CheckAuthStatusService from "../../service/auth/checkAuthStatusService"; - -class AuthIsAuthenticatedController { - constructor(worker, requestId) { - this.worker = worker; - this.requestId = requestId; - this.checkAuthStatusService = new CheckAuthStatusService(); - } - - /** - * Execute the controller. - * @param {boolean} flushCache should the auth status cache be flushed before or not. - * @returns {Promise} - */ - async _exec(flushCache) { - try { - const isAuthenticated = await this.exec(flushCache); - this.worker.port.emitQuiet(this.requestId, 'SUCCESS', isAuthenticated); - } catch (error) { - this.worker.port.emitQuiet(this.requestId, 'ERROR', error); - } - } - - /** - * Returns true if the current user is authenticated (regardless of the MFA status) - * @param {boolean} flushCache should the auth status cache be flushed before or not. - * @returns {Promise} - */ - async exec(flushCache) { - const authStatus = await this.checkAuthStatusService.checkAuthStatus(flushCache); - return authStatus.isAuthenticated; - } -} - -export default AuthIsAuthenticatedController; diff --git a/src/all/background_page/controller/auth/authIsAuthenticatedController.test.js b/src/all/background_page/controller/auth/authIsAuthenticatedController.test.js deleted file mode 100644 index 2d78b7c6..00000000 --- a/src/all/background_page/controller/auth/authIsAuthenticatedController.test.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 4.7.0 - */ - -import {userLoggedInAuthStatus, userLoggedOutAuthStatus, userRequireMfaAuthStatus} from "./authCheckStatus.test.data"; -import AuthIsAuthenticatedController from "./authIsAuthenticatedController"; - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe("AuthIsAuthenticatedController", () => { - it("should return true if the user is authenticated", async() => { - expect.assertions(1); - - const controller = new AuthIsAuthenticatedController(); - jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => userLoggedInAuthStatus()); - - const isAuthenticated = await controller.exec(); - expect(isAuthenticated).toStrictEqual(true); - }); - - it("should return true if the user requires MFA authenticate", async() => { - expect.assertions(1); - - const controller = new AuthIsAuthenticatedController(); - jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => userRequireMfaAuthStatus()); - - const isAuthenticated = await controller.exec(); - expect(isAuthenticated).toStrictEqual(true); - }); - - it("should return the isAuthenticated part of the AuthStatus", async() => { - expect.assertions(1); - - const controller = new AuthIsAuthenticatedController(); - - const authStatus = userLoggedOutAuthStatus(); - jest.spyOn(controller.checkAuthStatusService, "checkAuthStatus").mockImplementation(async() => authStatus); - - const isAuthenticated = await controller.exec(); - expect(isAuthenticated).toStrictEqual(authStatus.isAuthenticated); - }); -}); diff --git a/src/all/background_page/event/authEvents.js b/src/all/background_page/event/authEvents.js index f8724cf0..eb1d42eb 100644 --- a/src/all/background_page/event/authEvents.js +++ b/src/all/background_page/event/authEvents.js @@ -8,7 +8,6 @@ */ import AuthVerifyServerKeyController from "../controller/auth/authVerifyServerKeyController"; import AuthCheckStatusController from "../controller/auth/authCheckStatusController"; -import AuthIsAuthenticatedController from "../controller/auth/authIsAuthenticatedController"; import AuthIsMfaRequiredController from "../controller/auth/authIsMfaRequiredController"; import CheckPassphraseController from "../controller/crypto/checkPassphraseController"; import RequestHelpCredentialsLostController from "../controller/auth/requestHelpCredentialsLostController"; @@ -30,17 +29,6 @@ import ReplaceServerKeyController from "../controller/auth/replaceServerKeyContr * @param {AccountEntity} account The account */ const listen = function(worker, apiClientOptions, account) { - /* - * Check if the user is authenticated. - * - * @listens passbolt.auth.is-authenticated - * @param requestId {uuid} The request identifier - */ - worker.port.on('passbolt.auth.is-authenticated', async(requestId, flushCache = true) => { - const controller = new AuthIsAuthenticatedController(worker, requestId); - controller._exec(flushCache); - }); - /* * Check if the user requires to complete the mfa. * From 26bd73975c3549070b70cd45e3aff84976f66b5d Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Mon, 8 Apr 2024 11:55:17 +0200 Subject: [PATCH 28/56] PB-32915 Update code to remove the destruction of the public web sign-in on port disconnected --- .../extension/onExtensionUpdateAvailableController.js | 3 ++- .../onExtensionUpdateAvailableController.test.js | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js index 7db182d8..85f2e723 100644 --- a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js +++ b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js @@ -18,6 +18,7 @@ import AuthenticationStatusService from "../../service/authenticationStatusServi import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; import WorkerService from "../../service/worker/workerService"; +import PublicWebsiteSignInPagemod from "../../pagemod/publicWebsiteSignInPagemod"; class OnExtensionUpdateAvailableController { /** @@ -38,7 +39,7 @@ class OnExtensionUpdateAvailableController { * @return {Promise} */ static async cleanAndReload() { - await WorkerService.destroyWorkersByName([WebIntegrationPagemod.appName]); + await WorkerService.destroyWorkersByName([WebIntegrationPagemod.appName, PublicWebsiteSignInPagemod.appName]); browser.runtime.reload(); } } diff --git a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js index fdb10942..bd38a73d 100644 --- a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js +++ b/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js @@ -24,6 +24,7 @@ import Port from "../../sdk/port"; import {mockPort} from "../../sdk/port/portManager.test.data"; import PortManager from "../../sdk/port/portManager"; import BrowserTabService from "../../service/ui/browserTab.service"; +import PublicWebsiteSignInPagemod from "../../pagemod/publicWebsiteSignInPagemod"; // Reset the modules before each test. beforeEach(() => { @@ -85,7 +86,7 @@ describe("OnExtensionInstalledController", () => { }); it("Should clean and exec update", async() => { - expect.assertions(8); + expect.assertions(10); // data mocked const worker = readWorker(); await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); @@ -93,11 +94,16 @@ describe("OnExtensionInstalledController", () => { await WorkersSessionStorage.addWorker(new WorkerEntity(worker2)); const worker3 = readWorker({name: WebIntegrationPagemod.appName}); await WorkersSessionStorage.addWorker(new WorkerEntity(worker3)); + const worker4 = readWorker({name: PublicWebsiteSignInPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker4)); const webIntegrationPort = mockPort({name: worker2.id, tabId: worker2.tabId, frameId: worker2.frameId}); const webIntegrationPortWrapper = new Port(webIntegrationPort); const webIntegrationPort2 = mockPort({name: worker3.id, tabId: worker3.tabId, frameId: worker3.frameId}); const webIntegrationPortWrapper2 = new Port(webIntegrationPort2); + const publicWebsiteSignInPort = mockPort({name: worker4.id, tabId: worker4.tabId, frameId: worker4.frameId}); + const publicWebsiteSignInPortWrapper = new Port(publicWebsiteSignInPort); PortManager.registerPort(webIntegrationPortWrapper2); + PortManager.registerPort(publicWebsiteSignInPortWrapper); // mock function MockExtension.withConfiguredAccount(); jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); @@ -105,6 +111,7 @@ describe("OnExtensionInstalledController", () => { jest.spyOn(browser.runtime, "reload"); jest.spyOn(webIntegrationPortWrapper, "emit"); jest.spyOn(webIntegrationPortWrapper2, "emit"); + jest.spyOn(publicWebsiteSignInPortWrapper, "emit"); // process await OnExtensionUpdateAvailableController.exec(); // expectation @@ -121,6 +128,8 @@ describe("OnExtensionInstalledController", () => { expect(webIntegrationPortWrapper.emit).toHaveBeenCalledTimes(1); expect(webIntegrationPortWrapper2.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); expect(webIntegrationPortWrapper2.emit).toHaveBeenCalledTimes(1); + expect(publicWebsiteSignInPortWrapper.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); + expect(publicWebsiteSignInPortWrapper.emit).toHaveBeenCalledTimes(1); expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker2, "passbolt.port.connect", worker2.id); expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(1); expect(browser.runtime.reload).toHaveBeenCalledTimes(1); From cbac015f1fc76b0dfd49305c32d5d7d7b511b10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Sat, 6 Apr 2024 11:04:17 +0200 Subject: [PATCH 29/56] PB-32597 - Ensure from ToolbarController are set on index.js --- .../background_page/controller/toolbarController.js | 8 ++++---- src/all/background_page/index.js | 13 ++----------- src/all/background_page/model/auth/authModel.js | 4 ++-- .../service/auth/postLoginService.js | 4 ++++ .../service/auth/postLogoutService.js | 6 ++++++ .../systemRequirementService.js | 7 ++++--- 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/all/background_page/controller/toolbarController.js b/src/all/background_page/controller/toolbarController.js index 375c9105..a41c8d07 100644 --- a/src/all/background_page/controller/toolbarController.js +++ b/src/all/background_page/controller/toolbarController.js @@ -19,7 +19,7 @@ import GetLegacyAccountService from "../service/account/getLegacyAccountService" import BuildApiClientOptionsService from "../service/account/buildApiClientOptionsService"; class ToolbarController { - constructor() { + initialise() { // Initially, set the browser extension icon as inactive BrowserExtensionIconService.deactivate(); this.bindCallbacks(); @@ -45,8 +45,6 @@ class ToolbarController { */ addEventListeners() { browser.commands.onCommand.addListener(this.handleShortcutPressed); - self.addEventListener("passbolt.auth.after-logout", this.handleUserLoggedOut); - self.addEventListener("passbolt.auth.after-login", this.handleUserLoggedIn); } /** @@ -185,5 +183,7 @@ class ToolbarController { } } +const toolbarController = new ToolbarController(); + // Exports the Toolbar controller object. -export default ToolbarController; +export default toolbarController; diff --git a/src/all/background_page/index.js b/src/all/background_page/index.js index 5ddfec05..19f2b7be 100644 --- a/src/all/background_page/index.js +++ b/src/all/background_page/index.js @@ -11,11 +11,10 @@ import OnExtensionInstalledController from "./controller/extension/onExtensionIn import TabService from "./service/tab/tabService"; import User from "./model/user"; import Log from "./model/log"; -import StartLoopAuthSessionCheckService from "./service/auth/startLoopAuthSessionCheckService"; import OnExtensionUpdateAvailableController from "./controller/extension/onExtensionUpdateAvailableController"; -import PostLogoutService from "./service/auth/postLogoutService"; import CheckAuthStatusService from "./service/auth/checkAuthStatusService"; import GlobalAlarmService from "./service/alarm/globalAlarmService"; +import PostLoginService from "./service/auth/postLoginService"; const main = async() => { /** @@ -42,9 +41,7 @@ const checkAndProcessIfUserAuthenticated = async() => { try { const authStatus = await checkAuthStatusService.checkAuthStatus(true); if (authStatus.isAuthenticated) { - await StartLoopAuthSessionCheckService.exec(); - const event = new Event('passbolt.auth.after-login'); - self.dispatchEvent(event); + await PostLoginService.postLogin(); } } catch (error) { /* @@ -57,12 +54,6 @@ const checkAndProcessIfUserAuthenticated = async() => { main(); - -/** - * Add listener on passbolt logout - */ -self.addEventListener("passbolt.auth.after-logout", PostLogoutService.exec); - /** * On installed the extension, add first install in the url tab of setup or recover */ diff --git a/src/all/background_page/model/auth/authModel.js b/src/all/background_page/model/auth/authModel.js index b5dd327c..3709ef7c 100644 --- a/src/all/background_page/model/auth/authModel.js +++ b/src/all/background_page/model/auth/authModel.js @@ -12,6 +12,7 @@ */ import AuthLogoutService from 'passbolt-styleguide/src/shared/services/api/auth/AuthLogoutService'; import AuthStatusLocalStorage from "../../service/local_storage/authStatusLocalStorage"; +import PostLogoutService from '../../service/auth/postLogoutService'; class AuthModel { /** @@ -41,8 +42,7 @@ class AuthModel { const isAuthenticated = false; const isMfaRequired = false; await AuthStatusLocalStorage.set(isAuthenticated, isMfaRequired); - const event = new Event('passbolt.auth.after-logout'); - self.dispatchEvent(event); + await PostLogoutService.exec(); } } diff --git a/src/all/background_page/service/auth/postLoginService.js b/src/all/background_page/service/auth/postLoginService.js index 28a6cd8f..3dde3780 100644 --- a/src/all/background_page/service/auth/postLoginService.js +++ b/src/all/background_page/service/auth/postLoginService.js @@ -12,6 +12,7 @@ * @since 4.7.0 */ +import toolbarController from "../../controller/toolbarController"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; class PostLoginService { /** @@ -20,6 +21,9 @@ class PostLoginService { */ static async postLogin() { await StartLoopAuthSessionCheckService.exec(); + toolbarController.handleUserLoggedIn(); + + //@todo remove the dispatch event once every 'after-login' listeners are handled here const event = new Event('passbolt.auth.after-login'); self.dispatchEvent(event); } diff --git a/src/all/background_page/service/auth/postLogoutService.js b/src/all/background_page/service/auth/postLogoutService.js index 6b6faf98..904be590 100644 --- a/src/all/background_page/service/auth/postLogoutService.js +++ b/src/all/background_page/service/auth/postLogoutService.js @@ -17,6 +17,7 @@ import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; import PortManager from "../../sdk/port/portManager"; import LocalStorageService from "../localStorage/localStorageService"; import BrowserTabService from "../ui/browserTab.service"; +import toolbarController from "../../controller/toolbarController"; class PostLogoutService { /** @@ -26,6 +27,11 @@ class PostLogoutService { const workers = await WorkersSessionStorage.getWorkersByNames([AppPagemod.appName, WebIntegrationPagemod.appName]); PostLogoutService.sendLogoutEventForWorkerDisconnected(workers); LocalStorageService.flush(); + toolbarController.handleUserLoggedOut(); + + //@todo remove the dispatch event once every 'after-logout' listeners are handled here + const event = new Event('passbolt.auth.after-logout'); + self.dispatchEvent(event); } /** diff --git a/src/all/background_page/service/systemRequirementService/systemRequirementService.js b/src/all/background_page/service/systemRequirementService/systemRequirementService.js index 55ca2ca2..2bddaac1 100644 --- a/src/all/background_page/service/systemRequirementService/systemRequirementService.js +++ b/src/all/background_page/service/systemRequirementService/systemRequirementService.js @@ -1,8 +1,8 @@ import storage from "../../sdk/storage"; import {Config} from "../../model/config"; import Log from "../../model/log"; -import ToolbarController from "../../controller/toolbarController"; import * as openpgp from "openpgp"; +import toolbarController from "../../controller/toolbarController"; class SystemRequirementService { /** @@ -22,8 +22,9 @@ class SystemRequirementService { * due to an openpgpjs bug: https://github.com/openpgpjs/openpgpjs/pull/1148 */ openpgp.config.allowInsecureDecryptionWithSigningKeys = true; - // Toolbar controller - new ToolbarController(); + + // initialise the toolbar controller + toolbarController.initialise(); } } From 6f459b5dc098ff2ac45799a90f1a529c0451680d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Sat, 6 Apr 2024 13:05:49 +0200 Subject: [PATCH 30/56] PB-32597: fix unit tests --- .../reviewRequestController.test.js | 2 +- .../sso/ssoAuthenticationController.test.js | 4 ++-- .../controller/toolbarController.js | 2 +- .../controller/toolbarController.test.js | 16 ++++++++-------- .../entity/account/accountEntity.test.data.js | 7 +++---- .../service/auth/postLoginService.test.js | 3 +++ .../local_storage/AccountLocalStorage.test.js | 4 +++- .../userMeSessionStorageService.test.js | 13 ++++++++++--- test/fixtures/pgpKeys/keys.js | 5 +++++ test/mocks/mockExtension.js | 5 ++++- 10 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/all/background_page/controller/accountRecovery/reviewRequestController.test.js b/src/all/background_page/controller/accountRecovery/reviewRequestController.test.js index 9d4bdba9..a0624cf3 100644 --- a/src/all/background_page/controller/accountRecovery/reviewRequestController.test.js +++ b/src/all/background_page/controller/accountRecovery/reviewRequestController.test.js @@ -243,7 +243,7 @@ describe("ReviewRequestController", () => { it("Should assert the public key of the user making the account recovery is found.", async() => { expect.assertions(1); - await MockExtension.withConfiguredAccount(); + await MockExtension.withConfiguredAccount(pgpKeys.betty); // Mock API fetch account recovery organization policy response. fetch.doMockOnce(() => mockApiResponse(enabledAccountRecoveryOrganizationPolicyDto())); // Mock API get account recovery request. diff --git a/src/all/background_page/controller/sso/ssoAuthenticationController.test.js b/src/all/background_page/controller/sso/ssoAuthenticationController.test.js index 75904cdd..82515598 100644 --- a/src/all/background_page/controller/sso/ssoAuthenticationController.test.js +++ b/src/all/background_page/controller/sso/ssoAuthenticationController.test.js @@ -112,7 +112,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const controller = new SsoAuthenticationController(null, null, defaultApiClientOptions(), account); jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); jest.spyOn(PassphraseStorageService, "set"); - jest.spyOn(PostLoginService, "postLogin"); + jest.spyOn(PostLoginService, "postLogin").mockImplementation(() => {}); await controller.exec(scenario.providerId); @@ -142,7 +142,7 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const controller = new SsoAuthenticationController(null, null, defaultApiClientOptions(), account); jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); jest.spyOn(PassphraseStorageService, "set"); - jest.spyOn(PostLoginService, "postLogin"); + jest.spyOn(PostLoginService, "postLogin").mockImplementation(() => {}); await controller.exec(scenario.providerId, true); diff --git a/src/all/background_page/controller/toolbarController.js b/src/all/background_page/controller/toolbarController.js index a41c8d07..4385ffa8 100644 --- a/src/all/background_page/controller/toolbarController.js +++ b/src/all/background_page/controller/toolbarController.js @@ -144,7 +144,7 @@ class ToolbarController { */ async updateSuggestedResourcesBadge() { const tabs = await browser.tabs.query({'active': true, 'lastFocusedWindow': true}); - const currentTab = tabs[0]; + const currentTab = tabs?.[0]; const tabUrl = currentTab?.url; let suggestedResourcesCount = 0; diff --git a/src/all/background_page/controller/toolbarController.test.js b/src/all/background_page/controller/toolbarController.test.js index 3f3b8176..2b48427e 100644 --- a/src/all/background_page/controller/toolbarController.test.js +++ b/src/all/background_page/controller/toolbarController.test.js @@ -12,7 +12,7 @@ * @since 3.3.0 */ -import ToolbarController from "./toolbarController"; +import toolbarController from "./toolbarController"; import AccountEntity from "../model/entity/account/accountEntity"; import {defaultAccountDto} from "../model/entity/account/accountEntity.test.data"; import GetLegacyAccountService from "../service/account/getLegacyAccountService"; @@ -48,7 +48,7 @@ describe("ToolbarController", () => { it("Given the user is on a tab which has no suggested resource for, it should activate the passbolt icon and display no suggested resource.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - const toolbarController = new ToolbarController(); + toolbarController.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementation(() => [{url: 'https://www.wherever.com'}]); @@ -65,7 +65,7 @@ describe("ToolbarController", () => { it("Given the user is on a tab which has suggested resource for, it should activate the passbolt icon and display the number of suggested resources.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - const toolbarController = new ToolbarController(); + toolbarController.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementation(() => [{url: 'https://www.passbolt.com'}]); @@ -84,7 +84,7 @@ describe("ToolbarController", () => { it("Given the user signs out, it should deactivate the passbolt icon.", async() => { expect.assertions(1); const account = new AccountEntity(defaultAccountDto()); - const toolbarController = new ToolbarController(); + toolbarController.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementation(() => [{url: 'https://www.wherever.com'}]); @@ -103,7 +103,7 @@ describe("ToolbarController", () => { it("Given the user navigates to a url having suggested resources, it should change the passbolt icon suggested resources count.", async() => { expect.assertions(1); const account = new AccountEntity(defaultAccountDto()); - const toolbarController = new ToolbarController(); + toolbarController.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.wherever.com'}]); @@ -124,7 +124,7 @@ describe("ToolbarController", () => { it("Given the user activates a tab having suggested resources, it should change the passbolt icon suggested resources count.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - const toolbarController = new ToolbarController(); + toolbarController.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.wherever.com'}]); @@ -144,7 +144,7 @@ describe("ToolbarController", () => { it("Given the user switches to a window with a tab having suggested resources, it should change the passbolt icon suggested resources count.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - const toolbarController = new ToolbarController(); + toolbarController.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.wherever.com'}]); @@ -162,7 +162,7 @@ describe("ToolbarController", () => { it("Given the user switches to another application, it should reset the passbolt icon suggested resources count.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - const toolbarController = new ToolbarController(); + toolbarController.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.passbolt.com'}]); diff --git a/src/all/background_page/model/entity/account/accountEntity.test.data.js b/src/all/background_page/model/entity/account/accountEntity.test.data.js index 369b23c4..2f50eacf 100644 --- a/src/all/background_page/model/entity/account/accountEntity.test.data.js +++ b/src/all/background_page/model/entity/account/accountEntity.test.data.js @@ -12,7 +12,6 @@ * @since 3.6.0 */ -import {v4 as uuidv4} from 'uuid'; import AccountEntity from "./accountEntity"; import {defaultSecurityTokenDto} from "../securityToken/SecurityTokenEntity.test.data"; import {pgpKeys} from '../../../../../../test/fixtures/pgpKeys/keys'; @@ -23,8 +22,8 @@ export const defaultAccountDto = (data = {}) => { const defaultData = { "type": AccountEntity.TYPE_ACCOUNT, "domain": "https://passbolt.local", - "user_id": uuidv4(), - "username": "ada@passbolt.dev", + "user_id": pgpKeys.ada.userId, + "username": "ada@passbolt.com", "first_name": "Ada", "last_name": "Lovelace", "user_key_fingerprint": pgpKeys.ada.fingerprint, @@ -45,7 +44,7 @@ export const defaultAccountDto = (data = {}) => { export const adminAccountDto = (data = {}) => { const defaultData = { "user_id": pgpKeys.admin.userId, - "username": "admin@passbolt.dev", + "username": "admin@passbolt.com", "first_name": "Admin", "last_name": "User", "user_key_fingerprint": pgpKeys.admin.fingerprint, diff --git a/src/all/background_page/service/auth/postLoginService.test.js b/src/all/background_page/service/auth/postLoginService.test.js index c77f68a7..085f4e92 100644 --- a/src/all/background_page/service/auth/postLoginService.test.js +++ b/src/all/background_page/service/auth/postLoginService.test.js @@ -11,6 +11,7 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 4.7.0 */ +import MockExtension from "../../../../../test/mocks/mockExtension"; import PostLoginService from "./postLoginService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; @@ -22,6 +23,8 @@ describe("PostLoginService", () => { describe("PostLoinService::postLogin", () => { it("Should call the start loop auth session check service and dispatch a post login event", async() => { expect.assertions(2); + + await MockExtension.withConfiguredAccount(); jest.spyOn(StartLoopAuthSessionCheckService, "exec"); jest.spyOn(self, "dispatchEvent"); diff --git a/src/all/background_page/service/local_storage/AccountLocalStorage.test.js b/src/all/background_page/service/local_storage/AccountLocalStorage.test.js index 891c9a67..d9c4d29f 100644 --- a/src/all/background_page/service/local_storage/AccountLocalStorage.test.js +++ b/src/all/background_page/service/local_storage/AccountLocalStorage.test.js @@ -165,7 +165,9 @@ describe("AccountLocalStorage", () => { // Initialize the local storage with X accounts; for (let i = 0; i < sampleSize; i++) { - const account = new AccountEntity(defaultAccountDto()); + const account = new AccountEntity(defaultAccountDto({ + user_id: uuidv4(), + })); accountsToDelete.push(account); await AccountLocalStorage.add(account); } diff --git a/src/all/background_page/service/sessionStorage/userMeSessionStorageService.test.js b/src/all/background_page/service/sessionStorage/userMeSessionStorageService.test.js index b5535c82..fcce6c03 100644 --- a/src/all/background_page/service/sessionStorage/userMeSessionStorageService.test.js +++ b/src/all/background_page/service/sessionStorage/userMeSessionStorageService.test.js @@ -18,6 +18,7 @@ import {defaultAccountDto} from "../../model/entity/account/accountEntity.test.d import {defaultUserDto} from "passbolt-styleguide/src/shared/models/entity/user/userEntity.test.data"; import UserEntity from "../../model/entity/user/userEntity"; import ProfileEntity from "../../model/entity/profile/profileEntity"; +import {v4 as uuidv4} from "uuid"; describe("UserMeSessionStorageService", () => { beforeEach(async() => { @@ -49,7 +50,9 @@ describe("UserMeSessionStorageService", () => { expect.assertions(1); const otherAccount = new AccountEntity(defaultAccountDto()); - const account = new AccountEntity(defaultAccountDto()); + const account = new AccountEntity(defaultAccountDto({ + user_id: uuidv4(), + })); const user = new UserEntity(defaultUserDto()); await UserMeSessionStorageService.set(account, user); @@ -113,7 +116,9 @@ describe("UserMeSessionStorageService", () => { it('should not remove another cached user if there is none for the given account', async() => { expect.assertions(1); - const otherAccount = new AccountEntity(defaultAccountDto()); + const otherAccount = new AccountEntity(defaultAccountDto({ + user_id: uuidv4(), + })); const userOtherAccount = new UserEntity(defaultUserDto()); await UserMeSessionStorageService.set(otherAccount, userOtherAccount); @@ -127,7 +132,9 @@ describe("UserMeSessionStorageService", () => { it('should not remove another cached user if there is a cached user for the given account', async() => { expect.assertions(3); - const otherAccount = new AccountEntity(defaultAccountDto()); + const otherAccount = new AccountEntity(defaultAccountDto({ + user_id: uuidv4(), + })); const userOtherAccount = new UserEntity(defaultUserDto()); await UserMeSessionStorageService.set(otherAccount, userOtherAccount); diff --git a/test/fixtures/pgpKeys/keys.js b/test/fixtures/pgpKeys/keys.js index 2e6b0082..1d0bb3c8 100644 --- a/test/fixtures/pgpKeys/keys.js +++ b/test/fixtures/pgpKeys/keys.js @@ -34,6 +34,7 @@ exports.pgpKeys = { revoked: false }, admin: { + userId: "f642271d-bbb1-401e-bbd1-7ec370f8e19b", public: "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n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\nFYNCFtV1fw==\n=amwR\n-----END PGP PUBLIC KEY BLOCK-----", private: "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n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\n=/G+C\n-----END PGP PRIVATE KEY BLOCK-----", private_decrypted: "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxcZYBFY06pcBEADjYRuq05Zatu4qYtXmexbrwtUdakNJJHPlWxcusohdTLUm\nSXrt7LegXBE3OjvV9HbdBQfbpjitFp8eJw5krYQmh1+w/UYjb5Jy/A7ma3oa\nwzbVwNpLwuAafYma5LLLloZD/OpYKprhWfW9FHKyq6t+AcH5CFs/Hvixdrdb\nAO7K1/z6mgWcT6HBP5/dGTseAlrvUDTsW1kzo6qsrOWoUunrqm31umsvcfNR\nOtDKM16zgZl+GlYY1BxNcRKr1/AcZUrp4zdSSc6IXrYjJ+1kgHz/ZoSrKn5Q\niqEn7wQEveJu+jNGSv8jMvQgjq+AmzveJ/4f+RQirbe9JOeDgzX7NqloRil3\nI0FPFoivbRU0PHi4N2q7sN8eYpXxXzuL+OEq1GQe5fTsSotQTRZUJxbdUS8D\nfPckQaK79HoybTQAgA6mgQf/C+U0X2TiBUzgBuhayiW12kHmKyK02htDeRNO\nYs4bBMdeZhAFm+5C74LJ3FGQOHe+/o2oBktk0rAZScjizijzNzJviRB/3nAJ\nSBW6NSNYcbnosk0ET2osg2tLvzegRI6+NQJEb0EpByTMypUDhCNKgg5aEDUV\nWcq4iucps/1e6/2vg2XVB7xdphT4/K44ZeBHdFufhGQvs8rkAPzpkpsEWKgp\nTR+hdhbMmNiL984Ywk98nNuzgfkgpcP57xawNwARAQABAA//SpB4RbtQRZxS\nmvTBZ207FYJVZ+mGejBpjy+heQ9T96ClkRIsrWRgzUYT4TQIFkAuUImLS6UF\nVgGimX0+U5axTP88yqor+4flky6EZlyddLRKwasWCx0zZ4x+eRyBY5EPo7EH\nWnVSSA87rk9L07rHKLHD3fo3DgqGjI+9TisOo9dTnwyuRVW52kkWkCdrgwpL\nrsnzKQVFydDkse6a+2mBS2ae26PP7YWlQKCUgpJ4/k3EO53AWTEJvP2VHWRZ\nWb5/5BY+eI46m+7PNNUtnLUkCI8id40eUiQ7+AACJdntLO6ArQ7//xeBBkI0\nXGzLo60iwXJpf3vPUsgM4cxAAgspXym96pbiN2/kt4uXYAsZFjuDqcFQ4e9g\n145ZQXMlrpD2ckDaTXmuqRcWypE4X7WtRgVqnDj3WmBeKo4U5e7/jezt95aI\na7PioNVnzGlDpzpkmCprBXIkVyyy8Q/UiqvyrUqG1+azs8mHrtzxr6rfWElq\n6v7qJOlip9eiAZfP0lBTzZoxmYpA91QvcVv0anddDAfdJ4SWSoQG/QbzVgA4\nmtm5tno8Sz7rr+fqyVQdRFt5DMhW74rvKlrvqyt/Iwzko+LCgFKptQ5Zh4tg\nJUI0Dxse2ogTud4FDgy4Tzq+CDx1JYmvDeEkG8Aq87C7AmCHBXBc2xBaOpNC\nU7H9h5oIgdUIAOnLzQDspc3fFHZHNniqm2Geg0AhVSO7qZZs1RUITK0Mlugj\ng7FTMpyNcyW5sR3di/soN1dWhdIfBYan2cM9YgYP94EJYlBFg91CaINdWZBN\nBw8gky0O7zTpiyA8yeCjbs8buNd2DPooHdIDKIf9OwDeXIzyQDsjPrYngOuu\nMkoaO189Caxi9LfVeI91xyuC5Exn8LcFoLfLrysQambk8dc/Ph3Yh63Vjyao\nfu3pWuvCyOJqznLoPo4hxzwPi3v900r+R8CpSZwnc8g5x+2oPlcCpviYk/8V\ndI7AOd0BZxLuG/lKuE8kUhmibnhPBAR9LV2hJ5nA7Ge+fW1Cz6p7wO0IAPj5\nTHmS/0v76nJ0cY1/igpPyCYwv67rovK+q/lgPdap9qXBFe58xY1TPR5GOcLl\nXCZNGSwUPwwm+rOnxLSj5ZSDxvOIUqFSl2XKHDXDzkXu1PVXzEZ8Jn+tToOw\nY2ZqDUsdqo2u2j+XoArVFVMgToOEDsWqiQz31RWxLnsXpQNqdPKVslY3/fpG\n82GF9YGGEIqUSE4dcNifAdPf9tIzu+Oj9rUpkJir1kACUEsMjfU0srT+cFsp\n+2SbDusgOaReVE/tlyS3OTL7d+0wtDtid6QN2wGiXzWVY+yO4QRfJrmrOmwX\nb4ZPolWspWkQqvhqXoVnoxjBrd+njXjfZDMhJTMH/AgoqDeZ3Nd96ughsbjD\n6r4nB2vj3GkBlqNoTeRzEdJor+ufSxXA7ZDeTfjXXJM1+H+LTlzLJBkmgV8g\nUqH/XEhOV98anrVkBDmlAEm4Hx1+kefIDNAMB/ttg1glXIjp0cfBiouC3egN\nvITZPMLgz/WwqPA83ITCF4+aSaSSsDKl7iNKaH5T5P4NJnT3FfGxzXnXKuy8\nGkvpfWyxPbBbzoFi1dTNpPwAxLX+DxgArrETFZQNl6bzbxEA4aymFGDthF+T\nym4rKGw8G6ayPZvgWPBLECan4IMXUBVSwANF9mLAMHiljerRlWi5XjduLAh1\nf38k4544ul2cSHUWviQofSF2l80rUGFzc2JvbHQgRGVmYXVsdCBBZG1pbiA8\nYWRtaW5AcGFzc2JvbHQuY29tPsLBpQQTAQoAOAIbAwULCQgHAwUVCgkICwUW\nAgMBAAIeAQIXgBYhBAwdF2ERDR4zyQBtGlsbMy7QZCbTBQJdG5qGACEJEFsb\nMy7QZCbTFiEEDB0XYRENHjPJAG0aWxszLtBkJtPnxg//Q9WOWUGf7VOnfbaI\nix3NGAONI7rgXuLFc1E0vG20XWT2+C6xGskFwjoJbiyDrbMYnILGn7vDIn3M\nSoITemLjtt97/lEXK7AgbJEWQWF1lxpXm0nCvjJ6h+qatGK96ncjcua6ecUu\nt10A/CACpuqxfKOhD6CaM5l/ksEDtwvrv2MIaVajuCvwg+yUx0I0rfAQv0YT\nXbJ5MRn1pvOo3c6n5Q0z5eu/iiG0UNNIE3Tk5KpT02MTMv5ullpt3mtNjMHH\n0/TdPxCtUKVh4q34x3syiYLepaddf5Ctv9CL52VWfsG3qFPHp7euOFY8lfzu\nemoqD9jcE7QIJnkCmwtLXLQrE0O2RW/y/oXqrETXu2sFyHMr1Xw//QeJgIv6\n3LBGmcPOj93VyHIlcUDarM2oq2+DXKxrDs2xfnFKVCZwpSvecIfKXUKsnX3A\nGrpetoZdfw0jAUVI3nt6YCu8KvczXxetfjOV3HHXa40gtOZk5OoKbfuTjzQl\npc1oaDyLH8PT1GYsN3wWoDs4zulh6uKDpSt+4z58H1BfPFlrO2uhZSfk3E83\nuBQXZcABeXNxCdrTCJm8P90sbjLu1TlaeOnrWwVT7Yq8i8LE7lbAXnT1HjQl\nDi8GB2+2EnZZmOX+Z84a16jDElZazUNsE8zT7OmyjuB7GGDbQEFYzkb9dr1j\n1sukztzHxlgEVjTqlwEQAJ37C9s4pq4jvcEF3bJgL+q3SBolgBIpN1g1/woi\n9vEiOh+7e08Kr8mEhF04cpRDbhY6dcZ8OIXIQ99fgdNXfehlAWnI56NE/FOI\nyif8TvGBfO6yE35fKSskwGNdUZWIZ0U0pxSXQvB+KEGWlq2c3Uf/jhTZDnLN\nvfDjnYmn5ycp5sVWhtAmKFha9NJ6LGA0D1MC+jcCJCKtQRGgVvlqOESFDmQ7\nPu8/ayr2BO0URHJ0Ob30lHluCnoKIv50qGpL9BYuGAdCfLBHXzRQhHIbfc/c\nTPkK1kTXX5x/MkiEl88TeGN+yjNVS7qqdxYgs+QYnDDZqevhWEvVyXVQjcCW\nSIHfjL1x5NdqYL6+ci/OxyIFoPs4K2umN3JPmpFi+fIPh2CexKy6BnyE8oAg\nNvgdDb6ZOfAtvShZPM7QG4LZal2+nYp4n7gJRh6kepTQT/4Bua0xOtRQhgcI\n4nGtcCxEDRMMzjqbGYlcnciMjsiMg9LPpWPDA+xKrRZKYwVFy8vLx/alOz/h\n1BZjx2u7YmuaGENxE62Lfyh0xeoCBDTdnWEOQTH6LVsomVtUO1FVap1t5jkY\nSdpxBuHf8/2Ye7N3FTMRKe9n4e75sAJ00utnMl6P2Zca9mM4T29PK+LPFx2G\n2h35DQ7MbEid1cAZ8QVR3UyoiR8+u9jMek+9uFCm+nAxABEBAAEAD/sGjKrg\nKsgWPhsWzoRzabNy2qhdlSJrHlRSDuME65ArTQz11dL14u6Ivzqxlq6BYQ5G\nU6QgV3QMb9IIh7AdL+pjYRSe6xpXVXvUhr5CzB4FuyWPy8gtHArb5Akp1WuV\ndHM7lkQ7AU5gJArNNU4H4pH18y1Txe/oaIkwXG9ijphxsjYEBmNOa9aOWy79\nLt16G45rFZuD/k27Nk2VSn1wl6u/g3imRSKFzq5FuK9ZmNaBnDnsmyAwrJQ4\nnQT4YaO9zGpRJRYP7vy2Xi8fPxtOk78yh+KVDJL3hapMFaXjBcQ5bIg4L8B4\nQlgCZCDNxQtQMIkBKXT293+unS1d9Ln7uv9EenA208g1IqqbCee4f1L3BcaF\nqAFlet8x5Gd4J6NgF3bGJ1OfC8prjofFnTYkwf0ZqdKU1D+cR72gaTfhmUDg\nRhg/2ZEZWd9cVf50bmKYb98YNCUIvNlcTLE5+ARsD7hhRLyXaAWWo0zTzI7q\nUEkJvTD73evLhnqKmrLPjXZeHqN94Ua13jj+BBrq9wmeqdSfi2t3YQXNsA9x\nS2RhHUWmjFvkdlM7L65d5yET55QsmT/PDoANV/+6G7bDsbRKHpcVf71wPTQX\n4dv+m7rvZkxYn0LLCA6y+oFsed9/o6qfezieBP7R+kfwQ1F3vSlnc2vlQiUJ\nxm4jdYCDLFj+sZ0TQQgAx3OCF7dcNBkcmxa9dq2EbATT488onz2ZesV6Bl8a\nGunmI9PtLKvR4Wwi72w+cK4S3OJCPr9ojXvOFm1mQQD8rQA7lsROP8YQb6xj\npqa9dXnzNW4T+PKgMXeJkDWgKrjFJZyRKPXB0TIHZGdYwgIUtol8F2wyboMB\ntquiqLyetowHABU5eM5pBgShRbLjv+nPHvDCSYjVtheS3yzUwjULVSlb98c/\n9zeZU1lqaW0xZuZCjYyT+ikLQkyYHmJe9WQZywnJiZXtQ6hQ3lSJgl92E2/3\nI0t1XqPq5fZQcWAWMBw1uDGYn0Q5Wjz/nSBiaGxpK/hQjHTbhn/qUJMHnqF5\n2QgAysWJvrVzZ0ynpijPzsaS4hv4u//I5Azm6H2Z2TXE8BrsYnbKWUzIgvzJ\nc+aGBwc6xXV8e7eBGwDGyy2EOfT3Qr+0SC+/0GfSJ1n6DuZTEMTCoSLhhOMZ\nYukB0E6b6i1MMjUidnl1fPYP++WWf0jst1VHyQWL2qaKDHgjjbVsHdhLiHMV\nrjGn/IfNMkMcHVWdfdqrhI9FqS+sU/YjXfM7/+f8/yFGu0UnVtB3xdKg8KGG\nM2K+NxJnZJFPYouLEn29VIwguEtWzC8IxMyrFP66ivpSV8yQFJ1ivvQKGyt4\n/BdlIif42ie5x+i9mgKcPRO70MWUPCGwI8dDtU2z0pGaGQgAt8WxO4+Y9Y7y\nQrdzdeEKF0AykRmbZaETlfVZ1X7S8h7glwSR6rTvGCeoIf5cX0HZfs3Ap3Zs\nV6bBMjSP3KM3voYPmCbHL674fHIzDkoZu2Nssar1z08lsoGjkTo+m5SnXC5e\nTAhGYndOpmONK26uGa8nw3ngkbNn47R/lMT124YbIKF006oOT5WSVzgwbIsQ\nRZuegEJQVnZfBw3HUdB+YxDwuqghtoqTBHjqH+5bU5aPK6pQ02BHx+txtdAz\nioWhFCrU5Q5zldz6qv/8baspfzvBXmy8dhGLjaWSAhOKTAXDMpzT3bk8HXa5\nzFOEtj/bl9DlkAwObwALFAUYI50k4nIVwsGNBBgBCgAgAhsMFiEEDB0XYREN\nHjPJAG0aWxszLtBkJtMFAl0bmpkAIQkQWxszLtBkJtMWIQQMHRdhEQ0eM8kA\nbRpbGzMu0GQm004PD/9sFmFkdoSqwU/En77+h0gt4knlgZbgj0iRromnknIw\nLKBbJJXksHmMPXJB9b3WZ/gGV3pPVtDWDKg3NZW4HLK13w3s3wQ2ViVVA6Fz\nABDSkI3YBqkkasLRZU7oN9XajdFfph5wLhDSgTCjSncGfcjVzPugWKLqPPih\nZO6mpqxSFYEhx+p/O80Tlj90UsOFRdot7cqn5wOhXZtKsQ0RwaA/uq/sFe6U\nNKHG2RBgQfoj5JbazJbvlgMiWxhBalwZKQWs8IBh/4ag8AFwwoJN+gOtNM9C\n4UCHu+yt0Tv2/Tu+Apcj0oyFaKJD4uQUmChQ2fDRysqJEIhee+yL29mrdcB4\njG7Q2rt8HbhYwlsHKgas0YIHdR6dUOCiyw72i0khwrd2PDgxKRu5+cob6wMS\nqXbIIxFLLLACHy2sKd6fQcg8FxoivEiF0lRfMi32A/YWGJ/k1OoFCzW55KFX\nqqBMptYZWh2JezhttmidYHPc7jas7HEPnw3SvVM0gYAcmEVWWvjKfUpOhSYY\nkk/B71w9RuIpPyyI7G2XI8DbG2ttngDIOL8njS6ybU9Og6yTNUoHL1wWEZN1\nb3fznKHcC9lyr8MIg00QNeDItt9iILCOkjoEdUdauqlRIa+EmUu+AL+Jobrl\nQTzyrCIm7aaT3Hp9EyaEx5xvJDWtmjgfFYNCFtV1fw==\n=hJM/\n-----END PGP PRIVATE KEY BLOCK-----", @@ -54,6 +55,10 @@ exports.pgpKeys = { public: "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n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\n=KJsT\n-----END PGP PUBLIC KEY BLOCK-----", key_id: "d34374d5", fingerprint: "A754860C3ADE5AB04599025ED3F1FE4BE61D7009", + user_ids: [{ + email: "betty@passbolt.com", + name: "Betty Holberton", + }], }, account_recovery_organization: { fingerprint: "28FBD1034880416B2B8CA75A289BCE03F3C0893F", diff --git a/test/mocks/mockExtension.js b/test/mocks/mockExtension.js index d24e6c48..747bd1d9 100644 --- a/test/mocks/mockExtension.js +++ b/test/mocks/mockExtension.js @@ -17,6 +17,7 @@ import {defaultSecurityTokenDto} from "../../src/all/background_page/model/entit import {v4 as uuidv4} from "uuid"; import Keyring from "../../src/all/background_page/model/keyring"; import {pgpKeys} from "../fixtures/pgpKeys/keys"; +import {Uuid} from "../../src/all/background_page/utils/uuid"; class MockExtension { /** @@ -29,6 +30,8 @@ class MockExtension { // Mock user private key const keyring = new Keyring(); await keyring.importPrivate(keyData.private); + await keyring.importPublic(keyData.public, keyData.userId); + await keyring.importPublic(pgpKeys.server.public, Uuid.get(user.settings.getDomain())); return user; } @@ -44,7 +47,7 @@ class MockExtension { user.settings.setSecurityToken(defaultSecurityTokenDto()); const nameSplitted = keyData.user_ids[0].name.split(" "); const userDto = { - id: uuidv4(), + id: keyData.userId || uuidv4(), username: keyData.user_ids[0].email, firstname: nameSplitted.shift(), lastname: nameSplitted.join(" "), From 1517ab1a40b43479a408e60b64d498914b3a6418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Thu, 11 Apr 2024 13:46:14 +0000 Subject: [PATCH 31/56] Feature/pb 32598 ensure add listener from authentication event controller are set on indexjs --- .../auth/authenticationEventController.js | 41 +++---- ...authenticationEventController.test.data.js | 26 +++++ .../authenticationEventController.test.js | 109 ++++++++++++++++++ src/all/background_page/event/appEvents.js | 5 +- .../event/informCallToActionEvents.js | 4 +- .../service/auth/postLoginService.js | 2 + .../service/auth/postLogoutService.js | 2 + 7 files changed, 162 insertions(+), 27 deletions(-) create mode 100644 src/all/background_page/controller/auth/authenticationEventController.test.data.js create mode 100644 src/all/background_page/controller/auth/authenticationEventController.test.js diff --git a/src/all/background_page/controller/auth/authenticationEventController.js b/src/all/background_page/controller/auth/authenticationEventController.js index d9228db5..b52baff1 100644 --- a/src/all/background_page/controller/auth/authenticationEventController.js +++ b/src/all/background_page/controller/auth/authenticationEventController.js @@ -17,49 +17,44 @@ */ class AuthenticationEventController { /** - * AuthenticationEventController constructor + * AuthenticationEventController initialiser * @param {Worker} worker */ - constructor(worker) { + static initialise(worker) { this.worker = worker; - this.bindCallbacks(); - } - - /** - * Binds the callbacks - */ - bindCallbacks() { - this.handleUserLoggedOut = this.handleUserLoggedOut.bind(this); - this.handleUserLoggedIn = this.handleUserLoggedIn.bind(this); - this.handleRemoveListener = this.handleRemoveListener.bind(this); } /** * Start event listeners. */ - startListen() { - self.addEventListener("passbolt.auth.after-logout", this.handleUserLoggedOut); - self.addEventListener("passbolt.auth.after-login", this.handleUserLoggedIn); - this.worker.port._port.onDisconnect.addListener(this.handleRemoveListener); + static startListen() { + this.isPortConnected = true; + this.worker.port._port.onDisconnect.addListener(this.handlePortDisconnected); } /** * Handle when the user is logged in. */ - async handleUserLoggedIn() { - this.worker.port.emit("passbolt.auth.after-login"); + static handleUserLoggedIn() { + if (this.isPortConnected) { + this.worker?.port.emit("passbolt.auth.after-login"); + } } /** * Handle when the user is logged out. */ - handleUserLoggedOut() { - this.worker.port.emit("passbolt.auth.after-logout"); + static handleUserLoggedOut() { + if (this.isPortConnected) { + this.worker?.port.emit("passbolt.auth.after-logout"); + } } - handleRemoveListener() { - self.removeEventListener("passbolt.auth.after-logout", this.handleUserLoggedOut); - self.removeEventListener("passbolt.auth.after-login", this.handleUserLoggedIn); + /** + * Handle when the port is disconnected + */ + static handlePortDisconnected() { + this.isPortConnected = false; } } diff --git a/src/all/background_page/controller/auth/authenticationEventController.test.data.js b/src/all/background_page/controller/auth/authenticationEventController.test.data.js new file mode 100644 index 00000000..70d7d64e --- /dev/null +++ b/src/all/background_page/controller/auth/authenticationEventController.test.data.js @@ -0,0 +1,26 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +export const defaultWorker = (data = {}) => ({ + tabId: 1, + port: { + emit: jest.fn(), + _port: { + onDisconnect: { + addListener: jest.fn(), + }, + }, + }, + ...data +}); diff --git a/src/all/background_page/controller/auth/authenticationEventController.test.js b/src/all/background_page/controller/auth/authenticationEventController.test.js new file mode 100644 index 00000000..d882218d --- /dev/null +++ b/src/all/background_page/controller/auth/authenticationEventController.test.js @@ -0,0 +1,109 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import AuthenticationEventController from "./authenticationEventController"; +import { defaultWorker } from "./authenticationEventController.test.data"; + +describe("AuthenticationEventController", () => { + describe("::initialise", () => { + it("should accept a worker and register it", () => { + expect.assertions(2); + const worker = defaultWorker(); + + expect(AuthenticationEventController.worker).toBeUndefined(); + AuthenticationEventController.initialise(worker); + expect(AuthenticationEventController.worker).toStrictEqual(worker); + }); + }); + + describe("::startListen", () => { + it("should initialise its state and listen to port.onDisconnect", () => { + expect.assertions(4); + const worker = defaultWorker(); + + expect(AuthenticationEventController.isPortConnected).toBeFalsy(); + AuthenticationEventController.initialise(worker); + AuthenticationEventController.startListen(); + + expect(AuthenticationEventController.isPortConnected).toStrictEqual(true); + expect(worker.port._port.onDisconnect.addListener).toHaveBeenCalledTimes(1); + expect(worker.port._port.onDisconnect.addListener).toHaveBeenCalledWith(AuthenticationEventController.handlePortDisconnected); + }); + }); + + describe("::handleUserLoggedIn", () => { + it("should emit 'passbolt.auth.after-login' on port if it is marked as connected", () => { + expect.assertions(2); + + const worker = defaultWorker(); + AuthenticationEventController.initialise(worker); + AuthenticationEventController.startListen(); + AuthenticationEventController.handleUserLoggedIn(); + + expect(worker.port.emit).toHaveBeenCalledTimes(1); + expect(worker.port.emit).toHaveBeenCalledWith("passbolt.auth.after-login"); + }); + + it("should not emit 'passbolt.auth.after-login' on port if it is marked as disconnected", () => { + expect.assertions(1); + + const worker = defaultWorker(); + AuthenticationEventController.initialise(worker); + AuthenticationEventController.startListen(); + AuthenticationEventController.isPortConnected = false; + AuthenticationEventController.handleUserLoggedIn(); + + expect(worker.port.emit).not.toHaveBeenCalled(); + }); + }); + + describe("::handleUserLoggedOut", () => { + it("should emit 'passbolt.auth.after-logout' on port if it is marked as connected", () => { + expect.assertions(2); + + const worker = defaultWorker(); + AuthenticationEventController.initialise(worker); + AuthenticationEventController.startListen(); + AuthenticationEventController.handleUserLoggedOut(); + + expect(worker.port.emit).toHaveBeenCalledTimes(1); + expect(worker.port.emit).toHaveBeenCalledWith("passbolt.auth.after-logout"); + }); + + it("should not emit 'passbolt.auth.after-login' on port if it is marked as disconnected", () => { + expect.assertions(1); + const worker = defaultWorker(); + + AuthenticationEventController.initialise(worker); + AuthenticationEventController.startListen(); + AuthenticationEventController.isPortConnected = false; + AuthenticationEventController.handleUserLoggedOut(); + + expect(worker.port.emit).not.toHaveBeenCalled(); + }); + }); + + describe("::handlePortDisconnected", () => { + it("should mark the port as disconnected", () => { + expect.assertions(1); + const worker = defaultWorker(); + + AuthenticationEventController.initialise(worker); + AuthenticationEventController.startListen(); + AuthenticationEventController.handlePortDisconnected(); + + expect(AuthenticationEventController.isPortConnected).toStrictEqual(false); + }); + }); +}); diff --git a/src/all/background_page/event/appEvents.js b/src/all/background_page/event/appEvents.js index f79fe42c..a1d9f2db 100644 --- a/src/all/background_page/event/appEvents.js +++ b/src/all/background_page/event/appEvents.js @@ -45,8 +45,9 @@ import DeletePasswordExpirySettingsController from "../controller/passwordExpiry import GetOrFindPasswordExpirySettingsController from "../controller/passwordExpiry/getOrFindPasswordExpirySettingsController"; const listen = function(worker, apiClientOptions, account) { - const authenticationEventController = new AuthenticationEventController(worker); - authenticationEventController.startListen(); + AuthenticationEventController.initialise(worker); + AuthenticationEventController.startListen(); + /* * Whenever the (React) app changes his route * @listens passbolt.app.route-changed diff --git a/src/all/background_page/event/informCallToActionEvents.js b/src/all/background_page/event/informCallToActionEvents.js index 317da9a7..42bea779 100644 --- a/src/all/background_page/event/informCallToActionEvents.js +++ b/src/all/background_page/event/informCallToActionEvents.js @@ -21,8 +21,8 @@ import AuthCheckStatusController from "../controller/auth/authCheckStatusControl * @param {AccountEntity} account the user account */ const listen = function(worker, apiClientOptions, account) { - const authenticationEventController = new AuthenticationEventController(worker); - authenticationEventController.startListen(); + AuthenticationEventController.initialise(worker); + AuthenticationEventController.startListen(); /* * Whenever the in-form call-to-action status is required diff --git a/src/all/background_page/service/auth/postLoginService.js b/src/all/background_page/service/auth/postLoginService.js index 3dde3780..48fb7af0 100644 --- a/src/all/background_page/service/auth/postLoginService.js +++ b/src/all/background_page/service/auth/postLoginService.js @@ -12,6 +12,7 @@ * @since 4.7.0 */ +import AuthenticationEventController from "../../controller/auth/authenticationEventController"; import toolbarController from "../../controller/toolbarController"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; class PostLoginService { @@ -22,6 +23,7 @@ class PostLoginService { static async postLogin() { await StartLoopAuthSessionCheckService.exec(); toolbarController.handleUserLoggedIn(); + AuthenticationEventController.handleUserLoggedIn(); //@todo remove the dispatch event once every 'after-login' listeners are handled here const event = new Event('passbolt.auth.after-login'); diff --git a/src/all/background_page/service/auth/postLogoutService.js b/src/all/background_page/service/auth/postLogoutService.js index 904be590..55057096 100644 --- a/src/all/background_page/service/auth/postLogoutService.js +++ b/src/all/background_page/service/auth/postLogoutService.js @@ -18,6 +18,7 @@ import PortManager from "../../sdk/port/portManager"; import LocalStorageService from "../localStorage/localStorageService"; import BrowserTabService from "../ui/browserTab.service"; import toolbarController from "../../controller/toolbarController"; +import AuthenticationEventController from "../../controller/auth/authenticationEventController"; class PostLogoutService { /** @@ -28,6 +29,7 @@ class PostLogoutService { PostLogoutService.sendLogoutEventForWorkerDisconnected(workers); LocalStorageService.flush(); toolbarController.handleUserLoggedOut(); + AuthenticationEventController.handleUserLoggedOut(); //@todo remove the dispatch event once every 'after-logout' listeners are handled here const event = new Event('passbolt.auth.after-logout'); From 92ad7d7f3dc88c02ed872b3beff003a82216f8b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Sat, 6 Apr 2024 11:54:12 +0200 Subject: [PATCH 32/56] PB-32599 - Ensure 'addListener' from StartLoopAuthSessionCheckService are set on index.js --- .../auth/authenticationEventController.test.js | 2 +- .../service/auth/postLogoutService.js | 9 +++++---- .../auth/startLoopAuthSessionCheckService.js | 9 +++++---- .../auth/startLoopAuthSessionCheckService.test.js | 13 +++++-------- test/mocks/mockAlarms.js | 2 +- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/all/background_page/controller/auth/authenticationEventController.test.js b/src/all/background_page/controller/auth/authenticationEventController.test.js index d882218d..0fbe9a67 100644 --- a/src/all/background_page/controller/auth/authenticationEventController.test.js +++ b/src/all/background_page/controller/auth/authenticationEventController.test.js @@ -13,7 +13,7 @@ */ import AuthenticationEventController from "./authenticationEventController"; -import { defaultWorker } from "./authenticationEventController.test.data"; +import {defaultWorker} from "./authenticationEventController.test.data"; describe("AuthenticationEventController", () => { describe("::initialise", () => { diff --git a/src/all/background_page/service/auth/postLogoutService.js b/src/all/background_page/service/auth/postLogoutService.js index 55057096..6957a4d4 100644 --- a/src/all/background_page/service/auth/postLogoutService.js +++ b/src/all/background_page/service/auth/postLogoutService.js @@ -19,17 +19,18 @@ import LocalStorageService from "../localStorage/localStorageService"; import BrowserTabService from "../ui/browserTab.service"; import toolbarController from "../../controller/toolbarController"; import AuthenticationEventController from "../../controller/auth/authenticationEventController"; +import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; class PostLogoutService { /** * Execute all processes after a logout */ static async exec() { - const workers = await WorkersSessionStorage.getWorkersByNames([AppPagemod.appName, WebIntegrationPagemod.appName]); - PostLogoutService.sendLogoutEventForWorkerDisconnected(workers); + await PostLogoutService.sendLogoutEventForWorkerDisconnected(); LocalStorageService.flush(); toolbarController.handleUserLoggedOut(); AuthenticationEventController.handleUserLoggedOut(); + StartLoopAuthSessionCheckService.clearAlarm(); //@todo remove the dispatch event once every 'after-logout' listeners are handled here const event = new Event('passbolt.auth.after-logout'); @@ -38,10 +39,10 @@ class PostLogoutService { /** * Send logout event on workers disconnected port - * @param workers * @return {Promise} */ - static async sendLogoutEventForWorkerDisconnected(workers) { + static async sendLogoutEventForWorkerDisconnected() { + const workers = await WorkersSessionStorage.getWorkersByNames([AppPagemod.appName, WebIntegrationPagemod.appName]); for (const worker of workers) { if (!PortManager.isPortExist(worker.id)) { await BrowserTabService.sendMessage(worker, "passbolt.port.connect", worker.id); diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js index 544a82a9..20f18f56 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js @@ -12,6 +12,7 @@ * @since 4.0.0 */ import CheckAuthStatusService from "./checkAuthStatusService"; +import PostLogoutService from "./postLogoutService"; const CHECK_IS_AUTHENTICATED_INTERVAL_PERIOD = 60000; const AUTH_SESSION_CHECK_ALARM = "AuthSessionCheck"; @@ -23,7 +24,6 @@ class StartLoopAuthSessionCheckService { */ static async exec() { await StartLoopAuthSessionCheckService.scheduleAuthSessionCheck(); - self.addEventListener("passbolt.auth.after-logout", StartLoopAuthSessionCheckService.clearAlarm); } /** @@ -54,16 +54,17 @@ class StartLoopAuthSessionCheckService { * - In the case the user is logged out, trigger a passbolt.auth.after-logout event. * @param {Alarm} alarm * @returns {Promise} + * @private */ static async handleAuthStatusCheckAlarm(alarm) { if (alarm.name !== StartLoopAuthSessionCheckService.ALARM_NAME) { return; } - const checkAuthStatusService = new CheckAuthStatusService(); - const authStatus = await checkAuthStatusService.checkAuthStatus(true); + const checkAuthService = new CheckAuthStatusService(); + const authStatus = await checkAuthService.checkAuthStatus(true); if (!authStatus.isAuthenticated) { - self.dispatchEvent(new Event('passbolt.auth.after-logout')); + PostLogoutService.exec(); } } diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js index 1883a687..113d67d4 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js @@ -12,6 +12,7 @@ * @since 3.3.0 */ import CheckAuthStatusService from "./checkAuthStatusService"; +import PostLogoutService from "./postLogoutService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; jest.useFakeTimers(); @@ -53,20 +54,20 @@ describe("StartLoopAuthSessionCheckService", () => { jest.advanceTimersByTime(60000); expect(spyIsAuthenticated).toHaveBeenCalledTimes(2); - self.dispatchEvent(new Event('passbolt.auth.after-logout')); + await PostLogoutService.exec(); expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(2); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(1); }); it("should send logout event if not authenticated anymore", async() => { - expect.assertions(10); + expect.assertions(6); // Function mocked const spyScheduleAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); const spyClearAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "clearAlarm"); - const spyDispatchEvent = jest.spyOn(self, "dispatchEvent"); const authStatus = {isAuthenticated: false, isMfaRequired: false}; const spyIsAuthenticated = jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); + const spyOnPostLogout = jest.spyOn(PostLogoutService, "exec").mockImplementation(async() => {}); //mocking top-level alarm handler browser.alarms.onAlarm.addListener(async alarm => await StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm(alarm)); @@ -80,13 +81,9 @@ describe("StartLoopAuthSessionCheckService", () => { jest.advanceTimersByTime(60000); await Promise.resolve(); - expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); - expect(spyIsAuthenticated).toHaveBeenCalledTimes(1); - expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(1); - expect(spyDispatchEvent).toHaveBeenCalledWith(new Event('passbolt.auth.after-logout')); expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(1); - expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(1); + expect(spyOnPostLogout).toHaveBeenCalledTimes(1); }); }); diff --git a/test/mocks/mockAlarms.js b/test/mocks/mockAlarms.js index de386e85..451b810d 100644 --- a/test/mocks/mockAlarms.js +++ b/test/mocks/mockAlarms.js @@ -95,7 +95,7 @@ class MockAlarms { _createDelayedInterval(alarmName, options) { let scheduledTime = options.when; if (!scheduledTime && options.delayInMinutes) { - scheduledTime = Date.now() + options.delayInMinutes * 60_000 + scheduledTime = Date.now() + options.delayInMinutes * 60_000; } const alarm = { From b4f24bb7069144bfdadba9cbf9bec6f190f1714c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Sat, 6 Apr 2024 11:58:21 +0200 Subject: [PATCH 33/56] PB-32602 - Ensure 'addListener' from user.js are set on index.js --- src/all/background_page/model/user.js | 14 -------------- .../service/auth/postLogoutService.js | 2 ++ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/all/background_page/model/user.js b/src/all/background_page/model/user.js index 41bfc687..ea6d370c 100644 --- a/src/all/background_page/model/user.js +++ b/src/all/background_page/model/user.js @@ -9,8 +9,6 @@ import UserSettings from "./userSettings/userSettings"; import {ApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions"; import Validator from "validator"; import {ValidatorRule} from "../utils/validatorRules"; -import PassphraseStorageService from "../service/session_storage/passphraseStorageService"; -import KeepSessionAliveService from "../service/session_storage/keepSessionAliveService"; /** * The class that deals with users. @@ -353,18 +351,6 @@ const UserSingleton = (function() { } return instance; }, - - init: function() { - /* - * Observe when the user session is terminated. - * - Flush the temporary stored master password - */ - self.addEventListener("passbolt.auth.after-logout", () => Promise.all([ - PassphraseStorageService.flush(), - KeepSessionAliveService.stop() - ]) - ); - } }; })(); diff --git a/src/all/background_page/service/auth/postLogoutService.js b/src/all/background_page/service/auth/postLogoutService.js index 6957a4d4..9c7b75d9 100644 --- a/src/all/background_page/service/auth/postLogoutService.js +++ b/src/all/background_page/service/auth/postLogoutService.js @@ -20,6 +20,7 @@ import BrowserTabService from "../ui/browserTab.service"; import toolbarController from "../../controller/toolbarController"; import AuthenticationEventController from "../../controller/auth/authenticationEventController"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; +import PassphraseStorageService from "../session_storage/passphraseStorageService"; class PostLogoutService { /** @@ -31,6 +32,7 @@ class PostLogoutService { toolbarController.handleUserLoggedOut(); AuthenticationEventController.handleUserLoggedOut(); StartLoopAuthSessionCheckService.clearAlarm(); + PassphraseStorageService.flush(); //@todo remove the dispatch event once every 'after-logout' listeners are handled here const event = new Event('passbolt.auth.after-logout'); From a0e6d5c5377f2b522d14392874e797ba385c228b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Sat, 6 Apr 2024 12:08:01 +0200 Subject: [PATCH 34/56] PB-32603 - Ensure 'addListener' from ResourceInProgressCacheService are set on index.js --- src/all/background_page/service/auth/postLogoutService.js | 2 ++ .../service/cache/resourceInProgressCache.service.js | 4 ---- .../service/cache/resourceInProgressCache.service.test.js | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/all/background_page/service/auth/postLogoutService.js b/src/all/background_page/service/auth/postLogoutService.js index 9c7b75d9..bddd34c3 100644 --- a/src/all/background_page/service/auth/postLogoutService.js +++ b/src/all/background_page/service/auth/postLogoutService.js @@ -21,6 +21,7 @@ import toolbarController from "../../controller/toolbarController"; import AuthenticationEventController from "../../controller/auth/authenticationEventController"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; import PassphraseStorageService from "../session_storage/passphraseStorageService"; +import resourceInProgressCacheService from "../cache/resourceInProgressCache.service"; class PostLogoutService { /** @@ -33,6 +34,7 @@ class PostLogoutService { AuthenticationEventController.handleUserLoggedOut(); StartLoopAuthSessionCheckService.clearAlarm(); PassphraseStorageService.flush(); + resourceInProgressCacheService.reset(); //@todo remove the dispatch event once every 'after-logout' listeners are handled here const event = new Event('passbolt.auth.after-logout'); diff --git a/src/all/background_page/service/cache/resourceInProgressCache.service.js b/src/all/background_page/service/cache/resourceInProgressCache.service.js index 752dfe84..404435c8 100644 --- a/src/all/background_page/service/cache/resourceInProgressCache.service.js +++ b/src/all/background_page/service/cache/resourceInProgressCache.service.js @@ -65,9 +65,6 @@ class ResourceInProgressCacheService { await browser.storage.session.set({[RESOURCE_IN_PROGRESS_STORAGE_KEY]: resource.toDto()}); // Set a timeout to clean the cache if not consumed this.timeoutId = setTimeout(this.reset, timeoutInMs); - - // Invalid the cache if the user is logged out - self.addEventListener("passbolt.auth.after-logout", this.reset); } /** @@ -77,7 +74,6 @@ class ResourceInProgressCacheService { browser.storage.session.remove(RESOURCE_IN_PROGRESS_STORAGE_KEY); // Clear the timeout clearTimeout(this.timeoutId); - self.removeEventListener("passbolt.auth.after-logout", this.reset); } } diff --git a/src/all/background_page/service/cache/resourceInProgressCache.service.test.js b/src/all/background_page/service/cache/resourceInProgressCache.service.test.js index 9eae7e44..58a67f5b 100644 --- a/src/all/background_page/service/cache/resourceInProgressCache.service.test.js +++ b/src/all/background_page/service/cache/resourceInProgressCache.service.test.js @@ -13,6 +13,7 @@ */ import ResourceInProgressCacheService from "./resourceInProgressCache.service"; import ExternalResourceEntity from "../../model/entity/resource/external/externalResourceEntity"; +import PostLogoutService from "../auth/postLogoutService"; jest.useFakeTimers(); @@ -44,7 +45,7 @@ describe("ResourceInProgressCache service", () => { expect(spyOnStorageSet).toHaveBeenCalledTimes(1); expect(spyOnStorageSet).toHaveBeenCalledWith({resourceInProgress: fakeResource.toDto()}); - self.dispatchEvent(new Event('passbolt.auth.after-logout')); + await PostLogoutService.exec(); expect(spy).toHaveBeenCalledTimes(2); }); From a3b51b6ee55326f16c417934927d8443fd8f96b1 Mon Sep 17 00:00:00 2001 From: Cedric Alfonsi Date: Thu, 11 Apr 2024 14:15:51 +0000 Subject: [PATCH 35/56] PB-29946 - Investigate: when the service worker is shutdown and a navigation... --- doc/worker-port-lfecycle.md | 19 +++ .../model/entity/worker/workerEntity.js | 23 ++- .../background_page/sdk/port/portManager.js | 2 +- .../background_page/service/tab/tabService.js | 75 ++++++++-- .../service/tab/tabService.test.js | 137 +++++++++++++----- .../service/worker/workerService.js | 35 +++-- .../service/worker/workerService.test.js | 25 ++++ 7 files changed, 246 insertions(+), 70 deletions(-) create mode 100644 doc/worker-port-lfecycle.md diff --git a/doc/worker-port-lfecycle.md b/doc/worker-port-lfecycle.md new file mode 100644 index 00000000..103028f2 --- /dev/null +++ b/doc/worker-port-lfecycle.md @@ -0,0 +1,19 @@ +```mermaid +stateDiagram + classDef badBadEvent fill:#f00,color:white,font-weight:bold,stroke-width:2px,stroke:yellow + + [*] --> awaiting_connection: SW navigation url matching + + awaiting_connection --> connected: CC opening port + connected --> disconnected: SW stopped + disconnected --> reconnecting: SW navigation url same origin + disconnected --> connected: CC reopening port + reconnecting --> connected: SW requesting CC port opening ||\nCC reopening port (following a user interaction) + + awaiting_connection --> [*]: SW tab removed || \n SW navigation url diff origin + connected --> [*]: SW tab removed || \n SW navigation url diff origin + disconnected --> [*]: SW tab removed || \n SW navigation url diff origin + reconnecting --> [*]: SW tab removed || \n Cannot reconnect port || \n SW navigation url diff origin + + class reconnecting badBadEvent +``` diff --git a/src/all/background_page/model/entity/worker/workerEntity.js b/src/all/background_page/model/entity/worker/workerEntity.js index 6d649c3a..80e6144a 100644 --- a/src/all/background_page/model/entity/worker/workerEntity.js +++ b/src/all/background_page/model/entity/worker/workerEntity.js @@ -18,6 +18,7 @@ const ENTITY_NAME = 'Worker'; const STATUS_WAITING_CONNECTION = 'waiting_connection'; const STATUS_CONNECTED = 'connected'; +const STATUS_RECONNECTING = 'reconnecting'; class WorkerEntity extends Entity { /** @@ -64,7 +65,7 @@ class WorkerEntity extends Entity { }, "status": { "type": "string", - "enum": [this.STATUS_WAITING_CONNECTION, this.STATUS_CONNECTED] + "enum": [this.STATUS_WAITING_CONNECTION, this.STATUS_CONNECTED, this.STATUS_RECONNECTING] } } }; @@ -161,6 +162,14 @@ class WorkerEntity extends Entity { return this.status === STATUS_CONNECTED; } + /** + * Is reconnecting + * @return {boolean} + */ + get isReconnecting() { + return this.status === STATUS_RECONNECTING; + } + /* * ================================================== * Static properties getters @@ -175,7 +184,7 @@ class WorkerEntity extends Entity { } /** - * WorkerEntity.STATUS_APPROVED + * WorkerEntity.STATUS_WAITING_CONNECTION * @returns {string} */ static get STATUS_WAITING_CONNECTION() { @@ -183,12 +192,20 @@ class WorkerEntity extends Entity { } /** - * WorkerEntity.STATUS_REJECTED + * WorkerEntity.STATUS_CONNECTED * @returns {string} */ static get STATUS_CONNECTED() { return STATUS_CONNECTED; } + + /** + * WorkerEntity.STATUS_RECONNECTING + * @returns {string} + */ + static get STATUS_RECONNECTING() { + return STATUS_RECONNECTING; + } } export default WorkerEntity; diff --git a/src/all/background_page/sdk/port/portManager.js b/src/all/background_page/sdk/port/portManager.js index 3334be98..bdfe3cb5 100644 --- a/src/all/background_page/sdk/port/portManager.js +++ b/src/all/background_page/sdk/port/portManager.js @@ -48,7 +48,7 @@ class PortManager { const workerEntity = new WorkerEntity(worker); /* * If a port is already connected and is still in memory it should not be registered again - * In the MV3 case the memory is flushed when the servoce worker is down so the port should be able to reconnect + * In the MV3 case the memory is flushed when the service worker is down so the port should be able to reconnect */ if (!this.isPortExist(port.name) && await this.isKnownPortSender(workerEntity, port.sender)) { await this.updateWorkerStatus(workerEntity); diff --git a/src/all/background_page/service/tab/tabService.js b/src/all/background_page/service/tab/tabService.js index c3a2c631..6c59d46b 100644 --- a/src/all/background_page/service/tab/tabService.js +++ b/src/all/background_page/service/tab/tabService.js @@ -18,10 +18,13 @@ import WebNavigationService from "../webNavigation/webNavigationService"; import PromiseTimeoutService from "../../utils/promise/promiseTimeoutService"; import WorkerEntity from "../../model/entity/worker/workerEntity"; import WorkerService from "../worker/workerService"; +import BrowserTabService from "../ui/browserTab.service"; class TabService { /** - * Execute the navigation service process + * Handle tabs onUpdated events. + * @see /doc/worker-port-lifecycle.md to know more about worker and content script applications port lifecycle. + * * @param {number} tabId The tab id * @param {object} changeInfo The change info of the tab * @param {object} tab The tab @@ -45,40 +48,80 @@ class TabService { return; } + console.debug(`TabService::exec(id: ${tabId}, url: ${tab.url}): Navigation detected.`); // Get the worker on the main frame const worker = await WorkersSessionStorage.getWorkerOnMainFrame(tabId); - // If there is already a worker on the main frame + + /* + * If there is still a worker in memory relative to the tab top frame. It means that the tab was previously + * identified by a pagemod and a worker attached to it. + * + * If an application remains active on the tab, abort the pagemods identification process, otherwise start + * the process and flush the previous worker attached to this tab. + */ if (worker) { const workerEntity = new WorkerEntity(worker); - // If the worker status is still waiting and urls are the same - if (workerEntity.isWaitingConnection) { - /* - * The tabs onUpdated event can send too many events and to avoid multiple content script inserted an alarm will check after 300ms if the worker is still loading - * Especially when a user reload the page multiple times very fast the content script is not on the page and can block the worker waiting a port connection - * So, in this case an alarm is created and if the worker is still loading the navigation process is done manually. - * Also, in case of there is redirection the process wait the last update and trigger the alarm with the last tab url change - */ + /* + * A pagemod might already trying to attach a worker to the tab and is awaiting the content script to open or + * reopen the port and connect to it. It can happen when the tabs onUpdated event send too many events with the + * complete status, especially happening when a user reloads the tab multiple times very fast. + * + * To avoid this scenario, ensure that the worker attachment process triggered by the first tabs onUpdated + * event had time to complete its treatment. The attachment will be completed when the content script inserted + * in the tab successfully opened the port and connect to the background script. + * + * To avoid any deadlock on the tab, if the content script was not able to connect to the background page within + * 300ms, treat the last tabs onUpdated event and trigger a pagemod identification process on it. + */ + if (workerEntity.isWaitingConnection || workerEntity.isReconnecting) { + console.debug(`TabService::exec(id: ${tabId}): Waiting content script port initial connection or reconnection.`); await WorkerService.checkAndExecNavigationForWorkerWaitingConnection(workerEntity); return; - } else if (workerEntity.isConnected) { - // Get the port associate to a bootstrap application + } + + /* + * If a port associated to this worker still exists in memory, try to connect to the content script application + * that opened it. + */ + if (PortManager.isPortExist(worker.id)) { // Port exists in runtime memory. const port = PortManager.getPortById(workerEntity.id); - // Check if the url has the same origin + /* + * Only try to connect with the content script application if the origin of the tab url is similar to the + * origin of the application url referenced by the associated port. If the origin change, the tab DOM has + * been flushed and within any application on it. + */ if (hasUrlSameOrigin(port._port.sender.url, tab.url)) { try { - // Check if port is connected await PromiseTimeoutService.exec(port.request('passbolt.port.check')); + console.debug(`TabService::exec(id: ${tabId}): Content script application acknowledged presence on worker runtime memory port.`); return; } catch (error) { - console.debug('The port is not connected, navigation detected'); + console.debug(`TabService::exec(id: ${tabId}): No content script application acknowledged presence on worker runtime memory port.`, error); } } + } else { + /* + * If the worker port cannot be found in runtime memory, it could be due to the browser stopping the service + * worker (MV3) and with it disconnecting all the ports. If any application remains on the tab message it and + * request it to reconnect its port. + */ + try { + workerEntity.status = WorkerEntity.STATUS_RECONNECTING; + await WorkersSessionStorage.updateWorker(workerEntity); + await BrowserTabService.sendMessage(workerEntity, "passbolt.port.connect", workerEntity.id); + console.debug(`TabService::exec(id: ${tabId}): A content script application reconnected its port.`); + return; + } catch (error) { + console.debug(`TabService::exec(id: ${tabId}): No content script application was able to reconnect its port.`, error); + } } } + // Execute the process of a web navigation to detect pagemod and script to insert const frameDetails = mappingFrameDetailsFromTab(tab); await WebNavigationService.exec(frameDetails); + console.debug(`TabService::exec(id: ${tabId}): Trigger pagemods identification process.`); } } @@ -88,7 +131,7 @@ class TabService { * @return {{tabId, frameId: number, url}} */ function mappingFrameDetailsFromTab(tab) { - return { + return { // Mapping the tab info as a frame details to be compliant with webNavigation API frameId: 0, tabId: tab.id, diff --git a/src/all/background_page/service/tab/tabService.test.js b/src/all/background_page/service/tab/tabService.test.js index e80f49cc..669b8f03 100644 --- a/src/all/background_page/service/tab/tabService.test.js +++ b/src/all/background_page/service/tab/tabService.test.js @@ -18,22 +18,29 @@ import Port from "../../sdk/port"; import PortManager from "../../sdk/port/portManager"; import WebNavigationService from "../webNavigation/webNavigationService"; import TabService from "./tabService"; -import WorkerEntity from "../../model/entity/worker/workerEntity"; import BrowserTabService from "../ui/browserTab.service"; +import workerEntity from "../../model/entity/worker/workerEntity"; +import WorkerService from "../worker/workerService"; const mockGetPort = jest.spyOn(PortManager, "getPortById"); const mockIsPortExist = jest.spyOn(PortManager, "isPortExist"); const mockWorker = jest.spyOn(WorkersSessionStorage, "getWorkerOnMainFrame"); +const mockWorkerSessionStorageUpdate = jest.spyOn(WorkersSessionStorage, "updateWorker"); +const mockBrowserTabServiceSendMessage = jest.spyOn(BrowserTabService, "sendMessage"); +const mockBrowserTabServiceGetById = jest.spyOn(BrowserTabService, "getById"); +jest.spyOn(WorkerService, "execNavigationForWorkerWaitingConnection"); jest.spyOn(WebNavigationService, "exec"); describe("TabService", () => { beforeEach(async() => { jest.resetModules(); jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useFakeTimers(); }); describe("TabService::exec", () => { - it("Should do nothing if status is not completed", async() => { + it("exits if onUpdated event status is not completed", async() => { expect.assertions(3); // process await TabService.exec(1, {status: "loading"}, null); @@ -43,7 +50,7 @@ describe("TabService", () => { expect(WebNavigationService.exec).not.toHaveBeenCalled(); }); - it("Should do nothing if tab object has no url", async() => { + it("exits if tabs onUpdated event tab object has no url", async() => { expect.assertions(3); // process await TabService.exec(1, {status: "complete"}, {}); @@ -53,7 +60,7 @@ describe("TabService", () => { expect(WebNavigationService.exec).not.toHaveBeenCalled(); }); - it("Should do nothing if tab object has url about:blank", async() => { + it("exits if tabs onUpdated event tab object url protocol is not https or https", async() => { expect.assertions(3); // process await TabService.exec(1, {status: "complete"}, {url: "about:blank"}); @@ -63,7 +70,7 @@ describe("TabService", () => { expect(WebNavigationService.exec).not.toHaveBeenCalled(); }); - it("Should do nothing if worker on main frame and port is still connected", async() => { + it("exits if 1. a worker was attached to the tab 2. a port is found in runtime memory 3. a content script application is still connected to this port", async() => { expect.assertions(6); // data mocked const worker = readWorker(); @@ -79,6 +86,7 @@ describe("TabService", () => { jest.spyOn(global, "clearTimeout"); // mock function mockWorker.mockImplementationOnce(() => worker); + mockIsPortExist.mockImplementationOnce(() => true); mockGetPort.mockImplementationOnce(() => portWrapper); // process await TabService.exec(frameDetails.tabId, {status: "complete"}, {url: "https://passbolt.dev"}); @@ -94,7 +102,32 @@ describe("TabService", () => { expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); - it("Should exec if no worker on main frame", async() => { + it("exits if 1. a worker was attached to the tab 2. NO port is found in runtime memory 3. a content script application was able to reconnect the port", async() => { + expect.assertions(3); + // data mocked + const worker = readWorker(); + const frameDetails = { + url: "https://passbolt.dev", + tabId: worker.tabId, + frameId: worker.frameId + }; + // mock function + mockWorker.mockImplementationOnce(() => worker); + mockIsPortExist.mockImplementationOnce(() => false); + mockWorkerSessionStorageUpdate.mockImplementationOnce(() => {}); + mockBrowserTabServiceSendMessage.mockImplementationOnce(jest.fn); + + // process + await TabService.exec(frameDetails.tabId, {status: "complete"}, {url: "https://passbolt.dev"}); + + // expectations + expect(WorkersSessionStorage.getWorkerOnMainFrame).toHaveBeenCalledWith(frameDetails.tabId); + const expectWorker = {...worker, status: workerEntity.STATUS_RECONNECTING}; + expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(expect.objectContaining(expectWorker), 'passbolt.port.connect', expectWorker.id); + expect(WebNavigationService.exec).not.toHaveBeenCalled(); + }); + + it("triggers the pagemods identification process if no worker was previously attached to the tab", async() => { expect.assertions(5); // data mocked const frameDetails = { @@ -117,7 +150,7 @@ describe("TabService", () => { expect(spyOnAlarmClear).toHaveBeenCalledTimes(0); }); - it("Should exec if worker on main frame but not the same origin url", async() => { + it("triggers the pagemods identification process if 1. a worker was attached to the tab 2. a port is found in runtime memory 3. the port sender url and tab url origins are not similar", async() => { expect.assertions(5); // data mocked const worker = readWorker(); @@ -144,7 +177,7 @@ describe("TabService", () => { expect(spyOnAlarmClear).toHaveBeenCalledTimes(0); }); - it("Should exec if worker is on main frame and port is not connected", async() => { + it("triggers the pagemods identification process if 1. a worker was attached to the tab 2. A port is found in runtime memory 3. No content script application was able to aknowledge presence on the port", async() => { expect.assertions(5); // data mocked const worker = readWorker(); @@ -160,6 +193,7 @@ describe("TabService", () => { jest.spyOn(global, "clearTimeout"); // mock function mockWorker.mockImplementationOnce(() => worker); + mockIsPortExist.mockImplementationOnce(() => true); mockGetPort.mockImplementationOnce(() => portWrapper); // process await TabService.exec(frameDetails.tabId, {status: "complete"}, {id: frameDetails.tabId, url: frameDetails.url}); @@ -172,67 +206,98 @@ describe("TabService", () => { expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); - it("Should exec if worker is on main frame and port is not responding before timeout", async() => { - expect.assertions(5); + it("triggers the pagemods identification process if 1. a worker was attached to the tab 2. No port is found in runtime memory 3. No content script application was not able to reconnect its port.", async() => { + expect.assertions(3); // data mocked const worker = readWorker(); + const frameDetails = { + url: "https://passbolt.dev", + tabId: worker.tabId, + frameId: worker.frameId + }; + // mock function + mockWorker.mockImplementationOnce(() => worker); + mockIsPortExist.mockImplementationOnce(() => false); + mockBrowserTabServiceSendMessage.mockImplementationOnce(() => { + throw new Error("BrowserTabService.sendMessage was not able to reach the content script application"); + }); + + // process + await TabService.exec(frameDetails.tabId, {status: "complete"}, {id: frameDetails.tabId, url: "https://passbolt.dev"}); + + // expectations + expect(WorkersSessionStorage.getWorkerOnMainFrame).toHaveBeenCalledWith(frameDetails.tabId); + expect(BrowserTabService.sendMessage).not.toHaveBeenCalled(); + expect(WebNavigationService.exec).toHaveBeenCalledWith(frameDetails); + }); + + it("debounces the trigger of the pagemods identification process 1. a worker was attached to the tab 2. the worker is waiting for the content script application to connect its port", async() => { + expect.assertions(5); + // data mocked + const worker = readWorker({status: workerEntity.STATUS_WAITING_CONNECTION}); const frameDetails = { url: "https://url.com", tabId: worker.tabId, - frameId: 0 + frameId: 0, }; - const port = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId, url: frameDetails.url}); - const portWrapper = new Port(port); - jest.spyOn(portWrapper, "request").mockImplementationOnce(() => new Promise(() => null)); + jest.spyOn(WorkersSessionStorage, "getWorkerById").mockImplementationOnce(() => worker); jest.spyOn(global, "setTimeout"); jest.spyOn(global, "clearTimeout"); + // mock function mockWorker.mockImplementationOnce(() => worker); - mockGetPort.mockImplementationOnce(() => portWrapper); - jest.runAllTimers(); + mockBrowserTabServiceGetById.mockImplementationOnce(() => ({url: frameDetails.url})); + // process - const promise = TabService.exec(frameDetails.tabId, {status: "complete"}, {id: frameDetails.tabId, url: frameDetails.url}); + await TabService.exec(frameDetails.tabId, {status: "complete"}, {id: frameDetails.tabId, url: frameDetails.url}); // start the timeout promise jest.runAllTimers(); - await promise; + // Called 1 times after the timeout expect(global.setTimeout).toHaveBeenCalledTimes(1); - expect(global.clearTimeout).toHaveBeenCalledTimes(0); - // expectations + expect(global.clearTimeout).toHaveBeenCalledTimes(1); + expect(WorkersSessionStorage.getWorkerOnMainFrame).toHaveBeenCalledWith(frameDetails.tabId); - expect(PortManager.getPortById).toHaveBeenCalledWith(worker.id); + expect(WorkerService.execNavigationForWorkerWaitingConnection).toHaveBeenCalledWith(worker.id); + + // The double promise resolve seems to make possible the following assert of a service called by the timeout + await Promise.resolve(); + await Promise.resolve(); expect(WebNavigationService.exec).toHaveBeenCalledWith(frameDetails); }); - it("Should exec if worker is on main frame and waiting connection", async() => { + it("debounces the trigger of the pagemods identification process 1. a worker was attached to the tab 2. the worker is waiting for the content script application to reconnect its port ", async() => { expect.assertions(5); - jest.useFakeTimers(); - jest.clearAllTimers(); // data mocked - const worker = readWorker({status: WorkerEntity.STATUS_WAITING_CONNECTION}); + const worker = readWorker({status: workerEntity.STATUS_RECONNECTING}); const frameDetails = { url: "https://url.com", tabId: worker.tabId, - frameId: 0 + frameId: 0, }; + jest.spyOn(WorkersSessionStorage, "getWorkerById").mockImplementationOnce(() => worker); jest.spyOn(global, "setTimeout"); + jest.spyOn(global, "clearTimeout"); + // mock function mockWorker.mockImplementationOnce(() => worker); - jest.spyOn(WorkersSessionStorage, "getWorkerById").mockImplementationOnce(() => worker); - jest.spyOn(BrowserTabService, "getById").mockImplementationOnce(() => ({url: "https://url2.com"})); + mockBrowserTabServiceGetById.mockImplementationOnce(() => ({url: frameDetails.url})); + // process await TabService.exec(frameDetails.tabId, {status: "complete"}, {id: frameDetails.tabId, url: frameDetails.url}); - // expectations - expect(WorkersSessionStorage.getWorkerOnMainFrame).toHaveBeenCalledWith(frameDetails.tabId); - // Called 1 times during the execution + // start the timeout promise + jest.runAllTimers(); + + // Called 1 times after the timeout expect(global.setTimeout).toHaveBeenCalledTimes(1); - // start the timeout - jest.advanceTimersByTime(50); + expect(global.clearTimeout).toHaveBeenCalledTimes(1); + + expect(WorkersSessionStorage.getWorkerOnMainFrame).toHaveBeenCalledWith(frameDetails.tabId); + expect(WorkerService.execNavigationForWorkerWaitingConnection).toHaveBeenCalledWith(worker.id); + + // The double promise resolve seems to make possible the following assert of a service called by the timeout await Promise.resolve(); await Promise.resolve(); - expect(WorkersSessionStorage.getWorkerById).toHaveBeenCalledWith(worker.id); - expect(BrowserTabService.getById).toHaveBeenCalledWith(worker.tabId); - frameDetails.url = "https://url2.com"; expect(WebNavigationService.exec).toHaveBeenCalledWith(frameDetails); }); }); diff --git a/src/all/background_page/service/worker/workerService.js b/src/all/background_page/service/worker/workerService.js index b46bd1a1..fb20cc1a 100644 --- a/src/all/background_page/service/worker/workerService.js +++ b/src/all/background_page/service/worker/workerService.js @@ -87,30 +87,37 @@ class WorkerService { } /** - * Exec a navigation for worker block in waiting connection status + * Treat debounced navigation due to worker awaiting initial connection or reconnection with the content script + * application. * @private - * @param {string} workerId + * @param {string} workerId The worker identifier. * @return {Promise} */ static async execNavigationForWorkerWaitingConnection(workerId) { const worker = await WorkersSessionStorage.getWorkerById(workerId); if (!worker) { - console.debug("No worker has been found"); + console.debug(`WorkerService::execNavigationForWorkerWaitingConnection(${workerId}): Worker not found.`); return; } + const workerEntity = new WorkerEntity(worker); - if (workerEntity.isWaitingConnection) { - // Get the tab information by tab id to have the last url in case of redirection - const tab = await BrowserTabService.getById(workerEntity.tabId); - // Execute the process of a web navigation to detect pagemod and script to insert - const frameDetails = { - // Mapping the tab info as a frame details to be compliant with webNavigation API - frameId: 0, - tabId: worker.tabId, - url: tab.url - }; - await WebNavigationService.exec(frameDetails); + if (!workerEntity.isWaitingConnection && !workerEntity.isReconnecting) { + console.debug(`WorkerService::execNavigationForWorkerWaitingConnection(${workerId}): Worker port connected to the content script application.`); + return; } + + // Get the tab information by tab id to have the last url in case of redirection + const tab = await BrowserTabService.getById(workerEntity.tabId); + // Execute the process of a web navigation to detect pagemod and script to insert + const frameDetails = { + // Mapping the tab info as a frame details to be compliant with webNavigation API + frameId: 0, + tabId: worker.tabId, + url: tab.url + }; + + console.debug(`WorkerService::execNavigationForWorkerWaitingConnection(${workerId}): Trigger pagemods identification process.`); + await WebNavigationService.exec(frameDetails); } } diff --git a/src/all/background_page/service/worker/workerService.test.js b/src/all/background_page/service/worker/workerService.test.js index 161577e4..89648b59 100644 --- a/src/all/background_page/service/worker/workerService.test.js +++ b/src/all/background_page/service/worker/workerService.test.js @@ -147,6 +147,31 @@ describe("WorkerService", () => { expect(WebNavigationService.exec).toHaveBeenCalledWith(frameDetails); }); + it("Exec a navigation", async() => { + expect.assertions(3); + + const dto = readWorker({status: WorkerEntity.STATUS_RECONNECTING}); + + jest.spyOn(WorkersSessionStorage, "getWorkerById").mockImplementationOnce(() => dto); + jest.spyOn(BrowserTabService, "getById").mockImplementationOnce(() => ({url: "https://url.com"})); + jest.spyOn(WebNavigationService, "exec"); + + const frameDetails = { + url: "https://url.com", + tabId: dto.tabId, + frameId: 0 + }; + const entity = new WorkerEntity(dto); + await WorkerService.checkAndExecNavigationForWorkerWaitingConnection(entity); + + jest.advanceTimersByTime(50); + await Promise.resolve(); + await Promise.resolve(); + expect(entity.toDto()).toEqual(dto); + expect(BrowserTabService.getById).toHaveBeenCalledWith(dto.tabId); + expect(WebNavigationService.exec).toHaveBeenCalledWith(frameDetails); + }); + it("Should not exec a navigation if the worker is connected", async() => { expect.assertions(2); const dto = readWorker(); From d1333c222f01758f2ca07129d374d342c8466b65 Mon Sep 17 00:00:00 2001 From: Diego Lendoiro Date: Thu, 11 Apr 2024 17:44:06 +0200 Subject: [PATCH 36/56] PB-32984: use dependency proxy --- .gitlab-ci.yml | 4 ++-- .gitlab-ci/jobs/build.yml | 6 +++--- .gitlab-ci/jobs/test.yml | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c2b9303d..c6746f32 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: debian:stable-slim +image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/debian:stable-slim stages: - test @@ -18,4 +18,4 @@ include: - local: "/.gitlab-ci/jobs/test.yml" - local: "/.gitlab-ci/jobs/review.yml" - local: "/.gitlab-ci/jobs/publish.yml" - - local: ".gitlab-ci/jobs/release.yml" + - local: ".gitlab-ci/jobs/release.yml" \ No newline at end of file diff --git a/.gitlab-ci/jobs/build.yml b/.gitlab-ci/jobs/build.yml index 110ab262..6237b1ca 100644 --- a/.gitlab-ci/jobs/build.yml +++ b/.gitlab-ci/jobs/build.yml @@ -1,6 +1,6 @@ build: stage: build - image: node:18 + image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/node:18 extends: .rules artifacts: when: always @@ -22,7 +22,7 @@ build: build_mv3: stage: build - image: node:18 + image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/node:18 extends: .rules artifacts: when: always @@ -39,4 +39,4 @@ build_mv3: echo "Sending slack build notification" echo "================================" bash ./.gitlab-ci/scripts/bin/slack-status-messages.sh ":jigsaw: A new wild MV3 browser extension appeared! $CI_COMMIT_TAG" "$CI_PROJECT_URL/-/jobs/$CI_JOB_ID/artifacts/browse/dist/" - fi + fi \ No newline at end of file diff --git a/.gitlab-ci/jobs/test.yml b/.gitlab-ci/jobs/test.yml index a8f7ace2..61903dd1 100644 --- a/.gitlab-ci/jobs/test.yml +++ b/.gitlab-ci/jobs/test.yml @@ -1,6 +1,6 @@ tester: stage: test - image: node:18 + image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/node:18 coverage: /Lines\s* [:] ([\d\.]+)%/ extends: .rules script: @@ -17,7 +17,7 @@ tester: linter: stage: test - image: node:18 + image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/node:18 extends: .rules script: - npm ci @@ -29,4 +29,4 @@ audit: image: node:18 extends: .rules script: - - npm audit + - npm audit \ No newline at end of file From f2381f2f15c300da314809df791259abc4998983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Mon, 15 Apr 2024 11:39:22 +0000 Subject: [PATCH 37/56] Feature/pb 32604 ensure add listener from on extension update available controller are set on indexjs --- .../controller/auth/authLoginController.js | 2 +- .../auth/authLoginController.test.js | 4 +- .../auth/authenticationEventController.js | 62 ---------- ...authenticationEventController.test.data.js | 26 ----- .../authenticationEventController.test.js | 109 ------------------ .../controller/setup/signInSetupController.js | 2 +- .../setup/signInSetupController.test.js | 4 +- .../sso/ssoAuthenticationController.js | 2 +- .../sso/ssoAuthenticationController.test.js | 8 +- ...toolbarController.js => toolbarService.js} | 8 +- ...troller.test.js => toolbarService.test.js} | 40 +++---- .../event/appBootstrapEvents.js | 5 +- src/all/background_page/event/appEvents.js | 4 - .../event/informCallToActionEvents.js | 4 - src/all/background_page/index.js | 4 +- .../background_page/model/auth/authModel.js | 12 -- .../model/auth/authModel.test.js | 8 +- .../service/auth/postLoginService.js | 22 ++-- .../service/auth/postLoginService.test.js | 71 +++++++++++- .../service/auth/postLogoutService.js | 41 +++---- .../service/auth/postLogoutService.test.js | 61 ++++------ .../auth/startLoopAuthSessionCheckService.js | 10 -- .../startLoopAuthSessionCheckService.test.js | 13 +-- .../onExtensionUpdateAvailableService.js} | 31 +++-- ...onExtensionUpdateAvailableService.test.js} | 44 +++---- .../systemRequirementService.js | 6 +- .../service/worker/workerService.js | 14 ++- .../service/worker/workerService.test.js | 1 + src/chrome-mv3/index.js | 12 +- 29 files changed, 224 insertions(+), 406 deletions(-) delete mode 100644 src/all/background_page/controller/auth/authenticationEventController.js delete mode 100644 src/all/background_page/controller/auth/authenticationEventController.test.data.js delete mode 100644 src/all/background_page/controller/auth/authenticationEventController.test.js rename src/all/background_page/controller/{toolbarController.js => toolbarService.js} (97%) rename src/all/background_page/controller/{toolbarController.test.js => toolbarService.test.js} (88%) rename src/all/background_page/{controller/extension/onExtensionUpdateAvailableController.js => service/extension/onExtensionUpdateAvailableService.js} (74%) rename src/all/background_page/{controller/extension/onExtensionUpdateAvailableController.test.js => service/extension/onExtensionUpdateAvailableService.test.js} (85%) diff --git a/src/all/background_page/controller/auth/authLoginController.js b/src/all/background_page/controller/auth/authLoginController.js index ad79da5d..436a8300 100644 --- a/src/all/background_page/controller/auth/authLoginController.js +++ b/src/all/background_page/controller/auth/authLoginController.js @@ -105,7 +105,7 @@ class AuthLoginController { KeepSessionAliveService.start(), ]); } - await PostLoginService.postLogin(); + await PostLoginService.exec(); await this.registerRememberMeOption(rememberMe); } catch (error) { if (!(error instanceof UserAlreadyLoggedInError)) { diff --git a/src/all/background_page/controller/auth/authLoginController.test.js b/src/all/background_page/controller/auth/authLoginController.test.js index 09b57ab0..6ecee3a6 100644 --- a/src/all/background_page/controller/auth/authLoginController.test.js +++ b/src/all/background_page/controller/auth/authLoginController.test.js @@ -64,7 +64,7 @@ describe("AuthLoginController", () => { jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); jest.spyOn(PassphraseStorageService, "set"); - jest.spyOn(PostLoginService, "postLogin"); + jest.spyOn(PostLoginService, "exec"); jest.spyOn(browser.tabs, "update"); expect.assertions(4); @@ -81,7 +81,7 @@ describe("AuthLoginController", () => { } else { expect(browser.tabs.update).not.toHaveBeenCalled(); } - expect(PostLoginService.postLogin).toHaveBeenCalledWith(); + expect(PostLoginService.exec).toHaveBeenCalledTimes(1); }); }); diff --git a/src/all/background_page/controller/auth/authenticationEventController.js b/src/all/background_page/controller/auth/authenticationEventController.js deleted file mode 100644 index b52baff1..00000000 --- a/src/all/background_page/controller/auth/authenticationEventController.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 3.3.0 - */ - -/** - * Controller related to the in-form call-to-action - */ -class AuthenticationEventController { - /** - * AuthenticationEventController initialiser - * @param {Worker} worker - */ - static initialise(worker) { - this.worker = worker; - } - - /** - * Start event listeners. - */ - static startListen() { - this.isPortConnected = true; - this.worker.port._port.onDisconnect.addListener(this.handlePortDisconnected); - } - - /** - * Handle when the user is logged in. - */ - static handleUserLoggedIn() { - if (this.isPortConnected) { - this.worker?.port.emit("passbolt.auth.after-login"); - } - } - - /** - * Handle when the user is logged out. - */ - static handleUserLoggedOut() { - if (this.isPortConnected) { - this.worker?.port.emit("passbolt.auth.after-logout"); - } - } - - /** - * Handle when the port is disconnected - */ - static handlePortDisconnected() { - this.isPortConnected = false; - } -} - - -export default AuthenticationEventController; diff --git a/src/all/background_page/controller/auth/authenticationEventController.test.data.js b/src/all/background_page/controller/auth/authenticationEventController.test.data.js deleted file mode 100644 index 70d7d64e..00000000 --- a/src/all/background_page/controller/auth/authenticationEventController.test.data.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 4.7.0 - */ - -export const defaultWorker = (data = {}) => ({ - tabId: 1, - port: { - emit: jest.fn(), - _port: { - onDisconnect: { - addListener: jest.fn(), - }, - }, - }, - ...data -}); diff --git a/src/all/background_page/controller/auth/authenticationEventController.test.js b/src/all/background_page/controller/auth/authenticationEventController.test.js deleted file mode 100644 index 0fbe9a67..00000000 --- a/src/all/background_page/controller/auth/authenticationEventController.test.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Passbolt ~ Open source password manager for teams - * Copyright (c) Passbolt SA (https://www.passbolt.com) - * - * Licensed under GNU Affero General Public License version 3 of the or any later version. - * For full copyright and license information, please see the LICENSE.txt - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) - * @license https://opensource.org/licenses/AGPL-3.0 AGPL License - * @link https://www.passbolt.com Passbolt(tm) - * @since 4.7.0 - */ - -import AuthenticationEventController from "./authenticationEventController"; -import {defaultWorker} from "./authenticationEventController.test.data"; - -describe("AuthenticationEventController", () => { - describe("::initialise", () => { - it("should accept a worker and register it", () => { - expect.assertions(2); - const worker = defaultWorker(); - - expect(AuthenticationEventController.worker).toBeUndefined(); - AuthenticationEventController.initialise(worker); - expect(AuthenticationEventController.worker).toStrictEqual(worker); - }); - }); - - describe("::startListen", () => { - it("should initialise its state and listen to port.onDisconnect", () => { - expect.assertions(4); - const worker = defaultWorker(); - - expect(AuthenticationEventController.isPortConnected).toBeFalsy(); - AuthenticationEventController.initialise(worker); - AuthenticationEventController.startListen(); - - expect(AuthenticationEventController.isPortConnected).toStrictEqual(true); - expect(worker.port._port.onDisconnect.addListener).toHaveBeenCalledTimes(1); - expect(worker.port._port.onDisconnect.addListener).toHaveBeenCalledWith(AuthenticationEventController.handlePortDisconnected); - }); - }); - - describe("::handleUserLoggedIn", () => { - it("should emit 'passbolt.auth.after-login' on port if it is marked as connected", () => { - expect.assertions(2); - - const worker = defaultWorker(); - AuthenticationEventController.initialise(worker); - AuthenticationEventController.startListen(); - AuthenticationEventController.handleUserLoggedIn(); - - expect(worker.port.emit).toHaveBeenCalledTimes(1); - expect(worker.port.emit).toHaveBeenCalledWith("passbolt.auth.after-login"); - }); - - it("should not emit 'passbolt.auth.after-login' on port if it is marked as disconnected", () => { - expect.assertions(1); - - const worker = defaultWorker(); - AuthenticationEventController.initialise(worker); - AuthenticationEventController.startListen(); - AuthenticationEventController.isPortConnected = false; - AuthenticationEventController.handleUserLoggedIn(); - - expect(worker.port.emit).not.toHaveBeenCalled(); - }); - }); - - describe("::handleUserLoggedOut", () => { - it("should emit 'passbolt.auth.after-logout' on port if it is marked as connected", () => { - expect.assertions(2); - - const worker = defaultWorker(); - AuthenticationEventController.initialise(worker); - AuthenticationEventController.startListen(); - AuthenticationEventController.handleUserLoggedOut(); - - expect(worker.port.emit).toHaveBeenCalledTimes(1); - expect(worker.port.emit).toHaveBeenCalledWith("passbolt.auth.after-logout"); - }); - - it("should not emit 'passbolt.auth.after-login' on port if it is marked as disconnected", () => { - expect.assertions(1); - const worker = defaultWorker(); - - AuthenticationEventController.initialise(worker); - AuthenticationEventController.startListen(); - AuthenticationEventController.isPortConnected = false; - AuthenticationEventController.handleUserLoggedOut(); - - expect(worker.port.emit).not.toHaveBeenCalled(); - }); - }); - - describe("::handlePortDisconnected", () => { - it("should mark the port as disconnected", () => { - expect.assertions(1); - const worker = defaultWorker(); - - AuthenticationEventController.initialise(worker); - AuthenticationEventController.startListen(); - AuthenticationEventController.handlePortDisconnected(); - - expect(AuthenticationEventController.isPortConnected).toStrictEqual(false); - }); - }); -}); diff --git a/src/all/background_page/controller/setup/signInSetupController.js b/src/all/background_page/controller/setup/signInSetupController.js index 2f46a67a..9faca720 100644 --- a/src/all/background_page/controller/setup/signInSetupController.js +++ b/src/all/background_page/controller/setup/signInSetupController.js @@ -78,7 +78,7 @@ class SignInSetupController { KeepSessionAliveService.start(), ]); } - await PostLoginService.postLogin(); + await PostLoginService.exec(); await this.redirectToApp(); } diff --git a/src/all/background_page/controller/setup/signInSetupController.test.js b/src/all/background_page/controller/setup/signInSetupController.test.js index f1421ac2..c40716b5 100644 --- a/src/all/background_page/controller/setup/signInSetupController.test.js +++ b/src/all/background_page/controller/setup/signInSetupController.test.js @@ -85,7 +85,7 @@ describe("SignInSetupController", () => { fetch.doMockOnceIf(new RegExp('/sso/settings/current.json'), () => mockApiResponse(withAzureSsoSettings())); jest.spyOn(browser.cookies, "get").mockImplementationOnce(() => ({value: "csrf-token"})); jest.spyOn(PassphraseStorageService, "set"); - jest.spyOn(PostLoginService, "postLogin"); + jest.spyOn(PostLoginService, "exec"); jest.spyOn(browser.tabs, "update"); SsoDataStorage.setMockedData(null); @@ -102,7 +102,7 @@ describe("SignInSetupController", () => { await controller.exec(true); expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, runtimeMemory.passphrase); expect(PassphraseStorageService.set).toHaveBeenCalledWith(runtimeMemory.passphrase, -1); - expect(PostLoginService.postLogin).toHaveBeenCalled(); + expect(PostLoginService.exec).toHaveBeenCalled(); expect(GenerateSsoKitService.generate).toHaveBeenCalledWith("ada@passbolt.com", "azure"); expect(GenerateSsoKitService.generate).toHaveBeenCalledTimes(1); expect(browser.tabs.update).toHaveBeenCalledWith(1, {url: account.domain}); diff --git a/src/all/background_page/controller/sso/ssoAuthenticationController.js b/src/all/background_page/controller/sso/ssoAuthenticationController.js index 1d74053d..49cb3838 100644 --- a/src/all/background_page/controller/sso/ssoAuthenticationController.js +++ b/src/all/background_page/controller/sso/ssoAuthenticationController.js @@ -94,7 +94,7 @@ class SsoAuthenticationController { PassphraseStorageService.set(passphrase, -1), KeepSessionAliveService.start(), ]); - await PostLoginService.postLogin(); + await PostLoginService.exec(); if (isInQuickAccessMode) { await this.ensureRedirectionInQuickaccessMode(); } diff --git a/src/all/background_page/controller/sso/ssoAuthenticationController.test.js b/src/all/background_page/controller/sso/ssoAuthenticationController.test.js index 82515598..8c8142ef 100644 --- a/src/all/background_page/controller/sso/ssoAuthenticationController.test.js +++ b/src/all/background_page/controller/sso/ssoAuthenticationController.test.js @@ -112,13 +112,13 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const controller = new SsoAuthenticationController(null, null, defaultApiClientOptions(), account); jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); jest.spyOn(PassphraseStorageService, "set"); - jest.spyOn(PostLoginService, "postLogin").mockImplementation(() => {}); + jest.spyOn(PostLoginService, "exec").mockImplementation(() => {}); await controller.exec(scenario.providerId); expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, userPassphrase); expect(PassphraseStorageService.set).toHaveBeenCalledWith(userPassphrase, -1); - expect(PostLoginService.postLogin).toHaveBeenCalled(); + expect(PostLoginService.exec).toHaveBeenCalled(); }); it(`Should sign the user using a third party: ${scenario.providerId}`, async() => { @@ -142,13 +142,13 @@ each(scenarios).describe("SsoAuthenticationController", scenario => { const controller = new SsoAuthenticationController(null, null, defaultApiClientOptions(), account); jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); jest.spyOn(PassphraseStorageService, "set"); - jest.spyOn(PostLoginService, "postLogin").mockImplementation(() => {}); + jest.spyOn(PostLoginService, "exec").mockImplementation(() => {}); await controller.exec(scenario.providerId, true); expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, userPassphrase); expect(PassphraseStorageService.set).toHaveBeenCalledWith(userPassphrase, -1); - expect(PostLoginService.postLogin).toHaveBeenCalled(); + expect(PostLoginService.exec).toHaveBeenCalled(); const expectedQuickAccessCallParameters = [ {name: "uiMode", value: "detached"}, diff --git a/src/all/background_page/controller/toolbarController.js b/src/all/background_page/controller/toolbarService.js similarity index 97% rename from src/all/background_page/controller/toolbarController.js rename to src/all/background_page/controller/toolbarService.js index 4385ffa8..fff07f82 100644 --- a/src/all/background_page/controller/toolbarController.js +++ b/src/all/background_page/controller/toolbarService.js @@ -18,7 +18,7 @@ import {TabController as tabsController} from "./tabsController"; import GetLegacyAccountService from "../service/account/getLegacyAccountService"; import BuildApiClientOptionsService from "../service/account/buildApiClientOptionsService"; -class ToolbarController { +class ToolbarService { initialise() { // Initially, set the browser extension icon as inactive BrowserExtensionIconService.deactivate(); @@ -183,7 +183,7 @@ class ToolbarController { } } -const toolbarController = new ToolbarController(); +const toolbarService = new ToolbarService(); -// Exports the Toolbar controller object. -export default toolbarController; +// Exports the Toolbar service object. +export default toolbarService; diff --git a/src/all/background_page/controller/toolbarController.test.js b/src/all/background_page/controller/toolbarService.test.js similarity index 88% rename from src/all/background_page/controller/toolbarController.test.js rename to src/all/background_page/controller/toolbarService.test.js index 2b48427e..5d3c707b 100644 --- a/src/all/background_page/controller/toolbarController.test.js +++ b/src/all/background_page/controller/toolbarService.test.js @@ -12,7 +12,7 @@ * @since 3.3.0 */ -import toolbarController from "./toolbarController"; +import toolbarService from "./toolbarService"; import AccountEntity from "../model/entity/account/accountEntity"; import {defaultAccountDto} from "../model/entity/account/accountEntity.test.data"; import GetLegacyAccountService from "../service/account/getLegacyAccountService"; @@ -48,7 +48,7 @@ describe("ToolbarController", () => { it("Given the user is on a tab which has no suggested resource for, it should activate the passbolt icon and display no suggested resource.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - toolbarController.initialise(); + toolbarService.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementation(() => [{url: 'https://www.wherever.com'}]); @@ -56,7 +56,7 @@ describe("ToolbarController", () => { jest.spyOn(ResourceLocalStorage, "get").mockImplementation(() => defaultResourceDtosCollection()); jest.spyOn(ResourceTypeLocalStorage, "get").mockImplementation(() => resourceTypesCollectionDto()); - await toolbarController.handleUserLoggedIn(); + await toolbarService.handleUserLoggedIn(); expect(browserExtensionIconServiceActivateMock).toHaveBeenCalled(); expect(browserExtensionIconServiceSetCountMock).toHaveBeenCalledWith(0); @@ -65,7 +65,7 @@ describe("ToolbarController", () => { it("Given the user is on a tab which has suggested resource for, it should activate the passbolt icon and display the number of suggested resources.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - toolbarController.initialise(); + toolbarService.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementation(() => [{url: 'https://www.passbolt.com'}]); @@ -73,7 +73,7 @@ describe("ToolbarController", () => { jest.spyOn(ResourceLocalStorage, "get").mockImplementation(() => defaultResourceDtosCollection()); jest.spyOn(ResourceTypeLocalStorage, "get").mockImplementation(() => resourceTypesCollectionDto()); - await toolbarController.handleUserLoggedIn(); + await toolbarService.handleUserLoggedIn(); expect(browserExtensionIconServiceActivateMock).toHaveBeenCalled(); expect(browserExtensionIconServiceSetCountMock).toHaveBeenCalledWith(4); @@ -84,7 +84,7 @@ describe("ToolbarController", () => { it("Given the user signs out, it should deactivate the passbolt icon.", async() => { expect.assertions(1); const account = new AccountEntity(defaultAccountDto()); - toolbarController.initialise(); + toolbarService.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementation(() => [{url: 'https://www.wherever.com'}]); @@ -92,8 +92,8 @@ describe("ToolbarController", () => { jest.spyOn(ResourceLocalStorage, "get").mockImplementation(() => defaultResourceDtosCollection()); jest.spyOn(ResourceTypeLocalStorage, "get").mockImplementation(() => resourceTypesCollectionDto()); - await toolbarController.handleUserLoggedIn(); - await toolbarController.handleUserLoggedOut(); + await toolbarService.handleUserLoggedIn(); + await toolbarService.handleUserLoggedOut(); expect(browserExtensionIconServiceDeactivateMock).toHaveBeenCalled(); }); @@ -103,7 +103,7 @@ describe("ToolbarController", () => { it("Given the user navigates to a url having suggested resources, it should change the passbolt icon suggested resources count.", async() => { expect.assertions(1); const account = new AccountEntity(defaultAccountDto()); - toolbarController.initialise(); + toolbarService.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.wherever.com'}]); @@ -111,10 +111,10 @@ describe("ToolbarController", () => { jest.spyOn(ResourceLocalStorage, "get").mockImplementation(() => defaultResourceDtosCollection()); jest.spyOn(ResourceTypeLocalStorage, "get").mockImplementation(() => resourceTypesCollectionDto()); - await toolbarController.handleUserLoggedIn(); + await toolbarService.handleUserLoggedIn(); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.passbolt.com'}]); - await toolbarController.handleSuggestedResourcesOnUpdatedTab(null, {url: "https://www.passbolt.com"}); + await toolbarService.handleSuggestedResourcesOnUpdatedTab(null, {url: "https://www.passbolt.com"}); expect(browserExtensionIconServiceSetCountMock).toHaveBeenLastCalledWith(4); }); @@ -124,7 +124,7 @@ describe("ToolbarController", () => { it("Given the user activates a tab having suggested resources, it should change the passbolt icon suggested resources count.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - toolbarController.initialise(); + toolbarService.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.wherever.com'}]); @@ -132,10 +132,10 @@ describe("ToolbarController", () => { jest.spyOn(ResourceLocalStorage, "get").mockImplementation(() => defaultResourceDtosCollection()); jest.spyOn(ResourceTypeLocalStorage, "get").mockImplementation(() => resourceTypesCollectionDto()); - await toolbarController.handleUserLoggedIn(); + await toolbarService.handleUserLoggedIn(); expect(browserExtensionIconServiceSetCountMock).toHaveBeenLastCalledWith(0); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.passbolt.com'}]); - await toolbarController.handleSuggestedResourcesOnActivatedTab(); + await toolbarService.handleSuggestedResourcesOnActivatedTab(); expect(browserExtensionIconServiceSetCountMock).toHaveBeenLastCalledWith(4); }); }); @@ -144,7 +144,7 @@ describe("ToolbarController", () => { it("Given the user switches to a window with a tab having suggested resources, it should change the passbolt icon suggested resources count.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - toolbarController.initialise(); + toolbarService.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.wherever.com'}]); @@ -152,17 +152,17 @@ describe("ToolbarController", () => { jest.spyOn(ResourceLocalStorage, "get").mockImplementation(() => defaultResourceDtosCollection()); jest.spyOn(ResourceTypeLocalStorage, "get").mockImplementation(() => resourceTypesCollectionDto()); - await toolbarController.handleUserLoggedIn(); + await toolbarService.handleUserLoggedIn(); expect(browserExtensionIconServiceSetCountMock).toHaveBeenLastCalledWith(0); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.passbolt.com'}]); - await toolbarController.handleSuggestedResourcesOnFocusedWindow(42); + await toolbarService.handleSuggestedResourcesOnFocusedWindow(42); expect(browserExtensionIconServiceSetCountMock).toHaveBeenLastCalledWith(4); }); it("Given the user switches to another application, it should reset the passbolt icon suggested resources count.", async() => { expect.assertions(2); const account = new AccountEntity(defaultAccountDto()); - toolbarController.initialise(); + toolbarService.initialise(); jest.spyOn(browser.cookies, "get").mockImplementation(() => ({value: "csrf-token"})); jest.spyOn(browser.tabs, "query").mockImplementationOnce(() => [{url: 'https://www.passbolt.com'}]); @@ -170,9 +170,9 @@ describe("ToolbarController", () => { jest.spyOn(ResourceLocalStorage, "get").mockImplementation(() => defaultResourceDtosCollection()); jest.spyOn(ResourceTypeLocalStorage, "get").mockImplementation(() => resourceTypesCollectionDto()); - await toolbarController.handleUserLoggedIn(); + await toolbarService.handleUserLoggedIn(); expect(browserExtensionIconServiceSetCountMock).toHaveBeenLastCalledWith(4); - await toolbarController.handleSuggestedResourcesOnFocusedWindow(browser.windows.WINDOW_ID_NONE); + await toolbarService.handleSuggestedResourcesOnFocusedWindow(browser.windows.WINDOW_ID_NONE); expect(browserExtensionIconServiceSetCountMock).toHaveBeenLastCalledWith(0); }); }); diff --git a/src/all/background_page/event/appBootstrapEvents.js b/src/all/background_page/event/appBootstrapEvents.js index bbb574ff..4ab5b24c 100644 --- a/src/all/background_page/event/appBootstrapEvents.js +++ b/src/all/background_page/event/appBootstrapEvents.js @@ -10,7 +10,7 @@ * @license https://opensource.org/licenses/AGPL-3.0 AGPL License * @link https://www.passbolt.com Passbolt(tm) */ -import AuthModel from "../model/auth/authModel"; +import PostLogoutService from '../service/auth/postLogoutService'; /** * Listens to the application bootstrap events @@ -26,12 +26,11 @@ const listen = function(worker, apiClientOptions, account) { * @deprecated will be removed with v4. Helps to support legacy appjs logout. */ worker.port.on('passbolt.app-boostrap.navigate-to-logout', async() => { - const auth = new AuthModel(apiClientOptions); const url = `${account.domain}/auth/logout`; try { await chrome.tabs.update(worker.tab.id, {url: url}); - await auth.postLogout(); + await PostLogoutService.exec(); } catch (error) { console.error(error); } diff --git a/src/all/background_page/event/appEvents.js b/src/all/background_page/event/appEvents.js index a1d9f2db..9ff30137 100644 --- a/src/all/background_page/event/appEvents.js +++ b/src/all/background_page/event/appEvents.js @@ -31,7 +31,6 @@ import SaveSsoSettingsAsDraftController from "../controller/sso/saveSsoSettingsA import ActivateSsoSettingsController from "../controller/sso/activateSsoSettingsController"; import DeleteSsoSettingsController from "../controller/sso/deleteSsoSettingsController"; import GenerateSsoKitController from "../controller/auth/generateSsoKitController"; -import AuthenticationEventController from "../controller/auth/authenticationEventController"; import FindMeController from "../controller/rbac/findMeController"; import GetOrFindPasswordPoliciesController from "../controller/passwordPolicies/getOrFindPasswordPoliciesController"; import SavePasswordPoliciesController from "../controller/passwordPolicies/savePasswordPoliciesController"; @@ -45,9 +44,6 @@ import DeletePasswordExpirySettingsController from "../controller/passwordExpiry import GetOrFindPasswordExpirySettingsController from "../controller/passwordExpiry/getOrFindPasswordExpirySettingsController"; const listen = function(worker, apiClientOptions, account) { - AuthenticationEventController.initialise(worker); - AuthenticationEventController.startListen(); - /* * Whenever the (React) app changes his route * @listens passbolt.app.route-changed diff --git a/src/all/background_page/event/informCallToActionEvents.js b/src/all/background_page/event/informCallToActionEvents.js index 42bea779..99495428 100644 --- a/src/all/background_page/event/informCallToActionEvents.js +++ b/src/all/background_page/event/informCallToActionEvents.js @@ -11,7 +11,6 @@ * @link https://www.passbolt.com Passbolt(tm) */ import InformCallToActionController from "../controller/informCallToActionController/informCallToActionController"; -import AuthenticationEventController from "../controller/auth/authenticationEventController"; import AuthCheckStatusController from "../controller/auth/authCheckStatusController"; /** @@ -21,9 +20,6 @@ import AuthCheckStatusController from "../controller/auth/authCheckStatusControl * @param {AccountEntity} account the user account */ const listen = function(worker, apiClientOptions, account) { - AuthenticationEventController.initialise(worker); - AuthenticationEventController.startListen(); - /* * Whenever the in-form call-to-action status is required * @listens passbolt.in-form-cta.check-status diff --git a/src/all/background_page/index.js b/src/all/background_page/index.js index 19f2b7be..b35352c0 100644 --- a/src/all/background_page/index.js +++ b/src/all/background_page/index.js @@ -11,7 +11,7 @@ import OnExtensionInstalledController from "./controller/extension/onExtensionIn import TabService from "./service/tab/tabService"; import User from "./model/user"; import Log from "./model/log"; -import OnExtensionUpdateAvailableController from "./controller/extension/onExtensionUpdateAvailableController"; +import OnExtensionUpdateAvailableService from "./service/extension/onExtensionUpdateAvailableService"; import CheckAuthStatusService from "./service/auth/checkAuthStatusService"; import GlobalAlarmService from "./service/alarm/globalAlarmService"; import PostLoginService from "./service/auth/postLoginService"; @@ -62,7 +62,7 @@ browser.runtime.onInstalled.addListener(OnExtensionInstalledController.exec); /** * On update available of the extension, update it when the user is logout */ -browser.runtime.onUpdateAvailable.addListener(OnExtensionUpdateAvailableController.exec); +browser.runtime.onUpdateAvailable.addListener(OnExtensionUpdateAvailableService.exec); /** * Add listener on startup diff --git a/src/all/background_page/model/auth/authModel.js b/src/all/background_page/model/auth/authModel.js index 3709ef7c..16450870 100644 --- a/src/all/background_page/model/auth/authModel.js +++ b/src/all/background_page/model/auth/authModel.js @@ -11,7 +11,6 @@ * @link https://www.passbolt.com Passbolt(tm) */ import AuthLogoutService from 'passbolt-styleguide/src/shared/services/api/auth/AuthLogoutService'; -import AuthStatusLocalStorage from "../../service/local_storage/authStatusLocalStorage"; import PostLogoutService from '../../service/auth/postLogoutService'; class AuthModel { @@ -31,17 +30,6 @@ class AuthModel { */ async logout() { await this.authLogoutService.logout(); - await this.postLogout(); - } - - /** - * Post logout - * @returns {Promise} - */ - async postLogout() { - const isAuthenticated = false; - const isMfaRequired = false; - await AuthStatusLocalStorage.set(isAuthenticated, isMfaRequired); await PostLogoutService.exec(); } } diff --git a/src/all/background_page/model/auth/authModel.test.js b/src/all/background_page/model/auth/authModel.test.js index 43f6a75a..c483c924 100644 --- a/src/all/background_page/model/auth/authModel.test.js +++ b/src/all/background_page/model/auth/authModel.test.js @@ -14,6 +14,7 @@ import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; import AuthModel from "../../model/auth/authModel"; import AuthLogoutService from "passbolt-styleguide/src/shared/services/api/auth/AuthLogoutService"; +import PostLogoutService from "../../service/auth/postLogoutService"; beforeEach(async() => { jest.clearAllMocks(); @@ -22,18 +23,17 @@ beforeEach(async() => { describe("AuthModel", () => { describe("AuthModel::logout", () => { it("Should call the AuthLogoutService to logout and dispatch a logout event", async() => { - expect.assertions(3); + expect.assertions(2); const apiClientOptions = defaultApiClientOptions(); const model = new AuthModel(apiClientOptions); const logoutServiceSpy = jest.spyOn(AuthLogoutService.prototype, "logout").mockImplementation(() => {}); - const dispatchEventSpy = jest.spyOn(self, "dispatchEvent"); + const postLogoutSpy = jest.spyOn(PostLogoutService, "exec"); await model.logout(); expect(logoutServiceSpy).toHaveBeenCalledTimes(1); - expect(dispatchEventSpy).toHaveBeenCalledTimes(1); - expect(dispatchEventSpy).toHaveBeenCalledWith(new Event("passbolt.auth.after-logout")); + expect(postLogoutSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/all/background_page/service/auth/postLoginService.js b/src/all/background_page/service/auth/postLoginService.js index 48fb7af0..8c883707 100644 --- a/src/all/background_page/service/auth/postLoginService.js +++ b/src/all/background_page/service/auth/postLoginService.js @@ -12,22 +12,28 @@ * @since 4.7.0 */ -import AuthenticationEventController from "../../controller/auth/authenticationEventController"; -import toolbarController from "../../controller/toolbarController"; +import toolbarService from "../../controller/toolbarService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; +import InformCallToActionPagemod from "../../pagemod/informCallToActionPagemod"; +import WorkerService from "../worker/workerService"; + class PostLoginService { /** * Post login * @returns {Promise} */ - static async postLogin() { + static async exec() { + await PostLoginService.sendLoginEventForWorkers(); await StartLoopAuthSessionCheckService.exec(); - toolbarController.handleUserLoggedIn(); - AuthenticationEventController.handleUserLoggedIn(); + toolbarService.handleUserLoggedIn(); + } - //@todo remove the dispatch event once every 'after-login' listeners are handled here - const event = new Event('passbolt.auth.after-login'); - self.dispatchEvent(event); + /** + * Send login event on workers + * @return {Promise} + */ + static async sendLoginEventForWorkers() { + await WorkerService.emitOnWorkersWithName('passbolt.auth.after-login', [InformCallToActionPagemod.appName]); } } diff --git a/src/all/background_page/service/auth/postLoginService.test.js b/src/all/background_page/service/auth/postLoginService.test.js index 085f4e92..000c8e7c 100644 --- a/src/all/background_page/service/auth/postLoginService.test.js +++ b/src/all/background_page/service/auth/postLoginService.test.js @@ -11,27 +11,86 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 4.7.0 */ +import WorkersSessionStorage from "../sessionStorage/workersSessionStorage"; +import {readWorker} from "../../model/entity/worker/workerEntity.test.data"; +import WorkerEntity from "../../model/entity/worker/workerEntity"; +import BrowserTabService from "../ui/browserTab.service"; +import PortManager from "../../sdk/port/portManager"; +import {mockPort} from "../../sdk/port/portManager.test.data"; +import Port from "../../sdk/port"; import MockExtension from "../../../../../test/mocks/mockExtension"; +import toolbarController from "../../controller/toolbarService"; import PostLoginService from "./postLoginService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; +import InformCallToActionPagemod from "../../pagemod/informCallToActionPagemod"; beforeEach(async() => { jest.clearAllMocks(); + await MockExtension.withConfiguredAccount(); }); describe("PostLoginService", () => { describe("PostLoinService::postLogin", () => { - it("Should call the start loop auth session check service and dispatch a post login event", async() => { + it("Should send message to awake port and send post logout event", async() => { + expect.assertions(4); + // data mocked + const worker = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); + + const workerCta = readWorker({name: InformCallToActionPagemod.appName}); + await WorkersSessionStorage.addWorker(new WorkerEntity(workerCta)); + const informCtaPort = mockPort({name: workerCta.id, tabId: workerCta.tabId, frameId: workerCta.frameId}); + const informCtaPortWrapper = new Port(informCtaPort); + + // function mocked + jest.spyOn(BrowserTabService, "sendMessage").mockImplementation(jest.fn()); + jest.spyOn(PortManager, "getPortById").mockImplementationOnce(() => informCtaPortWrapper); + jest.spyOn(informCtaPortWrapper, "emit"); + + // execution + await PostLoginService.exec(); + + // Waiting all promises are resolved + await Promise.resolve(); + + // expectations + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(1); + expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(workerCta, "passbolt.port.connect", workerCta.id); + expect(informCtaPortWrapper.emit).toHaveBeenCalledWith('passbolt.auth.after-login'); + expect(informCtaPortWrapper.emit).toHaveBeenCalledTimes(1); + }); + + it("Should not send messages if no workers needs to receive post logout event", async() => { + expect.assertions(2); + // data mocked + const worker = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); + const worker2 = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker2)); + const worker3 = readWorker(); + await WorkersSessionStorage.addWorker(new WorkerEntity(worker3)); + // function mocked + jest.spyOn(BrowserTabService, "sendMessage"); + jest.spyOn(PortManager, "isPortExist").mockImplementation(() => true); + // execution + await PostLoginService.exec(); + // Waiting all promises are resolved + await Promise.resolve(); + // expectations + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(0); + expect(PortManager.isPortExist).toHaveBeenCalledTimes(0); + }); + + it("Should call all the services that reacts on a post login event", async() => { expect.assertions(2); - await MockExtension.withConfiguredAccount(); jest.spyOn(StartLoopAuthSessionCheckService, "exec"); - jest.spyOn(self, "dispatchEvent"); + jest.spyOn(toolbarController, "handleUserLoggedIn"); - await PostLoginService.postLogin(); + await PostLoginService.exec(); - expect(StartLoopAuthSessionCheckService.exec).toHaveBeenCalled(); - expect(self.dispatchEvent).toHaveBeenCalledWith(new Event('passbolt.auth.after-login')); + expect(StartLoopAuthSessionCheckService.exec).toHaveBeenCalledTimes(1); + expect(toolbarController.handleUserLoggedIn).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/all/background_page/service/auth/postLogoutService.js b/src/all/background_page/service/auth/postLogoutService.js index bddd34c3..a822a4d2 100644 --- a/src/all/background_page/service/auth/postLogoutService.js +++ b/src/all/background_page/service/auth/postLogoutService.js @@ -11,49 +11,34 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 4.7.0 */ -import WorkersSessionStorage from "../sessionStorage/workersSessionStorage"; import AppPagemod from "../../pagemod/appPagemod"; -import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; -import PortManager from "../../sdk/port/portManager"; import LocalStorageService from "../localStorage/localStorageService"; -import BrowserTabService from "../ui/browserTab.service"; -import toolbarController from "../../controller/toolbarController"; -import AuthenticationEventController from "../../controller/auth/authenticationEventController"; +import toolbarService from "../../controller/toolbarService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; -import PassphraseStorageService from "../session_storage/passphraseStorageService"; import resourceInProgressCacheService from "../cache/resourceInProgressCache.service"; - +import OnExtensionUpdateAvailableService from "../extension/onExtensionUpdateAvailableService"; +import InformCallToActionPagemod from "../../pagemod/informCallToActionPagemod"; +import WorkerService from "../worker/workerService"; class PostLogoutService { /** * Execute all processes after a logout + * Sends a passbolt.auth.after-logout event on workers */ static async exec() { - await PostLogoutService.sendLogoutEventForWorkerDisconnected(); - LocalStorageService.flush(); - toolbarController.handleUserLoggedOut(); - AuthenticationEventController.handleUserLoggedOut(); - StartLoopAuthSessionCheckService.clearAlarm(); - PassphraseStorageService.flush(); + await PostLogoutService.sendLogoutEventForWorkers(); + await LocalStorageService.flush(); + await StartLoopAuthSessionCheckService.clearAlarm(); + toolbarService.handleUserLoggedOut(); resourceInProgressCacheService.reset(); - - //@todo remove the dispatch event once every 'after-logout' listeners are handled here - const event = new Event('passbolt.auth.after-logout'); - self.dispatchEvent(event); + OnExtensionUpdateAvailableService.handleUserLoggedOut(); } /** - * Send logout event on workers disconnected port + * Send logout event on workers * @return {Promise} */ - static async sendLogoutEventForWorkerDisconnected() { - const workers = await WorkersSessionStorage.getWorkersByNames([AppPagemod.appName, WebIntegrationPagemod.appName]); - for (const worker of workers) { - if (!PortManager.isPortExist(worker.id)) { - await BrowserTabService.sendMessage(worker, "passbolt.port.connect", worker.id); - const port = PortManager.getPortById(worker.id); - port.emit('passbolt.auth.after-logout'); - } - } + static async sendLogoutEventForWorkers() { + await WorkerService.emitOnWorkersWithName('passbolt.auth.after-logout', [AppPagemod.appName, InformCallToActionPagemod.appName]); } } diff --git a/src/all/background_page/service/auth/postLogoutService.test.js b/src/all/background_page/service/auth/postLogoutService.test.js index 8dd1c90f..b0fae386 100644 --- a/src/all/background_page/service/auth/postLogoutService.test.js +++ b/src/all/background_page/service/auth/postLogoutService.test.js @@ -12,18 +12,20 @@ * @since 4.7.0 */ - import PostLogoutService from "./postLogoutService"; import WorkersSessionStorage from "../sessionStorage/workersSessionStorage"; import {readWorker} from "../../model/entity/worker/workerEntity.test.data"; import WorkerEntity from "../../model/entity/worker/workerEntity"; import AppPagemod from "../../pagemod/appPagemod"; -import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; import BrowserTabService from "../ui/browserTab.service"; import PortManager from "../../sdk/port/portManager"; import {mockPort} from "../../sdk/port/portManager.test.data"; import Port from "../../sdk/port"; import LocalStorageService from "../localStorage/localStorageService"; +import toolbarController from "../../controller/toolbarService"; +import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; +import resourceInProgressCacheService from "../cache/resourceInProgressCache.service"; +import OnExtensionUpdateAvailableService from "../extension/onExtensionUpdateAvailableService"; describe("PostLogoutService", () => { beforeEach(() => { @@ -32,60 +34,29 @@ describe("PostLogoutService", () => { describe("PostLogoutService:exec", () => { it("Should send message to awake port and send post logout event", async() => { - expect.assertions(8); + expect.assertions(5); // data mocked const worker = readWorker(); await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); const worker2 = readWorker({name: AppPagemod.appName}); await WorkersSessionStorage.addWorker(new WorkerEntity(worker2)); - const worker3 = readWorker({name: WebIntegrationPagemod.appName}); - await WorkersSessionStorage.addWorker(new WorkerEntity(worker3)); const appPort = mockPort({name: worker2.id, tabId: worker2.tabId, frameId: worker2.frameId}); const appPortWrapper = new Port(appPort); - const webIntegrationPort = mockPort({name: worker3.id, tabId: worker3.tabId, frameId: worker3.frameId}); - const webIntegrationPortWrapper2 = new Port(webIntegrationPort); + // function mocked jest.spyOn(BrowserTabService, "sendMessage").mockImplementation(jest.fn()); jest.spyOn(PortManager, "getPortById").mockImplementationOnce(() => appPortWrapper); - jest.spyOn(PortManager, "getPortById").mockImplementationOnce(() => webIntegrationPortWrapper2); jest.spyOn(appPortWrapper, "emit"); - jest.spyOn(webIntegrationPortWrapper2, "emit"); jest.spyOn(LocalStorageService, "flush"); // execution await PostLogoutService.exec(); // Waiting all promises are resolved await Promise.resolve(); // expectations - expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(2); + expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(1); expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker2, "passbolt.port.connect", worker2.id); - expect(BrowserTabService.sendMessage).toHaveBeenCalledWith(worker3, "passbolt.port.connect", worker3.id); expect(appPortWrapper.emit).toHaveBeenCalledWith('passbolt.auth.after-logout'); expect(appPortWrapper.emit).toHaveBeenCalledTimes(1); - expect(webIntegrationPortWrapper2.emit).toHaveBeenCalledWith('passbolt.auth.after-logout'); - expect(webIntegrationPortWrapper2.emit).toHaveBeenCalledTimes(1); - expect(LocalStorageService.flush).toHaveBeenCalled(); - }); - - it("Should not send messages if workers port are still connected", async() => { - expect.assertions(3); - // data mocked - const worker = readWorker(); - await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); - const worker2 = readWorker({name: AppPagemod.appName}); - await WorkersSessionStorage.addWorker(new WorkerEntity(worker2)); - const worker3 = readWorker({name: WebIntegrationPagemod.appName}); - await WorkersSessionStorage.addWorker(new WorkerEntity(worker3)); - // function mocked - jest.spyOn(BrowserTabService, "sendMessage"); - jest.spyOn(PortManager, "isPortExist").mockImplementation(() => true); - jest.spyOn(LocalStorageService, "flush"); - // execution - await PostLogoutService.exec(); - // Waiting all promises are resolved - await Promise.resolve(); - // expectations - expect(BrowserTabService.sendMessage).toHaveBeenCalledTimes(0); - expect(PortManager.isPortExist).toHaveBeenCalledTimes(2); expect(LocalStorageService.flush).toHaveBeenCalled(); }); @@ -111,5 +82,23 @@ describe("PostLogoutService", () => { expect(PortManager.isPortExist).toHaveBeenCalledTimes(0); expect(LocalStorageService.flush).toHaveBeenCalled(); }); + + it("Should call all services that needs to run processes on logout", async() => { + expect.assertions(5); + jest.spyOn(PortManager, "isPortExist").mockImplementation(() => false); + jest.spyOn(LocalStorageService, "flush"); + jest.spyOn(toolbarController, "handleUserLoggedOut"); + jest.spyOn(StartLoopAuthSessionCheckService, "clearAlarm"); + jest.spyOn(resourceInProgressCacheService, "reset"); + jest.spyOn(OnExtensionUpdateAvailableService, "handleUserLoggedOut"); + + await PostLogoutService.exec(); + + expect(LocalStorageService.flush).toHaveBeenCalledTimes(1); + expect(toolbarController.handleUserLoggedOut).toHaveBeenCalledTimes(1); + expect(StartLoopAuthSessionCheckService.clearAlarm).toHaveBeenCalledTimes(1); + expect(resourceInProgressCacheService.reset).toHaveBeenCalledTimes(1); + expect(OnExtensionUpdateAvailableService.handleUserLoggedOut).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js index 20f18f56..0b87b263 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.js @@ -23,15 +23,6 @@ class StartLoopAuthSessionCheckService { * @return {Promise} */ static async exec() { - await StartLoopAuthSessionCheckService.scheduleAuthSessionCheck(); - } - - /** - * Schedule an alarm to check if the user is authenticated. - * @returns {Promise} - * @private - */ - static async scheduleAuthSessionCheck() { // Create an alarm to check the auth session. This alarm is managed in `handleTopLevelAlarms` await browser.alarms.create(StartLoopAuthSessionCheckService.ALARM_NAME, { // this `periodInMinutes` is set to ensure that after going back from sleep mode the alarms still triggers @@ -51,7 +42,6 @@ class StartLoopAuthSessionCheckService { /** * Check if the user is authenticated when the AuthSessionCheck alarm triggers. - * - In the case the user is logged out, trigger a passbolt.auth.after-logout event. * @param {Alarm} alarm * @returns {Promise} * @private diff --git a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js index 113d67d4..218173d7 100644 --- a/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js +++ b/src/all/background_page/service/auth/startLoopAuthSessionCheckService.test.js @@ -27,9 +27,8 @@ beforeEach(async() => { describe("StartLoopAuthSessionCheckService", () => { it("should trigger a check authentication and clear alarm on logout", async() => { - expect.assertions(11); + expect.assertions(7); // Function mocked - const spyScheduleAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); const spyClearAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "clearAlarm"); const authStatus = {isAuthenticated: true, isMfaRequired: false}; const spyIsAuthenticated = jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); @@ -37,17 +36,13 @@ describe("StartLoopAuthSessionCheckService", () => { //mocking top-level alarm handler browser.alarms.onAlarm.addListener(async alarm => await StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm(alarm)); - expect(spyScheduleAuthSessionCheck).not.toHaveBeenCalled(); - // Process await StartLoopAuthSessionCheckService.exec(); // Expectation - expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(0); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(0); jest.advanceTimersByTime(60000); - expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(1); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(0); @@ -55,15 +50,13 @@ describe("StartLoopAuthSessionCheckService", () => { expect(spyIsAuthenticated).toHaveBeenCalledTimes(2); await PostLogoutService.exec(); - expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(2); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(1); }); it("should send logout event if not authenticated anymore", async() => { - expect.assertions(6); + expect.assertions(4); // Function mocked - const spyScheduleAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "scheduleAuthSessionCheck"); const spyClearAuthSessionCheck = jest.spyOn(StartLoopAuthSessionCheckService, "clearAlarm"); const authStatus = {isAuthenticated: false, isMfaRequired: false}; const spyIsAuthenticated = jest.spyOn(CheckAuthStatusService.prototype, "checkAuthStatus").mockImplementation(() => Promise.resolve(authStatus)); @@ -75,14 +68,12 @@ describe("StartLoopAuthSessionCheckService", () => { // Process await StartLoopAuthSessionCheckService.exec(); // Expectation - expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(0); expect(spyClearAuthSessionCheck).toHaveBeenCalledTimes(0); jest.advanceTimersByTime(60000); await Promise.resolve(); - expect(spyScheduleAuthSessionCheck).toHaveBeenCalledTimes(1); expect(spyIsAuthenticated).toHaveBeenCalledTimes(1); expect(spyOnPostLogout).toHaveBeenCalledTimes(1); }); diff --git a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js b/src/all/background_page/service/extension/onExtensionUpdateAvailableService.js similarity index 74% rename from src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js rename to src/all/background_page/service/extension/onExtensionUpdateAvailableService.js index 85f2e723..cdfc3928 100644 --- a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.js +++ b/src/all/background_page/service/extension/onExtensionUpdateAvailableService.js @@ -11,24 +11,23 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 4.6.0 * - * On extension update available controller + * On extension update available service */ import User from "../../model/user"; -import AuthenticationStatusService from "../../service/authenticationStatusService"; +import AuthenticationStatusService from "../authenticationStatusService"; import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; -import WorkerService from "../../service/worker/workerService"; +import WorkerService from "../worker/workerService"; import PublicWebsiteSignInPagemod from "../../pagemod/publicWebsiteSignInPagemod"; -class OnExtensionUpdateAvailableController { +class OnExtensionUpdateAvailableService { /** - * Execute the OnExtensionUpdateAvailableController process + * Execute the OnExtensionUpdateAvailableService process * @returns {Promise} */ static async exec() { if (await isUserAuthenticated()) { - // Add listener on passbolt logout to update the extension - self.addEventListener("passbolt.auth.after-logout", this.cleanAndReload); + this.shouldReload = true; } else { await this.cleanAndReload(); } @@ -42,19 +41,29 @@ class OnExtensionUpdateAvailableController { await WorkerService.destroyWorkersByName([WebIntegrationPagemod.appName, PublicWebsiteSignInPagemod.appName]); browser.runtime.reload(); } + + /** + * Handles user logged out event + * It triggers a runtime reload if an extension update was available while the user was signed in. + */ + static async handleUserLoggedOut() { + if (this.shouldReload) { + this.shouldReload = false; + await this.cleanAndReload(); + } + } } /** * Check and process event if the user is authenticated - * @return {Promise} + * @returns {Promise} */ const isUserAuthenticated = async() => { const user = User.getInstance(); // Check if user is valid if (user.isValid()) { try { - const isAuth = await AuthenticationStatusService.isAuthenticated(); - return isAuth; + return await AuthenticationStatusService.isAuthenticated(); } catch (error) { if (error instanceof MfaAuthenticationRequiredError) { /* @@ -74,4 +83,4 @@ const isUserAuthenticated = async() => { return false; }; -export default OnExtensionUpdateAvailableController; +export default OnExtensionUpdateAvailableService; diff --git a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js b/src/all/background_page/service/extension/onExtensionUpdateAvailableService.test.js similarity index 85% rename from src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js rename to src/all/background_page/service/extension/onExtensionUpdateAvailableService.test.js index bd38a73d..4ead7e23 100644 --- a/src/all/background_page/controller/extension/onExtensionUpdateAvailableController.test.js +++ b/src/all/background_page/service/extension/onExtensionUpdateAvailableService.test.js @@ -12,19 +12,20 @@ * @since 4.6.0 */ -import OnExtensionUpdateAvailableController from "./onExtensionUpdateAvailableController"; -import AuthenticationStatusService from "../../service/authenticationStatusService"; +import OnExtensionUpdateAvailableService from "./onExtensionUpdateAvailableService"; +import AuthenticationStatusService from "../authenticationStatusService"; import MockExtension from "../../../../../test/mocks/mockExtension"; import MfaAuthenticationRequiredError from "../../error/mfaAuthenticationRequiredError"; import {readWorker} from "../../model/entity/worker/workerEntity.test.data"; -import WorkersSessionStorage from "../../service/sessionStorage/workersSessionStorage"; +import WorkersSessionStorage from "../sessionStorage/workersSessionStorage"; import WorkerEntity from "../../model/entity/worker/workerEntity"; import WebIntegrationPagemod from "../../pagemod/webIntegrationPagemod"; import Port from "../../sdk/port"; import {mockPort} from "../../sdk/port/portManager.test.data"; import PortManager from "../../sdk/port/portManager"; -import BrowserTabService from "../../service/ui/browserTab.service"; +import BrowserTabService from "../ui/browserTab.service"; import PublicWebsiteSignInPagemod from "../../pagemod/publicWebsiteSignInPagemod"; +import PostLogoutService from "../auth/postLogoutService"; // Reset the modules before each test. beforeEach(() => { @@ -32,23 +33,24 @@ beforeEach(() => { jest.clearAllMocks(); }); -describe("OnExtensionInstalledController", () => { - describe("OnExtensionInstalledController::exec", () => { +describe("OnExtensionUpdateAvailableService", () => { + describe("OnExtensionUpdateAvailableService::exec", () => { it("Should exec update if the user is not signed-in", async() => { expect.assertions(3); + // data mocked + await MockExtension.withConfiguredAccount(); const worker = readWorker({name: WebIntegrationPagemod.appName}); await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); const webIntegrationPort = mockPort({name: worker.id, tabId: worker.tabId, frameId: worker.frameId}); const webIntegrationPortWrapper = new Port(webIntegrationPort); PortManager.registerPort(webIntegrationPortWrapper); // mock function - MockExtension.withConfiguredAccount(); jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => false); jest.spyOn(browser.runtime, "reload"); jest.spyOn(webIntegrationPortWrapper, "emit"); // process - await OnExtensionUpdateAvailableController.exec(); + await OnExtensionUpdateAvailableService.exec(); // expectation expect(webIntegrationPortWrapper.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); expect(webIntegrationPortWrapper.emit).toHaveBeenCalledTimes(1); @@ -58,10 +60,10 @@ describe("OnExtensionInstalledController", () => { it("Should exec update if the user is not valid", async() => { expect.assertions(1); // mock function - MockExtension.withMissingPrivateKeyAccount(); + await MockExtension.withMissingPrivateKeyAccount(); jest.spyOn(browser.runtime, "reload"); // process - await OnExtensionUpdateAvailableController.exec(); + await OnExtensionUpdateAvailableService.exec(); // expectation expect(browser.runtime.reload).toHaveBeenCalledTimes(1); }); @@ -69,14 +71,14 @@ describe("OnExtensionInstalledController", () => { it("Should exec update only when the user is signed-out", async() => { expect.assertions(2); // mock function - MockExtension.withConfiguredAccount(); + await MockExtension.withConfiguredAccount(); jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); jest.spyOn(browser.runtime, "reload"); // process - await OnExtensionUpdateAvailableController.exec(); + await OnExtensionUpdateAvailableService.exec(); // expectation expect(browser.runtime.reload).not.toHaveBeenCalled(); - self.dispatchEvent(new Event('passbolt.auth.after-logout')); + await PostLogoutService.exec(); // Waiting all promises has been finished await Promise.resolve(); await Promise.resolve(); @@ -88,6 +90,7 @@ describe("OnExtensionInstalledController", () => { it("Should clean and exec update", async() => { expect.assertions(10); // data mocked + await MockExtension.withConfiguredAccount(); const worker = readWorker(); await WorkersSessionStorage.addWorker(new WorkerEntity(worker)); const worker2 = readWorker({name: WebIntegrationPagemod.appName}); @@ -105,7 +108,6 @@ describe("OnExtensionInstalledController", () => { PortManager.registerPort(webIntegrationPortWrapper2); PortManager.registerPort(publicWebsiteSignInPortWrapper); // mock function - MockExtension.withConfiguredAccount(); jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => true); jest.spyOn(BrowserTabService, "sendMessage").mockImplementation(() => PortManager.registerPort(webIntegrationPortWrapper)); jest.spyOn(browser.runtime, "reload"); @@ -113,10 +115,10 @@ describe("OnExtensionInstalledController", () => { jest.spyOn(webIntegrationPortWrapper2, "emit"); jest.spyOn(publicWebsiteSignInPortWrapper, "emit"); // process - await OnExtensionUpdateAvailableController.exec(); + await OnExtensionUpdateAvailableService.exec(); // expectation expect(browser.runtime.reload).not.toHaveBeenCalled(); - self.dispatchEvent(new Event('passbolt.auth.after-logout')); + await PostLogoutService.exec(); // Waiting all promises has been finished await Promise.resolve(); await Promise.resolve(); @@ -138,11 +140,11 @@ describe("OnExtensionInstalledController", () => { it("Should exec update if an error occurred and there is no possibility to check if the user is authenticated", async() => { expect.assertions(1); // mock function - MockExtension.withConfiguredAccount(); + await MockExtension.withConfiguredAccount(); jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => { throw new Error("Error"); }); jest.spyOn(browser.runtime, "reload"); // process - await OnExtensionUpdateAvailableController.exec(); + await OnExtensionUpdateAvailableService.exec(); // expectation expect(browser.runtime.reload).toHaveBeenCalledTimes(1); }); @@ -150,14 +152,14 @@ describe("OnExtensionInstalledController", () => { it("Should not exec update when the user is not fully signed-in", async() => { expect.assertions(2); // mock function - MockExtension.withConfiguredAccount(); + await MockExtension.withConfiguredAccount(); jest.spyOn(AuthenticationStatusService, "isAuthenticated").mockImplementation(() => { throw new MfaAuthenticationRequiredError(); }); jest.spyOn(browser.runtime, "reload"); // process - await OnExtensionUpdateAvailableController.exec(); + await OnExtensionUpdateAvailableService.exec(); // expectation expect(browser.runtime.reload).not.toHaveBeenCalled(); - self.dispatchEvent(new Event('passbolt.auth.after-logout')); + await PostLogoutService.exec(); // Waiting all promises has been finished await Promise.resolve(); await Promise.resolve(); diff --git a/src/all/background_page/service/systemRequirementService/systemRequirementService.js b/src/all/background_page/service/systemRequirementService/systemRequirementService.js index 2bddaac1..4fa17327 100644 --- a/src/all/background_page/service/systemRequirementService/systemRequirementService.js +++ b/src/all/background_page/service/systemRequirementService/systemRequirementService.js @@ -2,7 +2,7 @@ import storage from "../../sdk/storage"; import {Config} from "../../model/config"; import Log from "../../model/log"; import * as openpgp from "openpgp"; -import toolbarController from "../../controller/toolbarController"; +import toolbarService from "../../controller/toolbarService"; class SystemRequirementService { /** @@ -23,8 +23,8 @@ class SystemRequirementService { */ openpgp.config.allowInsecureDecryptionWithSigningKeys = true; - // initialise the toolbar controller - toolbarController.initialise(); + // initialise the toolbar service + toolbarService.initialise(); } } diff --git a/src/all/background_page/service/worker/workerService.js b/src/all/background_page/service/worker/workerService.js index 9bfd79f2..a2936c23 100644 --- a/src/all/background_page/service/worker/workerService.js +++ b/src/all/background_page/service/worker/workerService.js @@ -126,19 +126,29 @@ class WorkerService { * @return {Promise} */ static async destroyWorkersByName(workersName) { + this.emitOnWorkersWithName('passbolt.content-script.destroy', workersName); + } + + /** + * Emit an event to all workers matching the given workersName + * @param {string} eventName + * @param {Array} workersName + * @return {Promise} + */ + static async emitOnWorkersWithName(eventName, workersName) { const workers = await WorkersSessionStorage.getWorkersByNames(workersName); for (const worker of workers) { if (!PortManager.isPortExist(worker.id)) { try { await BrowserTabService.sendMessage(worker, "passbolt.port.connect", worker.id); } catch (error) { - console.debug("Unable to reconnect the port before to update the extension"); + console.debug("Unable to reconnect the port prior to emitting event"); console.error(error); continue; } } const port = PortManager.getPortById(worker.id); - port.emit('passbolt.content-script.destroy'); + port.emit(eventName); } } } diff --git a/src/all/background_page/service/worker/workerService.test.js b/src/all/background_page/service/worker/workerService.test.js index 99011fc7..599f3722 100644 --- a/src/all/background_page/service/worker/workerService.test.js +++ b/src/all/background_page/service/worker/workerService.test.js @@ -212,6 +212,7 @@ describe("WorkerService", () => { jest.spyOn(portWrapper2, "emit"); await WorkerService.destroyWorkersByName([worker.name, worker2.name]); + await Promise.resolve(); // expectation expect(portWrapper.emit).toHaveBeenCalledWith("passbolt.content-script.destroy"); diff --git a/src/chrome-mv3/index.js b/src/chrome-mv3/index.js index 147a72b5..9a3ea78e 100644 --- a/src/chrome-mv3/index.js +++ b/src/chrome-mv3/index.js @@ -16,9 +16,8 @@ import LocalStorageService from "../all/background_page/service/localStorage/loc import SystemRequirementService from "../all/background_page/service/systemRequirementService/systemRequirementService"; import OnExtensionInstalledController from "../all/background_page/controller/extension/onExtensionInstalledController"; import TabService from "../all/background_page/service/tab/tabService"; -import OnExtensionUpdateAvailableController - from "../all/background_page/controller/extension/onExtensionUpdateAvailableController"; -import PostLogoutService from "../all/background_page/service/auth/postLogoutService"; +import OnExtensionUpdateAvailableService + from "../all/background_page/service/extension/onExtensionUpdateAvailableService"; import GlobalAlarmService from "../all/background_page/service/alarm/globalAlarmService"; /** @@ -26,11 +25,6 @@ import GlobalAlarmService from "../all/background_page/service/alarm/globalAlarm */ SystemRequirementService.get(); -/** - * Add listener on passbolt logout - */ -self.addEventListener("passbolt.auth.after-logout", PostLogoutService.exec); - /** * Add listener on startup */ @@ -44,7 +38,7 @@ browser.runtime.onInstalled.addListener(OnExtensionInstalledController.exec); /** * On update available of the extension, update it when the user is logout */ -browser.runtime.onUpdateAvailable.addListener(OnExtensionUpdateAvailableController.exec); +browser.runtime.onUpdateAvailable.addListener(OnExtensionUpdateAvailableService.exec); /** * Add listener on any tab update From 70b87f8120b418dda782c57c5c20cefb5af1792d Mon Sep 17 00:00:00 2001 From: Cedric Alfonsi Date: Tue, 16 Apr 2024 07:25:25 +0000 Subject: [PATCH 38/56] PB-33018 - Automate browser extension npm publication --- .gitlab-ci/jobs/publish.yml | 10 ++++++++++ .gitlab-ci/scripts/bin/publish_npm.sh | 24 ++++++++++++++++++++++++ .gitlab-ci/scripts/lib/version-check.sh | 16 ++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/chrome-mv3/manifest.json | 2 +- src/chrome/manifest.json | 2 +- src/firefox/manifest.json | 2 +- src/safari/manifest.json | 2 +- 9 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 .gitlab-ci/scripts/bin/publish_npm.sh diff --git a/.gitlab-ci/jobs/publish.yml b/.gitlab-ci/jobs/publish.yml index 06a1be52..6ef385ba 100644 --- a/.gitlab-ci/jobs/publish.yml +++ b/.gitlab-ci/jobs/publish.yml @@ -36,3 +36,13 @@ publish-edge: echo "Sending slack build notification" echo "================================" bash ./.gitlab-ci/scripts/bin/slack-status-messages.sh ":rocket: passbolt-edge-extension $CI_COMMIT_TAG has been published!" "$CI_PROJECT_URL/-/jobs/$CI_JOB_ID" + +publish-to-npmjs: + stage: publish + image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/node:18 + rules: + - if: "$CI_COMMIT_TAG" + script: + - | + bash .gitlab-ci/scripts/bin/publish_npm.sh + bash ./.gitlab-ci/scripts/bin/slack-status-messages.sh ":rocket: passbolt-browser-extension $CI_COMMIT_TAG has been published in https://www.npmjs.com/package/passbolt-browser-extension" "$CI_PROJECT_URL/-/jobs/$CI_JOB_ID" \ No newline at end of file diff --git a/.gitlab-ci/scripts/bin/publish_npm.sh b/.gitlab-ci/scripts/bin/publish_npm.sh new file mode 100644 index 00000000..f5ddbd3b --- /dev/null +++ b/.gitlab-ci/scripts/bin/publish_npm.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC1091 + +set -eu + +CI_SCRIPTS_DIR=$(dirname "$0")/.. + +# shellcheck source=.gitlab-ci/scripts/lib/version-check.sh +source "$CI_SCRIPTS_DIR"/lib/version-check.sh + +echo //registry.npmjs.org/:_authToken="$NPM_PUBLISH_TOKEN" > .npmrc +echo email="$NPM_PUBLISH_EMAIL" >> .npmrc +echo always-auth=true >> .npmrc + +if is_release_candidate "$CI_COMMIT_TAG"; then + npm publish --tag next +elif is_release_alpha "$CI_COMMIT_TAG"; then + npm publish --tag alpha +elif is_release_beta "$CI_COMMIT_TAG"; then + npm publish --tag beta +else + npm publish +fi diff --git a/.gitlab-ci/scripts/lib/version-check.sh b/.gitlab-ci/scripts/lib/version-check.sh index f7a8d1e6..050481a2 100644 --- a/.gitlab-ci/scripts/lib/version-check.sh +++ b/.gitlab-ci/scripts/lib/version-check.sh @@ -13,6 +13,22 @@ function is_release_candidate () { return 0 } +function is_release_alpha () { + local version=$1 + if [[ ! $version =~ [0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+ ]];then + return 1 + fi + return 0 +} + +function is_release_beta () { + local version=$1 + if [[ ! $version =~ [0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+ ]];then + return 1 + fi + return 0 +} + function validate_config_version_and_api_tag () { local version_file="$1" local version diff --git a/package-lock.json b/package-lock.json index 1957f0f9..f3557a6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "passbolt-browser-extension", - "version": "4.6.2", + "version": "4.7.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "passbolt-browser-extension", - "version": "4.6.2", + "version": "4.7.0-alpha.1", "license": "AGPL-3.0", "dependencies": { "await-lock": "^2.1.0", diff --git a/package.json b/package.json index bff18761..08a92b5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passbolt-browser-extension", - "version": "4.6.2", + "version": "4.7.0-alpha.1", "license": "AGPL-3.0", "copyright": "Copyright 2022 Passbolt SA", "description": "Passbolt web extension for the open source password manager for teams", diff --git a/src/chrome-mv3/manifest.json b/src/chrome-mv3/manifest.json index d00c8989..f968bb89 100644 --- a/src/chrome-mv3/manifest.json +++ b/src/chrome-mv3/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_appName__", "short_name": "passbolt", - "version": "4.6.2", + "version": "4.7.0", "description": "__MSG_appDescription__", "default_locale": "en", "externally_connectable": { diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index 06f58995..e31aaf4a 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_appName__", "short_name": "passbolt", - "version": "4.6.2", + "version": "4.7.0", "description": "__MSG_appDescription__", "default_locale": "en", "externally_connectable": {}, diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json index 31d97f26..a68a344a 100644 --- a/src/firefox/manifest.json +++ b/src/firefox/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_appName__", "short_name": "passbolt", - "version": "4.6.2", + "version": "4.7.0", "description": "__MSG_appDescription__", "default_locale": "en", "browser_specific_settings": { diff --git a/src/safari/manifest.json b/src/safari/manifest.json index 2ab6f4f7..024d6490 100644 --- a/src/safari/manifest.json +++ b/src/safari/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_appName__", "short_name": "passbolt", - "version": "4.6.2", + "version": "4.7.0", "description": "__MSG_appDescription__", "default_locale": "en", "externally_connectable": {}, From 77d867b85d3e93bd4f3adab239693c631c95b948 Mon Sep 17 00:00:00 2001 From: Cedric Alfonsi Date: Tue, 16 Apr 2024 09:34:52 +0200 Subject: [PATCH 39/56] PB-33024 - Ensure only stable tag of the browser extension are sent for review or publish to the store --- .gitlab-ci/scripts/bin/publish.sh | 4 ++-- .gitlab-ci/scripts/bin/publish_npm.sh | 2 +- .gitlab-ci/scripts/bin/review.sh | 2 +- .gitlab-ci/scripts/lib/version-check.sh | 8 ++++++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci/scripts/bin/publish.sh b/.gitlab-ci/scripts/bin/publish.sh index f2ef73b3..b65be01e 100644 --- a/.gitlab-ci/scripts/bin/publish.sh +++ b/.gitlab-ci/scripts/bin/publish.sh @@ -46,7 +46,7 @@ if is_release_candidate "$CI_COMMIT_TAG"; then *) echo "I don't recognize this option" ;; esac -else +elif is_stable "$CI_COMMIT_TAG"; then case "$1" in chrome) send_to_chrome "$PASSBOLT_STABLE_CHROME_ID" @@ -60,4 +60,4 @@ else *) echo "I don't recognize this option" ;; esac -fi \ No newline at end of file +fi diff --git a/.gitlab-ci/scripts/bin/publish_npm.sh b/.gitlab-ci/scripts/bin/publish_npm.sh index f5ddbd3b..2c06c8e2 100644 --- a/.gitlab-ci/scripts/bin/publish_npm.sh +++ b/.gitlab-ci/scripts/bin/publish_npm.sh @@ -19,6 +19,6 @@ elif is_release_alpha "$CI_COMMIT_TAG"; then npm publish --tag alpha elif is_release_beta "$CI_COMMIT_TAG"; then npm publish --tag beta -else +elif is_stable "$CI_COMMIT_TAG"; then npm publish fi diff --git a/.gitlab-ci/scripts/bin/review.sh b/.gitlab-ci/scripts/bin/review.sh index 91097efc..42713f10 100644 --- a/.gitlab-ci/scripts/bin/review.sh +++ b/.gitlab-ci/scripts/bin/review.sh @@ -35,7 +35,7 @@ if is_release_candidate "$CI_COMMIT_TAG"; then *) echo "I don't recognize this option" ;; esac -else +elif is_stable "$CI_COMMIT_TAG"; then case $1 in chrome) send_to_chrome "$PASSBOLT_STABLE_CHROME_ID" diff --git a/.gitlab-ci/scripts/lib/version-check.sh b/.gitlab-ci/scripts/lib/version-check.sh index 050481a2..673beadc 100644 --- a/.gitlab-ci/scripts/lib/version-check.sh +++ b/.gitlab-ci/scripts/lib/version-check.sh @@ -5,6 +5,14 @@ function is_valid_api_tag () { fi } +function is_stable () { + local version=$1 + if [[ ! $version =~ [0-9]+\.[0-9]+\.[0-9]+$ ]];then + return 1 + fi + return 0 +} + function is_release_candidate () { local version=$1 if [[ ! $version =~ [0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+ ]];then From a0e18ece045b2f2f8c63431af0776e360fbcfdf2 Mon Sep 17 00:00:00 2001 From: Cedric Alfonsi Date: Tue, 16 Apr 2024 10:02:09 +0200 Subject: [PATCH 40/56] PB-33024 Output debut if tag format is not supported Signed-off-by: Cedric Alfonsi --- .gitlab-ci/scripts/bin/publish.sh | 2 ++ .gitlab-ci/scripts/bin/publish_npm.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.gitlab-ci/scripts/bin/publish.sh b/.gitlab-ci/scripts/bin/publish.sh index b65be01e..f5a7d8f8 100644 --- a/.gitlab-ci/scripts/bin/publish.sh +++ b/.gitlab-ci/scripts/bin/publish.sh @@ -60,4 +60,6 @@ elif is_stable "$CI_COMMIT_TAG"; then *) echo "I don't recognize this option" ;; esac +else + echo "The tag format is no supported" fi diff --git a/.gitlab-ci/scripts/bin/publish_npm.sh b/.gitlab-ci/scripts/bin/publish_npm.sh index 2c06c8e2..7c218122 100644 --- a/.gitlab-ci/scripts/bin/publish_npm.sh +++ b/.gitlab-ci/scripts/bin/publish_npm.sh @@ -21,4 +21,6 @@ elif is_release_beta "$CI_COMMIT_TAG"; then npm publish --tag beta elif is_stable "$CI_COMMIT_TAG"; then npm publish +else + echo "The tag format is no supported" fi From 2e0644839638964cccae3fbdbf714c2d8485fbce Mon Sep 17 00:00:00 2001 From: Cedric Alfonsi Date: Tue, 16 Apr 2024 11:11:00 +0200 Subject: [PATCH 41/56] PB-33024 Fix debug message typo --- .gitlab-ci/scripts/bin/publish.sh | 2 +- .gitlab-ci/scripts/bin/publish_npm.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci/scripts/bin/publish.sh b/.gitlab-ci/scripts/bin/publish.sh index f5a7d8f8..5cdb028e 100644 --- a/.gitlab-ci/scripts/bin/publish.sh +++ b/.gitlab-ci/scripts/bin/publish.sh @@ -61,5 +61,5 @@ elif is_stable "$CI_COMMIT_TAG"; then ;; esac else - echo "The tag format is no supported" + echo "The tag format is not supported" fi diff --git a/.gitlab-ci/scripts/bin/publish_npm.sh b/.gitlab-ci/scripts/bin/publish_npm.sh index 7c218122..52740ae4 100644 --- a/.gitlab-ci/scripts/bin/publish_npm.sh +++ b/.gitlab-ci/scripts/bin/publish_npm.sh @@ -22,5 +22,5 @@ elif is_release_beta "$CI_COMMIT_TAG"; then elif is_stable "$CI_COMMIT_TAG"; then npm publish else - echo "The tag format is no supported" + echo "The tag format is not supported" fi From e1770d3e2de9329a7b27c9695079fb88fb59c3f2 Mon Sep 17 00:00:00 2001 From: pierre Date: Fri, 19 Apr 2024 09:41:39 +0200 Subject: [PATCH 42/56] PB-33070 - Request passphrase when exporting account kit --- .../exportAccount/exportDesktopAccountController.js | 2 +- .../exportDesktopAccountController.test.data.js | 3 ++- .../exportAccount/exportDesktopAccountController.test.js | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/all/background_page/controller/exportAccount/exportDesktopAccountController.js b/src/all/background_page/controller/exportAccount/exportDesktopAccountController.js index 6bd40090..d38c00a2 100644 --- a/src/all/background_page/controller/exportAccount/exportDesktopAccountController.js +++ b/src/all/background_page/controller/exportAccount/exportDesktopAccountController.js @@ -58,7 +58,7 @@ class ExportDesktopAccountController { * @return {Promise} */ async exec() { - const passphrase = await this.getPassphraseService.getPassphrase(this.worker); + const passphrase = await this.getPassphraseService.requestPassphrase(this.worker); const accountKit = await this.desktopTransferModel.getAccountKit(this.account); const accountKitDto = accountKit.toDto(); diff --git a/src/all/background_page/controller/exportAccount/exportDesktopAccountController.test.data.js b/src/all/background_page/controller/exportAccount/exportDesktopAccountController.test.data.js index 0050987a..581ab613 100644 --- a/src/all/background_page/controller/exportAccount/exportDesktopAccountController.test.data.js +++ b/src/all/background_page/controller/exportAccount/exportDesktopAccountController.test.data.js @@ -15,7 +15,8 @@ import {v4 as uuid} from "uuid"; export const worker = { port: { - emit: jest.fn() + emit: jest.fn(), + request: jest.fn() }, tab: { id: uuid() diff --git a/src/all/background_page/controller/exportAccount/exportDesktopAccountController.test.js b/src/all/background_page/controller/exportAccount/exportDesktopAccountController.test.js index ac6d19a3..5c862dc2 100644 --- a/src/all/background_page/controller/exportAccount/exportDesktopAccountController.test.js +++ b/src/all/background_page/controller/exportAccount/exportDesktopAccountController.test.js @@ -28,7 +28,7 @@ describe("ExportDesktopAccountController", () => { const controller = new ExportDesktopAccountController(worker, requestId, account); beforeEach(() => { - jest.spyOn(controller.getPassphraseService, "getPassphrase").mockImplementation(() => pgpKeys.ada.passphrase); + jest.spyOn(controller.getPassphraseService, "requestPassphrase").mockImplementation(() => pgpKeys.ada.passphrase); jest.spyOn(GetLegacyAccountService, "get").mockImplementation(() => account); jest.spyOn(controller.desktopTransferModel, "getAccountKit"); }); @@ -37,14 +37,14 @@ describe("ExportDesktopAccountController", () => { it("Should request the passphrase before any other action.", async() => { expect.assertions(3); //Simulate an error to check is the other methods have not been called - jest.spyOn(controller.getPassphraseService, "getPassphrase").mockRejectedValue(() => new Error()); + jest.spyOn(controller.getPassphraseService, "requestPassphrase").mockRejectedValue(() => new Error()); jest.spyOn(FileService, "saveFile").mockImplementation(jest.fn()); try { await controller.exec(); } catch {} - expect(controller.getPassphraseService.getPassphrase).toHaveBeenCalledWith(worker); + expect(controller.getPassphraseService.requestPassphrase).toHaveBeenCalledWith(worker); expect(controller.desktopTransferModel.getAccountKit).not.toHaveBeenCalled(); expect(FileService.saveFile).not.toHaveBeenCalled(); }); @@ -93,7 +93,7 @@ describe("ExportDesktopAccountController", () => { it("Should emit error when an error occured.", async() => { expect.assertions(1); const error = new Error('Cannot download'); - controller.getPassphraseService.getPassphrase.mockRejectedValue(error); + controller.getPassphraseService.requestPassphrase.mockRejectedValue(error); await controller._exec(); From 14111c133b5d3dd77b4e29d019652f30c5d0460d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Sat, 20 Apr 2024 04:17:09 +0000 Subject: [PATCH 43/56] PB-22644 - The passbolt icon should detect if the user is still connected... --- .../onExtensionInstalledController.js | 34 +++++++++++++++++++ src/all/background_page/index.js | 21 +++++++----- .../service/auth/postLoginService.js | 2 +- .../service/auth/postLoginService.test.js | 6 ++-- .../service/auth/postLogoutService.js | 2 +- .../service/auth/postLogoutService.test.js | 6 ++-- .../systemRequirementService.js | 2 +- .../toolbar}/toolbarService.js | 14 ++++---- .../toolbar}/toolbarService.test.js | 16 ++++----- 9 files changed, 69 insertions(+), 34 deletions(-) rename src/all/background_page/{controller => service/toolbar}/toolbarService.js (91%) rename src/all/background_page/{controller => service/toolbar}/toolbarService.test.js (93%) diff --git a/src/all/background_page/controller/extension/onExtensionInstalledController.js b/src/all/background_page/controller/extension/onExtensionInstalledController.js index 0bd810c2..fb897c54 100644 --- a/src/all/background_page/controller/extension/onExtensionInstalledController.js +++ b/src/all/background_page/controller/extension/onExtensionInstalledController.js @@ -17,6 +17,10 @@ import PagemodManager from "../../pagemod/pagemodManager"; import WebNavigationService from "../../service/webNavigation/webNavigationService"; import ParseSetupUrlService from "../../service/setup/parseSetupUrlService"; import ParseRecoverUrlService from "../../service/recover/parseRecoverUrlService"; +import CheckAuthStatusService from "../../service/auth/checkAuthStatusService"; +import User from "../../model/user"; +import Log from "../../model/log"; +import {BrowserExtensionIconService} from "../../service/ui/browserExtensionIcon.service"; class OnExtensionInstalledController { /** @@ -51,6 +55,36 @@ class OnExtensionInstalledController { static async onUpdate() { // Apply on tabs that match the pagemod url regex with refresh option await browser.tabs.query({}).then(reloadTabsMatchPagemodUrl); + await OnExtensionInstalledController.updateToolbarIcon(); + } + + /** + * Updates the Passbolt icon in the toolbar according to the sign-in status of the current user. + * @returns {Promise} + */ + static async updateToolbarIcon() { + const user = User.getInstance(); + // Check if user is valid + if (!user.isValid()) { + return; + } + + let authStatus; + try { + const checkAuthStatusService = new CheckAuthStatusService(); + // user the cached data as the worker could wake up every 30 secondes. + authStatus = await checkAuthStatusService.checkAuthStatus(false); + } catch (error) { + // Service is unavailable, do nothing... + Log.write({level: 'debug', message: 'The Service is unavailable to check if the user is authenticated'}); + return; + } + + if (authStatus.isAuthenticated) { + BrowserExtensionIconService.activate(); + } else { + BrowserExtensionIconService.deactivate(); + } } } diff --git a/src/all/background_page/index.js b/src/all/background_page/index.js index b35352c0..3da8aaeb 100644 --- a/src/all/background_page/index.js +++ b/src/all/background_page/index.js @@ -15,6 +15,7 @@ import OnExtensionUpdateAvailableService from "./service/extension/onExtensionUp import CheckAuthStatusService from "./service/auth/checkAuthStatusService"; import GlobalAlarmService from "./service/alarm/globalAlarmService"; import PostLoginService from "./service/auth/postLoginService"; +import PostLogoutService from "./service/auth/postLogoutService"; const main = async() => { /** @@ -37,18 +38,20 @@ const checkAndProcessIfUserAuthenticated = async() => { return; } - const checkAuthStatusService = new CheckAuthStatusService(); + let authStatus; try { - const authStatus = await checkAuthStatusService.checkAuthStatus(true); - if (authStatus.isAuthenticated) { - await PostLoginService.postLogin(); - } + const checkAuthStatusService = new CheckAuthStatusService(); + authStatus = await checkAuthStatusService.checkAuthStatus(true); } catch (error) { - /* - * Service unavailable - * Do nothing... - */ + // Service is unavailable, do nothing... Log.write({level: 'debug', message: 'The Service is unavailable to check if the user is authenticated'}); + return; + } + + if (authStatus.isAuthenticated) { + PostLoginService.exec(); + } else { + PostLogoutService.exec(); } }; diff --git a/src/all/background_page/service/auth/postLoginService.js b/src/all/background_page/service/auth/postLoginService.js index 8c883707..34e1a814 100644 --- a/src/all/background_page/service/auth/postLoginService.js +++ b/src/all/background_page/service/auth/postLoginService.js @@ -12,7 +12,7 @@ * @since 4.7.0 */ -import toolbarService from "../../controller/toolbarService"; +import toolbarService from "../toolbar/toolbarService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; import InformCallToActionPagemod from "../../pagemod/informCallToActionPagemod"; import WorkerService from "../worker/workerService"; diff --git a/src/all/background_page/service/auth/postLoginService.test.js b/src/all/background_page/service/auth/postLoginService.test.js index 000c8e7c..8bad3099 100644 --- a/src/all/background_page/service/auth/postLoginService.test.js +++ b/src/all/background_page/service/auth/postLoginService.test.js @@ -19,10 +19,10 @@ import PortManager from "../../sdk/port/portManager"; import {mockPort} from "../../sdk/port/portManager.test.data"; import Port from "../../sdk/port"; import MockExtension from "../../../../../test/mocks/mockExtension"; -import toolbarController from "../../controller/toolbarService"; import PostLoginService from "./postLoginService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; import InformCallToActionPagemod from "../../pagemod/informCallToActionPagemod"; +import toolbarService from "../toolbar/toolbarService"; beforeEach(async() => { jest.clearAllMocks(); @@ -85,12 +85,12 @@ describe("PostLoginService", () => { expect.assertions(2); jest.spyOn(StartLoopAuthSessionCheckService, "exec"); - jest.spyOn(toolbarController, "handleUserLoggedIn"); + jest.spyOn(toolbarService, "handleUserLoggedIn"); await PostLoginService.exec(); expect(StartLoopAuthSessionCheckService.exec).toHaveBeenCalledTimes(1); - expect(toolbarController.handleUserLoggedIn).toHaveBeenCalledTimes(1); + expect(toolbarService.handleUserLoggedIn).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/all/background_page/service/auth/postLogoutService.js b/src/all/background_page/service/auth/postLogoutService.js index a822a4d2..fa25958e 100644 --- a/src/all/background_page/service/auth/postLogoutService.js +++ b/src/all/background_page/service/auth/postLogoutService.js @@ -13,7 +13,7 @@ */ import AppPagemod from "../../pagemod/appPagemod"; import LocalStorageService from "../localStorage/localStorageService"; -import toolbarService from "../../controller/toolbarService"; +import toolbarService from "../toolbar/toolbarService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; import resourceInProgressCacheService from "../cache/resourceInProgressCache.service"; import OnExtensionUpdateAvailableService from "../extension/onExtensionUpdateAvailableService"; diff --git a/src/all/background_page/service/auth/postLogoutService.test.js b/src/all/background_page/service/auth/postLogoutService.test.js index b0fae386..97806600 100644 --- a/src/all/background_page/service/auth/postLogoutService.test.js +++ b/src/all/background_page/service/auth/postLogoutService.test.js @@ -22,10 +22,10 @@ import PortManager from "../../sdk/port/portManager"; import {mockPort} from "../../sdk/port/portManager.test.data"; import Port from "../../sdk/port"; import LocalStorageService from "../localStorage/localStorageService"; -import toolbarController from "../../controller/toolbarService"; import StartLoopAuthSessionCheckService from "./startLoopAuthSessionCheckService"; import resourceInProgressCacheService from "../cache/resourceInProgressCache.service"; import OnExtensionUpdateAvailableService from "../extension/onExtensionUpdateAvailableService"; +import toolbarService from "../toolbar/toolbarService"; describe("PostLogoutService", () => { beforeEach(() => { @@ -87,7 +87,7 @@ describe("PostLogoutService", () => { expect.assertions(5); jest.spyOn(PortManager, "isPortExist").mockImplementation(() => false); jest.spyOn(LocalStorageService, "flush"); - jest.spyOn(toolbarController, "handleUserLoggedOut"); + jest.spyOn(toolbarService, "handleUserLoggedOut"); jest.spyOn(StartLoopAuthSessionCheckService, "clearAlarm"); jest.spyOn(resourceInProgressCacheService, "reset"); jest.spyOn(OnExtensionUpdateAvailableService, "handleUserLoggedOut"); @@ -95,7 +95,7 @@ describe("PostLogoutService", () => { await PostLogoutService.exec(); expect(LocalStorageService.flush).toHaveBeenCalledTimes(1); - expect(toolbarController.handleUserLoggedOut).toHaveBeenCalledTimes(1); + expect(toolbarService.handleUserLoggedOut).toHaveBeenCalledTimes(1); expect(StartLoopAuthSessionCheckService.clearAlarm).toHaveBeenCalledTimes(1); expect(resourceInProgressCacheService.reset).toHaveBeenCalledTimes(1); expect(OnExtensionUpdateAvailableService.handleUserLoggedOut).toHaveBeenCalledTimes(1); diff --git a/src/all/background_page/service/systemRequirementService/systemRequirementService.js b/src/all/background_page/service/systemRequirementService/systemRequirementService.js index 4fa17327..bf95c730 100644 --- a/src/all/background_page/service/systemRequirementService/systemRequirementService.js +++ b/src/all/background_page/service/systemRequirementService/systemRequirementService.js @@ -2,7 +2,7 @@ import storage from "../../sdk/storage"; import {Config} from "../../model/config"; import Log from "../../model/log"; import * as openpgp from "openpgp"; -import toolbarService from "../../controller/toolbarService"; +import toolbarService from "../toolbar/toolbarService"; class SystemRequirementService { /** diff --git a/src/all/background_page/controller/toolbarService.js b/src/all/background_page/service/toolbar/toolbarService.js similarity index 91% rename from src/all/background_page/controller/toolbarService.js rename to src/all/background_page/service/toolbar/toolbarService.js index fff07f82..4c1a0a21 100644 --- a/src/all/background_page/controller/toolbarService.js +++ b/src/all/background_page/service/toolbar/toolbarService.js @@ -11,17 +11,15 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 2.0.0 */ -import {BrowserExtensionIconService} from "../service/ui/browserExtensionIcon.service"; -import ResourceModel from "../model/resource/resourceModel"; -import Toolbar from "../model/toolbar"; -import {TabController as tabsController} from "./tabsController"; -import GetLegacyAccountService from "../service/account/getLegacyAccountService"; -import BuildApiClientOptionsService from "../service/account/buildApiClientOptionsService"; +import {BrowserExtensionIconService} from "../ui/browserExtensionIcon.service"; +import ResourceModel from "../../model/resource/resourceModel"; +import Toolbar from "../../model/toolbar"; +import {TabController as tabsController} from "../../controller/tabsController"; +import GetLegacyAccountService from "../account/getLegacyAccountService"; +import BuildApiClientOptionsService from "../account/buildApiClientOptionsService"; class ToolbarService { initialise() { - // Initially, set the browser extension icon as inactive - BrowserExtensionIconService.deactivate(); this.bindCallbacks(); this.addEventListeners(); this.account = null; // The user account diff --git a/src/all/background_page/controller/toolbarService.test.js b/src/all/background_page/service/toolbar/toolbarService.test.js similarity index 93% rename from src/all/background_page/controller/toolbarService.test.js rename to src/all/background_page/service/toolbar/toolbarService.test.js index 5d3c707b..c39b092b 100644 --- a/src/all/background_page/controller/toolbarService.test.js +++ b/src/all/background_page/service/toolbar/toolbarService.test.js @@ -13,16 +13,16 @@ */ import toolbarService from "./toolbarService"; -import AccountEntity from "../model/entity/account/accountEntity"; -import {defaultAccountDto} from "../model/entity/account/accountEntity.test.data"; -import GetLegacyAccountService from "../service/account/getLegacyAccountService"; -import {BrowserExtensionIconService} from "../service/ui/browserExtensionIcon.service"; -import {defaultResourceDtosCollection} from "../model/entity/resource/resourcesCollection.test.data"; -import ResourceLocalStorage from "../service/local_storage/resourceLocalStorage"; +import AccountEntity from "../../model/entity/account/accountEntity"; +import {defaultAccountDto} from "../../model/entity/account/accountEntity.test.data"; +import GetLegacyAccountService from "../account/getLegacyAccountService"; +import {BrowserExtensionIconService} from "../ui/browserExtensionIcon.service"; +import {defaultResourceDtosCollection} from "../../model/entity/resource/resourcesCollection.test.data"; +import ResourceLocalStorage from "../local_storage/resourceLocalStorage"; import { resourceTypesCollectionDto } from "passbolt-styleguide/src/shared/models/entity/resourceType/resourceTypesCollection.test.data"; -import ResourceTypeLocalStorage from "../service/local_storage/resourceTypeLocalStorage"; +import ResourceTypeLocalStorage from "../local_storage/resourceTypeLocalStorage"; jest.useFakeTimers(); @@ -33,7 +33,7 @@ beforeEach(() => { jest.clearAllTimers(); }); -describe("ToolbarController", () => { +describe("ToolbarService", () => { const browserExtensionIconServiceActivateMock = jest.fn(); const browserExtensionIconServiceDeactivateMock = jest.fn(); const browserExtensionIconServiceSetCountMock = jest.fn(); From a3eeedb36aafd8857c788d6b4af62b325410b055 Mon Sep 17 00:00:00 2001 From: Cedric Alfonsi Date: Tue, 23 Apr 2024 12:35:53 +0000 Subject: [PATCH 44/56] PB-22623 As a user I should be able to use passbolt serving an invalid certificate if previously allowed it --- Gruntfile.js | 8 +- package.json | 4 +- src/chrome-mv3/index.js | 6 + src/chrome-mv3/manifest.json | 3 +- src/chrome-mv3/offscreens/fetch.html | 10 + src/chrome-mv3/offscreens/fetch.js | 18 ++ .../service/network/fetchOffscreenService.js | 122 +++++++++ .../polyfill/fetchOffscreenPolyfill.js | 17 ++ .../network/requestFetchOffscreenService.js | 174 ++++++++++++ .../requestFetchOffscreenService.test.data.js | 38 +++ .../requestFetchOffscreenService.test.js | 257 ++++++++++++++++++ .../network/responseFetchOffscreenService.js | 99 +++++++ ...responseFetchOffscreenService.test.data.js | 128 +++++++++ .../responseFetchOffscreenService.test.js | 181 ++++++++++++ test/jest.setup.js | 1 + test/mocks/mockCrypto.js | 22 ++ test/mocks/mockWebExtensionPolyfill.js | 9 +- webpack-offscreens.fetch.config.js | 55 ++++ webpack.service-worker.config.js | 2 + 19 files changed, 1147 insertions(+), 7 deletions(-) create mode 100644 src/chrome-mv3/offscreens/fetch.html create mode 100644 src/chrome-mv3/offscreens/fetch.js create mode 100644 src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js create mode 100644 src/chrome-mv3/polyfill/fetchOffscreenPolyfill.js create mode 100644 src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.js create mode 100644 src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.data.js create mode 100644 src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js create mode 100644 src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.js create mode 100644 src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.data.js create mode 100644 src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.js create mode 100644 test/mocks/mockCrypto.js create mode 100644 webpack-offscreens.fetch.config.js diff --git a/Gruntfile.js b/Gruntfile.js index 5d2fbb8b..ca5a29e0 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -118,7 +118,8 @@ module.exports = function (grunt) { }, service_worker: { files: [ - { expand: true, cwd: path.src_chrome_mv3, src: 'serviceWorker.js', dest: path.build + 'serviceWorker' } + { expand: true, cwd: path.src_chrome_mv3, src: 'serviceWorker.js', dest: path.build + 'serviceWorker' }, + { expand: true, cwd: `${path.src_chrome_mv3}/offscreens`, src: 'fetch.html', dest: `${path.build}/offscreens` } ] }, web_accessible_resources: { @@ -309,14 +310,15 @@ module.exports = function (grunt) { */ build_service_worker_prod: { command: [ - 'npm run build:service-worker' + 'npm run build:service-worker', ].join(' && ') }, build_service_worker_debug: { command: [ - 'npm run dev:build:service-worker' + 'npm run dev:build:service-worker', ].join(' && ') }, + /** * Build content script */ diff --git a/package.json b/package.json index 08a92b5b..fd14eb0b 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "scripts": { "build": "npx grunt build", "build:background-page": "webpack --config webpack.background-page.config.js", - "build:service-worker": "webpack --config webpack.service-worker.config.js", + "build:service-worker": "webpack --config webpack.service-worker.config.js; webpack --config webpack-offscreens.fetch.config.js", "build:content-scripts": "npm run build:content-scripts:app; npm run build:content-scripts:browser-integration; npm run build:content-scripts:public-website", "build:content-scripts:app": "webpack --config webpack-content-scripts.config.js", "build:content-scripts:browser-integration": "webpack --config webpack-content-scripts.browser-integration.config.js", @@ -86,7 +86,7 @@ "build:web-accessible-resources:app": "webpack --config webpack-data.config.js; webpack --config webpack-data.download.config.js", "build:web-accessible-resources:browser-integration": "webpack --config webpack-data.in-form-call-to-action.config.js; webpack --config webpack-data.in-form-menu.config.js", "dev:build:background-page": "webpack --env debug=true --config webpack.background-page.config.js", - "dev:build:service-worker": "webpack --env debug=true --config webpack.service-worker.config.js", + "dev:build:service-worker": "webpack --env debug=true --config webpack.service-worker.config.js; webpack --env debug=true --config webpack-offscreens.fetch.config.js", "dev:build:content-scripts": "npm run dev:build:content-scripts:app; npm run dev:build:content-scripts:browser-integration; npm run dev:build:content-scripts:public-website", "dev:build:content-scripts:app": "webpack --env debug=true --config webpack-content-scripts.config.js", "dev:build:content-scripts:browser-integration": "webpack --env debug=true --config webpack-content-scripts.browser-integration.config.js", diff --git a/src/chrome-mv3/index.js b/src/chrome-mv3/index.js index 9a3ea78e..7331caee 100644 --- a/src/chrome-mv3/index.js +++ b/src/chrome-mv3/index.js @@ -19,6 +19,7 @@ import TabService from "../all/background_page/service/tab/tabService"; import OnExtensionUpdateAvailableService from "../all/background_page/service/extension/onExtensionUpdateAvailableService"; import GlobalAlarmService from "../all/background_page/service/alarm/globalAlarmService"; +import ResponseFetchOffscreenService from "./serviceWorker/service/network/responseFetchOffscreenService"; /** * Load all system requirement @@ -64,3 +65,8 @@ browser.alarms.onAlarm.removeListener(GlobalAlarmService.exec); * Add a top-level alarm handler. */ browser.alarms.onAlarm.addListener(GlobalAlarmService.exec); + +/** + * Handle offscreen fetch responses. + */ +chrome.runtime.onMessage.addListener(ResponseFetchOffscreenService.handleFetchResponse); diff --git a/src/chrome-mv3/manifest.json b/src/chrome-mv3/manifest.json index f968bb89..62e5268e 100644 --- a/src/chrome-mv3/manifest.json +++ b/src/chrome-mv3/manifest.json @@ -45,7 +45,8 @@ "downloads", "cookies", "clipboardWrite", - "background" + "background", + "offscreen" ], "host_permissions": [ "*://*/*" diff --git a/src/chrome-mv3/offscreens/fetch.html b/src/chrome-mv3/offscreens/fetch.html new file mode 100644 index 00000000..29f92259 --- /dev/null +++ b/src/chrome-mv3/offscreens/fetch.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/chrome-mv3/offscreens/fetch.js b/src/chrome-mv3/offscreens/fetch.js new file mode 100644 index 00000000..d9c044fd --- /dev/null +++ b/src/chrome-mv3/offscreens/fetch.js @@ -0,0 +1,18 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + + +import FetchOffscreenService from "./service/network/fetchOffscreenService"; + +chrome.runtime.onMessage.addListener(FetchOffscreenService.handleFetchRequest); diff --git a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js new file mode 100644 index 00000000..58771108 --- /dev/null +++ b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js @@ -0,0 +1,122 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import Validator from "validator"; + +export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN = "fetch-offscreen"; +export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER = "service-worker-fetch-offscreen-response-handler"; +export const FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS = "success"; +export const FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR = "error"; + +export default class FetchOffscreenService { + /** + * Handle fetch request. + * @param {object} message Browser runtime.onMessage listener message. + * @returns {Promise} + */ + static async handleFetchRequest(message) { + // Return early if this message isn't meant for the offscreen document. + if (message.target !== SEND_MESSAGE_TARGET_FETCH_OFFSCREEN) { + console.debug("FetchOffscreenService received message not specific to offscreen."); + return; + } + + if (!(await FetchOffscreenService.validateMessageData(message.data))) { + return; + } + const {id, resource, options} = message?.data || {}; + + try { + const response = await fetch(resource, options); + await FetchOffscreenService.handleSuccessResponse(id, response); + } catch (error) { + await FetchOffscreenService.handleErrorResponse(id, error); + } + } + + /** + * Validate message data. + * @param {object} messageData The message data + * @returns {Promise} + */ + static async validateMessageData(messageData = {}) { + let error; + + if (!messageData.id || !Validator.isUUID(messageData.id)) { + error = new Error("FetchOffscreenService: message.id should be a valid uuid."); + } else if (typeof messageData.resource !== "string") { + error = new Error("FetchOffscreenService: message.resource should be a valid valid."); + } else if (typeof messageData.options !== "undefined" && !(messageData.options instanceof Object)) { + error = new Error("FetchOffscreenService: message.options should be an object."); + } + + if (error) { + await FetchOffscreenService.handleErrorResponse(messageData.id, error); + return false; + } + + return true; + } + + /** + * Handle fetch success, and send response to the service worker. + * @param {string} id The fetch offscreen request id + * @param {Response} response The fetch response + * @returns {Promise} + */ + static async handleSuccessResponse(id, response) { + await chrome.runtime.sendMessage({ + target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER, + id: id, + type: FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS, + data: await FetchOffscreenService.serializeResponse(response) + }); + } + + /** + * Handle fetch error, and communicate it the service worker. + * @param {string} id The fetch offscreen request id + * @param {Error} error The fetch error + * @returns {Promise} + */ + static async handleErrorResponse(id, error) { + console.error(error); + await chrome.runtime.sendMessage({ + target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER, + id: id, + type: FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR, + data: { + name: error?.name, + message: error?.message || "FetchOffscreenService: an unexpected error occurred" + } + }); + } + + /** + * Serialize the fetch response to return to the service worker. + * @param {Response} response The response to serialize + * @returns {Promise} + */ + static async serializeResponse(response) { + return { + status: response.status, + statusText: response.statusText, + headers: Array.from(response.headers.entries()), + redirected: response.redirected, + url: response.url, + ok: response.ok, + text: await response.text() + }; + } +} diff --git a/src/chrome-mv3/polyfill/fetchOffscreenPolyfill.js b/src/chrome-mv3/polyfill/fetchOffscreenPolyfill.js new file mode 100644 index 00000000..89bb5c73 --- /dev/null +++ b/src/chrome-mv3/polyfill/fetchOffscreenPolyfill.js @@ -0,0 +1,17 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +const {RequestFetchOffscreenService} = require("../serviceWorker/service/network/requestFetchOffscreenService"); + +module.exports = async(resource, options) => RequestFetchOffscreenService.fetch(resource, options); diff --git a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.js b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.js new file mode 100644 index 00000000..358e981d --- /dev/null +++ b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.js @@ -0,0 +1,174 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +const {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} = require("../../../offscreens/service/network/fetchOffscreenService"); + +export const IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY = "IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY"; +const LOCK_CREATE_OFFSCREEN_FETCH_DOCUMENT = "LOCK_CREATE_OFFSCREEN_FETCH_DOCUMENT"; +const FETCH_OFFSCREEN_DOCUMENT_REASON = "WORKERS"; +const OFFSCREEN_URL = "offscreens/fetch.html"; + +export class RequestFetchOffscreenService { + /** + * Preferred strategy cache. + * @type {boolean|null} + */ + static isFetchOffscreenPreferredCache = null; + + /** + * The stack of requests promises callbacks using the request id as reference. + * @type {object} + */ + static offscreenRequestsPromisesCallbacks = {}; + + /** + * Fetch external service through fetch offscreen document. + * @param {string} resource The fetch url resource, similar to the native fetch resource parameter. + * @param {object} options The fetch options, similar to the native fetch option parameter. + * @returns {Promise} + */ + static async fetch(resource, options) { + const fetchStrategy = await RequestFetchOffscreenService.isFetchOffscreenPreferred() + ? RequestFetchOffscreenService.fetchOffscreen + : RequestFetchOffscreenService.fetchNative; + + return fetchStrategy(resource, options); + } + + /** + * Check if the fetch offscreen strategy is preferred. + * @returns {Promise} + */ + static async isFetchOffscreenPreferred() { + if (RequestFetchOffscreenService.isFetchOffscreenPreferredCache === null) { + const storageData = await browser.storage.session.get([IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]); + RequestFetchOffscreenService.isFetchOffscreenPreferredCache = Boolean(storageData?.[IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]); + } + + return RequestFetchOffscreenService.isFetchOffscreenPreferredCache; + } + + /** + * Perform a fetch using the browser native API. Fallback on the offscreen fetch in case of unexpected error. + * @param {string} resource The fetch url resource, similar to the native fetch resource parameter. + * @param {object} options The fetch options, similar to the native fetch option parameter. + * @returns {Promise} + */ + static async fetchNative(resource, options) { + try { + return await fetch(resource, options); + } catch (error) { + // Let the fetch happen even if offline in case it requests a local url or a cache, however do not fallback on offscreen strategy in that case. + if (!navigator.onLine) { + throw new Error("RequestFetchOffscreenService::fetchNative: offline error."); + } + console.error("RequestFetchOffscreenService::fetchNative: An error occurred while using the native fetch API, fallback on offscreen strategy until browser restart.", error); + RequestFetchOffscreenService.markFetchOffscreenStrategyAsPreferred(); + return await RequestFetchOffscreenService.fetchOffscreen(resource, options); + } + } + + /** + * Perform a fetch using the offscreen API. + * @param {string} resource The fetch url resource, similar to the native fetch resource parameter. + * @param {object} options The fetch options, similar to the native fetch option parameter. + * @returns {Promise} + */ + static async fetchOffscreen(resource, options) { + // Create offscreen document if it does not already exist. + await navigator.locks.request( + LOCK_CREATE_OFFSCREEN_FETCH_DOCUMENT, + RequestFetchOffscreenService.createIfNotExistOffscreenDocument); + + const offscreenFetchId = crypto.randomUUID(); + const offscreenFetchData = RequestFetchOffscreenService.buildOffscreenData(offscreenFetchId, resource, options); + + return new Promise((resolve, reject) => { + // Stack the response listener callbacks. + RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[offscreenFetchId] = {resolve, reject}; + return RequestFetchOffscreenService.sendOffscreenMessage(offscreenFetchData) + .catch(reject); + }); + } + + /** + * Create fetch offscreen document if it does not exist yet. + * @returns {Promise} + */ + static async createIfNotExistOffscreenDocument() { + const existingContexts = await chrome.runtime.getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT"], + documentUrls: [chrome.runtime.getURL(OFFSCREEN_URL)] + }); + + if (existingContexts.length > 0) { + return; + } + + await chrome.offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: [FETCH_OFFSCREEN_DOCUMENT_REASON], + justification: "Used to perform fetch to services such as the passbolt API serving invalid certificate.", + }); + } + + /** + * Build offscreen message data. + * @param {string} id The identifier of the offscreen fetch request. + * @param {string} resource The fetch url resource, similar to the native fetch resource parameter. + * @param {object} fetchOptions The fetch options, similar to the native fetch option parameter. + * @returns {object} + */ + static buildOffscreenData(id, resource, fetchOptions = {}) { + const options = JSON.parse(JSON.stringify(fetchOptions)); + + // Format FormData fetch options to allow its serialization. + if (fetchOptions?.body instanceof FormData) { + const formDataValues = []; + for (const key of fetchOptions.body.keys()) { + formDataValues.push(`${encodeURIComponent(key)}=${encodeURIComponent(fetchOptions.body.get(key))}`); + } + options.body = formDataValues.join('&'); + // Ensure the request content type reflect the content of its body. + options.headers = options.headers ?? {}; + options.headers['Content-type'] = 'application/x-www-form-urlencoded'; + } + + return {id, resource, options}; + } + + /** + * Send message to the offscreen fetch document. + * @param {object} offscreenData The offscreen message data. + * @param {string} offscreenData.id The identifier of the offscreen fetch request. + * @param {string} offscreenData.resource The fetch url resource, similar to the native fetch resource parameter. + * @param {object} offscreenData.fetchOptions The fetch options, similar to the native fetch option parameter. + * @returns {Promise<*>} + */ + static async sendOffscreenMessage(offscreenData) { + return chrome.runtime.sendMessage({ + target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN, + data: offscreenData + }); + } + + /** + * Mark the fetch offscreen strategy as preferred. + * return {Promise} + */ + static async markFetchOffscreenStrategyAsPreferred() { + RequestFetchOffscreenService.isFetchOffscreenPreferredCache = true; + await browser.storage.session.set({[IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]: RequestFetchOffscreenService.isFetchOffscreenPreferredCache}); + } +} diff --git a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.data.js b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.data.js new file mode 100644 index 00000000..49ef9471 --- /dev/null +++ b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.data.js @@ -0,0 +1,38 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +export const fetchOptionsHeaders = () => ({ + "X-CSRF-Token": crypto.randomUUID() +}); + +export const fetchOptionWithBodyData = () => ({ + credentials: "include", + headers: fetchOptionsHeaders(), + body: { + prop1: "value 1", + prop2: "value 2" + } +}); + +export const fetchOptionsWithBodyFormData = () => { + const formDataBody = (new FormData()); + formDataBody.append("prop1", "value 1"); + formDataBody.append("prop1", "value 2"); + return { + method: "POST", + credentials: "include", + headers: fetchOptionsHeaders(), + body: formDataBody + }; +}; diff --git a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js new file mode 100644 index 00000000..e2bbc799 --- /dev/null +++ b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js @@ -0,0 +1,257 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import each from "jest-each"; +import Validator from "validator"; +import {enableFetchMocks} from "jest-fetch-mock"; +import { + IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY, + RequestFetchOffscreenService +} from "./requestFetchOffscreenService"; +import {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} from "../../../offscreens/service/network/fetchOffscreenService"; +import {fetchOptionsWithBodyFormData, fetchOptionWithBodyData} from "./requestFetchOffscreenService.test.data"; + + +beforeEach(() => { + enableFetchMocks(); + fetch.resetMocks(); + jest.clearAllMocks(); + // Flush preferred strategy runtime cache. + RequestFetchOffscreenService.isFetchOffscreenPreferredCache = null; +}); + +describe("RequestFetchOffscreenService", () => { + describe("::isFetchOffscreenPreferred", () => { + it("should return false if no value found in runtime or session storage caches", async() => { + expect.assertions(1); + expect(await RequestFetchOffscreenService.isFetchOffscreenPreferred()).toBeFalsy(); + }); + + each([ + {label: "true", value: true}, + {label: "false", value: false}, + ]).describe("should return the runtime cached value", scenario => { + it(`should return the runtime cached value for scenario: ${scenario.label}`, async() => { + expect.assertions(1); + // Mock runtime cached value. + RequestFetchOffscreenService.isFetchOffscreenPreferredCache = scenario.value; + expect(await RequestFetchOffscreenService.isFetchOffscreenPreferred()).toEqual(scenario.value); + }); + }); + + each([ + {label: "true", value: true}, + {label: "false", value: false}, + ]).describe("should return the session storage value if no runtime value is present", scenario => { + it(`should return the runtime cached value for scenario: ${scenario.label}`, async() => { + expect.assertions(1); + // Mock session storage cached value. + browser.storage.session.set({[IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]: scenario.value}); + expect(await RequestFetchOffscreenService.isFetchOffscreenPreferred()).toEqual(scenario.value); + }); + }); + + each([ + {label: "true", value: true}, + {label: "false", value: false}, + ]).describe("should return the runtime cached value if set and if the session storage value is also set", scenario => { + it(`should return the runtime cached value for scenario: ${scenario.label}`, async() => { + expect.assertions(1); + // Mock runtime cached value. + RequestFetchOffscreenService.isFetchOffscreenPreferredCache = scenario.value; + browser.storage.session.set({[IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY]: !scenario.value}); + expect(await RequestFetchOffscreenService.isFetchOffscreenPreferred()).toEqual(scenario.value); + }); + }); + }); + + describe("::createIfNotExistOffscreenDocument", () => { + it("should create the offscreen document if it does not exist yet ", async() => { + expect.assertions(2); + jest.spyOn(chrome.runtime, "getContexts").mockImplementationOnce(() => []); + await RequestFetchOffscreenService.createIfNotExistOffscreenDocument(); + + const expectedGetContextsData = { + contextTypes: ["OFFSCREEN_DOCUMENT"], + documentUrls: ["chrome-extension://didegimhafipceonhjepacocaffmoppf/offscreens/fetch.html"] + }; + const expectedCreateDocumentData = { + url: "offscreens/fetch.html", + reasons: ["WORKERS"], + justification: "Used to perform fetch to services such as the passbolt API serving invalid certificate." + }; + expect(chrome.runtime.getContexts).toHaveBeenCalledWith(expectedGetContextsData); + expect(chrome.offscreen.createDocument).toHaveBeenCalledWith(expectedCreateDocumentData); + }); + + it("should not create the offscreen document if it already exist ", async() => { + expect.assertions(1); + jest.spyOn(chrome.runtime, "getContexts").mockImplementationOnce(() => ["shallow-offscreen-document-mock"]); + await RequestFetchOffscreenService.createIfNotExistOffscreenDocument(); + expect(chrome.offscreen.createDocument).not.toHaveBeenCalled(); + }); + }); + + describe("::buildOffscreenData", () => { + it("should build data to send to the offscreen document", async() => { + expect.assertions(1); + const id = crypto.randomUUID(); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionWithBodyData(); + const offscreenData = RequestFetchOffscreenService.buildOffscreenData(id, resource, options); + // Ensure body remains a form data after serialization. + expect(offscreenData).toEqual({id, resource, options}); + }); + + it("should ensure given fetch options body will not be altered", async() => { + expect.assertions(1); + const id = crypto.randomUUID(); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const fetchOptions = fetchOptionsWithBodyFormData(); + RequestFetchOffscreenService.buildOffscreenData(id, resource, fetchOptions); + // Ensure body remains a form data after serialization. + expect(fetchOptions.body).toBeInstanceOf(FormData); + }); + + it("should transform FormData body into serialized encoded url parameters", async() => { + expect.assertions(1); + const id = crypto.randomUUID(); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionsWithBodyFormData(); + + const offscreenData = RequestFetchOffscreenService.buildOffscreenData(id, resource, options); + // eslint-disable-next-line object-shorthand + const expectedOffscreenMessageData = { + id, + resource, + options: { + ...options, + headers: { + ...options.headers, + "Content-type": "application/x-www-form-urlencoded" // ensure the content type reflect the body type parameter. + }, + body: "prop1=value%201&prop1=value%201" // ensure the body is serialized as url encoded parameter + } + }; + expect(offscreenData).toEqual(expectedOffscreenMessageData); + }); + }); + + describe("::sendOffscreenMessage", () => { + it("should send a message to the offscreen document", async() => { + expect.assertions(1); + const data = {prop1: "value1"}; + await RequestFetchOffscreenService.sendOffscreenMessage(data); + const target = SEND_MESSAGE_TARGET_FETCH_OFFSCREEN; + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({target, data}); + }); + }); + + describe("::fetchOffscreen", () => { + it("should send a message to the offscreen document and stack the response callback handlers", async() => { + expect.assertions(4); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionsWithBodyFormData(); + jest.spyOn(chrome.runtime, "sendMessage").mockImplementationOnce(message => { + expect(Validator.isUUID(message.data.id)).toBe(true); + const expectedMessageData = { + target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN, + data: { + ...message.data, + options: { + ...message.data.options, + headers: { + ...message.data.options.headers, + "Content-type": "application/x-www-form-urlencoded" // ensure the content type reflect the body type parameter. + }, + body: "prop1=value%201&prop1=value%201" // ensure the body is serialized as url encoded parameter + } + }, + }; + expect(message).toEqual(expectedMessageData); + RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[message.data.id].resolve(); + }); + const requestPromise = RequestFetchOffscreenService.fetchOffscreen(resource, options); + expect(requestPromise).toBeInstanceOf(Promise); + await expect(requestPromise).resolves.not.toThrow(); + }); + + it("should throw if the message cannot be sent to the offscreen document for unexpected reason", async() => { + expect.assertions(2); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionsWithBodyFormData(); + jest.spyOn(chrome.runtime, "sendMessage").mockImplementationOnce(() => { + throw new Error("Test error"); + }); + const requestPromise = RequestFetchOffscreenService.fetchOffscreen(resource, options); + expect(requestPromise).toBeInstanceOf(Promise); + await expect(requestPromise).rejects.toThrow(); + }); + }); + + describe("::fetchNative", () => { + it("should call the native fetch API", async() => { + expect.assertions(2); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionsWithBodyFormData(); + fetch.doMockOnce(() => Promise.resolve({})); + const requestPromise = RequestFetchOffscreenService.fetchNative(resource, options); + await expect(requestPromise).resolves.not.toThrow(); + expect(fetch).toHaveBeenCalledWith(resource, options); + }); + + it("should fallback on fetchOffscreen if an unexpected error occurred", async() => { + expect.assertions(3); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionsWithBodyFormData(); + fetch.doMockOnce(() => Promise.reject({})); + jest.spyOn(RequestFetchOffscreenService, "fetchOffscreen").mockImplementationOnce(() => jest.fn); + const requestPromise = RequestFetchOffscreenService.fetchNative(resource, options); + await expect(requestPromise).resolves.not.toThrow(); + expect(RequestFetchOffscreenService.isFetchOffscreenPreferredCache).toBeTruthy(); + expect(RequestFetchOffscreenService.fetchOffscreen).toHaveBeenCalledWith(resource, options); + }); + + it("should throw and not fallback on fetchOffscreen if the navigator is not online", async() => { + expect.assertions(1); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionsWithBodyFormData(); + fetch.doMockOnce(() => Promise.reject({})); + jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(false); + const requestPromise = RequestFetchOffscreenService.fetchNative(resource, options); + await expect(requestPromise).rejects.toThrow(); + }); + }); + + describe("::fetch", () => { + it("should call the native fetch API if offscreen strategy has not been marked as preferred", async() => { + expect.assertions(1); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionsWithBodyFormData(); + jest.spyOn(RequestFetchOffscreenService, "fetchNative").mockImplementationOnce(() => jest.fn); + await RequestFetchOffscreenService.fetch(resource, options); + expect(RequestFetchOffscreenService.fetchNative).toHaveBeenCalledWith(resource, options); + }); + + it("should call the native fetch offscreen if this strategy has been marked as preferred", async() => { + expect.assertions(1); + const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; + const options = fetchOptionsWithBodyFormData(); + jest.spyOn(RequestFetchOffscreenService, "fetchOffscreen").mockImplementationOnce(() => jest.fn); + RequestFetchOffscreenService.isFetchOffscreenPreferredCache = true; + await RequestFetchOffscreenService.fetch(resource, options); + expect(RequestFetchOffscreenService.fetchOffscreen).toHaveBeenCalledWith(resource, options); + }); + }); +}); diff --git a/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.js b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.js new file mode 100644 index 00000000..c6976049 --- /dev/null +++ b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.js @@ -0,0 +1,99 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import {assertUuid} from "../../../../all/background_page/utils/assertions"; +import { + FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR, + FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS, + SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER +} from "../../../offscreens/service/network/fetchOffscreenService"; +import {RequestFetchOffscreenService} from "./requestFetchOffscreenService"; + +export default class ResponseFetchOffscreenService { + /** + * Handle fetch offscreen response message. + * @param {object} message The message itself. + * @return {void} + */ + static handleFetchResponse(message) { + // Return early if this message isn't meant for the offscreen document. + if (message.target !== SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER) { + console.debug("ResponseFetchOffscreenService: received message not specific to the service worker fetch offscreen response handler."); + return; + } + + ResponseFetchOffscreenService.assertMessage(message); + const {id, type, data} = message; + const offscreenRequestPromiseCallbacks = ResponseFetchOffscreenService.consumeRequestPromiseCallbacksOrFail(id); + + if (type === FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS) { + offscreenRequestPromiseCallbacks.resolve(ResponseFetchOffscreenService.buildFetchResponse(data)); + } else if (type === FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR) { + offscreenRequestPromiseCallbacks.reject(new Error(data.message)); + } + } + + /** + * Assert message data. + * @param {object} message The message. + * @returns {void} + * @throws {Error} If the message id is not a valid uuid. + * @throws {Error} If the message data is not an object. + * @throws {Error} If the message type is not valid. + */ + static assertMessage(message) { + // console.log(message); + const FETCH_OFFSCREEN_RESPONSE_TYPES = [FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS, FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR]; + + if (!FETCH_OFFSCREEN_RESPONSE_TYPES.includes(message?.type)) { + throw new Error(`ResponseFetchOffscreenService: message.type should be one of the following ${FETCH_OFFSCREEN_RESPONSE_TYPES.join(", ")}.`); + } + assertUuid(message?.id, "ResponseFetchOffscreenService: message.id should be a valid uuid."); + if (!(message?.data instanceof Object)) { + throw new Error("ResponseFetchOffscreenService: message.data should be an object."); + } + } + + /** + * Consume the offscreen request promise callbacks or fail. + * @param {string} id The identifier of the offscreen fetch request. + * @returns {object} + * @throws {Error} If no request promise callbacks can be found for the given offscreen fetch request id. + */ + static consumeRequestPromiseCallbacksOrFail(id) { + const offscreenRequestPromiseCallback = RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id]; + if (!offscreenRequestPromiseCallback) { + throw new Error("ResponseFetchOffscreenService: No request promise callbacks found for the given offscreen fetch request id."); + } + delete RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id]; + + return offscreenRequestPromiseCallback; + } + + /** + * Build native fetch response object based on offscreen message response data. + * @param {object} data The fetch offscreen message response data. + * @returns {Response} + */ + static buildFetchResponse(data) { + return new Response(data.text, { + status: data.status, + statusText: data.statusText, + headers: data.headers, + redirected: data.redirected, + url: data.url, + ok: data.ok, + }); + } +} diff --git a/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.data.js b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.data.js new file mode 100644 index 00000000..590816af --- /dev/null +++ b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.data.js @@ -0,0 +1,128 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import { + FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS, + SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER +} from "../../../offscreens/service/network/fetchOffscreenService"; + +export const defaultResponseMessage = (message = {}) => ({ + target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER, + id: crypto.randomUUID(), + type: FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS, + data: { + "status": 200, + "statusText": "OK", + "headers": [ + [ + "access-control-expose-headers", + "X-GPGAuth-Verify-Response, X-GPGAuth-Progress, X-GPGAuth-User-Auth-Token, X-GPGAuth-Authenticated, X-GPGAuth-Refer, X-GPGAuth-Debug, X-GPGAuth-Error, X-GPGAuth-Pubkey, X-GPGAuth-Logout-Url, X-GPGAuth-Version" + ], + [ + "cache-control", + "no-store, no-cache, must-revalidate" + ], + [ + "content-security-policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self' https://*.duosecurity.com;" + ], + [ + "content-type", + "application/json" + ], + [ + "date", + "Tue, 23 Apr 2024 09:03:48 GMT" + ], + [ + "expires", + "Thu, 19 Nov 1981 08:52:00 GMT" + ], + [ + "pragma", + "no-cache" + ], + [ + "referrer-policy", + "same-origin" + ], + [ + "server", + "nginx/1.24.0 (Ubuntu)" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-download-options", + "noopen" + ], + [ + "x-frame-options", + "sameorigin" + ], + [ + "x-gpgauth-authenticated", + "false" + ], + [ + "x-gpgauth-debug", + "There is no user associated with this key. No key id set." + ], + [ + "x-gpgauth-error", + "true" + ], + [ + "x-gpgauth-login-url", + "/auth/login" + ], + [ + "x-gpgauth-logout-url", + "/auth/logout" + ], + [ + "x-gpgauth-progress", + "stage0" + ], + [ + "x-gpgauth-pubkey-url", + "/auth/verify.json" + ], + [ + "x-gpgauth-verify-url", + "/auth/verify" + ], + [ + "x-gpgauth-version", + "1.3.0" + ], + [ + "x-permitted-cross-domain-policies", + "all" + ] + ], + "redirected": false, + "url": "https://www.passbolt.test/settings.json?api-version=v2", + "ok": true, + "text": "{\n \"header\": {\n \"id\": \"0682ab8f-ecba-4336-a628-8b6cac609f49\",\n \"status\": \"success\",\n \"servertime\": 1713863028,\n \"action\": \"bef9f3ca-86ef-5c6a-9b38-320e03ceb5df\",\n \"message\": \"The operation was successful.\",\n \"url\": \"\\/settings.json?api-version=v2\",\n \"code\": 200\n },\n \"body\": {\n \"app\": {\n \"url\": \"https:\\/\\/www.passbolt.test\\/\",\n \"locale\": \"en-UK\"\n },\n \"passbolt\": {\n \"legal\": {\n \"privacy_policy\": {\n \"url\": \"\"\n },\n \"terms\": {\n \"url\": \"https:\\/\\/www.passbolt.com\\/terms\"\n }\n },\n \"edition\": \"pro\",\n \"plugins\": {\n \"jwtAuthentication\": {\n \"enabled\": true\n },\n \"accountRecoveryRequestHelp\": {\n \"enabled\": true\n },\n \"accountRecovery\": {\n \"enabled\": true\n },\n \"selfRegistration\": {\n \"enabled\": true\n },\n \"sso\": {\n \"enabled\": true\n },\n \"mfaPolicies\": {\n \"enabled\": true\n },\n \"ssoRecover\": {\n \"enabled\": true\n },\n \"userPassphrasePolicies\": {\n \"enabled\": true\n },\n \"inFormIntegration\": {\n \"enabled\": true\n },\n \"locale\": {\n \"options\": [\n {\n \"locale\": \"de-DE\",\n \"label\": \"Deutsch\"\n },\n {\n \"locale\": \"en-UK\",\n \"label\": \"English\"\n },\n {\n \"locale\": \"es-ES\",\n \"label\": \"Espa\\u00f1ol\"\n },\n {\n \"locale\": \"fr-FR\",\n \"label\": \"Fran\\u00e7ais\"\n },\n {\n \"locale\": \"it-IT\",\n \"label\": \"Italiano (beta)\"\n },\n {\n \"locale\": \"ja-JP\",\n \"label\": \"\\u65e5\\u672c\\u8a9e\"\n },\n {\n \"locale\": \"ko-KR\",\n \"label\": \"\\ud55c\\uad6d\\uc5b4 (beta)\"\n },\n {\n \"locale\": \"lt-LT\",\n \"label\": \"Lietuvi\\u0173\"\n },\n {\n \"locale\": \"nl-NL\",\n \"label\": \"Nederlands\"\n },\n {\n \"locale\": \"pl-PL\",\n \"label\": \"Polski\"\n },\n {\n \"locale\": \"pt-BR\",\n \"label\": \"Portugu\\u00eas Brasil (beta)\"\n },\n {\n \"locale\": \"ro-RO\",\n \"label\": \"Rom\\u00e2n\\u0103 (beta)\"\n },\n {\n \"locale\": \"ru-RU\",\n \"label\": \"P\\u0443\\u0441\\u0441\\u043a\\u0438\\u0439 (beta)\"\n },\n {\n \"locale\": \"sv-SE\",\n \"label\": \"Svenska\"\n }\n ]\n },\n \"rememberMe\": {\n \"options\": {\n \"300\": \"5 minutes\",\n \"900\": \"15 minutes\",\n \"1800\": \"30 minutes\",\n \"3600\": \"1 hour\",\n \"-1\": \"until I log out\"\n }\n }\n }\n }\n }\n}" + }, + ...message +}); + +export const defaultCallbacks = (data = {}) => ({ + resolve: jest.fn(), + reject: jest.fn(), + ...data +}); diff --git a/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.js b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.js new file mode 100644 index 00000000..cdd97a2c --- /dev/null +++ b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.js @@ -0,0 +1,181 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import each from "jest-each"; +import {enableFetchMocks} from "jest-fetch-mock"; +import {RequestFetchOffscreenService} from "./requestFetchOffscreenService"; +import { + FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR, + FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS +} from "../../../offscreens/service/network/fetchOffscreenService"; +import ResponseFetchOffscreenService from "./responseFetchOffscreenService"; +import {defaultCallbacks, defaultResponseMessage} from "./responseFetchOffscreenService.test.data"; + +beforeEach(() => { + enableFetchMocks(); + fetch.resetMocks(); + jest.clearAllMocks(); + // Flush the requests promises callbacks stack. + RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks = {}; +}); + +describe("ResponseFetchOffscreenService", () => { + describe("::assertMessage", () => { + each([ + {scenario: "success", type: FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS}, + {scenario: "error", type: FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR}, + ]).describe("should accept if message type is valid", _props => { + it(`should validate message type: ${_props.scenario}`, () => { + const message = defaultResponseMessage({type: _props.type}); + try { + ResponseFetchOffscreenService.assertMessage(message); + expect(true).toBeTruthy(); + } catch (error) { + expect(error).toBeNull(); + } + }); + }); + + each([ + {scenario: "undefined", type: undefined}, + {scenario: "null", type: null}, + {scenario: "invalid string", type: "invalid"}, + {scenario: "boolean", type: true}, + {scenario: "object", type: {data: FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS}}, + ]).describe("should throw if message type is not valid", _props => { + it(`should trow if message type: ${_props.scenario}`, () => { + const message = defaultResponseMessage({type: _props.type}); + try { + ResponseFetchOffscreenService.assertMessage(message); + expect(true).toBeFalsy(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + }); + + it("should validate message id", () => { + expect(() => defaultResponseMessage({id: crypto.randomUUID()})).not.toThrow(); + }); + + each([ + {scenario: "undefined", id: undefined}, + {scenario: "null", id: null}, + {scenario: "invalid string", id: "invalid"}, + {scenario: "boolean", id: true}, + {scenario: "object", id: {data: crypto.randomUUID()}}, + ]).describe("should throw if message id is not valid", _props => { + it(`should trow if message id: ${_props.scenario}`, () => { + const message = defaultResponseMessage({id: _props.id}); + try { + ResponseFetchOffscreenService.assertMessage(message); + expect(true).toBeFalsy(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + }); + + it("should validate message data", () => { + expect(() => defaultResponseMessage({data: {prop: "value"}})).not.toThrow(); + }); + + each([ + {scenario: "undefined", data: undefined}, + {scenario: "null", data: null}, + {scenario: "invalid string", data: "invalid"}, + {scenario: "boolean", data: true}, + ]).describe("should throw if message data is not valid", _props => { + it(`should trow if message id: ${_props.scenario}`, () => { + const message = defaultResponseMessage({data: _props.data}); + try { + ResponseFetchOffscreenService.assertMessage(message); + expect(true).toBeFalsy(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + }); + }); + + describe("::consumeRequestPromiseCallbacksOrFail", () => { + it("should consume the response handler associated to the given id", () => { + expect.assertions(3); + const id = crypto.randomUUID(); + const callbacks = defaultCallbacks(); + RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id] = callbacks; + const consumedCallbacks = ResponseFetchOffscreenService.consumeRequestPromiseCallbacksOrFail(id); + expect(consumedCallbacks).not.toBeNull(); + expect(consumedCallbacks).toEqual(callbacks); + expect(Object.keys(RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks).length).toEqual(0); + }); + + it("should throw if no associated callbacks found for the given id", () => { + expect.assertions(1); + const id = crypto.randomUUID(); + expect(() => ResponseFetchOffscreenService.consumeRequestPromiseCallbacksOrFail(id)).toThrow(); + }); + }); + + describe("::buildFetchResponse", () => { + it("should build the fetch response object based on the offscreen message data", async() => { + expect.assertions(8); + const message = defaultResponseMessage(); + const response = ResponseFetchOffscreenService.buildFetchResponse(message.data); + expect(response).toBeInstanceOf(Response); + expect(response.status).toEqual(message.data.status); + expect(response.statusText).toEqual(message.data.statusText); + expect(Array.from(response.headers.entries())).toEqual(message.data.headers); + expect(response.redirected).toEqual(message.data.redirected); + expect(response.url).toEqual(message.data.url); + expect(response.ok).toEqual(message.data.ok); + expect(await response.text()).toEqual(message.data.text); + }); + }); + + describe("::handleFetchResponse", () => { + it("should handle success response and execute the resolve callback", () => { + expect.assertions(1); + const id = crypto.randomUUID(); + const callbacks = defaultCallbacks(); + RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id] = callbacks; + const message = defaultResponseMessage({id}); + ResponseFetchOffscreenService.handleFetchResponse(message); + expect(callbacks.resolve).toHaveBeenCalledWith(expect.any(Response)); + }); + + it("should handle error response and execute the reject callback", () => { + expect.assertions(1); + const id = crypto.randomUUID(); + const callbacks = defaultCallbacks(); + RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id] = callbacks; + // eslint-disable-next-line object-shorthand + const message = defaultResponseMessage({id, type: FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR}); + ResponseFetchOffscreenService.handleFetchResponse(message); + expect(callbacks.reject).toHaveBeenCalledWith(expect.any(Error)); + }); + + it("should ignore message having the wrong target", () => { + expect.assertions(2); + const id = crypto.randomUUID(); + const callbacks = defaultCallbacks(); + RequestFetchOffscreenService.offscreenRequestsPromisesCallbacks[id] = callbacks; + // eslint-disable-next-line object-shorthand + const message = defaultResponseMessage({id, target: "other-target"}); + ResponseFetchOffscreenService.handleFetchResponse(message); + expect(callbacks.resolve).not.toHaveBeenCalled(); + expect(callbacks.reject).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/jest.setup.js b/test/jest.setup.js index 0117a91b..9c3631f4 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -15,6 +15,7 @@ import "./mocks/mockWebExtensionPolyfill"; import browser from "../src/all/common/polyfill/browserPolyfill"; import "./mocks/mockTextEncoder"; +import "./mocks/mockCrypto"; import "./matchers/extendExpect"; import MockNavigatorLocks from './mocks/mockNavigatorLocks'; import OrganizationSettingsModel from "../src/all/background_page/model/organizationSettings/organizationSettingsModel"; diff --git a/test/mocks/mockCrypto.js b/test/mocks/mockCrypto.js new file mode 100644 index 00000000..d21751b1 --- /dev/null +++ b/test/mocks/mockCrypto.js @@ -0,0 +1,22 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import {v4 as uuid} from "uuid"; + +if (!global.crypto) { + global.crypto = {}; +} +if (!global.crypto.randomUUID) { + global.crypto.randomUUID = uuid; +} diff --git a/test/mocks/mockWebExtensionPolyfill.js b/test/mocks/mockWebExtensionPolyfill.js index 26d63c1f..fe4ac021 100644 --- a/test/mocks/mockWebExtensionPolyfill.js +++ b/test/mocks/mockWebExtensionPolyfill.js @@ -29,10 +29,17 @@ jest.mock("webextension-polyfill", () => { cookies: { get: jest.fn() }, + // offscreen is not mocked by jest-webextension-mock v3.8.9 + offscreen: { + closeDocument: jest.fn(), + createDocument: jest.fn(), + }, runtime: { ...originalBrowser.runtime, + // getContexts not mocked by jest-webextension-mock v3.8.9 + getContexts: jest.fn(() => []), // Force the extension runtime url - getURL: jest.fn(() => "chrome-extension://didegimhafipceonhjepacocaffmoppf"), + getURL: jest.fn(path => `chrome-extension://didegimhafipceonhjepacocaffmoppf/${path}`), // Force extension version getManifest: jest.fn(() => ({ version: "v3.6.0" diff --git a/webpack-offscreens.fetch.config.js b/webpack-offscreens.fetch.config.js new file mode 100644 index 00000000..a67afcea --- /dev/null +++ b/webpack-offscreens.fetch.config.js @@ -0,0 +1,55 @@ +const path = require('path'); +const TerserPlugin = require("terser-webpack-plugin"); + +const config = { + entry: { + 'fetch': path.resolve(__dirname, './src/chrome-mv3/offscreens/fetch.js'), + }, + mode: 'production', + module: { + rules: [ + { + test: /\.(js|jsx)$/, + exclude: /(node_modules[\\/]((?!(passbolt\-styleguide))))/, + loader: "babel-loader", + options: { + presets: ["@babel/react"], + } + } + ] + }, + optimization: { + minimize: true, + minimizer: [new TerserPlugin()], + splitChunks: { + minSize: 0, + cacheGroups: { + commons: { + test: /[\\/]node_modules[\\/]((?!(passbolt\-styleguide)).*)[\\/]/, + name: 'vendors', + chunks: 'all' + }, + } + }, + }, + resolve: {extensions: ["*", ".js"], fallback: {crypto: false}}, + output: { + // Set a unique name to ensure the cohabitation of multiple webpack loader on the same page. + chunkLoadingGlobal: 'offscreensFetchChunkLoadingGlobal', + path: path.resolve(__dirname, './build/all/offscreens'), + pathinfo: true, + filename: '[name].js' + } +}; + +exports.default = function (env) { + env = env || {}; + // Enable debug mode. + if (env.debug) { + config.mode = "development"; + config.devtool = "inline-source-map"; + config.optimization.minimize = false; + config.optimization.minimizer = []; + } + return config; +}; diff --git a/webpack.service-worker.config.js b/webpack.service-worker.config.js index 42f8348f..9d907e1e 100644 --- a/webpack.service-worker.config.js +++ b/webpack.service-worker.config.js @@ -11,6 +11,8 @@ const config = { new webpack.ProvidePlugin({ // Inject browser polyfill as a global API, and adapt it depending on the environment (MV2/MV3/Windows app). browser: path.resolve(__dirname, './src/all/common/polyfill/browserPolyfill.js'), + // Inject custom api client fetch to MV3 extension as workaround of the invalid certificate issue. + customApiClientFetch: path.resolve(__dirname, './src/chrome-mv3/polyfill/fetchOffscreenPolyfill.js'), }) ], module: { From f46e95d8c9a960ec4d37cf39496c51aa40037ad5 Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Tue, 23 Apr 2024 13:59:19 +0000 Subject: [PATCH 45/56] PB-33067 After an unexpected error during setup, recover or account recovery,... --- .../controller/tab/reloadTabController.js | 51 +++++++++++++++++++ .../tab/reloadTabController.test.js | 40 +++++++++++++++ .../event/accountRecoveryEvents.js | 6 +++ .../background_page/event/recoverEvents.js | 6 +++ src/all/background_page/event/setupEvents.js | 6 +++ .../service/ui/browserTab.service.js | 9 ++++ .../service/ui/browserTab.service.test.js | 14 +++++ 7 files changed, 132 insertions(+) create mode 100644 src/all/background_page/controller/tab/reloadTabController.js create mode 100644 src/all/background_page/controller/tab/reloadTabController.test.js diff --git a/src/all/background_page/controller/tab/reloadTabController.js b/src/all/background_page/controller/tab/reloadTabController.js new file mode 100644 index 00000000..923d3012 --- /dev/null +++ b/src/all/background_page/controller/tab/reloadTabController.js @@ -0,0 +1,51 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import BrowserTabService from "../../service/ui/browserTab.service"; + + +class ReloadTabController { + /** + * ReloadTabController constructor + * @param {Worker} worker The associated worker. + * @param {string} requestId The associated request id. + */ + constructor(worker, requestId) { + this.worker = worker; + this.requestId = requestId; + } + + /** + * Controller executor. + * @returns {Promise} + */ + async _exec() { + try { + this.exec(); + this.worker.port.emit(this.requestId, 'SUCCESS'); + } catch (error) { + console.error(error); + this.worker.port.emit(this.requestId, 'ERROR', error); + } + } + + /** + * Reload the tab + * @returns {Promise} + */ + async exec() { + await BrowserTabService.reloadTab(this.worker.tab.id); + } +} + +export default ReloadTabController; diff --git a/src/all/background_page/controller/tab/reloadTabController.test.js b/src/all/background_page/controller/tab/reloadTabController.test.js new file mode 100644 index 00000000..8b0c2fc4 --- /dev/null +++ b/src/all/background_page/controller/tab/reloadTabController.test.js @@ -0,0 +1,40 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import ReloadTabController from "./reloadTabController"; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("ReloadTabController", () => { + describe("ReloadTabController::exec", () => { + it("Should reload the tab.", async() => { + const tab = {id: 1}; + const controller = new ReloadTabController({tab: tab}, null); + jest.spyOn(browser.tabs, "reload"); + + expect.assertions(1); + await controller.exec(); + expect(browser.tabs.reload).toHaveBeenCalledWith(tab.id); + }); + + it("Should not add the account to the local storage if the complete API request fails.", async() => { + const controller = new ReloadTabController(null, null); + const promise = controller.exec(); + + expect.assertions(1); + await expect(promise).rejects.toThrow(); + }); + }); +}); diff --git a/src/all/background_page/event/accountRecoveryEvents.js b/src/all/background_page/event/accountRecoveryEvents.js index 239add41..780f4ec7 100644 --- a/src/all/background_page/event/accountRecoveryEvents.js +++ b/src/all/background_page/event/accountRecoveryEvents.js @@ -22,6 +22,7 @@ import GetAndInitializeAccountLocaleController from "../controller/account/getAn import GetExtensionVersionController from "../controller/extension/getExtensionVersionController"; import GetAccountController from "../controller/account/getAccountController"; import AuthLoginController from "../controller/auth/authLoginController"; +import ReloadTabController from "../controller/tab/reloadTabController"; /** * Listens to the account recovery continue application events @@ -84,6 +85,11 @@ const listen = function(worker, apiClientOptions, account) { const controller = new SetSetupLocaleController(worker, requestId, apiClientOptions, account); await controller._exec(localeDto); }); + + worker.port.on('passbolt.tab.reload', async requestId => { + const controller = new ReloadTabController(worker, requestId); + await controller._exec(); + }); }; export const AccountRecoveryEvents = {listen}; diff --git a/src/all/background_page/event/recoverEvents.js b/src/all/background_page/event/recoverEvents.js index 895e7bdf..bf173f0a 100644 --- a/src/all/background_page/event/recoverEvents.js +++ b/src/all/background_page/event/recoverEvents.js @@ -31,6 +31,7 @@ import SetSetupSecurityTokenController from "../controller/setup/setSetupSecurit import HasRecoverUserEnabledAccountRecoveryController from "../controller/recover/hasRecoverUserEnabledAccountRecoveryController"; import GeneratePortIdController from "../controller/port/generatePortIdController"; import GetUserPassphrasePoliciesController from "../controller/setup/getUserPassphrasePoliciesController"; +import ReloadTabController from "../controller/tab/reloadTabController"; const listen = (worker, apiClientOptions, account) => { @@ -136,5 +137,10 @@ const listen = (worker, apiClientOptions, account) => { const controller = new GetUserPassphrasePoliciesController(worker, requestId, runtimeMemory); await controller._exec(); }); + + worker.port.on('passbolt.tab.reload', async requestId => { + const controller = new ReloadTabController(worker, requestId); + await controller._exec(); + }); }; export const RecoverEvents = {listen}; diff --git a/src/all/background_page/event/setupEvents.js b/src/all/background_page/event/setupEvents.js index 32ecb708..82c4fe80 100644 --- a/src/all/background_page/event/setupEvents.js +++ b/src/all/background_page/event/setupEvents.js @@ -29,6 +29,7 @@ import IsExtensionFirstInstallController from "../controller/extension/isExtensi import SetSetupSecurityTokenController from "../controller/setup/setSetupSecurityTokenController"; import GetAccountRecoveryOrganizationPolicyController from "../controller/setup/getAccountRecoveryOrganizationPolicyController"; import GetUserPassphrasePoliciesController from "../controller/setup/getUserPassphrasePoliciesController"; +import ReloadTabController from "../controller/tab/reloadTabController"; const listen = function(worker, apiClientOptions, account) { /* @@ -124,5 +125,10 @@ const listen = function(worker, apiClientOptions, account) { const controller = new GetUserPassphrasePoliciesController(worker, requestId, runtimeMemory); await controller._exec(); }); + + worker.port.on('passbolt.tab.reload', async requestId => { + const controller = new ReloadTabController(worker, requestId); + await controller._exec(); + }); }; export const SetupEvents = {listen}; diff --git a/src/all/background_page/service/ui/browserTab.service.js b/src/all/background_page/service/ui/browserTab.service.js index 0dee6df3..7aee68f4 100644 --- a/src/all/background_page/service/ui/browserTab.service.js +++ b/src/all/background_page/service/ui/browserTab.service.js @@ -41,6 +41,15 @@ class BrowserTabService { const requestArgs = [message].concat(args); return browser.tabs.sendMessage(worker.tabId, requestArgs, {frameId: worker.frameId}); } + + /** + * Reload the tab + * @param id The id of the tab + * @return {Promise} + */ + static async reloadTab(id) { + await browser.tabs.reload(id); + } } export default BrowserTabService; diff --git a/src/all/background_page/service/ui/browserTab.service.test.js b/src/all/background_page/service/ui/browserTab.service.test.js index 8345053d..7be44cb2 100644 --- a/src/all/background_page/service/ui/browserTab.service.test.js +++ b/src/all/background_page/service/ui/browserTab.service.test.js @@ -78,4 +78,18 @@ describe("BrowserTabService", () => { } }); }); + + describe("BrowserTabService::reloadTab", () => { + it("Should reload the tab by id", async() => { + expect.assertions(1); + // mock data + const tab = {id: 1}; + // mock functions + jest.spyOn(browser.tabs, 'reload'); + // process + await BrowserTabService.reloadTab(tab.id); + // expectations + expect(browser.tabs.reload).toHaveBeenCalledWith(tab.id); + }); + }); }); From e05f1962d44cf6977bff394da76116521ac4dc0a Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Wed, 17 Apr 2024 11:08:11 +0200 Subject: [PATCH 46/56] PB-33061 Create account temporary storage --- .../account/accountAccountRecoveryEntity.js | 10 +- .../entity/account/accountRecoverEntity.js | 8 + .../entity/account/accountSetupEntity.js | 8 + .../account/accountSetupEntity.test.data.js | 2 +- .../entity/account/accountTemporaryEntity.js | 258 ++++++++++++++++++ .../accountTemporaryEntity.test.data.js | 45 +++ .../account/accountTemporaryEntity.test.js | 138 ++++++++++ .../accountTemporarySessionStorageService.js | 67 +++++ ...ountTemporarySessionStorageService.test.js | 109 ++++++++ 9 files changed, 643 insertions(+), 2 deletions(-) create mode 100644 src/all/background_page/model/entity/account/accountTemporaryEntity.js create mode 100644 src/all/background_page/model/entity/account/accountTemporaryEntity.test.data.js create mode 100644 src/all/background_page/model/entity/account/accountTemporaryEntity.test.js create mode 100644 src/all/background_page/service/sessionStorage/accountTemporarySessionStorageService.js create mode 100644 src/all/background_page/service/sessionStorage/accountTemporarySessionStorageService.test.js diff --git a/src/all/background_page/model/entity/account/accountAccountRecoveryEntity.js b/src/all/background_page/model/entity/account/accountAccountRecoveryEntity.js index 7d4a5e48..4adb62ed 100644 --- a/src/all/background_page/model/entity/account/accountAccountRecoveryEntity.js +++ b/src/all/background_page/model/entity/account/accountAccountRecoveryEntity.js @@ -140,7 +140,7 @@ class AccountAccountRecoveryEntity extends AbstractAccountEntity { if (contains.security_token && this._security_token) { result.security_token = this._security_token.toDto(); } - if (contains.accountRecoveryRequest && this._account_recovery_request) { + if (contains.account_recovery_request && this.accountRecoveryRequest) { result.account_recovery_request = this._account_recovery_request.toDto(AccountRecoveryRequestEntity.ALL_CONTAIN_OPTIONS); } @@ -198,6 +198,14 @@ class AccountAccountRecoveryEntity extends AbstractAccountEntity { return this._props.authentication_token_token || null; } + /** + * Get the type. + * @return {string} + */ + get type() { + return this._props.type; + } + /** * Get the account recovery request id * @returns {string|null} diff --git a/src/all/background_page/model/entity/account/accountRecoverEntity.js b/src/all/background_page/model/entity/account/accountRecoverEntity.js index 91186025..7ab82d74 100644 --- a/src/all/background_page/model/entity/account/accountRecoverEntity.js +++ b/src/all/background_page/model/entity/account/accountRecoverEntity.js @@ -190,6 +190,14 @@ class AccountRecoverEntity extends AbstractAccountEntity { return this._props.authentication_token_token || null; } + /** + * Get the type. + * @return {string} + */ + get type() { + return this._props.type; + } + /* * ================================================== * Other associated properties methods diff --git a/src/all/background_page/model/entity/account/accountSetupEntity.js b/src/all/background_page/model/entity/account/accountSetupEntity.js index db380bf9..1b84cdcd 100644 --- a/src/all/background_page/model/entity/account/accountSetupEntity.js +++ b/src/all/background_page/model/entity/account/accountSetupEntity.js @@ -161,6 +161,14 @@ class AccountSetupEntity extends AbstractAccountEntity { return this._props.authentication_token_token || null; } + /** + * Get the type. + * @return {string} + */ + get type() { + return this._props.type; + } + /* * ================================================== * Other associated properties methods diff --git a/src/all/background_page/model/entity/account/accountSetupEntity.test.data.js b/src/all/background_page/model/entity/account/accountSetupEntity.test.data.js index 7b4dcaef..7d8a5b4d 100644 --- a/src/all/background_page/model/entity/account/accountSetupEntity.test.data.js +++ b/src/all/background_page/model/entity/account/accountSetupEntity.test.data.js @@ -19,7 +19,7 @@ import {defaultSecurityTokenDto} from "../securityToken/SecurityTokenEntity.test export const initialAccountSetupDto = (data = {}) => { const defaultData = { - "type": AccountSetupEntity.TYPE_ACCOUNT, + "type": AccountSetupEntity.TYPE_ACCOUNT_SETUP, "domain": "https://passbolt.local", "user_id": pgpKeys.ada.userId, "authentication_token_token": uuidv4(), diff --git a/src/all/background_page/model/entity/account/accountTemporaryEntity.js b/src/all/background_page/model/entity/account/accountTemporaryEntity.js new file mode 100644 index 00000000..a534b86e --- /dev/null +++ b/src/all/background_page/model/entity/account/accountTemporaryEntity.js @@ -0,0 +1,258 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import AbstractAccountEntity from "./abstractAccountEntity"; +import EntitySchema from "passbolt-styleguide/src/shared/models/entity/abstract/entitySchema"; +import AccountAccountRecoveryEntity from "./accountAccountRecoveryEntity"; +import AccountSetupEntity from "./accountSetupEntity"; +import AccountRecoverEntity from "./accountRecoverEntity"; +import AccountRecoveryOrganizationPolicyEntity from "../accountRecovery/accountRecoveryOrganizationPolicyEntity"; +import UserPassphrasePoliciesEntity from "passbolt-styleguide/src/shared/models/entity/userPassphrasePolicies/userPassphrasePoliciesEntity"; + +const ENTITY_NAME = "AccountTemporary"; + +class AccountTemporaryEntity extends AbstractAccountEntity { + /** + * @inheritDoc + */ + constructor(AccountEntityDto) { + super(EntitySchema.validate( + AccountTemporaryEntity.ENTITY_NAME, + AccountEntityDto, + AccountTemporaryEntity.getSchema() + )); + + // Associations + if (this._props.account) { + switch (this._props.account.type) { + case AccountSetupEntity.TYPE_ACCOUNT_SETUP: + this._account = new AccountSetupEntity(this._props.account, {clone: false}); + break; + case AccountRecoverEntity.TYPE_ACCOUNT_RECOVER: + this._account = new AccountRecoverEntity(this._props.account, {clone: false}); + break; + case AccountAccountRecoveryEntity.TYPE_ACCOUNT_ACCOUNT_RECOVERY: + this._account = new AccountAccountRecoveryEntity(this._props.account, {clone: false}); + break; + default: + throw new TypeError('The account should have a known type.'); + } + delete this._props.account; + } + + if (this._props.account_recovery_organization_policy) { + this._account_recovery_organization_policy = new AccountRecoveryOrganizationPolicyEntity(this._props.account_recovery_organization_policy, {clone: false}); + delete this._props.account_recovery_organization_policy; + } + + if (this._props.user_passphrase_policies) { + this._user_passphrase_policies = new UserPassphrasePoliciesEntity(this._props.user_passphrase_policies, {clone: false}); + delete this._props.user_passphrase_policies; + } + } + + /** + * Get entity schema + * @returns {Object} schema + */ + static getSchema() { + return { + "type": "object", + "required": [ + "worker_id", + "account" + ], + "properties": { + "account": {"anyOf": [ + AccountSetupEntity.getSchema(), + AccountRecoverEntity.getSchema(), + AccountAccountRecoveryEntity.getSchema() + ]}, + "passphrase": { + "type": "string", + }, + "worker_id": { + "type": "string", + "format": "uuid" + }, + "account_recovery_organization_policy": AccountRecoveryOrganizationPolicyEntity.getSchema(), + "user_passphrase_policies": UserPassphrasePoliciesEntity.getSchema(), + } + }; + } + + /* + * ================================================== + * Serialization + * ================================================== + */ + /** + * Return a DTO ready to be sent to API or content code + * @param {Object} contains The contains + * @returns {object} + */ + toDto(contains = {}) { + const result = Object.assign({}, this._props); + + // Ensure some properties are not leaked by default and require an explicit contain. + delete result.passphrase; + delete result.worker_id; + + if (!contains) { + return result; + } + + if (contains.account) { + switch (this.account.type) { + case AccountSetupEntity.TYPE_ACCOUNT_SETUP: + result.account = this.account.toDto(AccountSetupEntity.ALL_CONTAIN_OPTIONS); + break; + case AccountRecoverEntity.TYPE_ACCOUNT_RECOVER: + result.account = this.account.toDto(AccountRecoverEntity.ALL_CONTAIN_OPTIONS); + break; + case AccountAccountRecoveryEntity.TYPE_ACCOUNT_ACCOUNT_RECOVERY: + result.account = this.account.toDto(AccountAccountRecoveryEntity.ALL_CONTAIN_OPTIONS); + break; + } + } + if (contains.passphrase && this.passphrase) { + result.passphrase = this.passphrase; + } + if (contains.worker_id) { + result.worker_id = this.workerId; + } + if (contains.account_recovery_organization_policy && this.accountRecoveryOrganizationPolicy) { + result.account_recovery_organization_policy = this.accountRecoveryOrganizationPolicy.toDto(AccountRecoveryOrganizationPolicyEntity.ALL_CONTAIN_OPTIONS); + } + if (contains.user_passphrase_policies && this.userPassphrasePolicy) { + result.user_passphrase_policies = this.userPassphrasePolicy.toDto(); + } + + return result; + } + + /* + * ================================================== + * Dynamic properties getters + * ================================================== + */ + + /** + * Get the worker Id. + * @return {string} + */ + get workerId() { + return this._props.worker_id; + } + + /** + * Get the passphrase. + * @return {string|null} + */ + get passphrase() { + return this._props.passphrase || null; + } + + /** + * Set the passphrase. + * @param {string} passphrase The passphrase + */ + set passphrase(passphrase) { + this._props.passphrase = passphrase; + } + + /* + * ================================================== + * Other associated properties methods + * ================================================== + */ + + /** + * Get the associated account + * @returns {(AccountSetupEntity|AccountRecoverEntity|AccountAccountRecoveryEntity)} + */ + get account() { + return this._account; + } + + /** + * Get the account recovery organization policy + * @returns {(AccountRecoveryOrganizationPolicyEntity|null)} + */ + get accountRecoveryOrganizationPolicy() { + return this._account_recovery_organization_policy || null; + } + + /** + * Set the account recovery organization policy + * @param {AccountRecoveryOrganizationPolicyEntity} accountRecoveryOrganizationPolicy The account recovery organization policy + * @throws {TypeError} If the accountRecoveryOrganizationPolicy parameter is not a valid AccountRecoveryOrganizationPolicyEntity + */ + set accountRecoveryOrganizationPolicy(accountRecoveryOrganizationPolicy) { + if (!accountRecoveryOrganizationPolicy || !(accountRecoveryOrganizationPolicy instanceof AccountRecoveryOrganizationPolicyEntity)) { + throw new TypeError('Failed to assert the parameter is a valid AccountRecoveryOrganizationPolicyEntity'); + } + this._account_recovery_organization_policy = accountRecoveryOrganizationPolicy; + } + + /** + * Get the user passphrase policy + * @returns {(UserPassphrasePoliciesEntity|null)} + */ + get userPassphrasePolicy() { + return this._user_passphrase_policies || null; + } + + /** + * Set the user passphrase policy + * @param {UserPassphrasePoliciesEntity} userPassphrasePolicy The account recovery organization policy + * @throws {TypeError} If the userPassphrasePolicy parameter is not a valid UserPassphrasePoliciesEntity + */ + set userPassphrasePolicy(userPassphrasePolicy) { + if (!userPassphrasePolicy || !(userPassphrasePolicy instanceof UserPassphrasePoliciesEntity)) { + throw new TypeError('Failed to assert the parameter is a valid UserPassphrasePoliciesEntity'); + } + this._user_passphrase_policies = userPassphrasePolicy; + } + + /* + * ================================================== + * Static properties getters + * ================================================== + */ + + /** + * AccountTemporaryEntity.ALL_CONTAIN_OPTIONS + * @returns {object} all contain options that can be used in toDto() + */ + static get ALL_CONTAIN_OPTIONS() { + return { + passphrase: true, + worker_id: true, + account: true, + account_recovery_organization_policy: true, + user_passphrase_policies: true, + }; + } + + /** + * AccountTemporaryEntity.ENTITY_NAME + * @returns {string} + */ + static get ENTITY_NAME() { + return ENTITY_NAME; + } +} + +export default AccountTemporaryEntity; diff --git a/src/all/background_page/model/entity/account/accountTemporaryEntity.test.data.js b/src/all/background_page/model/entity/account/accountTemporaryEntity.test.data.js new file mode 100644 index 00000000..6287aca8 --- /dev/null +++ b/src/all/background_page/model/entity/account/accountTemporaryEntity.test.data.js @@ -0,0 +1,45 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import {v4 as uuidv4} from "uuid"; +import {startAccountSetupDto} from "./accountSetupEntity.test.data"; +import {startWithApprovedAccountRecoveryAccountRecoverDto} from "./accountRecoverEntity.test.data"; +import {defaultAccountAccountRecoveryDto} from "./accountAccountRecoveryEntity.test.data"; + +export const temporarySetupAccountDto = (data = {}) => { + const defaultData = { + account: startAccountSetupDto(), + worker_id: uuidv4() + }; + + return Object.assign(defaultData, data); +}; + +export const temporaryRecoverAccountDto = (data = {}) => { + const defaultData = { + account: startWithApprovedAccountRecoveryAccountRecoverDto(), + worker_id: uuidv4() + }; + + return Object.assign(defaultData, data); +}; + +export const temporaryAccountRecoveryAccountDto = (data = {}) => { + const defaultData = { + account: defaultAccountAccountRecoveryDto(), + worker_id: uuidv4() + }; + + return Object.assign(defaultData, data); +}; diff --git a/src/all/background_page/model/entity/account/accountTemporaryEntity.test.js b/src/all/background_page/model/entity/account/accountTemporaryEntity.test.js new file mode 100644 index 00000000..8bed57e5 --- /dev/null +++ b/src/all/background_page/model/entity/account/accountTemporaryEntity.test.js @@ -0,0 +1,138 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ + +import EntitySchema from "passbolt-styleguide/src/shared/models/entity/abstract/entitySchema"; +import {defaultUserPassphrasePoliciesDto} from "passbolt-styleguide/src/shared/models/entity/userPassphrasePolicies/userPassphrasePoliciesEntity.test.data"; +import AccountTemporaryEntity from "./accountTemporaryEntity"; +import { + temporaryAccountRecoveryAccountDto, + temporaryRecoverAccountDto, + temporarySetupAccountDto +} from "./accountTemporaryEntity.test.data"; +import { + enabledAccountRecoveryOrganizationPolicyDto +} from "../accountRecovery/accountRecoveryOrganizationPolicyEntity.test.data"; + +describe("AccountTemporaryEntity", () => { + describe("AccountTemporaryEntity:constructor", () => { + it("schema must validate", () => { + EntitySchema.validateSchema(AccountTemporaryEntity.ENTITY_NAME, AccountTemporaryEntity.getSchema()); + }); + + it("it should instantiate the entity with a minimal dto (setup account)", () => { + expect.assertions(1); + const dto = temporarySetupAccountDto(); + const entity = new AccountTemporaryEntity(dto); + expect(entity).toBeInstanceOf(AccountTemporaryEntity); + }); + + it("it should instantiate the entity with a minimal dto (recover account)", () => { + expect.assertions(1); + const dto = temporaryRecoverAccountDto(); + const entity = new AccountTemporaryEntity(dto); + expect(entity).toBeInstanceOf(AccountTemporaryEntity); + }); + + it("it should instantiate the entity with a minimal dto (account recovery account)", () => { + expect.assertions(1); + const dto = temporaryAccountRecoveryAccountDto(); + const entity = new AccountTemporaryEntity(dto); + expect(entity).toBeInstanceOf(AccountTemporaryEntity); + }); + + it("it should raise an error if the account type is unkonwn (account recovery account)", () => { + expect.assertions(1); + const dto = temporarySetupAccountDto({account: {type: "unknown"}}); + try { + new AccountTemporaryEntity(dto); + } catch (error) { + expect(error.message).toStrictEqual("The account should have a known type."); + } + }); + }); + + describe("AccountTemporaryEntity:toDto", () => { + it("should return the expected properties.", () => { + expect.assertions(1); + + const dto = temporarySetupAccountDto(); + const entity = new AccountTemporaryEntity(dto); + const resultDto = entity.toDto(); + expect(Object.keys(resultDto).length).toBe(0); + }); + + it("it should return the account and account recovery organization policy if requested", () => { + expect.assertions(2); + const expectedKeys = [ + 'account', + 'account_recovery_organization_policy' + ]; + + const dto = temporarySetupAccountDto({account_recovery_organization_policy: enabledAccountRecoveryOrganizationPolicyDto()}); + const entity = new AccountTemporaryEntity(dto); + const resultDto = entity.toDto({account: true, account_recovery_organization_policy: true}); + const keys = Object.keys(resultDto); + expect(Object.keys(resultDto).length).toBe(2); + expect(keys).toEqual(expectedKeys); + }); + + it("it should return all the options if requested", () => { + expect.assertions(2); + const expectedKeys = [ + 'account', + 'passphrase', + 'worker_id', + 'user_passphrase_policies', + ]; + + const dto = temporaryRecoverAccountDto({passphrase: "passphrase", user_passphrase_policies: defaultUserPassphrasePoliciesDto()}); + const entity = new AccountTemporaryEntity(dto); + const resultDto = entity.toDto(AccountTemporaryEntity.ALL_CONTAIN_OPTIONS); + const keys = Object.keys(resultDto); + expect(Object.keys(resultDto).length).toBe(4); + expect(keys).toEqual(expectedKeys); + }); + + it("it should return the account and passphrase if requested", () => { + expect.assertions(2); + const expectedKeys = [ + 'account', + 'passphrase' + ]; + + const dto = temporarySetupAccountDto({passphrase: "passphrase"}); + const entity = new AccountTemporaryEntity(dto); + const resultDto = entity.toDto({account: true, passphrase: true}); + const keys = Object.keys(resultDto); + expect(Object.keys(resultDto).length).toBe(2); + expect(keys).toEqual(expectedKeys); + }); + + it("it should return all options if requested", () => { + expect.assertions(2); + const expectedKeys = [ + 'account', + 'passphrase', + 'worker_id', + ]; + + const dto = temporarySetupAccountDto({passphrase: "passphrase"}); + const entity = new AccountTemporaryEntity(dto); + const resultDto = entity.toDto(AccountTemporaryEntity.ALL_CONTAIN_OPTIONS); + const keys = Object.keys(resultDto); + expect(Object.keys(resultDto).length).toBe(3); + expect(keys).toEqual(expectedKeys); + }); + }); +}); diff --git a/src/all/background_page/service/sessionStorage/accountTemporarySessionStorageService.js b/src/all/background_page/service/sessionStorage/accountTemporarySessionStorageService.js new file mode 100644 index 00000000..8bdbfefd --- /dev/null +++ b/src/all/background_page/service/sessionStorage/accountTemporarySessionStorageService.js @@ -0,0 +1,67 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import AccountTemporaryEntity from "../../model/entity/account/accountTemporaryEntity"; + +const ACCOUNT_TEMPORARY_KEY = "account-temporary"; + +/** + * A cache service used to store the temporary account during a setup, recover or account recovery. + */ +class AccountTemporarySessionStorageService { + /** + * Get an account temporary for a given worker id. + * @param {string} workerId The worker id to get the cached data for. + * @returns {Promise} Return the account temporary entity or null. + */ + static async get(workerId) { + const storageData = await browser.storage.session.get(ACCOUNT_TEMPORARY_KEY); + const accountTemporaryDto = storageData?.[ACCOUNT_TEMPORARY_KEY]; + if (accountTemporaryDto) { + const accountTemporaryEntity = new AccountTemporaryEntity(accountTemporaryDto); + if (accountTemporaryEntity.workerId === workerId) { + return accountTemporaryEntity; + } + } + return null; + } + + /** + * Store a temporary account in the session storage. + * @param {AccountTemporaryEntity} account The account to store + * @throws {Error} If the account is not an AccountTemporaryEntity + * @returns {Promise} + */ + static async set(account) { + // Prevent any wrong data set in the session storage + if (account instanceof AccountTemporaryEntity) { + await navigator.locks.request(ACCOUNT_TEMPORARY_KEY, async() => { + await browser.storage.session.set({[ACCOUNT_TEMPORARY_KEY]: account.toDto(AccountTemporaryEntity.ALL_CONTAIN_OPTIONS)}); + }); + } else { + throw new Error("The account is not an AccountTemporaryEntity, storage has not been set"); + } + } + + /** + * Remove the temporary account in the session storage. + * @returns {Promise} + */ + static async remove() { + await navigator.locks.request(ACCOUNT_TEMPORARY_KEY, async() => { + await browser.storage.session.remove(ACCOUNT_TEMPORARY_KEY); + }); + } +} + +export default AccountTemporarySessionStorageService; diff --git a/src/all/background_page/service/sessionStorage/accountTemporarySessionStorageService.test.js b/src/all/background_page/service/sessionStorage/accountTemporarySessionStorageService.test.js new file mode 100644 index 00000000..ac9da921 --- /dev/null +++ b/src/all/background_page/service/sessionStorage/accountTemporarySessionStorageService.test.js @@ -0,0 +1,109 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import { + temporaryAccountRecoveryAccountDto, + temporaryRecoverAccountDto, + temporarySetupAccountDto +} from "../../model/entity/account/accountTemporaryEntity.test.data"; +import AccountTemporarySessionStorageService from "./accountTemporarySessionStorageService"; +import AccountTemporaryEntity from "../../model/entity/account/accountTemporaryEntity"; + +describe("AccountTemporarySessionStorage", () => { + beforeEach(async() => { + await browser.storage.session.clear(); + }); + + describe("AccountTemporarySessionStorage::set", () => { + it("Should set AccountTemporary in storage session", async() => { + expect.assertions(1); + // data mocked + const accountTemporaryEntity = new AccountTemporaryEntity(temporarySetupAccountDto()); + // process + await AccountTemporarySessionStorageService.set(accountTemporaryEntity); + // expectations + expect(await AccountTemporarySessionStorageService.get(accountTemporaryEntity.workerId)).toEqual(accountTemporaryEntity); + }); + + it("Should set AccountTemporary and keep only the last one in storage session", async() => { + expect.assertions(3); + // data mocked + const accountTemporaryEntity = new AccountTemporaryEntity(temporarySetupAccountDto()); + const accountTemporaryEntity2 = new AccountTemporaryEntity(temporaryRecoverAccountDto()); + const accountTemporaryEntity3 = new AccountTemporaryEntity(temporaryAccountRecoveryAccountDto()); + // process + await AccountTemporarySessionStorageService.set(accountTemporaryEntity); + await AccountTemporarySessionStorageService.set(accountTemporaryEntity2); + await AccountTemporarySessionStorageService.set(accountTemporaryEntity3); + // expectations + expect(await AccountTemporarySessionStorageService.get(accountTemporaryEntity.workerId)).toEqual(null); + expect(await AccountTemporarySessionStorageService.get(accountTemporaryEntity2.workerId)).toEqual(null); + expect(await AccountTemporarySessionStorageService.get(accountTemporaryEntity3.workerId)).toEqual(accountTemporaryEntity3); + }); + + it("Should not set the session storage if the account is not an AccountTemporaryEntity", async() => { + expect.assertions(3); + // data mocked + const accountTemporaryEntity = new AccountTemporaryEntity(temporarySetupAccountDto()); + const accountTemporaryEntity2 = "test"; + // process + await AccountTemporarySessionStorageService.set(accountTemporaryEntity); + try { + await AccountTemporarySessionStorageService.set(accountTemporaryEntity2); + } catch (error) { + // expectations + expect(await AccountTemporarySessionStorageService.get(accountTemporaryEntity.workerId)).toEqual(accountTemporaryEntity); + expect(await AccountTemporarySessionStorageService.get(accountTemporaryEntity2)).toEqual(null); + expect(error.message).toEqual("The account is not an AccountTemporaryEntity, storage has not been set"); + } + }); + }); + + describe("AccountTemporarySessionStorage::get", () => { + it("Should get an AccountTemporary if present", async() => { + expect.assertions(1); + // data mocked + const accountTemporaryEntity = new AccountTemporaryEntity(temporarySetupAccountDto()); + // process + await AccountTemporarySessionStorageService.set(accountTemporaryEntity); + const accountTemporaryEntityStored = await AccountTemporarySessionStorageService.get(accountTemporaryEntity.workerId); + // expectations + expect(accountTemporaryEntity.toDto(AccountTemporaryEntity.ALL_CONTAIN_OPTIONS)).toStrictEqual(accountTemporaryEntityStored.toDto(AccountTemporaryEntity.ALL_CONTAIN_OPTIONS)); + }); + + it("Should return null if the AccountTemporary doesn't exist", async() => { + expect.assertions(1); + // data mocked + const accountTemporaryEntity = new AccountTemporaryEntity(temporarySetupAccountDto()); + // process + await AccountTemporarySessionStorageService.set(accountTemporaryEntity); + const accountTemporaryEntityStored = await AccountTemporarySessionStorageService.get("test"); + // expectations + expect(accountTemporaryEntityStored).toEqual(null); + }); + }); + + describe("AccountTemporarysSessionStorage::remove", () => { + it("Should remove AccountTemporary", async() => { + expect.assertions(1); + // data mocked + const accountTemporaryEntity = new AccountTemporaryEntity(temporarySetupAccountDto()); + await AccountTemporarySessionStorageService.set(accountTemporaryEntity); + // process + await AccountTemporarySessionStorageService.remove(); + const accountTemporaryEntityStored = await AccountTemporarySessionStorageService.get(accountTemporaryEntity.workerId); + // expectations + expect(accountTemporaryEntityStored).toEqual(null); + }); + }); +}); From ca48b1900f40bdf7072008f497f8a0db692138d0 Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Thu, 18 Apr 2024 13:11:12 +0200 Subject: [PATCH 47/56] PB-33062 Use temporary account storage for setup process --- .../setup/completeSetupController.js | 10 ++-- .../setup/completeSetupController.test.js | 17 +++++- .../setup/downloadRecoverKitController.js | 10 ++-- .../downloadRecoverKitController.test.js | 21 ++++++- .../setup/generateSetupKeyPairController.js | 27 ++++----- .../generateSetupKeyPairController.test.js | 32 +++++++--- ...untRecoveryOrganizationPolicyController.js | 8 +-- ...coveryOrganizationPolicyController.test.js | 17 +++--- .../getUserPassphrasePoliciesController.js | 8 +-- ...etUserPassphrasePoliciesController.test.js | 14 ++--- .../setup/importSetupPrivateKeyController.js | 21 ++++--- .../importSetupPrivateKeyController.test.js | 15 +++-- ...tupAccountRecoveryUserSettingController.js | 26 +++++---- ...countRecoveryUserSettingController.test.js | 27 ++++++--- .../setup/setSetupLocaleController.js | 7 +++ .../setup/setSetupLocaleController.test.js | 8 ++- .../setup/setSetupSecurityTokenController.js | 13 +++-- .../setSetupSecurityTokenController.test.js | 16 ++++- .../controller/setup/signInSetupController.js | 29 +++++----- .../setup/signInSetupController.test.js | 40 +++++++++---- .../controller/setup/startSetupController.js | 47 ++++++++++----- .../setup/startSetupController.test.js | 58 ++++++++++--------- .../verifyImportedKeyPassphraseController.js | 16 ++--- ...ifyImportedKeyPassphraseController.test.js | 28 ++++++--- .../event/accountRecoveryEvents.js | 2 +- src/all/background_page/event/setupEvents.js | 31 ++++------ .../entity/account/accountRecoverEntity.js | 8 ++- .../entity/account/accountSetupEntity.js | 14 ++++- .../entity/account/accountTemporaryEntity.js | 8 +-- .../account/findAccountTemporaryService.js | 33 +++++++++++ .../findAccountTemporaryService.test.js | 41 +++++++++++++ 31 files changed, 439 insertions(+), 213 deletions(-) create mode 100644 src/all/background_page/service/account/findAccountTemporaryService.js create mode 100644 src/all/background_page/service/account/findAccountTemporaryService.test.js diff --git a/src/all/background_page/controller/setup/completeSetupController.js b/src/all/background_page/controller/setup/completeSetupController.js index 3dd703a8..44be8e4e 100644 --- a/src/all/background_page/controller/setup/completeSetupController.js +++ b/src/all/background_page/controller/setup/completeSetupController.js @@ -16,6 +16,7 @@ import AccountModel from "../../model/account/accountModel"; import SetupModel from "../../model/setup/setupModel"; import AccountEntity from "../../model/entity/account/accountEntity"; import AccountSetupEntity from "../../model/entity/account/accountSetupEntity"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class CompleteSetupController { /** @@ -23,12 +24,10 @@ class CompleteSetupController { * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions The api client options. - * @param {AccountSetupEntity} account The account being setup. */ - constructor(worker, requestId, apiClientOptions, account) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; - this.account = account; this.accountModel = new AccountModel(apiClientOptions); this.setupModel = new SetupModel(apiClientOptions); } @@ -54,8 +53,9 @@ class CompleteSetupController { * @returns {Promise} */ async exec() { - const accountSetup = new AccountEntity(this.account.toDto(AccountSetupEntity.ALL_CONTAIN_OPTIONS)); - await this.setupModel.completeSetup(this.account); + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + const accountSetup = new AccountEntity(temporaryAccount.account.toDto(AccountSetupEntity.ALL_CONTAIN_OPTIONS)); + await this.setupModel.completeSetup(temporaryAccount.account); await this.accountModel.add(accountSetup); } } diff --git a/src/all/background_page/controller/setup/completeSetupController.test.js b/src/all/background_page/controller/setup/completeSetupController.test.js index b3127496..5359bc8b 100644 --- a/src/all/background_page/controller/setup/completeSetupController.test.js +++ b/src/all/background_page/controller/setup/completeSetupController.test.js @@ -21,6 +21,7 @@ import AccountSetupEntity from "../../model/entity/account/accountSetupEntity"; import User from "../../model/user"; import Keyring from "../../model/keyring"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; // Reset the modules before each test. beforeEach(() => { @@ -31,7 +32,8 @@ describe("CompleteSetupController", () => { describe("CompleteSetupController::exec", () => { it("Should complete the setup.", async() => { const account = new AccountSetupEntity(withSecurityTokenAccountSetupDto()); - const controller = new CompleteSetupController(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new CompleteSetupController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); // Mock API complete request. fetch.doMockOnce(() => mockApiResponse()); @@ -62,7 +64,8 @@ describe("CompleteSetupController", () => { it("Should not add the account to the local storage if the complete API request fails.", async() => { const account = new AccountSetupEntity(withSecurityTokenAccountSetupDto()); - const controller = new CompleteSetupController(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new CompleteSetupController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); // Mock API complete request. fetch.doMockOnce(() => Promise.reject()); @@ -73,5 +76,15 @@ describe("CompleteSetupController", () => { await expect(promise).rejects.toThrow(); expect(() => User.getInstance().get()).toThrow("The user is not set"); }); + + it("Should raise an error if no account has been found.", async() => { + const controller = new CompleteSetupController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/setup/downloadRecoverKitController.js b/src/all/background_page/controller/setup/downloadRecoverKitController.js index 8a3e13b8..f8a3d05c 100644 --- a/src/all/background_page/controller/setup/downloadRecoverKitController.js +++ b/src/all/background_page/controller/setup/downloadRecoverKitController.js @@ -13,6 +13,7 @@ */ import FileService from "../../service/file/fileService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; // The recovery kit file name. const RECOVERY_KIT_FILENAME = "passbolt-recovery-kit.asc"; @@ -21,12 +22,10 @@ class DownloadRecoveryKitController { * Constructor. * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. - * @param {AccountSetupEntity} account The account being setup. */ - constructor(worker, requestId, account) { + constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.account = account; } /** @@ -48,10 +47,11 @@ class DownloadRecoveryKitController { * @returns {Promise} */ async exec() { - if (!this.account?.userPrivateArmoredKey) { + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + if (!temporaryAccount.account?.userPrivateArmoredKey) { throw new Error('An account user private armored key is required.'); } - const userPrivateArmoredKey = this.account.userPrivateArmoredKey; + const userPrivateArmoredKey = temporaryAccount.account.userPrivateArmoredKey; await FileService.saveFile(RECOVERY_KIT_FILENAME, userPrivateArmoredKey, "text/plain", this.worker.tab.id); } } diff --git a/src/all/background_page/controller/setup/downloadRecoverKitController.test.js b/src/all/background_page/controller/setup/downloadRecoverKitController.test.js index 2f236fa3..ae93ba72 100644 --- a/src/all/background_page/controller/setup/downloadRecoverKitController.test.js +++ b/src/all/background_page/controller/setup/downloadRecoverKitController.test.js @@ -19,6 +19,7 @@ import { } from "../../model/entity/account/accountSetupEntity.test.data"; import AccountSetupEntity from "../../model/entity/account/accountSetupEntity"; import FileService from "../../service/file/fileService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; jest.mock("../../service/file/fileService"); @@ -26,7 +27,9 @@ describe("DownloadRecoveryKitController", () => { describe("DownloadRecoveryKitController::exec", () => { it("Should throw an exception if the account does have a defined user armored private key.", async() => { const account = new AccountSetupEntity(startAccountSetupDto()); - const controller = new DownloadRecoveryKitController(null, null, account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + + const controller = new DownloadRecoveryKitController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const promise = controller.exec(); @@ -38,11 +41,13 @@ describe("DownloadRecoveryKitController", () => { const mockedWorker = { tab: { id: "id" - } + }, + port: {_port: {name: "test"}} }; const account = new AccountSetupEntity(withUserKeyAccountSetupDto()); - const controller = new DownloadRecoveryKitController(mockedWorker, null, account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new DownloadRecoveryKitController(mockedWorker, null); expect.assertions(1); await controller.exec(); @@ -53,5 +58,15 @@ describe("DownloadRecoveryKitController", () => { mockedWorker.tab.id ); }); + + it("Should raise an error if no account has been found.", async() => { + const controller = new DownloadRecoveryKitController({port: {_port: {name: "test"}}}, null); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/setup/generateSetupKeyPairController.js b/src/all/background_page/controller/setup/generateSetupKeyPairController.js index e6bdf858..cd5c98c4 100644 --- a/src/all/background_page/controller/setup/generateSetupKeyPairController.js +++ b/src/all/background_page/controller/setup/generateSetupKeyPairController.js @@ -15,7 +15,8 @@ import GetGpgKeyCreationDateService from "../../service/crypto/getGpgKeyCreation import GenerateGpgKeyPairOptionsEntity from "../../model/entity/gpgkey/generate/generateGpgKeyPairOptionsEntity"; import GenerateGpgKeyPairService from "../../service/crypto/generateGpgKeyPairService"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; - +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; /** * @typedef {({passphrase: string})} GenerateKeyPairPassphraseDto @@ -27,16 +28,13 @@ class GenerateSetupKeyPairController { * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions The api client options. - * @param {AccountSetupEntity} account The account being setup. - * @param {Object} runtimeMemory The setup runtime memory. */ - constructor(worker, requestId, apiClientOptions, account, runtimeMemory) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; this.apiClientOptions = apiClientOptions; - this.account = account; - this.runtimeMemory = runtimeMemory; - this.runtimeMemory.passphrase = null; + // The temporary account stored in the session storage + this.temporaryAccount = null; } /** @@ -61,15 +59,18 @@ class GenerateSetupKeyPairController { * @returns {Promise} */ async exec(passphraseDto) { + this.temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); const generateGpgKeyPairOptionsEntity = await this._buildGenerateKeyPairOptionsEntity(passphraseDto.passphrase); const keyPair = await GenerateGpgKeyPairService.generateKeyPair(generateGpgKeyPairOptionsEntity); const generatedPublicKey = await OpenpgpAssertion.readKeyOrFail(keyPair.publicKey.armoredKey); - this.account.userKeyFingerprint = generatedPublicKey.getFingerprint().toUpperCase(); - this.account.userPrivateArmoredKey = keyPair.privateKey.armoredKey; - this.account.userPublicArmoredKey = keyPair.publicKey.armoredKey; + this.temporaryAccount.account.userKeyFingerprint = generatedPublicKey.getFingerprint().toUpperCase(); + this.temporaryAccount.account.userPrivateArmoredKey = keyPair.privateKey.armoredKey; + this.temporaryAccount.account.userPublicArmoredKey = keyPair.publicKey.armoredKey; // The passphrase will be later use to sign in the user. - this.runtimeMemory.passphrase = passphraseDto.passphrase; + this.temporaryAccount.passphrase = passphraseDto.passphrase; + // Update all data in the temporary account stored + await AccountTemporarySessionStorageService.set(this.temporaryAccount); } /** @@ -82,8 +83,8 @@ class GenerateSetupKeyPairController { */ async _buildGenerateKeyPairOptionsEntity(passphrase) { return new GenerateGpgKeyPairOptionsEntity({ - name: `${this.account.firstName} ${this.account.lastName}`, - email: this.account.username, + name: `${this.temporaryAccount.account.firstName} ${this.temporaryAccount.account.lastName}`, + email: this.temporaryAccount.account.username, passphrase: passphrase, date: await GetGpgKeyCreationDateService.getDate(this.apiClientOptions), }); diff --git a/src/all/background_page/controller/setup/generateSetupKeyPairController.test.js b/src/all/background_page/controller/setup/generateSetupKeyPairController.test.js index a60365c0..1c22a9ad 100644 --- a/src/all/background_page/controller/setup/generateSetupKeyPairController.test.js +++ b/src/all/background_page/controller/setup/generateSetupKeyPairController.test.js @@ -20,14 +20,16 @@ import AccountSetupEntity from "../../model/entity/account/accountSetupEntity"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import MockExtension from "../../../../../test/mocks/mockExtension"; import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import {v4 as uuidv4} from "uuid"; +import AccountTemporaryEntity from "../../model/entity/account/accountTemporaryEntity"; describe("GenerateSetupKeyPairController", () => { describe("GenerateSetupKeyPairController::exec", () => { it("Should throw an exception if the passed DTO is not valid.", async() => { await MockExtension.withConfiguredAccount(); const account = new AccountSetupEntity(startAccountSetupDto()); - const runtimeMemory = {}; - const controller = new GenerateSetupKeyPairController(null, null, defaultApiClientOptions(), account, runtimeMemory); + const controller = new GenerateSetupKeyPairController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); const scenarios = [ {dto: null, expectedError: TypeError}, @@ -46,15 +48,15 @@ describe("GenerateSetupKeyPairController", () => { {dto: {passphrase: undefined}, expectedError: EntityValidationError}, ]; - expect.assertions(scenarios.length * 2); + expect.assertions(scenarios.length); for (let i = 0; i < scenarios.length; i++) { const scenario = scenarios[i]; try { + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); await controller.exec(scenario.dto); } catch (e) { expect(e).toBeInstanceOf(scenario.expectedError); - expect(runtimeMemory.passphrase).toBeFalsy(); } } }); @@ -63,11 +65,15 @@ describe("GenerateSetupKeyPairController", () => { expect.assertions(12); await MockExtension.withConfiguredAccount(); const generateKeyPairDto = {passphrase: "What a great passphrase!"}; - const account = new AccountSetupEntity(startAccountSetupDto()); - const runtimeMemory = {}; - const controller = new GenerateSetupKeyPairController(null, null, defaultApiClientOptions(), account, runtimeMemory); + const workerId = uuidv4(); + const setupAccountDto = startAccountSetupDto(); + const temporaryAccountEntity = new AccountTemporaryEntity({account: setupAccountDto, worker_id: workerId}); + await AccountTemporarySessionStorageService.set(temporaryAccountEntity); + const controller = new GenerateSetupKeyPairController({port: {_port: {name: workerId}}}, null, defaultApiClientOptions()); await controller.exec(generateKeyPairDto); + const expectedAccount = await AccountTemporarySessionStorageService.get(workerId); + const account = expectedAccount.account; await expect(account.userKeyFingerprint).not.toBeNull(); await expect(account.userKeyFingerprint).toHaveLength(40); await expect(account.userPublicArmoredKey).toBeOpenpgpPublicKey(); @@ -92,7 +98,17 @@ describe("GenerateSetupKeyPairController", () => { const userPrivateKey = await OpenpgpAssertion.readKeyOrFail(account.userPrivateArmoredKey); const decryptedPrivateKey = await DecryptPrivateKeyService.decrypt(userPrivateKey, generateKeyPairDto.passphrase); expect(decryptedPrivateKey).not.toBeNull(); - expect(runtimeMemory.passphrase).toStrictEqual(generateKeyPairDto.passphrase); + expect(expectedAccount.passphrase).toStrictEqual(generateKeyPairDto.passphrase); }, 10000); + + it("Should raise an error if no account has been found.", async() => { + const controller = new GenerateSetupKeyPairController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + expect.assertions(1); + try { + await controller.exec({passphrase: "passphrase"}); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/setup/getAccountRecoveryOrganizationPolicyController.js b/src/all/background_page/controller/setup/getAccountRecoveryOrganizationPolicyController.js index de5f3417..ef211709 100644 --- a/src/all/background_page/controller/setup/getAccountRecoveryOrganizationPolicyController.js +++ b/src/all/background_page/controller/setup/getAccountRecoveryOrganizationPolicyController.js @@ -11,18 +11,17 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; class GetAccountRecoveryOrganizationPolicyController { /** * Constructor. * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. - * @param {Object} runtimeMemory The setup runtime memory. */ - constructor(worker, requestId, runtimeMemory) { + constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.runtimeMemory = runtimeMemory; } /** @@ -44,7 +43,8 @@ class GetAccountRecoveryOrganizationPolicyController { * @returns {Promise} */ async exec() { - return this.runtimeMemory.accountRecoveryOrganizationPolicy; + const temporaryAccount = await AccountTemporarySessionStorageService.get(this.worker.port._port.name); + return temporaryAccount?.accountRecoveryOrganizationPolicy; } } diff --git a/src/all/background_page/controller/setup/getAccountRecoveryOrganizationPolicyController.test.js b/src/all/background_page/controller/setup/getAccountRecoveryOrganizationPolicyController.test.js index 652581a0..0f11ce3c 100644 --- a/src/all/background_page/controller/setup/getAccountRecoveryOrganizationPolicyController.test.js +++ b/src/all/background_page/controller/setup/getAccountRecoveryOrganizationPolicyController.test.js @@ -15,23 +15,24 @@ import GetAccountRecoveryOrganizationPolicyController from "./getAccountRecoveryOrganizationPolicyController"; import {enabledAccountRecoveryOrganizationPolicyDto} from "../../model/entity/accountRecovery/accountRecoveryOrganizationPolicyEntity.test.data"; import AccountRecoveryOrganizationPolicyEntity from "../../model/entity/accountRecovery/accountRecoveryOrganizationPolicyEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; describe("GetAccountRecoveryOrganizationPolicyController", () => { describe("GetAccountRecoveryOrganizationPolicyController::exec", () => { it("Should return the account recovery organization policy", async() => { - const runtimeMemory = { - accountRecoveryOrganizationPolicy: new AccountRecoveryOrganizationPolicyEntity(enabledAccountRecoveryOrganizationPolicyDto()) - }; - const controller = new GetAccountRecoveryOrganizationPolicyController(null, null, runtimeMemory); + const accountRecoveryOrganizationPolicy = new AccountRecoveryOrganizationPolicyEntity(enabledAccountRecoveryOrganizationPolicyDto()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({accountRecoveryOrganizationPolicy: accountRecoveryOrganizationPolicy})); + + const controller = new GetAccountRecoveryOrganizationPolicyController({port: {_port: {name: "test"}}}, null); expect.assertions(1); - const accountRecoveryOrganizationPolicy = await controller.exec(); - expect(accountRecoveryOrganizationPolicy).toBeInstanceOf(AccountRecoveryOrganizationPolicyEntity); + const accountRecoveryOrganizationPolicyReceived = await controller.exec(); + expect(accountRecoveryOrganizationPolicyReceived).toBeInstanceOf(AccountRecoveryOrganizationPolicyEntity); }); it("Should not return the account recovery organization policy if not defined", async() => { - const runtimeMemory = {}; - const controller = new GetAccountRecoveryOrganizationPolicyController(null, null, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => null); + const controller = new GetAccountRecoveryOrganizationPolicyController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const accountRecoveryOrganizationPolicy = await controller.exec(); diff --git a/src/all/background_page/controller/setup/getUserPassphrasePoliciesController.js b/src/all/background_page/controller/setup/getUserPassphrasePoliciesController.js index 7616d718..b08af306 100644 --- a/src/all/background_page/controller/setup/getUserPassphrasePoliciesController.js +++ b/src/all/background_page/controller/setup/getUserPassphrasePoliciesController.js @@ -13,18 +13,17 @@ */ import UserPassphrasePoliciesEntity from "passbolt-styleguide/src/shared/models/entity/userPassphrasePolicies/userPassphrasePoliciesEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; class GetUserPassphrasePoliciesController { /** * Constructor. * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. - * @param {Object} runtimeMemory The setup runtime memory. */ - constructor(worker, requestId, runtimeMemory) { + constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.runtimeMemory = runtimeMemory; } /** @@ -46,7 +45,8 @@ class GetUserPassphrasePoliciesController { * @returns {Promise} */ async exec() { - const userPassphrasePolicies = this.runtimeMemory.userPassphrasePolicies; + const temporaryAccount = await AccountTemporarySessionStorageService.get(this.worker.port._port.name); + const userPassphrasePolicies = temporaryAccount?.userPassphrasePolicies; return userPassphrasePolicies || UserPassphrasePoliciesEntity.createFromDefault(); } } diff --git a/src/all/background_page/controller/setup/getUserPassphrasePoliciesController.test.js b/src/all/background_page/controller/setup/getUserPassphrasePoliciesController.test.js index cd1b2290..97830941 100644 --- a/src/all/background_page/controller/setup/getUserPassphrasePoliciesController.test.js +++ b/src/all/background_page/controller/setup/getUserPassphrasePoliciesController.test.js @@ -15,23 +15,23 @@ import GetUserPassphrasePoliciesController from "./getUserPassphrasePoliciesController"; import {defaultUserPassphrasePoliciesEntityDto} from "passbolt-styleguide/src/shared/models/userPassphrasePolicies/UserPassphrasePoliciesDto.test.data"; import UserPassphrasePoliciesEntity from "passbolt-styleguide/src/shared/models/entity/userPassphrasePolicies/userPassphrasePoliciesEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; describe("GetUserPassphrasePoliciesController", () => { it("Should return the user passphrase policies from the runtime memory", async() => { - const runtimeMemory = { - userPassphrasePolicies: new UserPassphrasePoliciesEntity(defaultUserPassphrasePoliciesEntityDto()) - }; - const controller = new GetUserPassphrasePoliciesController(null, null, runtimeMemory); + const userPassphrasePolicies = new UserPassphrasePoliciesEntity(defaultUserPassphrasePoliciesEntityDto()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({userPassphrasePolicies: userPassphrasePolicies})); + const controller = new GetUserPassphrasePoliciesController({port: {_port: {name: "test"}}}, null); expect.assertions(2); const accountRecoveryOrganizationPolicy = await controller.exec(); expect(accountRecoveryOrganizationPolicy).toBeInstanceOf(UserPassphrasePoliciesEntity); - expect(accountRecoveryOrganizationPolicy).toStrictEqual(runtimeMemory.userPassphrasePolicies); + expect(accountRecoveryOrganizationPolicy).toStrictEqual(userPassphrasePolicies); }); it("Should return a default user passphrase policies if not defined on the runtime memory", async() => { - const runtimeMemory = {}; - const controller = new GetUserPassphrasePoliciesController(null, null, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => null); + const controller = new GetUserPassphrasePoliciesController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const userPassphrasePolicies = await controller.exec(); diff --git a/src/all/background_page/controller/setup/importSetupPrivateKeyController.js b/src/all/background_page/controller/setup/importSetupPrivateKeyController.js index f12af127..721366e6 100644 --- a/src/all/background_page/controller/setup/importSetupPrivateKeyController.js +++ b/src/all/background_page/controller/setup/importSetupPrivateKeyController.js @@ -16,6 +16,8 @@ import i18n from "../../sdk/i18n"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import GpgKeyError from "../../error/GpgKeyError"; import AuthVerifyServerChallengeService from "../../service/auth/authVerifyServerChallengeService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class ImportSetupPrivateKeyController { /** @@ -23,12 +25,10 @@ class ImportSetupPrivateKeyController { * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions the api client options - * @param {AccountSetupEntity} account The account being setup. */ - constructor(worker, requestId, apiClientOptions, account) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; - this.account = account; this.authVerifyServerChallengeService = new AuthVerifyServerChallengeService(apiClientOptions); } @@ -53,25 +53,28 @@ class ImportSetupPrivateKeyController { * @returns {Promise} */ async exec(armoredKey) { + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); const privateKey = await OpenpgpAssertion.readKeyOrFail(armoredKey); OpenpgpAssertion.assertPrivateKey(privateKey); const privateKeyFingerprint = privateKey.getFingerprint().toUpperCase(); - await this._assertImportKeyNotUsed(privateKeyFingerprint); + await this._assertImportKeyNotUsed(privateKeyFingerprint, temporaryAccount.account.serverPublicArmoredKey); - this.account.userKeyFingerprint = privateKeyFingerprint; - this.account.userPrivateArmoredKey = privateKey.armor(); - this.account.userPublicArmoredKey = privateKey.toPublic().armor(); + temporaryAccount.account.userKeyFingerprint = privateKeyFingerprint; + temporaryAccount.account.userPrivateArmoredKey = privateKey.armor(); + temporaryAccount.account.userPublicArmoredKey = privateKey.toPublic().armor(); + // Update all data in the temporary account stored + await AccountTemporarySessionStorageService.set(temporaryAccount); } /** * Assert import key is not already used * @param {string} fingerprint The import key fingerprint + * @param {string} serverPublicArmoredKey The server public armored key * @returns {Promise} * @throws {GpgKeyError} If the key is already used * @private */ - async _assertImportKeyNotUsed(fingerprint) { - const serverPublicArmoredKey = this.account.serverPublicArmoredKey; + async _assertImportKeyNotUsed(fingerprint, serverPublicArmoredKey) { if (!serverPublicArmoredKey) { throw new Error('The server public key should have been provided before importing a private key'); } diff --git a/src/all/background_page/controller/setup/importSetupPrivateKeyController.test.js b/src/all/background_page/controller/setup/importSetupPrivateKeyController.test.js index f3bbf977..3bce1816 100644 --- a/src/all/background_page/controller/setup/importSetupPrivateKeyController.test.js +++ b/src/all/background_page/controller/setup/importSetupPrivateKeyController.test.js @@ -23,6 +23,7 @@ import { withServerKeyAccountSetupDto } from "../../model/entity/account/accountSetupEntity.test.data"; import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(() => { jest.clearAllMocks(); @@ -43,7 +44,7 @@ describe("ImportSetupPrivateKeyController", () => { describe("GenerateKeyPairSetupController::exec", () => { it("Should throw an exception if the passed DTO is not valid.", async() => { const account = new AccountSetupEntity(withServerKeyAccountSetupDto()); - const controller = new ImportSetupPrivateKeyController(null, null, defaultApiClientOptions(), account); + const controller = new ImportSetupPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); const scenarios = [ {dto: null, expectedError: Error}, @@ -62,6 +63,7 @@ describe("ImportSetupPrivateKeyController", () => { for (let i = 0; i < scenarios.length; i++) { const scenario = scenarios[i]; try { + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); await controller.exec(scenario.dto); } catch (e) { expect(e).toBeInstanceOf(scenario.expectedError); @@ -72,7 +74,9 @@ describe("ImportSetupPrivateKeyController", () => { it("Should throw an exception if the setupEntity is not initialized properly.", async() => { expect.assertions(1); const account = new AccountSetupEntity(startAccountSetupDto()); - const controller = new ImportSetupPrivateKeyController(null, null, defaultApiClientOptions(), account); + const controller = new ImportSetupPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + try { await controller.exec(pgpKeys.ada.private); } catch (e) { @@ -85,8 +89,9 @@ describe("ImportSetupPrivateKeyController", () => { await MockExtension.withConfiguredAccount(); const account = new AccountSetupEntity(withServerKeyAccountSetupDto()); - const controller = new ImportSetupPrivateKeyController(null, null, defaultApiClientOptions(), account); + const controller = new ImportSetupPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(jest.fn()); try { @@ -102,7 +107,9 @@ describe("ImportSetupPrivateKeyController", () => { const expectedKeyData = pgpKeys.ada; const account = new AccountSetupEntity(withServerKeyAccountSetupDto()); - const controller = new ImportSetupPrivateKeyController(null, null, defaultApiClientOptions(), account); + const controller = new ImportSetupPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(() => { throw new Error('User not known'); }); diff --git a/src/all/background_page/controller/setup/setSetupAccountRecoveryUserSettingController.js b/src/all/background_page/controller/setup/setSetupAccountRecoveryUserSettingController.js index 394dc851..a852d47f 100644 --- a/src/all/background_page/controller/setup/setSetupAccountRecoveryUserSettingController.js +++ b/src/all/background_page/controller/setup/setSetupAccountRecoveryUserSettingController.js @@ -16,20 +16,19 @@ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import DecryptPrivateKeyService from "../../service/crypto/decryptPrivateKeyService"; import AccountRecoveryUserSettingEntity from "../../model/entity/accountRecovery/accountRecoveryUserSettingEntity"; import BuildApprovedAccountRecoveryUserSettingEntityService from "../../service/accountRecovery/buildApprovedAccountRecoveryUserSettingEntityService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class SetSetupAccountRecoveryUserSettingController { /** * Constructor * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. - * @param {AccountSetupEntity} account The account being setup. - * @param {Object} runtimeMemory The setup runtime memory. */ - constructor(worker, requestId, account, runtimeMemory) { + constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.account = account; - this.runtimeMemory = runtimeMemory; + this.temporaryAccount = null; } /** @@ -54,6 +53,7 @@ class SetSetupAccountRecoveryUserSettingController { * @return {Promise} */ async exec(status) { + this.temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); let accountRecoveryUserSettingEntity; const isApproved = status === AccountRecoveryUserSettingEntity.STATUS_APPROVED; @@ -63,7 +63,9 @@ class SetSetupAccountRecoveryUserSettingController { accountRecoveryUserSettingEntity = await this.buildRejectedUserSetting(); } - this.account.accountRecoveryUserSetting = accountRecoveryUserSettingEntity; + this.temporaryAccount.account.accountRecoveryUserSetting = accountRecoveryUserSettingEntity; + // Update all data in the temporary account stored + await AccountTemporarySessionStorageService.set(this.temporaryAccount); } /** @@ -72,15 +74,15 @@ class SetSetupAccountRecoveryUserSettingController { * @throw {TypeError} if no passphrase defined in the setup runtime memory. */ async buildApprovedUserSetting() { - if (!this?.runtimeMemory?.passphrase) { + if (!this.temporaryAccount?.passphrase) { throw new Error('A passphrase is required.'); } - const userPrivateKey = await OpenpgpAssertion.readKeyOrFail(this.account.userPrivateArmoredKey); - const userDecryptedPrivateKey = await DecryptPrivateKeyService.decrypt(userPrivateKey, this.runtimeMemory.passphrase); - const organizationPolicy = this.runtimeMemory.accountRecoveryOrganizationPolicy; + const userPrivateKey = await OpenpgpAssertion.readKeyOrFail(this.temporaryAccount.account.userPrivateArmoredKey); + const userDecryptedPrivateKey = await DecryptPrivateKeyService.decrypt(userPrivateKey, this.temporaryAccount.passphrase); + const organizationPolicy = this.temporaryAccount.accountRecoveryOrganizationPolicy; - return BuildApprovedAccountRecoveryUserSettingEntityService.build(this.account, userDecryptedPrivateKey, organizationPolicy); + return BuildApprovedAccountRecoveryUserSettingEntityService.build(this.temporaryAccount.account, userDecryptedPrivateKey, organizationPolicy); } /** @@ -88,7 +90,7 @@ class SetSetupAccountRecoveryUserSettingController { * @returns {Promise} */ async buildRejectedUserSetting() { - const userId = this.account.userId; + const userId = this.temporaryAccount.account.userId; const userSettingDto = {user_id: userId, status: AccountRecoveryUserSettingEntity.STATUS_REJECTED}; return new AccountRecoveryUserSettingEntity(userSettingDto); diff --git a/src/all/background_page/controller/setup/setSetupAccountRecoveryUserSettingController.test.js b/src/all/background_page/controller/setup/setSetupAccountRecoveryUserSettingController.test.js index 38894953..21ef1594 100644 --- a/src/all/background_page/controller/setup/setSetupAccountRecoveryUserSettingController.test.js +++ b/src/all/background_page/controller/setup/setSetupAccountRecoveryUserSettingController.test.js @@ -21,6 +21,7 @@ import {enabledAccountRecoveryOrganizationPolicyDto} from "../../model/entity/ac import AccountRecoveryOrganizationPolicyEntity from "../../model/entity/accountRecovery/accountRecoveryOrganizationPolicyEntity"; import {withUserKeyAccountSetupDto} from "../../model/entity/account/accountSetupEntity.test.data"; import AccountSetupEntity from "../../model/entity/account/accountSetupEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(async() => { enableFetchMocks(); @@ -31,7 +32,9 @@ describe("SetSetupAccountRecoveryUserSettingController", () => { describe("SetSetupAccountRecoveryUserSettingController::exec", () => { it("Should save a rejected account recovery user setting.", async() => { const account = new AccountSetupEntity(withUserKeyAccountSetupDto()); - const controller = new SetSetupAccountRecoveryUserSettingController(null, null, account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); + const controller = new SetSetupAccountRecoveryUserSettingController({port: {_port: {name: "test"}}}, null); const accountRecoveryUserSettingDto = createRejectedAccountRecoveryUserSettingDto({user_id: account.userId}); await controller.exec(AccountRecoveryUserSettingEntity.STATUS_REJECTED); @@ -42,11 +45,10 @@ describe("SetSetupAccountRecoveryUserSettingController", () => { it("Should save an approved account recovery user setting.", async() => { const account = new AccountSetupEntity(withUserKeyAccountSetupDto()); - const runtimeMemory = { - accountRecoveryOrganizationPolicy: new AccountRecoveryOrganizationPolicyEntity(enabledAccountRecoveryOrganizationPolicyDto()), - passphrase: pgpKeys.ada.passphrase - }; - const controller = new SetSetupAccountRecoveryUserSettingController(null, null, account, runtimeMemory); + const accountRecoveryOrganizationPolicy = new AccountRecoveryOrganizationPolicyEntity(enabledAccountRecoveryOrganizationPolicyDto()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account, accountRecoveryOrganizationPolicy: accountRecoveryOrganizationPolicy, passphrase: pgpKeys.ada.passphrase})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); + const controller = new SetSetupAccountRecoveryUserSettingController({port: {_port: {name: "test"}}}, null); await controller.exec(AccountRecoveryUserSettingEntity.STATUS_APPROVED); expect.assertions(5); @@ -61,11 +63,22 @@ describe("SetSetupAccountRecoveryUserSettingController", () => { it("Should throw an error if an attempt to save an approved account recovery user setting without passphrase.", async() => { const account = new AccountSetupEntity(withUserKeyAccountSetupDto()); - const controller = new SetSetupAccountRecoveryUserSettingController(null, null, account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new SetSetupAccountRecoveryUserSettingController({port: {_port: {name: "test"}}}, null); const promise = controller.exec(AccountRecoveryUserSettingEntity.STATUS_APPROVED); expect.assertions(1); await expect(promise).rejects.toThrowError("A passphrase is required."); }); + + it("Should raise an error if no account has been found.", async() => { + const controller = new SetSetupAccountRecoveryUserSettingController({port: {_port: {name: "test"}}}, null); + expect.assertions(1); + try { + await controller.exec(AccountRecoveryUserSettingEntity.STATUS_APPROVED); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/setup/setSetupLocaleController.js b/src/all/background_page/controller/setup/setSetupLocaleController.js index f0a76fa5..e1859c31 100644 --- a/src/all/background_page/controller/setup/setSetupLocaleController.js +++ b/src/all/background_page/controller/setup/setSetupLocaleController.js @@ -13,6 +13,7 @@ */ import LocaleModel from "../../model/locale/localeModel"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; class SetSetupLocaleController { /** @@ -55,6 +56,12 @@ class SetSetupLocaleController { if (!locale) { throw new Error('Unsupported locale.'); } + // Update the temporary account locale, don't need to check the worker. + const temporaryAccount = await AccountTemporarySessionStorageService.get(this.worker.port._port.name); + if (temporaryAccount) { + temporaryAccount.account.locale = locale.locale; + await AccountTemporarySessionStorageService.set(temporaryAccount); + } this.account.locale = locale.locale; await this.localeModel.initializeI18next(locale); } diff --git a/src/all/background_page/controller/setup/setSetupLocaleController.test.js b/src/all/background_page/controller/setup/setSetupLocaleController.test.js index 27b9cc13..60c34dd3 100644 --- a/src/all/background_page/controller/setup/setSetupLocaleController.test.js +++ b/src/all/background_page/controller/setup/setSetupLocaleController.test.js @@ -19,6 +19,7 @@ import {mockApiResponse} from "../../../../../test/mocks/mockApiResponse"; import SetSetupLocaleController from "./setSetupLocaleController"; import AccountSetupEntity from "../../model/entity/account/accountSetupEntity"; import {initialAccountSetupDto} from "../../model/entity/account/accountSetupEntity.test.data"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(() => { enableFetchMocks(); @@ -28,7 +29,9 @@ describe("SetAccountLocaleController", () => { describe("SetAccountLocaleController::exec", () => { it("Should set the account locale and initialize i18next with it.", async() => { const account = new AccountSetupEntity(initialAccountSetupDto()); - const controller = new SetSetupLocaleController(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); + const controller = new SetSetupLocaleController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockApiResult = anonymousOrganizationSettings(); @@ -42,7 +45,8 @@ describe("SetAccountLocaleController", () => { it("Should not accept unsupported locale.", async() => { const account = new AccountSetupEntity(initialAccountSetupDto()); - const controller = new SetSetupLocaleController(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new SetSetupLocaleController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockApiResult = anonymousOrganizationSettings(); diff --git a/src/all/background_page/controller/setup/setSetupSecurityTokenController.js b/src/all/background_page/controller/setup/setSetupSecurityTokenController.js index ee1e27ef..62b3b7ab 100644 --- a/src/all/background_page/controller/setup/setSetupSecurityTokenController.js +++ b/src/all/background_page/controller/setup/setSetupSecurityTokenController.js @@ -12,18 +12,18 @@ * @since 3.6.0 */ import SecurityTokenEntity from "../../model/entity/securityToken/securityTokenEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class SetSetupSecurityTokenController { /** * GetRecoverLocaleController constructor. * @param {Worker} worker The worker the controller is executed on. * @param {string} requestId The associated request id. - * @param {AccountSetupEntity} account The account being setup. */ - constructor(worker, requestId, account) { + constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.account = account; } /** @@ -33,7 +33,7 @@ class SetSetupSecurityTokenController { */ async _exec(securityTokenDto) { try { - this.exec(securityTokenDto); + await this.exec(securityTokenDto); this.worker.port.emit(this.requestId, 'SUCCESS'); } catch (error) { console.error(error); @@ -48,7 +48,10 @@ class SetSetupSecurityTokenController { * @throw {EntityValidationError} if the security token dto does not validate. */ async exec(securityTokenDto) { - this.account.securityToken = new SecurityTokenEntity(securityTokenDto); + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + temporaryAccount.account.securityToken = new SecurityTokenEntity(securityTokenDto); + // Update all data in the temporary account stored + await AccountTemporarySessionStorageService.set(temporaryAccount); } } diff --git a/src/all/background_page/controller/setup/setSetupSecurityTokenController.test.js b/src/all/background_page/controller/setup/setSetupSecurityTokenController.test.js index e32b4701..9d9f4694 100644 --- a/src/all/background_page/controller/setup/setSetupSecurityTokenController.test.js +++ b/src/all/background_page/controller/setup/setSetupSecurityTokenController.test.js @@ -16,17 +16,31 @@ import SetSetupSecurityTokenController from "./setSetupSecurityTokenController"; import AccountSetupEntity from "../../model/entity/account/accountSetupEntity"; import {withUserKeyAccountSetupDto} from "../../model/entity/account/accountSetupEntity.test.data"; import {defaultSecurityTokenDto} from "../../model/entity/securityToken/SecurityTokenEntity.test.data"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; describe("SetSetupSecurityTokenController", () => { describe("SetSetupSecurityTokenController::exec", () => { it("Should set the setup security token.", async() => { const account = new AccountSetupEntity(withUserKeyAccountSetupDto()); - const controller = new SetSetupSecurityTokenController(null, null, account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); + const controller = new SetSetupSecurityTokenController({port: {_port: {name: "test"}}}, null); const securityTokenDto = defaultSecurityTokenDto(); await controller.exec(securityTokenDto); expect.assertions(1); await expect(account.securityToken.toDto()).toEqual(securityTokenDto); }); + + it("Should raise an error if no account has been found.", async() => { + const controller = new SetSetupSecurityTokenController({port: {_port: {name: "test"}}}, null); + expect.assertions(1); + const securityTokenDto = defaultSecurityTokenDto(); + try { + await controller.exec(securityTokenDto); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/setup/signInSetupController.js b/src/all/background_page/controller/setup/signInSetupController.js index 9faca720..bd7379ef 100644 --- a/src/all/background_page/controller/setup/signInSetupController.js +++ b/src/all/background_page/controller/setup/signInSetupController.js @@ -18,6 +18,8 @@ import AuthVerifyLoginChallengeService from "../../service/auth/authVerifyLoginC import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; import PostLoginService from "../../service/auth/postLoginService"; import KeepSessionAliveService from "../../service/session_storage/keepSessionAliveService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class SignInSetupController { /** @@ -25,15 +27,11 @@ class SignInSetupController { * @param {Worker} worker * @param {string} requestId uuid * @param {ApiClientOptions} apiClientOptions - * @param {AccountEntity} account The account - * @param {Object} runtimeMemory The setup runtime memory. */ - constructor(worker, requestId, apiClientOptions, account, runtimeMemory) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; - this.account = account; this.authVerifyLoginChallengeService = new AuthVerifyLoginChallengeService(apiClientOptions); - this.runtimeMemory = runtimeMemory; this.updateSsoCredentialsService = new UpdateSsoCredentialsService(apiClientOptions); this.checkPassphraseService = new CheckPassphraseService(new Keyring()); } @@ -58,36 +56,39 @@ class SignInSetupController { * @return {Promise} */ async exec(rememberMe = false) { - if (typeof this.runtimeMemory.passphrase === "undefined") { + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + if (typeof temporaryAccount.passphrase === "undefined") { throw new Error("A passphrase is required."); } - if (typeof this.runtimeMemory.passphrase !== "string") { + if (typeof temporaryAccount.passphrase !== "string") { throw new Error("The passphrase should be a string."); } if (typeof rememberMe !== "undefined" && typeof rememberMe !== "boolean") { throw new Error("The rememberMe should be a boolean."); } - await this.checkPassphraseService.checkPassphrase(this.runtimeMemory.passphrase); - await this.updateSsoCredentialsService.forceUpdateSsoKit(this.runtimeMemory.passphrase); + await this.checkPassphraseService.checkPassphrase(temporaryAccount.passphrase); + await this.updateSsoCredentialsService.forceUpdateSsoKit(temporaryAccount.passphrase); - await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(this.account.userKeyFingerprint, this.account.userPrivateArmoredKey, this.runtimeMemory.passphrase); + await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(temporaryAccount.account.userKeyFingerprint, temporaryAccount.account.userPrivateArmoredKey, temporaryAccount.passphrase); if (rememberMe) { await Promise.all([ - PassphraseStorageService.set(this.runtimeMemory.passphrase, -1), + PassphraseStorageService.set(temporaryAccount.passphrase, -1), KeepSessionAliveService.start(), ]); } await PostLoginService.exec(); - await this.redirectToApp(); + await this.redirectToApp(temporaryAccount.account.domain); + // Clear all data in the temporary account session storage + await AccountTemporarySessionStorageService.remove(); } /** * Redirect the user to the application + * @param {string} url The url * @returns {Promise} */ - async redirectToApp() { - const url = this.account.domain; + async redirectToApp(url) { browser.tabs.update(this.worker.tab.id, {url}); } } diff --git a/src/all/background_page/controller/setup/signInSetupController.test.js b/src/all/background_page/controller/setup/signInSetupController.test.js index c40716b5..be95375b 100644 --- a/src/all/background_page/controller/setup/signInSetupController.test.js +++ b/src/all/background_page/controller/setup/signInSetupController.test.js @@ -29,6 +29,7 @@ import SsoDataStorage from "../../service/indexedDB_storage/ssoDataStorage"; import {withAzureSsoSettings} from "../sso/getCurrentSsoSettingsController.test.data"; import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; import PostLoginService from "../../service/auth/postLoginService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(() => { enableFetchMocks(); @@ -42,20 +43,21 @@ describe("SignInSetupController", () => { it("Should throw an exception if the passphrase is not a valid.", async() => { const account = new AccountEntity(defaultAccountDto()); - const runtimeMemory = {}; - const controller = new SignInSetupController(null, null, defaultApiClientOptions(), account, runtimeMemory); + const controller = new SignInSetupController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); expect.assertions(2); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); const promiseMissingParameter = controller.exec(); await expect(promiseMissingParameter).rejects.toThrowError("A passphrase is required."); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account, passphrase: {}})); const promiseInvalidTypeParameter = controller.exec(); - await expect(promiseInvalidTypeParameter).rejects.toThrowError("A passphrase is required."); + await expect(promiseInvalidTypeParameter).rejects.toThrowError("The passphrase should be a string."); }, 10000); it("Should throw an exception if the provided remember me is not a valid boolean.", async() => { const account = new AccountEntity(defaultAccountDto()); - const runtimeMemory = {passphrase: pgpKeys.ada.passphrase}; - const controller = new SignInSetupController(null, null, defaultApiClientOptions(), account, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account, passphrase: pgpKeys.ada.passphrase})); + const controller = new SignInSetupController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); expect.assertions(1); const promiseInvalidTypeParameter = controller.exec(42); @@ -65,8 +67,9 @@ describe("SignInSetupController", () => { it("Should throw an exception if the provided passphrase can't decrypt the current private key.", async() => { await MockExtension.withConfiguredAccount(); const account = new AccountEntity(defaultAccountDto()); - const runtimeMemory = {passphrase: "fake passphrase"}; - const controller = new SignInSetupController(null, null, defaultApiClientOptions(), account, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account, passphrase: "fake passphrase"})); + + const controller = new SignInSetupController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); expect.assertions(1); try { @@ -94,18 +97,31 @@ describe("SignInSetupController", () => { await MockExtension.withConfiguredAccount(); const account = new AccountEntity(defaultAccountDto()); - const runtimeMemory = {passphrase: "ada@passbolt.com"}; - const controller = new SignInSetupController({tab: {id: 1}}, null, defaultApiClientOptions(), account, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account, passphrase: "ada@passbolt.com"})); + + const controller = new SignInSetupController({tab: {id: 1}, port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + jest.spyOn(AccountTemporarySessionStorageService, "remove"); - expect.assertions(6); + expect.assertions(7); await controller.exec(true); - expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, runtimeMemory.passphrase); - expect(PassphraseStorageService.set).toHaveBeenCalledWith(runtimeMemory.passphrase, -1); + expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, "ada@passbolt.com"); + expect(PassphraseStorageService.set).toHaveBeenCalledWith("ada@passbolt.com", -1); expect(PostLoginService.exec).toHaveBeenCalled(); expect(GenerateSsoKitService.generate).toHaveBeenCalledWith("ada@passbolt.com", "azure"); expect(GenerateSsoKitService.generate).toHaveBeenCalledTimes(1); expect(browser.tabs.update).toHaveBeenCalledWith(1, {url: account.domain}); + expect(AccountTemporarySessionStorageService.remove).toHaveBeenCalledTimes(1); }, 10000); + + it("Should raise an error if no account has been found.", async() => { + const controller = new SignInSetupController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/setup/startSetupController.js b/src/all/background_page/controller/setup/startSetupController.js index 03b9f459..9050f515 100644 --- a/src/all/background_page/controller/setup/startSetupController.js +++ b/src/all/background_page/controller/setup/startSetupController.js @@ -15,6 +15,9 @@ import SetupModel from "../../model/setup/setupModel"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import WorkerService from "../../service/worker/workerService"; import AuthVerifyServerKeyService from "../../service/api/auth/authVerifyServerKeyService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import AccountSetupEntity from "../../model/entity/account/accountSetupEntity"; +import AccountTemporaryEntity from "../../model/entity/account/accountTemporaryEntity"; class StartSetupController { /** @@ -22,16 +25,14 @@ class StartSetupController { * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions The api client options. - * @param {AccountEntity} account The account being setting up. - * @param {Object} runtimeMemory The setup runtime memory. + * @param {AccountSetupEntity} account The account being setting up. */ - constructor(worker, requestId, apiClientOptions, account, runtimeMemory) { + constructor(worker, requestId, apiClientOptions, account) { this.worker = worker; this.requestId = requestId; this.account = account; this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); this.setupModel = new SetupModel(apiClientOptions); - this.runtimeMemory = runtimeMemory; } /** @@ -54,13 +55,29 @@ class StartSetupController { */ async exec() { try { + await this._buildTemporaryAccountEntity(); await this._findAndSetAccountSetupServerPublicKey(); await this._findAndSetAccountSetupMeta(); + // Set all data in the temporary account stored + await AccountTemporarySessionStorageService.set(this.temporaryAccount); } catch (error) { await this._handleUnexpectedError(error); } } + /** + * Create or replace the account temporary in the session storage. + * @returns {Promise} + * @private + */ + async _buildTemporaryAccountEntity() { + const temporaryAccountDto = { + account: this.account.toDto(AccountSetupEntity.ALL_CONTAIN_OPTIONS), + worker_id: this.worker.port._port.name + }; + this.temporaryAccount = new AccountTemporaryEntity(temporaryAccountDto); + } + /** * Find and set the account server public key. * @returns {Promise} @@ -72,7 +89,7 @@ class StartSetupController { OpenpgpAssertion.assertPublicKey(serverPublicKey); // associate the server public key to the account being set up. - this.account.serverPublicArmoredKey = serverPublicKey.armor(); + this.temporaryAccount.account.serverPublicArmoredKey = serverPublicKey.armor(); } /** @@ -82,26 +99,26 @@ class StartSetupController { */ async _findAndSetAccountSetupMeta() { const {user, accountRecoveryOrganizationPolicy, userPassphrasePolicies} = await this.setupModel.startSetup( - this.account.userId, - this.account.authenticationTokenToken + this.temporaryAccount.account.userId, + this.temporaryAccount.account.authenticationTokenToken ); // Associate the user meta to the account being set up. - this.account.username = user?.username; - this.account.firstName = user?.profile.firstName; - this.account.lastName = user?.profile.lastName; + this.temporaryAccount.account.username = user?.username; + this.temporaryAccount.account.firstName = user?.profile.firstName; + this.temporaryAccount.account.lastName = user?.profile.lastName; // As of v3.6.0 the user is stored only to know if account recovery was enabled for this account. - this.account.user = user; + this.temporaryAccount.account.user = user; - // Keep the account recovery organization policy in the setup runtime memory. + // Keep the account recovery organization policy in the setup temporary account storage. if (accountRecoveryOrganizationPolicy) { - this.runtimeMemory.accountRecoveryOrganizationPolicy = accountRecoveryOrganizationPolicy; + this.temporaryAccount.accountRecoveryOrganizationPolicy = accountRecoveryOrganizationPolicy; } - // Keep the user passphrase policies in the setup runtime memory. + // Keep the user passphrase policies in the setup temporary account storage. if (userPassphrasePolicies) { - this.runtimeMemory.userPassphrasePolicies = userPassphrasePolicies; + this.temporaryAccount.userPassphrasePolicies = userPassphrasePolicies; } } diff --git a/src/all/background_page/controller/setup/startSetupController.test.js b/src/all/background_page/controller/setup/startSetupController.test.js index 920d125b..094129cc 100644 --- a/src/all/background_page/controller/setup/startSetupController.test.js +++ b/src/all/background_page/controller/setup/startSetupController.test.js @@ -28,6 +28,8 @@ import AccountRecoveryOrganizationPolicyEntity from "../../model/entity/accountR import UserPassphrasePoliciesEntity from "passbolt-styleguide/src/shared/models/entity/userPassphrasePolicies/userPassphrasePoliciesEntity"; import {defaultUserPassphrasePoliciesEntityDto} from "passbolt-styleguide/src/shared/models/userPassphrasePolicies/UserPassphrasePoliciesDto.test.data"; import {enabledAccountRecoveryOrganizationPolicyDto} from "../../model/entity/accountRecovery/accountRecoveryOrganizationPolicyEntity.test.data"; +import {v4 as uuidv4} from "uuid"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; // Reset the modules before each test. beforeEach(() => { @@ -38,8 +40,8 @@ describe("StartSetupController", () => { describe("StartSetupController::exec", () => { it("Should initiate the setup process and retrieve the setup material", async() => { const account = new AccountSetupEntity(initialAccountSetupDto()); - const runtimeMemory = {}; - const controller = new StartSetupController(null, null, defaultApiClientOptions(), account, runtimeMemory); + const workerId = uuidv4(); + const controller = new StartSetupController({port: {_port: {name: workerId}}}, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockVerifyDto = defaultVerifyDto(); @@ -50,22 +52,23 @@ describe("StartSetupController", () => { expect.assertions(7); await controller.exec(); + const expectedAccount = await AccountTemporarySessionStorageService.get(workerId); const key = await OpenpgpAssertion.readKeyOrFail(mockVerifyDto.keydata); - expect(account.serverPublicArmoredKey).toEqual((await GetGpgKeyInfoService.getKeyInfo(key)).armoredKey); - expect(account.username).toEqual(mockSetupStartDto.user.username); - expect(account.firstName).toEqual(mockSetupStartDto.user.profile.first_name); - expect(account.lastName).toEqual(mockSetupStartDto.user.profile.last_name); - expect(account.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS)).toEqual(mockSetupStartDto.user); - expect(runtimeMemory.accountRecoveryOrganizationPolicy).toBeUndefined(); - expect(runtimeMemory.userPassphrasePolicies).toBeUndefined(); + expect(expectedAccount.account.serverPublicArmoredKey).toEqual((await GetGpgKeyInfoService.getKeyInfo(key)).armoredKey); + expect(expectedAccount.account.username).toEqual(mockSetupStartDto.user.username); + expect(expectedAccount.account.firstName).toEqual(mockSetupStartDto.user.profile.first_name); + expect(expectedAccount.account.lastName).toEqual(mockSetupStartDto.user.profile.last_name); + expect(expectedAccount.account.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS)).toEqual(mockSetupStartDto.user); + expect(expectedAccount.accountRecoveryOrganizationPolicy).toBeNull(); + expect(expectedAccount.userPassphrasePolicies).toBeNull(); }, 10 * 1000); it("Should initiate the setup process and retrieve the setup material with all configuration", async() => { const account = new AccountSetupEntity(initialAccountSetupDto()); const accountRecoveryOrganizationPolicyDto = enabledAccountRecoveryOrganizationPolicyDto(); const userPassphrasePoliciesDto = defaultUserPassphrasePoliciesEntityDto(); - const runtimeMemory = {}; - const controller = new StartSetupController(null, null, defaultApiClientOptions(), account, runtimeMemory); + const workerId = uuidv4(); + const controller = new StartSetupController({port: {_port: {name: workerId}}}, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockVerifyDto = defaultVerifyDto(); @@ -80,21 +83,22 @@ describe("StartSetupController", () => { expect.assertions(7); await controller.exec(); + const expectedAccount = await AccountTemporarySessionStorageService.get(workerId); const key = await OpenpgpAssertion.readKeyOrFail(mockVerifyDto.keydata); - expect(account.serverPublicArmoredKey).toEqual((await GetGpgKeyInfoService.getKeyInfo(key)).armoredKey); - expect(account.username).toEqual(mockSetupStartDto.user.username); - expect(account.firstName).toEqual(mockSetupStartDto.user.profile.first_name); - expect(account.lastName).toEqual(mockSetupStartDto.user.profile.last_name); - expect(account.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS)).toEqual(mockSetupStartDto.user); - expect(runtimeMemory.accountRecoveryOrganizationPolicy).toStrictEqual(new AccountRecoveryOrganizationPolicyEntity(accountRecoveryOrganizationPolicyDto)); - expect(runtimeMemory.userPassphrasePolicies).toStrictEqual(new UserPassphrasePoliciesEntity(userPassphrasePoliciesDto)); + expect(expectedAccount.account.serverPublicArmoredKey).toEqual((await GetGpgKeyInfoService.getKeyInfo(key)).armoredKey); + expect(expectedAccount.account.username).toEqual(mockSetupStartDto.user.username); + expect(expectedAccount.account.firstName).toEqual(mockSetupStartDto.user.profile.first_name); + expect(expectedAccount.account.lastName).toEqual(mockSetupStartDto.user.profile.last_name); + expect(expectedAccount.account.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS)).toEqual(mockSetupStartDto.user); + expect(expectedAccount.accountRecoveryOrganizationPolicy).toStrictEqual(new AccountRecoveryOrganizationPolicyEntity(accountRecoveryOrganizationPolicyDto)); + expect(expectedAccount.userPassphrasePolicies).toStrictEqual(new UserPassphrasePoliciesEntity(userPassphrasePoliciesDto)); }, 10 * 1000); it("Should not initiate the setup if the API does not provide a valid server public key", async() => { - const mockedWorker = {tab: {id: "tabID"}}; + const workerId = uuidv4(); + const mockedWorker = {tab: {id: "tabID"}, port: {_port: {name: workerId}}}; const account = new AccountSetupEntity(initialAccountSetupDto()); - const runtimeMemory = {}; - const controller = new StartSetupController(mockedWorker, null, defaultApiClientOptions(), account, runtimeMemory); + const controller = new StartSetupController(mockedWorker, null, defaultApiClientOptions(), account); // Mock API fetch verify const mockVerifyDto = defaultVerifyDto({keydata: "not a valid key"}); @@ -114,10 +118,10 @@ describe("StartSetupController", () => { }); it("Should not initiate the setup if the API does not provide a valid user", async() => { - const mockedWorker = {tab: {id: "tabID"}}; + const workerId = uuidv4(); + const mockedWorker = {tab: {id: "tabID"}, port: {_port: {name: workerId}}}; const account = new AccountSetupEntity(initialAccountSetupDto()); - const runtimeMemory = {}; - const controller = new StartSetupController(mockedWorker, null, defaultApiClientOptions(), account, runtimeMemory); + const controller = new StartSetupController(mockedWorker, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockVerifyDto = defaultVerifyDto(); @@ -140,10 +144,10 @@ describe("StartSetupController", () => { }); it("Should not initiate the setup if the API does not provide a valid account recovery organization policy (not mandatory)", async() => { - const mockedWorker = {tab: {id: "tabID"}}; + const workerId = uuidv4(); + const mockedWorker = {tab: {id: "tabID"}, port: {_port: {name: workerId}}}; const account = new AccountSetupEntity(initialAccountSetupDto()); - const runtimeMemory = {}; - const controller = new StartSetupController(mockedWorker, null, defaultApiClientOptions(), account, runtimeMemory); + const controller = new StartSetupController(mockedWorker, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockVerifyDto = defaultVerifyDto(); diff --git a/src/all/background_page/controller/setup/verifyImportedKeyPassphraseController.js b/src/all/background_page/controller/setup/verifyImportedKeyPassphraseController.js index 631587f5..6b274033 100644 --- a/src/all/background_page/controller/setup/verifyImportedKeyPassphraseController.js +++ b/src/all/background_page/controller/setup/verifyImportedKeyPassphraseController.js @@ -14,21 +14,18 @@ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import DecryptPrivateKeyService from "../../service/crypto/decryptPrivateKeyService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class VerifyImportedKeyPassphraseController { /** * Constructor. * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. - * @param {AbstractAccountEntity} account The account associated to the worker. - * @param {Object} runtimeMemory The setup runtime memory. */ - constructor(worker, requestId, account, runtimeMemory) { + constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.account = account; - this.runtimeMemory = runtimeMemory; - this.runtimeMemory.passphrase = null; } /** @@ -55,7 +52,8 @@ class VerifyImportedKeyPassphraseController { * @returns {Promise} */ async exec(passphrase) { - const privateArmoredKey = this.account?.userPrivateArmoredKey; + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + const privateArmoredKey = temporaryAccount.account?.userPrivateArmoredKey; if (!privateArmoredKey) { throw new Error('An account user private key is required.'); } @@ -65,7 +63,9 @@ class VerifyImportedKeyPassphraseController { const privateKey = await OpenpgpAssertion.readKeyOrFail(privateArmoredKey); await DecryptPrivateKeyService.decrypt(privateKey, passphrase); // The passphrase will be later use to sign in the user. - this.runtimeMemory.passphrase = passphrase; + temporaryAccount.passphrase = passphrase; + // Update all data in the temporary account stored + await AccountTemporarySessionStorageService.set(temporaryAccount); } } diff --git a/src/all/background_page/controller/setup/verifyImportedKeyPassphraseController.test.js b/src/all/background_page/controller/setup/verifyImportedKeyPassphraseController.test.js index 282ef551..693ada9c 100644 --- a/src/all/background_page/controller/setup/verifyImportedKeyPassphraseController.test.js +++ b/src/all/background_page/controller/setup/verifyImportedKeyPassphraseController.test.js @@ -16,13 +16,15 @@ import InvalidMasterPasswordError from "../../error/invalidMasterPasswordError"; import AccountEntity from "../../model/entity/account/accountEntity"; import {defaultAccountDto} from "../../model/entity/account/accountEntity.test.data"; import {pgpKeys} from "../../../../../test/fixtures/pgpKeys/keys"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; describe("VerifyImportedKeyPassphraseController", () => { describe("VerifyImportedKeyPassphraseController::exec", () => { it("Should pass if the passphrase is correct.", async() => { const account = new AccountEntity(defaultAccountDto()); - const runtimeMemory = {}; - const controller = new VerifyImportedKeyPassphraseController(null, null, account, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); + const controller = new VerifyImportedKeyPassphraseController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const promise = controller.exec(pgpKeys.ada.passphrase); @@ -31,8 +33,8 @@ describe("VerifyImportedKeyPassphraseController", () => { it("Should throw an exception if no passphrase provided.", () => { const account = new AccountEntity(defaultAccountDto()); - const runtimeMemory = {}; - const controller = new VerifyImportedKeyPassphraseController(null, null, account, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new VerifyImportedKeyPassphraseController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const promise = controller.exec(); @@ -41,8 +43,8 @@ describe("VerifyImportedKeyPassphraseController", () => { it("Should throw an exception if the passphrase is incorrect.", () => { const account = new AccountEntity(defaultAccountDto()); - const runtimeMemory = {}; - const controller = new VerifyImportedKeyPassphraseController(null, null, account, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new VerifyImportedKeyPassphraseController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const promise = controller.exec("wrong passphrase"); @@ -52,12 +54,22 @@ describe("VerifyImportedKeyPassphraseController", () => { it("Should throw an exception if the setupEntity doesn't have a private key set.", () => { const account = new AccountEntity(defaultAccountDto()); delete account._props.user_private_armored_key; - const runtimeMemory = {}; - const controller = new VerifyImportedKeyPassphraseController(null, null, account, runtimeMemory); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new VerifyImportedKeyPassphraseController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const promise = controller.exec("whatever passphrase"); return expect(promise).rejects.toThrowError(new Error("An account user private key is required.")); }); + + it("Should raise an error if no account has been found.", async() => { + const controller = new VerifyImportedKeyPassphraseController({port: {_port: {name: "test"}}}, null); + expect.assertions(1); + try { + await controller.exec("test"); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/event/accountRecoveryEvents.js b/src/all/background_page/event/accountRecoveryEvents.js index 780f4ec7..c9e3716a 100644 --- a/src/all/background_page/event/accountRecoveryEvents.js +++ b/src/all/background_page/event/accountRecoveryEvents.js @@ -77,7 +77,7 @@ const listen = function(worker, apiClientOptions, account) { }); worker.port.on('passbolt.account-recovery.download-recovery-kit', async requestId => { - const controller = new DownloadRecoveryKitController(worker, requestId, account); + const controller = new DownloadRecoveryKitController(worker, requestId); await controller._exec(); }); diff --git a/src/all/background_page/event/setupEvents.js b/src/all/background_page/event/setupEvents.js index 82c4fe80..9fc8abec 100644 --- a/src/all/background_page/event/setupEvents.js +++ b/src/all/background_page/event/setupEvents.js @@ -32,15 +32,6 @@ import GetUserPassphrasePoliciesController from "../controller/setup/getUserPass import ReloadTabController from "../controller/tab/reloadTabController"; const listen = function(worker, apiClientOptions, account) { - /* - * The setup runtime memory. - * - * Used to store information collected during the user setup journey that shouldn't be stored on the react side of - * the application because of their confidentiality or for logic reason. By instance the account recovery organization - * policy, collected during a setup start, and used later during the process to encrypt the private escrow of the user. - */ - const runtimeMemory = {}; - worker.port.on('passbolt.setup.is-first-install', async requestId => { const controller = new IsExtensionFirstInstallController(worker, requestId); await controller._exec(); @@ -52,7 +43,7 @@ const listen = function(worker, apiClientOptions, account) { }); worker.port.on('passbolt.setup.start', async requestId => { - const controller = new StartSetupController(worker, requestId, apiClientOptions, account, runtimeMemory); + const controller = new StartSetupController(worker, requestId, apiClientOptions, account); await controller._exec(); }); @@ -67,42 +58,42 @@ const listen = function(worker, apiClientOptions, account) { }); worker.port.on('passbolt.setup.generate-key', async(requestId, generateGpgKeyDto) => { - const controller = new GenerateSetupKeyPairController(worker, requestId, apiClientOptions, account, runtimeMemory); + const controller = new GenerateSetupKeyPairController(worker, requestId, apiClientOptions); await controller._exec(generateGpgKeyDto); }); worker.port.on('passbolt.setup.download-recovery-kit', async requestId => { - const controller = new DownloadRecoveryKitController(worker, requestId, account); + const controller = new DownloadRecoveryKitController(worker, requestId); await controller._exec(); }); worker.port.on('passbolt.setup.get-account-recovery-organization-policy', async requestId => { - const controller = new GetAccountRecoveryOrganizationPolicyController(worker, requestId, runtimeMemory); + const controller = new GetAccountRecoveryOrganizationPolicyController(worker, requestId); await controller._exec(); }); worker.port.on('passbolt.setup.set-account-recovery-user-setting', async(requestId, status) => { - const controller = new SetSetupAccountRecoveryUserSettingController(worker, requestId, account, runtimeMemory); + const controller = new SetSetupAccountRecoveryUserSettingController(worker, requestId); await controller._exec(status); }); worker.port.on('passbolt.setup.import-key', async(requestId, armoredKey) => { - const controller = new ImportSetupPrivateKeyController(worker, requestId, apiClientOptions, account); + const controller = new ImportSetupPrivateKeyController(worker, requestId, apiClientOptions); await controller._exec(armoredKey); }); worker.port.on('passbolt.setup.verify-passphrase', async(requestId, passphrase) => { - const controller = new VerifyImportedKeyPassphraseController(worker, requestId, account, runtimeMemory); + const controller = new VerifyImportedKeyPassphraseController(worker, requestId); await controller._exec(passphrase); }); worker.port.on('passbolt.setup.set-security-token', async(requestId, securityTokenDto) => { - const controller = new SetSetupSecurityTokenController(worker, requestId, account); + const controller = new SetSetupSecurityTokenController(worker, requestId); await controller._exec(securityTokenDto); }); worker.port.on('passbolt.setup.complete', async requestId => { - const controller = new CompleteSetupController(worker, requestId, apiClientOptions, account, runtimeMemory); + const controller = new CompleteSetupController(worker, requestId, apiClientOptions); await controller._exec(); }); @@ -112,7 +103,7 @@ const listen = function(worker, apiClientOptions, account) { }); worker.port.on('passbolt.setup.sign-in', async(requestId, rememberMe) => { - const controller = new SignInSetupController(worker, requestId, apiClientOptions, account, runtimeMemory); + const controller = new SignInSetupController(worker, requestId, apiClientOptions); await controller._exec(rememberMe); }); @@ -122,7 +113,7 @@ const listen = function(worker, apiClientOptions, account) { }); worker.port.on('passbolt.setup.get-user-passphrase-policies', async requestId => { - const controller = new GetUserPassphrasePoliciesController(worker, requestId, runtimeMemory); + const controller = new GetUserPassphrasePoliciesController(worker, requestId); await controller._exec(); }); diff --git a/src/all/background_page/model/entity/account/accountRecoverEntity.js b/src/all/background_page/model/entity/account/accountRecoverEntity.js index 7ab82d74..b5053615 100644 --- a/src/all/background_page/model/entity/account/accountRecoverEntity.js +++ b/src/all/background_page/model/entity/account/accountRecoverEntity.js @@ -115,8 +115,11 @@ class AccountRecoverEntity extends AbstractAccountEntity { if (contains.authentication_token_token) { result.authentication_token_token = this.authenticationTokenToken; } - if (contains.security_token && this._security_token) { - result.security_token = this._security_token.toDto(); + if (contains.security_token && this.securityToken) { + result.security_token = this.securityToken.toDto(); + } + if (contains.account_recovery_user_setting && this.accountRecoveryUserSetting) { + result.account_recovery_user_setting = this.accountRecoveryUserSetting.toDto(AccountRecoveryUserSettingEntity.ALL_CONTAIN_OPTIONS); } if (contains.user && this._user) { result.user = this._user.toDto(UserEntity.ALL_CONTAIN_OPTIONS); @@ -244,6 +247,7 @@ class AccountRecoverEntity extends AbstractAccountEntity { user_private_armored_key: true, security_token: true, authentication_token_token: true, + account_recovery_user_setting: true, user: true }; } diff --git a/src/all/background_page/model/entity/account/accountSetupEntity.js b/src/all/background_page/model/entity/account/accountSetupEntity.js index 1b84cdcd..d95670df 100644 --- a/src/all/background_page/model/entity/account/accountSetupEntity.js +++ b/src/all/background_page/model/entity/account/accountSetupEntity.js @@ -39,6 +39,10 @@ class AccountSetupEntity extends AbstractAccountEntity { this._account_recovery_user_setting = new AccountRecoveryUserSettingEntity(this._props.account_recovery_user_setting, {clone: false}); delete this._props.account_recovery_user_setting; } + if (this._props.user) { + this._user = new UserEntity(this._props.user, {clone: false}); + delete this._props.user; + } } /** @@ -112,11 +116,14 @@ class AccountSetupEntity extends AbstractAccountEntity { if (contains.authentication_token_token) { result.authentication_token_token = this.authenticationTokenToken; } - if (contains.security_token && this._security_token) { - result.security_token = this._security_token.toDto(); + if (contains.security_token && this.securityToken) { + result.security_token = this.securityToken.toDto(); + } + if (contains.account_recovery_user_setting && this.accountRecoveryUserSetting) { + result.account_recovery_user_setting = this.accountRecoveryUserSetting.toDto(AccountRecoveryUserSettingEntity.ALL_CONTAIN_OPTIONS); } if (contains.user && this._user) { - result.user = this._user.toDto(UserEntity.ALL_CONTAIN_OPTIONS); + result.user = this.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS); } return result; @@ -226,6 +233,7 @@ class AccountSetupEntity extends AbstractAccountEntity { user_private_armored_key: true, security_token: true, authentication_token_token: true, + account_recovery_user_setting: true, user: true }; } diff --git a/src/all/background_page/model/entity/account/accountTemporaryEntity.js b/src/all/background_page/model/entity/account/accountTemporaryEntity.js index a534b86e..084c1d7b 100644 --- a/src/all/background_page/model/entity/account/accountTemporaryEntity.js +++ b/src/all/background_page/model/entity/account/accountTemporaryEntity.js @@ -135,8 +135,8 @@ class AccountTemporaryEntity extends AbstractAccountEntity { if (contains.account_recovery_organization_policy && this.accountRecoveryOrganizationPolicy) { result.account_recovery_organization_policy = this.accountRecoveryOrganizationPolicy.toDto(AccountRecoveryOrganizationPolicyEntity.ALL_CONTAIN_OPTIONS); } - if (contains.user_passphrase_policies && this.userPassphrasePolicy) { - result.user_passphrase_policies = this.userPassphrasePolicy.toDto(); + if (contains.user_passphrase_policies && this.userPassphrasePolicies) { + result.user_passphrase_policies = this.userPassphrasePolicies.toDto(); } return result; @@ -210,7 +210,7 @@ class AccountTemporaryEntity extends AbstractAccountEntity { * Get the user passphrase policy * @returns {(UserPassphrasePoliciesEntity|null)} */ - get userPassphrasePolicy() { + get userPassphrasePolicies() { return this._user_passphrase_policies || null; } @@ -219,7 +219,7 @@ class AccountTemporaryEntity extends AbstractAccountEntity { * @param {UserPassphrasePoliciesEntity} userPassphrasePolicy The account recovery organization policy * @throws {TypeError} If the userPassphrasePolicy parameter is not a valid UserPassphrasePoliciesEntity */ - set userPassphrasePolicy(userPassphrasePolicy) { + set userPassphrasePolicies(userPassphrasePolicy) { if (!userPassphrasePolicy || !(userPassphrasePolicy instanceof UserPassphrasePoliciesEntity)) { throw new TypeError('Failed to assert the parameter is a valid UserPassphrasePoliciesEntity'); } diff --git a/src/all/background_page/service/account/findAccountTemporaryService.js b/src/all/background_page/service/account/findAccountTemporaryService.js new file mode 100644 index 00000000..9c69ac2d --- /dev/null +++ b/src/all/background_page/service/account/findAccountTemporaryService.js @@ -0,0 +1,33 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import AccountTemporarySessionStorageService from "../sessionStorage/accountTemporarySessionStorageService"; +import i18n from "../../sdk/i18n"; + +class FindAccountTemporaryService { + /** + * Find an account temporary in the session storage + * @param {string} workerId The worker id + * @returns {Promise} + * @throws {Error} if no temporary account is found + */ + static async exec(workerId) { + const temporaryAccount = await AccountTemporarySessionStorageService.get(workerId); + if (!temporaryAccount) { + throw new Error(i18n.t('You have already started the process on another tab.')); + } + return temporaryAccount; + } +} + +export default FindAccountTemporaryService; diff --git a/src/all/background_page/service/account/findAccountTemporaryService.test.js b/src/all/background_page/service/account/findAccountTemporaryService.test.js new file mode 100644 index 00000000..02dc940a --- /dev/null +++ b/src/all/background_page/service/account/findAccountTemporaryService.test.js @@ -0,0 +1,41 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import AccountTemporaryEntity from "../../model/entity/account/accountTemporaryEntity"; +import {temporarySetupAccountDto} from "../../model/entity/account/accountTemporaryEntity.test.data"; +import AccountTemporarySessionStorageService from "../sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "./findAccountTemporaryService"; + +describe("FindAccountTemporaryService", () => { + it("FindAccountTemporaryService:exec", async() => { + expect.assertions(1); + // data + const accountTemporaryEntity = new AccountTemporaryEntity(temporarySetupAccountDto()); + await AccountTemporarySessionStorageService.set(accountTemporaryEntity); + // execution + const temporaryAccount = await FindAccountTemporaryService.exec(accountTemporaryEntity.workerId); + // expectations + expect(temporaryAccount).not.toBeNull(); + }); + + it("FindAccountTemporaryService:exec with workerId unknown", async() => { + expect.assertions(1); + // execution + try { + await FindAccountTemporaryService.exec(null); + } catch (error) { + // expectations + expect(error.message).toStrictEqual("You have already started the process on another tab."); + } + }); +}); From 80da77744c79927ee001d3ef040ff285fa417e94 Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Fri, 19 Apr 2024 12:03:01 +0200 Subject: [PATCH 48/56] PB-33063 Use temporary account storage for recover process --- .../recover/abortAndRequestHelpController.js | 9 ++-- .../abortAndRequestHelpController.test.js | 14 +++++- .../recover/completeRecoverController.js | 11 ++--- .../recover/completeRecoverController.test.js | 7 ++- ...overAccountRecoveryRequestKeyController.js | 17 ++++--- ...ccountRecoveryRequestKeyController.test.js | 18 +++++++- ...verUserEnabledAccountRecoveryController.js | 8 ++-- ...erEnabledAccountRecoveryController.test.js | 18 +++++++- .../importRecoverPrivateKeyController.js | 21 +++++---- .../importRecoverPrivateKeyController.test.js | 24 ++++++++-- .../requestAccountRecoveryController.js | 13 +++--- .../requestAccountRecoveryController.test.js | 18 +++++++- .../recover/startRecoverController.js | 45 +++++++++++++----- .../recover/startRecoverController.test.js | 46 ++++++++++--------- .../controller/setup/startSetupController.js | 2 +- .../background_page/event/recoverEvents.js | 35 +++++--------- 16 files changed, 198 insertions(+), 108 deletions(-) diff --git a/src/all/background_page/controller/recover/abortAndRequestHelpController.js b/src/all/background_page/controller/recover/abortAndRequestHelpController.js index 0ac1cd9a..b2edfc38 100644 --- a/src/all/background_page/controller/recover/abortAndRequestHelpController.js +++ b/src/all/background_page/controller/recover/abortAndRequestHelpController.js @@ -12,7 +12,7 @@ * @since 3.6.0 */ import SetupModel from "../../model/setup/setupModel"; - +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class AbortAndRequestHelp { /** @@ -20,12 +20,10 @@ class AbortAndRequestHelp { * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions The api client options. - * @param {AccountRecoverEntity} account The account being recovered. */ - constructor(worker, requestId, apiClientOptions, account) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; - this.account = account; this.recoverModel = new SetupModel(apiClientOptions); } @@ -48,7 +46,8 @@ class AbortAndRequestHelp { * @returns {Promise} */ async exec() { - await this.recoverModel.abortRecover(this.account); + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + await this.recoverModel.abortRecover(temporaryAccount.account); } } diff --git a/src/all/background_page/controller/recover/abortAndRequestHelpController.test.js b/src/all/background_page/controller/recover/abortAndRequestHelpController.test.js index 29373284..5617c25e 100644 --- a/src/all/background_page/controller/recover/abortAndRequestHelpController.test.js +++ b/src/all/background_page/controller/recover/abortAndRequestHelpController.test.js @@ -18,6 +18,7 @@ import {initialAccountRecoverDto} from "../../model/entity/account/accountRecove import AbortAndRequestHelp from "./abortAndRequestHelpController"; import {mockApiResponse} from "../../../../../test/mocks/mockApiResponse"; import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(() => { enableFetchMocks(); @@ -27,7 +28,8 @@ describe("AbortAndRequestHelpController", () => { describe("AbortAndRequestHelpController::exec", () => { it("Should request help to an administrator and abort the recover request.", async() => { const account = new AccountRecoverEntity(initialAccountRecoverDto()); - const controller = new AbortAndRequestHelp(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new AbortAndRequestHelp({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); // Mock the API response. const mockApiFetch = fetch.doMockOnceIf(new RegExp(`/setup/recover/abort/${account.userId}.json`), () => mockApiResponse()); @@ -38,5 +40,15 @@ describe("AbortAndRequestHelpController", () => { // Expect the API to have been called. expect(mockApiFetch).toHaveBeenCalled(); }); + + it("Should raise an error if no account has been found.", async() => { + const controller = new AbortAndRequestHelp({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/recover/completeRecoverController.js b/src/all/background_page/controller/recover/completeRecoverController.js index 9404ba36..c1ecd1cf 100644 --- a/src/all/background_page/controller/recover/completeRecoverController.js +++ b/src/all/background_page/controller/recover/completeRecoverController.js @@ -16,7 +16,7 @@ import AccountModel from "../../model/account/accountModel"; import SetupModel from "../../model/setup/setupModel"; import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; import AccountEntity from "../../model/entity/account/accountEntity"; - +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class CompleteRecoverController { /** @@ -24,12 +24,10 @@ class CompleteRecoverController { * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions The api client options. - * @param {AccountRecoverEntity} account The account being recovered. */ - constructor(worker, requestId, apiClientOptions, account) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; - this.account = account; this.accountModel = new AccountModel(apiClientOptions); this.setupModel = new SetupModel(apiClientOptions); } @@ -55,8 +53,9 @@ class CompleteRecoverController { * @returns {Promise} */ async exec() { - const accountRecovered = new AccountEntity(this.account.toDto(AccountRecoverEntity.ALL_CONTAIN_OPTIONS)); - await this.setupModel.completeRecover(this.account); + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + const accountRecovered = new AccountEntity(temporaryAccount.account.toDto(AccountRecoverEntity.ALL_CONTAIN_OPTIONS)); + await this.setupModel.completeRecover(temporaryAccount.account); await this.accountModel.add(accountRecovered); } } diff --git a/src/all/background_page/controller/recover/completeRecoverController.test.js b/src/all/background_page/controller/recover/completeRecoverController.test.js index 3952dad7..64118166 100644 --- a/src/all/background_page/controller/recover/completeRecoverController.test.js +++ b/src/all/background_page/controller/recover/completeRecoverController.test.js @@ -21,6 +21,7 @@ import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntit import User from "../../model/user"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import Keyring from "../../model/keyring"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; // Reset the modules before each test. beforeEach(() => { @@ -31,7 +32,8 @@ describe("CompleteRecoverController", () => { describe("CompleteRecoverController::exec", () => { it("Should complete the recover.", async() => { const account = new AccountRecoverEntity(withSecurityTokenAccountRecoverDto()); - const controller = new CompleteRecoverController(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new CompleteRecoverController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); // Mock API complete request. fetch.doMockOnce(() => mockApiResponse()); @@ -62,7 +64,8 @@ describe("CompleteRecoverController", () => { it("Should not add the account to the local storage if the complete API request fails.", async() => { const account = new AccountRecoverEntity(withSecurityTokenAccountRecoverDto()); - const controller = new CompleteRecoverController(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new CompleteRecoverController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); // Mock API complete request. fetch.doMockOnce(() => Promise.reject()); diff --git a/src/all/background_page/controller/recover/generateRecoverAccountRecoveryRequestKeyController.js b/src/all/background_page/controller/recover/generateRecoverAccountRecoveryRequestKeyController.js index f531bbf1..8e55e8bf 100644 --- a/src/all/background_page/controller/recover/generateRecoverAccountRecoveryRequestKeyController.js +++ b/src/all/background_page/controller/recover/generateRecoverAccountRecoveryRequestKeyController.js @@ -16,6 +16,8 @@ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import GetGpgKeyCreationDateService from "../../service/crypto/getGpgKeyCreationDateService"; import GenerateGpgKeyPairOptionsEntity from "../../model/entity/gpgkey/generate/generateGpgKeyPairOptionsEntity"; import GenerateGpgKeyPairService from "../../service/crypto/generateGpgKeyPairService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; const ACCOUNT_RECOVERY_REQUEST_KEY_SIZE = 4096; @@ -25,13 +27,11 @@ class GenerateRecoverAccountRecoveryRequestKeyController { * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions The api client options. - * @param {AccountRecoverEntity} account The account being recovered. */ - constructor(worker, requestId, apiClientOptions, account) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; this.apiClientOptions = apiClientOptions; - this.account = account; } /** @@ -55,9 +55,10 @@ class GenerateRecoverAccountRecoveryRequestKeyController { * @returns {Promise} */ async exec(generateGpgKeyPairDto) { + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); const dto = { name: 'Account recovery request key', - email: this.account?.username, + email: temporaryAccount.account?.username, passphrase: generateGpgKeyPairDto?.passphrase, keySize: ACCOUNT_RECOVERY_REQUEST_KEY_SIZE, date: await GetGpgKeyCreationDateService.getDate(this.apiClientOptions), @@ -65,9 +66,11 @@ class GenerateRecoverAccountRecoveryRequestKeyController { const generateGpgKeyPairOptionsEntity = new GenerateGpgKeyPairOptionsEntity(dto); const externalGpgKeyPair = await GenerateGpgKeyPairService.generateKeyPair(generateGpgKeyPairOptionsEntity); const generatedPublicKey = await OpenpgpAssertion.readKeyOrFail(externalGpgKeyPair.publicKey.armoredKey); - this.account.userPrivateArmoredKey = externalGpgKeyPair.privateKey.armoredKey; - this.account.userPublicArmoredKey = externalGpgKeyPair.publicKey.armoredKey; - this.account.userKeyFingerprint = generatedPublicKey.getFingerprint().toUpperCase(); + temporaryAccount.account.userPrivateArmoredKey = externalGpgKeyPair.privateKey.armoredKey; + temporaryAccount.account.userPublicArmoredKey = externalGpgKeyPair.publicKey.armoredKey; + temporaryAccount.account.userKeyFingerprint = generatedPublicKey.getFingerprint().toUpperCase(); + // Update all data in the temporary account stored + await AccountTemporarySessionStorageService.set(temporaryAccount); } } diff --git a/src/all/background_page/controller/recover/generateRecoverAccountRecoveryRequestKeyController.test.js b/src/all/background_page/controller/recover/generateRecoverAccountRecoveryRequestKeyController.test.js index 613c6ce1..404445fb 100644 --- a/src/all/background_page/controller/recover/generateRecoverAccountRecoveryRequestKeyController.test.js +++ b/src/all/background_page/controller/recover/generateRecoverAccountRecoveryRequestKeyController.test.js @@ -20,13 +20,15 @@ import { import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; import MockExtension from "../../../../../test/mocks/mockExtension"; import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; describe("GenerateRecoverAccountRecoveryRequestKeyController", () => { describe("GenerateRecoverAccountRecoveryRequestKeyController::exec", () => { it("Should assert provided generate key pair dto is valid.", async() => { await MockExtension.withConfiguredAccount(); const account = new AccountRecoverEntity(initialAccountRecoverDto()); - const controller = new GenerateRecoverAccountRecoveryRequestKeyController(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new GenerateRecoverAccountRecoveryRequestKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); expect.assertions(2); const promise = controller.exec(); @@ -37,7 +39,9 @@ describe("GenerateRecoverAccountRecoveryRequestKeyController", () => { it("Should generate a key pair.", async() => { await MockExtension.withConfiguredAccount(); const account = new AccountRecoverEntity(startAccountRecoverDto()); - const controller = new GenerateRecoverAccountRecoveryRequestKeyController(null, null, defaultApiClientOptions(), account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); + const controller = new GenerateRecoverAccountRecoveryRequestKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); expect.assertions(3); const generateKeyPairDto = { @@ -48,5 +52,15 @@ describe("GenerateRecoverAccountRecoveryRequestKeyController", () => { expect(account.userPrivateArmoredKey).not.toBeNull(); expect(account.userKeyFingerprint).not.toBeNull(); }, 20000); + + it("Should raise an error if no account has been found.", async() => { + const controller = new GenerateRecoverAccountRecoveryRequestKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/recover/hasRecoverUserEnabledAccountRecoveryController.js b/src/all/background_page/controller/recover/hasRecoverUserEnabledAccountRecoveryController.js index 1cd854e6..43c8c542 100644 --- a/src/all/background_page/controller/recover/hasRecoverUserEnabledAccountRecoveryController.js +++ b/src/all/background_page/controller/recover/hasRecoverUserEnabledAccountRecoveryController.js @@ -11,18 +11,17 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 3.6.0 */ +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class HasRecoverUserEnabledAccountRecoveryController { /** * GetRecoverLocaleController constructor. * @param {Worker} worker The worker the controller is executed on. * @param {string} requestId The associated request id. - * @param {AccountRecoverEntity} account The account being recovered. */ - constructor(worker, requestId, account) { + constructor(worker, requestId) { this.worker = worker; this.requestId = requestId; - this.account = account; } /** @@ -44,7 +43,8 @@ class HasRecoverUserEnabledAccountRecoveryController { * @returns {Promise} */ async exec() { - return this.account?.user?.accountRecoveryUserSetting?.isApproved === true; + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + return temporaryAccount.account?.user?.accountRecoveryUserSetting?.isApproved === true; } } diff --git a/src/all/background_page/controller/recover/hasRecoverUserEnabledAccountRecoveryController.test.js b/src/all/background_page/controller/recover/hasRecoverUserEnabledAccountRecoveryController.test.js index c1b2c619..4b74fa2a 100644 --- a/src/all/background_page/controller/recover/hasRecoverUserEnabledAccountRecoveryController.test.js +++ b/src/all/background_page/controller/recover/hasRecoverUserEnabledAccountRecoveryController.test.js @@ -18,12 +18,14 @@ import { } from "../../model/entity/account/accountRecoverEntity.test.data"; import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; import HasRecoverUserEnabledAccountRecoveryController from "./hasRecoverUserEnabledAccountRecoveryController"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; describe("HasRecoverUserEnabledAccountRecoveryController", () => { describe("HasRecoverUserEnabledAccountRecoveryController::exec", () => { it("Should return true if the user has approved the program", async() => { const account = new AccountRecoverEntity(startWithApprovedAccountRecoveryAccountRecoverDto()); - const controller = new HasRecoverUserEnabledAccountRecoveryController(null, null, account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new HasRecoverUserEnabledAccountRecoveryController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const hasApproved = await controller.exec(); @@ -32,11 +34,23 @@ describe("HasRecoverUserEnabledAccountRecoveryController", () => { it("Should return false if the user didn't subscribe to the program yet", async() => { const account = new AccountRecoverEntity(startAccountRecoverDto()); - const controller = new HasRecoverUserEnabledAccountRecoveryController(null, null, account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new HasRecoverUserEnabledAccountRecoveryController({port: {_port: {name: "test"}}}, null); expect.assertions(1); const hasApproved = await controller.exec(); expect(hasApproved).toStrictEqual(false); }); + + it("Should raise an error if no account has been found.", async() => { + const account = new AccountRecoverEntity(startAccountRecoverDto()); + const controller = new HasRecoverUserEnabledAccountRecoveryController({port: {_port: {name: "test"}}}, null, account); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js index c3993342..4b856bf4 100644 --- a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js +++ b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.js @@ -15,6 +15,8 @@ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import GpgKeyError from "../../error/GpgKeyError"; import i18n from "../../sdk/i18n"; import AuthVerifyServerChallengeService from "../../service/auth/authVerifyServerChallengeService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class ImportRecoverPrivateKeyController { /** @@ -22,12 +24,10 @@ class ImportRecoverPrivateKeyController { * @param {Worker} worker The associated worker. * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions the api client options - * @param {AccountRecoverEntity} account The account being recovered. */ - constructor(worker, requestId, apiClientOptions, account) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; - this.account = account; this.authVerifyServerChallengeService = new AuthVerifyServerChallengeService(apiClientOptions); } @@ -52,25 +52,28 @@ class ImportRecoverPrivateKeyController { * @returns {Promise} */ async exec(armoredKey) { + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); const privateKey = await OpenpgpAssertion.readKeyOrFail(armoredKey); OpenpgpAssertion.assertPrivateKey(privateKey); const fingerprint = privateKey.getFingerprint().toUpperCase(); - await this._assertImportKeyOwnedByUser(fingerprint); - this.account.userPrivateArmoredKey = privateKey.armor(); - this.account.userPublicArmoredKey = privateKey.toPublic().armor(); - this.account.userKeyFingerprint = fingerprint; + await this._assertImportKeyOwnedByUser(fingerprint, temporaryAccount.account.serverPublicArmoredKey); + temporaryAccount.account.userPrivateArmoredKey = privateKey.armor(); + temporaryAccount.account.userPublicArmoredKey = privateKey.toPublic().armor(); + temporaryAccount.account.userKeyFingerprint = fingerprint; + // Update all data in the temporary account stored + await AccountTemporarySessionStorageService.set(temporaryAccount); } /** * Assert import key is owned by the user doing the recover. * @todo for now the function only check that the key is recognized by the server. The API does offer yet a way to verify that a key is associated to a user id. * @param {string} fingerprint The import key fingerprint + * @param {string} serverPublicArmoredKey The server public armored key * @returns {Promise} * @throws {GpgKeyError} If the key is already used * @private */ - async _assertImportKeyOwnedByUser(fingerprint) { - const serverPublicArmoredKey = this.account.serverPublicArmoredKey; + async _assertImportKeyOwnedByUser(fingerprint, serverPublicArmoredKey) { if (!serverPublicArmoredKey) { throw new Error('The server public key should have been provided before importing a private key'); } diff --git a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.test.js b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.test.js index 42722661..1c779cad 100644 --- a/src/all/background_page/controller/recover/importRecoverPrivateKeyController.test.js +++ b/src/all/background_page/controller/recover/importRecoverPrivateKeyController.test.js @@ -23,6 +23,7 @@ import { import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(() => { jest.clearAllMocks(); @@ -43,7 +44,7 @@ describe("ImportRecoverPrivateKeyController", () => { describe("ImportRecoverPrivateKeyController::exec", () => { it("Should throw an exception if the passed DTO is not valid.", async() => { const account = new AccountRecoverEntity(withServerKeyAccountRecoverDto()); - const controller = new ImportRecoverPrivateKeyController(null, null, defaultApiClientOptions(), account); + const controller = new ImportRecoverPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); const scenarios = [ {dto: null, expectedError: Error}, @@ -62,6 +63,7 @@ describe("ImportRecoverPrivateKeyController", () => { for (let i = 0; i < scenarios.length; i++) { const scenario = scenarios[i]; try { + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); await controller.exec(scenario.dto); } catch (e) { expect(e).toBeInstanceOf(scenario.expectedError); @@ -72,7 +74,8 @@ describe("ImportRecoverPrivateKeyController", () => { it("Should throw an exception if the setupEntity is not initialized properly.", async() => { expect.assertions(1); const account = new AccountRecoverEntity(initialAccountRecoverDto()); - const controller = new ImportRecoverPrivateKeyController(null, null, defaultApiClientOptions(), account); + const controller = new ImportRecoverPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); try { await controller.exec(pgpKeys.ada.private); } catch (e) { @@ -85,7 +88,8 @@ describe("ImportRecoverPrivateKeyController", () => { await MockExtension.withConfiguredAccount(); const account = new AccountRecoverEntity(withServerKeyAccountRecoverDto()); - const controller = new ImportRecoverPrivateKeyController(null, null, defaultApiClientOptions(), account); + const controller = new ImportRecoverPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(() => { throw new Error("Error"); }); try { await controller.exec(pgpKeys.ada.private); @@ -100,8 +104,10 @@ describe("ImportRecoverPrivateKeyController", () => { const expectedKeyData = pgpKeys.ada; const account = new AccountRecoverEntity(withServerKeyAccountRecoverDto()); - const controller = new ImportRecoverPrivateKeyController(null, null, defaultApiClientOptions(), account); + const controller = new ImportRecoverPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); jest.spyOn(controller.authVerifyServerChallengeService, "verifyAndValidateServerChallenge").mockImplementationOnce(jest.fn()); await controller.exec(expectedKeyData.private); @@ -126,5 +132,15 @@ describe("ImportRecoverPrivateKeyController", () => { expect(controller.authVerifyServerChallengeService.verifyAndValidateServerChallenge).toHaveBeenCalledWith(expectedKeyData.fingerprint, account.serverPublicArmoredKey); expect(controller.authVerifyServerChallengeService.verifyAndValidateServerChallenge).toHaveBeenCalledTimes(1); }, 10000); + + it("Should raise an error if no account has been found.", async() => { + const controller = new ImportRecoverPrivateKeyController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions()); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/recover/requestAccountRecoveryController.js b/src/all/background_page/controller/recover/requestAccountRecoveryController.js index 4fee6dc3..3fb2c34b 100644 --- a/src/all/background_page/controller/recover/requestAccountRecoveryController.js +++ b/src/all/background_page/controller/recover/requestAccountRecoveryController.js @@ -19,7 +19,8 @@ import AccountRecoveryRequestService from "../../service/api/accountRecovery/acc import AccountRecoveryRequestCreateEntity from "../../model/entity/accountRecovery/accountRecoveryRequestCreateEntity"; import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; import AccountAccountRecoveryEntity from "../../model/entity/account/accountAccountRecoveryEntity"; - +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class RequestAccountRecoveryController { /** @@ -27,12 +28,10 @@ class RequestAccountRecoveryController { * @param {Worker} worker The associated worker. * @param {ApiClientOptions} apiClientOptions The api client options. * @param {string} requestId The associated request id. - * @param {AccountRecoverEntity} account The account being recovered. */ - constructor(worker, apiClientOptions, requestId, account) { + constructor(worker, apiClientOptions, requestId) { this.worker = worker; this.requestId = requestId; - this.account = account; this.accountModel = new AccountModel(apiClientOptions); this.accountRecoveryModel = new AccountRecoveryModel(apiClientOptions); this.accountRecoveryRequestService = new AccountRecoveryRequestService(apiClientOptions); @@ -57,17 +56,19 @@ class RequestAccountRecoveryController { * @returns {Promise} */ async exec() { - const accountRecoveryRequestDto = this.account.toAccountRecoveryRequestDto(); + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + const accountRecoveryRequestDto = temporaryAccount.account.toAccountRecoveryRequestDto(); const accountRecoverRequestCreate = new AccountRecoveryRequestCreateEntity(accountRecoveryRequestDto); const accountRecoveryRequest = await this.accountRecoveryRequestService.create(accountRecoverRequestCreate); - const accountAccountRecoveryDto = this.account.toDto(AccountRecoverEntity.ALL_CONTAIN_OPTIONS); + const accountAccountRecoveryDto = temporaryAccount.account.toDto(AccountRecoverEntity.ALL_CONTAIN_OPTIONS); accountAccountRecoveryDto.account_recovery_request_id = accountRecoveryRequest.id; const accountAccountRecovery = new AccountAccountRecoveryEntity(accountAccountRecoveryDto); // Delete any existing account recovery request temporary accounts, as the API will anyway cancel other on going requests. await AccountLocalStorage.deleteByUserIdAndType(accountAccountRecovery.userId, AccountAccountRecoveryEntity.TYPE_ACCOUNT_ACCOUNT_RECOVERY); await AccountLocalStorage.add(accountAccountRecovery); + await AccountTemporarySessionStorageService.remove(); } } diff --git a/src/all/background_page/controller/recover/requestAccountRecoveryController.test.js b/src/all/background_page/controller/recover/requestAccountRecoveryController.test.js index 72c4cbf1..73e538a7 100644 --- a/src/all/background_page/controller/recover/requestAccountRecoveryController.test.js +++ b/src/all/background_page/controller/recover/requestAccountRecoveryController.test.js @@ -21,6 +21,7 @@ import {pendingAccountRecoveryRequestDto} from "../../model/entity/accountRecove import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; import {withSecurityTokenAccountRecoverDto} from "../../model/entity/account/accountRecoverEntity.test.data"; import AccountAccountRecoveryEntity from "../../model/entity/account/accountAccountRecoveryEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(() => { enableFetchMocks(); @@ -30,7 +31,9 @@ describe("RequestAccountRecoveryController", () => { describe("RequestAccountRecoveryController::exec", () => { it("Should initiate an account recovery from the recover journey.", async() => { const account = new AccountRecoverEntity(withSecurityTokenAccountRecoverDto()); - const controller = new RequestAccountRecoveryController(null, defaultApiClientOptions(), null, account); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + jest.spyOn(AccountTemporarySessionStorageService, "remove"); + const controller = new RequestAccountRecoveryController({port: {_port: {name: "test"}}}, defaultApiClientOptions(), null); // Mock the API response. const mockApiResponseDto = pendingAccountRecoveryRequestDto(); @@ -38,7 +41,7 @@ describe("RequestAccountRecoveryController", () => { await controller.exec(); - expect.assertions(3); + expect.assertions(4); // Expect the API to have been called. expect(mockApiFetch).toHaveBeenCalled(); // Expect the temporary account created in the local storage. @@ -61,6 +64,17 @@ describe("RequestAccountRecoveryController", () => { account_recovery_request_id: mockApiResponseDto.id }; expect(accountForAccountRecovery).toEqual(expectedAccountDto); + expect(AccountTemporarySessionStorageService.remove).toHaveBeenCalledTimes(1); + }); + + it("Should raise an error if no account has been found.", async() => { + const controller = new RequestAccountRecoveryController({port: {_port: {name: "test"}}}, defaultApiClientOptions(), null); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } }); }); }); diff --git a/src/all/background_page/controller/recover/startRecoverController.js b/src/all/background_page/controller/recover/startRecoverController.js index f94268dc..fbb3bb32 100644 --- a/src/all/background_page/controller/recover/startRecoverController.js +++ b/src/all/background_page/controller/recover/startRecoverController.js @@ -17,6 +17,9 @@ import SetupModel from "../../model/setup/setupModel"; import AccountRecoveryUserSettingEntity from "../../model/entity/accountRecovery/accountRecoveryUserSettingEntity"; import WorkerService from "../../service/worker/workerService"; import AuthVerifyServerKeyService from "../../service/api/auth/authVerifyServerKeyService"; +import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; +import AccountTemporaryEntity from "../../model/entity/account/accountTemporaryEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; class StartRecoverController { /** @@ -25,15 +28,14 @@ class StartRecoverController { * @param {string} requestId The associated request id. * @param {ApiClientOptions} apiClientOptions The api client options. * @param {AccountRecoverEntity} account The account being recovered. - * @param {object} runtimeMemory the runtime memory that stores the data during the process */ - constructor(worker, requestId, apiClientOptions, account, runtimeMemory) { + constructor(worker, requestId, apiClientOptions, account) { this.worker = worker; this.requestId = requestId; this.account = account; this.authVerifyServerKeyService = new AuthVerifyServerKeyService(apiClientOptions); this.setupModel = new SetupModel(apiClientOptions); - this.runtimeMemory = runtimeMemory; + this.temporaryAccount = null; } /** @@ -56,15 +58,34 @@ class StartRecoverController { */ async exec() { try { + await this._buildTemporaryAccountEntity(); await this._findAndSetAccountServerPublicKey(); - const {user, userPassphrasePolicies} = await this.setupModel.startRecover(this.account.userId, this.account.authenticationTokenToken); - this.runtimeMemory.userPassphrasePolicies = userPassphrasePolicies; + const {user, userPassphrasePolicies} = await this.setupModel.startRecover(this.temporaryAccount.account.userId, this.temporaryAccount.account.authenticationTokenToken); + // Keep the user passphrase policies in the setup temporary account storage. + if (userPassphrasePolicies) { + this.temporaryAccount.userPassphrasePolicies = userPassphrasePolicies; + } this._setAccountUserMeta(user); + // Set all data in the temporary account stored + await AccountTemporarySessionStorageService.set(this.temporaryAccount); } catch (error) { await this._handleUnexpectedError(error); } } + /** + * Built the account temporary. + * @returns {Promise} + * @private + */ + async _buildTemporaryAccountEntity() { + const temporaryAccountDto = { + account: this.account.toDto(AccountRecoverEntity.ALL_CONTAIN_OPTIONS), + worker_id: this.worker.port._port.name + }; + this.temporaryAccount = new AccountTemporaryEntity(temporaryAccountDto); + } + /** * Find and set the account server public key. * @returns {Promise} @@ -75,7 +96,7 @@ class StartRecoverController { const serverKey = await OpenpgpAssertion.readKeyOrFail(serverKeyDto.armored_key); OpenpgpAssertion.assertPublicKey(serverKey); // associate the server public key to the current account. - this.account.serverPublicArmoredKey = serverKey.armor(); + this.temporaryAccount.account.serverPublicArmoredKey = serverKey.armor(); } /** @@ -85,17 +106,17 @@ class StartRecoverController { */ _setAccountUserMeta(user) { // Extract the user meta data and associate them to the current temporary account. - this.account.username = user?.username; - this.account.firstName = user?.profile?.firstName; - this.account.lastName = user?.profile?.lastName; + this.temporaryAccount.account.username = user?.username; + this.temporaryAccount.account.firstName = user?.profile?.firstName; + this.temporaryAccount.account.lastName = user?.profile?.lastName; if (user?.locale) { - this.account.locale = user.locale; + this.temporaryAccount.account.locale = user.locale; } if (user?.accountRecoveryUserSetting?.status === AccountRecoveryUserSettingEntity.STATUS_APPROVED) { - this.account.hasApprovedAccountRecoveryUserSetting = true; + this.temporaryAccount.account.hasApprovedAccountRecoveryUserSetting = true; } // As of v3.6.0 the user is stored only to know if account recovery was enabled for this account. - this.account.user = user; + this.temporaryAccount.account.user = user; } /** diff --git a/src/all/background_page/controller/recover/startRecoverController.test.js b/src/all/background_page/controller/recover/startRecoverController.test.js index a11e7f14..18873dad 100644 --- a/src/all/background_page/controller/recover/startRecoverController.test.js +++ b/src/all/background_page/controller/recover/startRecoverController.test.js @@ -26,6 +26,8 @@ import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import WorkerService from "../../service/worker/workerService"; import UserPassphrasePoliciesEntity from "passbolt-styleguide/src/shared/models/entity/userPassphrasePolicies/userPassphrasePoliciesEntity"; import {defaultUserPassphrasePoliciesEntityDto} from "passbolt-styleguide/src/shared/models/userPassphrasePolicies/UserPassphrasePoliciesDto.test.data"; +import {v4 as uuidv4} from "uuid"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; jest.mock("../../service/worker/workerService"); @@ -38,8 +40,8 @@ describe("StartRecoverController", () => { describe("StartRecoverController::exec", () => { it("Should initiate the recover process and retrieve the recover material", async() => { const account = new AccountRecoverEntity(initialAccountRecoverDto()); - const runtimeMemory = {}; - const controller = new StartRecoverController(null, null, defaultApiClientOptions(), account, runtimeMemory); + const workerId = uuidv4(); + const controller = new StartRecoverController({port: {_port: {name: workerId}}}, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockVerifyDto = defaultVerifyDto(); @@ -50,20 +52,21 @@ describe("StartRecoverController", () => { expect.assertions(5); await controller.exec(); + const expectedAccount = await AccountTemporarySessionStorageService.get(workerId); const key = await OpenpgpAssertion.readKeyOrFail(mockVerifyDto.keydata); - expect(account.serverPublicArmoredKey).toEqual((await GetGpgKeyInfoService.getKeyInfo(key)).armoredKey); - expect(account.username).toEqual(mockRecoverStartDto.user.username); - expect(account.firstName).toEqual(mockRecoverStartDto.user.profile.first_name); - expect(account.lastName).toEqual(mockRecoverStartDto.user.profile.last_name); - expect(account.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS)).toEqual(mockRecoverStartDto.user); + expect(expectedAccount.account.serverPublicArmoredKey).toEqual((await GetGpgKeyInfoService.getKeyInfo(key)).armoredKey); + expect(expectedAccount.account.username).toEqual(mockRecoverStartDto.user.username); + expect(expectedAccount.account.firstName).toEqual(mockRecoverStartDto.user.profile.first_name); + expect(expectedAccount.account.lastName).toEqual(mockRecoverStartDto.user.profile.last_name); + expect(expectedAccount.account.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS)).toEqual(mockRecoverStartDto.user); }, 10 * 1000); it("Should initiate the recover process and retrieve the recover material with all configuration", async() => { expect.assertions(6); const userPassphrasePoliciesDto = defaultUserPassphrasePoliciesEntityDto(); const account = new AccountRecoverEntity(initialAccountRecoverDto()); - const runtimeMemory = {}; - const controller = new StartRecoverController(null, null, defaultApiClientOptions(), account, runtimeMemory); + const workerId = uuidv4(); + const controller = new StartRecoverController({port: {_port: {name: workerId}}}, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockVerifyDto = defaultVerifyDto(); @@ -76,20 +79,21 @@ describe("StartRecoverController", () => { fetch.doMockOnce(() => mockApiResponse(mockRecoverStartDto)); await controller.exec(); + const expectedAccount = await AccountTemporarySessionStorageService.get(workerId); const key = await OpenpgpAssertion.readKeyOrFail(mockVerifyDto.keydata); - expect(account.serverPublicArmoredKey).toEqual((await GetGpgKeyInfoService.getKeyInfo(key)).armoredKey); - expect(account.username).toEqual(mockRecoverStartDto.user.username); - expect(account.firstName).toEqual(mockRecoverStartDto.user.profile.first_name); - expect(account.lastName).toEqual(mockRecoverStartDto.user.profile.last_name); - expect(account.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS)).toEqual(mockRecoverStartDto.user); - expect(runtimeMemory.userPassphrasePolicies).toStrictEqual(new UserPassphrasePoliciesEntity(userPassphrasePoliciesDto)); + expect(expectedAccount.account.serverPublicArmoredKey).toEqual((await GetGpgKeyInfoService.getKeyInfo(key)).armoredKey); + expect(expectedAccount.account.username).toEqual(mockRecoverStartDto.user.username); + expect(expectedAccount.account.firstName).toEqual(mockRecoverStartDto.user.profile.first_name); + expect(expectedAccount.account.lastName).toEqual(mockRecoverStartDto.user.profile.last_name); + expect(expectedAccount.account.user.toDto(UserEntity.ALL_CONTAIN_OPTIONS)).toEqual(mockRecoverStartDto.user); + expect(expectedAccount.userPassphrasePolicies).toStrictEqual(new UserPassphrasePoliciesEntity(userPassphrasePoliciesDto)); }, 10 * 1000); it("Should not initiate the recover if the API does not provide a valid server public key", async() => { - const mockedWorker = {tab: {id: "tabID"}}; + const workerId = uuidv4(); + const mockedWorker = {tab: {id: "tabID"}, port: {_port: {name: workerId}}}; const account = new AccountRecoverEntity(initialAccountRecoverDto()); - const runtimeMemory = {}; - const controller = new StartRecoverController(mockedWorker, null, defaultApiClientOptions(), account, runtimeMemory); + const controller = new StartRecoverController(mockedWorker, null, defaultApiClientOptions(), account); // Mock API fetch verify const mockVerifyDto = defaultVerifyDto({keydata: "not a valid key"}); @@ -109,10 +113,10 @@ describe("StartRecoverController", () => { }); it("Should not initiate the recover if the API does not provide a valid user", async() => { - const mockedWorker = {tab: {id: "tabID"}}; + const workerId = uuidv4(); + const mockedWorker = {tab: {id: "tabID"}, port: {_port: {name: workerId}}}; const account = new AccountRecoverEntity(initialAccountRecoverDto()); - const runtimeMemory = {}; - const controller = new StartRecoverController(mockedWorker, null, defaultApiClientOptions(), account, runtimeMemory); + const controller = new StartRecoverController(mockedWorker, null, defaultApiClientOptions(), account); // Mock API fetch organization settings const mockVerifyDto = defaultVerifyDto(); diff --git a/src/all/background_page/controller/setup/startSetupController.js b/src/all/background_page/controller/setup/startSetupController.js index 9050f515..8469009c 100644 --- a/src/all/background_page/controller/setup/startSetupController.js +++ b/src/all/background_page/controller/setup/startSetupController.js @@ -66,7 +66,7 @@ class StartSetupController { } /** - * Create or replace the account temporary in the session storage. + * Built the account temporary. * @returns {Promise} * @private */ diff --git a/src/all/background_page/event/recoverEvents.js b/src/all/background_page/event/recoverEvents.js index bf173f0a..4521bdb7 100644 --- a/src/all/background_page/event/recoverEvents.js +++ b/src/all/background_page/event/recoverEvents.js @@ -29,19 +29,11 @@ import IsExtensionFirstInstallController from "../controller/extension/isExtensi import IsLostPassphraseCaseController from "../controller/accountRecovery/isLostPassphraseCaseController"; import SetSetupSecurityTokenController from "../controller/setup/setSetupSecurityTokenController"; import HasRecoverUserEnabledAccountRecoveryController from "../controller/recover/hasRecoverUserEnabledAccountRecoveryController"; -import GeneratePortIdController from "../controller/port/generatePortIdController"; import GetUserPassphrasePoliciesController from "../controller/setup/getUserPassphrasePoliciesController"; import ReloadTabController from "../controller/tab/reloadTabController"; const listen = (worker, apiClientOptions, account) => { - /* - * The recover runtime memory. - * - * Used to store information collected during the user setup journey that shouldn't be stored on the react side of - * the application because of their confidentiality or for logic reason. By instance the passphrase of the user. - */ - const runtimeMemory = {}; worker.port.on('passbolt.recover.first-install', async requestId => { const controller = new IsExtensionFirstInstallController(worker, requestId); @@ -59,7 +51,7 @@ const listen = (worker, apiClientOptions, account) => { }); worker.port.on('passbolt.recover.start', async requestId => { - const controller = new StartRecoverController(worker, requestId, apiClientOptions, account, runtimeMemory); + const controller = new StartRecoverController(worker, requestId, apiClientOptions, account); await controller._exec(); }); @@ -74,42 +66,42 @@ const listen = (worker, apiClientOptions, account) => { }); worker.port.on('passbolt.recover.has-user-enabled-account-recovery', async requestId => { - const controller = new HasRecoverUserEnabledAccountRecoveryController(worker, requestId, account); + const controller = new HasRecoverUserEnabledAccountRecoveryController(worker, requestId); await controller._exec(); }); worker.port.on('passbolt.recover.import-key', async(requestId, armoredKey) => { - const controller = new ImportRecoverPrivateKeyController(worker, requestId, apiClientOptions, account); + const controller = new ImportRecoverPrivateKeyController(worker, requestId, apiClientOptions); await controller._exec(armoredKey); }); worker.port.on('passbolt.recover.verify-passphrase', async(requestId, passphrase) => { - const controller = new VerifyImportedKeyPassphraseController(worker, requestId, account, runtimeMemory); + const controller = new VerifyImportedKeyPassphraseController(worker, requestId); await controller._exec(passphrase); }); worker.port.on('passbolt.recover.set-security-token', async(requestId, securityTokenDto) => { - const controller = new SetSetupSecurityTokenController(worker, requestId, account); + const controller = new SetSetupSecurityTokenController(worker, requestId); await controller._exec(securityTokenDto); }); worker.port.on('passbolt.recover.complete', async requestId => { - const controller = new CompleteRecoverController(worker, requestId, apiClientOptions, account); + const controller = new CompleteRecoverController(worker, requestId, apiClientOptions); await controller._exec(); }); worker.port.on('passbolt.recover.sign-in', async(requestId, rememberMe) => { - const controller = new SignInSetupController(worker, requestId, apiClientOptions, account, runtimeMemory); + const controller = new SignInSetupController(worker, requestId, apiClientOptions); await controller._exec(rememberMe); }); worker.port.on('passbolt.recover.generate-account-recovery-request-key', async(requestId, generateGpgKeyPairDto) => { - const controller = new GenerateRecoverAccountRecoveryRequestKeyController(worker, requestId, apiClientOptions, account); + const controller = new GenerateRecoverAccountRecoveryRequestKeyController(worker, requestId, apiClientOptions); await controller._exec(generateGpgKeyPairDto); }); worker.port.on('passbolt.recover.initiate-account-recovery-request', async requestId => { - const controller = new RequestAccountRecoveryController(worker, apiClientOptions, requestId, account); + const controller = new RequestAccountRecoveryController(worker, apiClientOptions, requestId); await controller._exec(); }); @@ -124,17 +116,12 @@ const listen = (worker, apiClientOptions, account) => { }); worker.port.on('passbolt.recover.request-help-credentials-lost', async requestId => { - const controller = new AbortAndRequestHelp(worker, requestId, apiClientOptions, account); - await controller._exec(); - }); - - worker.port.on('passbolt.port.generate-id', async requestId => { - const controller = new GeneratePortIdController(worker, requestId); + const controller = new AbortAndRequestHelp(worker, requestId, apiClientOptions); await controller._exec(); }); worker.port.on('passbolt.recover.get-user-passphrase-policies', async requestId => { - const controller = new GetUserPassphrasePoliciesController(worker, requestId, runtimeMemory); + const controller = new GetUserPassphrasePoliciesController(worker, requestId); await controller._exec(); }); From 64adc10d077bbd39e86b4d72bbefe8c44735890c Mon Sep 17 00:00:00 2001 From: Crowdin Date: Wed, 24 Apr 2024 13:20:36 +0000 Subject: [PATCH 49/56] New translations messages.json (Korean) [skip-ci] --- src/all/_locales/ko/messages.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/all/_locales/ko/messages.json b/src/all/_locales/ko/messages.json index 217e7505..602d2ee3 100644 --- a/src/all/_locales/ko/messages.json +++ b/src/all/_locales/ko/messages.json @@ -4,7 +4,7 @@ "description": "The application name of the extension, displayed in the web store. 45 characters max." }, "appDescription": { - "message": "팀을 위한 오픈 소스 암호 관리자용 패스볼트 확장 프로그램", + "message": "팀을 위한 오픈 소스 암호 관리자 패스볼트 확장 프로그램", "description": "The description of the extension, displayed in the web store. 85 characters max." } } From 3ad690c6174b867db9f90ff05051cf96f35bfcb4 Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Fri, 19 Apr 2024 14:32:04 +0200 Subject: [PATCH 50/56] PB-33064 Use temporary account storage for account recovery process --- .../accountRecoveryLoginController.js | 147 ++++++++++++ .../accountRecoveryLoginController.test.js | 213 ++++++++++++++++++ .../continueAccountRecoveryController.js | 20 +- .../continueAccountRecoveryController.test.js | 13 +- .../recoverAccountController.js | 48 ++-- .../recoverAccountController.test.js | 48 +++- .../auth/authLoginController.test.js | 2 + .../event/accountRecoveryEvents.js | 8 +- .../background_page/event/recoverEvents.js | 1 - .../account/accountAccountRecoveryEntity.js | 4 +- src/all/locales/en-UK/common.json | 3 +- 11 files changed, 460 insertions(+), 47 deletions(-) create mode 100644 src/all/background_page/controller/accountRecovery/accountRecoveryLoginController.js create mode 100644 src/all/background_page/controller/accountRecovery/accountRecoveryLoginController.test.js diff --git a/src/all/background_page/controller/accountRecovery/accountRecoveryLoginController.js b/src/all/background_page/controller/accountRecovery/accountRecoveryLoginController.js new file mode 100644 index 00000000..73e02562 --- /dev/null +++ b/src/all/background_page/controller/accountRecovery/accountRecoveryLoginController.js @@ -0,0 +1,147 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 3.9.0 + */ +import UserAlreadyLoggedInError from "../../error/userAlreadyLoggedInError"; +import Keyring from "../../model/keyring"; +import CheckPassphraseService from "../../service/crypto/checkPassphraseService"; +import UpdateSsoCredentialsService from "../../service/account/updateSsoCredentialsService"; +import UserRememberMeLatestChoiceLocalStorage from "../../service/local_storage/userRememberMeLatestChoiceLocalStorage"; +import UserRememberMeLatestChoiceEntity from "../../model/entity/rememberMe/userRememberMeLatestChoiceEntity"; +import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; +import PostLoginService from "../../service/auth/postLoginService"; +import AuthVerifyLoginChallengeService from "../../service/auth/authVerifyLoginChallengeService"; +import KeepSessionAliveService from "../../service/session_storage/keepSessionAliveService"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import AccountLocalStorage from "../../service/local_storage/accountLocalStorage"; +import AccountAccountRecoveryEntity from "../../model/entity/account/accountAccountRecoveryEntity"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; + +class AccountRecoveryLoginController { + /** + * AccountRecoveryLoginController constructor + * @param {Worker} worker + * @param {string} requestId uuid + * @param {ApiClientOptions} apiClientOptions the api client options + * @param {AccountAccountRecoveryEntity} account The user account + */ + constructor(worker, requestId, apiClientOptions, account) { + this.worker = worker; + this.requestId = requestId; + this.authVerifyLoginChallengeService = new AuthVerifyLoginChallengeService(apiClientOptions); + this.updateSsoCredentialsService = new UpdateSsoCredentialsService(apiClientOptions); + this.checkPassphraseService = new CheckPassphraseService(new Keyring()); + this.userRememberMeLatestChoiceLocalStorage = new UserRememberMeLatestChoiceLocalStorage(account); + } + + /** + * Wrapper of exec function to run it with worker. + * + * @param {string} passphrase The passphrase to decryt the private key + * @param {boolean} remember whether to remember the passphrase or not + * @param {boolean} shouldRefreshCurrentTab should refresh the current tab + * @return {Promise} + */ + async _exec(passphrase, remember, shouldRefreshCurrentTab = false) { + try { + await this.exec(passphrase, remember, shouldRefreshCurrentTab); + this.worker.port.emit(this.requestId, 'SUCCESS'); + } catch (error) { + console.error(error); + this.worker.port.emit(this.requestId, 'ERROR', error); + } + } + + /** + * Attemps to sign in the current user. + * + * @param {string} passphrase The passphrase to decryt the private key + * @param {boolean} rememberMe whether to remember the passphrase + * @return {Promise} + */ + async exec(passphrase, rememberMe) { + const temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); + if (typeof passphrase === "undefined") { + throw new Error("A passphrase is required."); + } + if (typeof passphrase !== "string") { + throw new Error("The passphrase should be a string."); + } + if (typeof rememberMe !== "undefined" && typeof rememberMe !== "boolean") { + throw new Error("The rememberMe should be a boolean."); + } + + /* + * In order to generate the SSO kit, a call to the API is made to retrieve the SSO settings and ensure it's needed. + * But, for this call we must be logged out or fully logged in (with MFA). + * In the case when MFA is required, finding the SSO settings is blocked as MFA is demanded. + * So in order to proceed with the SSO kit and ensure to encrypt a working passphrase, we do a passphrase check first. + * Then we proceed with the SSO kit and afterward the login process. + */ + await this.checkPassphraseService.checkPassphrase(passphrase); + try { + await this.updateSsoCredentialsService.updateSsoKitIfNeeded(passphrase); + } catch (e) { + // If something goes wrong we just log the error and do not block the login + console.error(e); + } + + try { + await this.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge(temporaryAccount.account.userKeyFingerprint, temporaryAccount.account.userPrivateArmoredKey, passphrase); + /* + * Post login operations + * MFA may not be complete yet, so no need to preload things here + */ + if (rememberMe) { + await Promise.all([ + PassphraseStorageService.set(passphrase, -1), + KeepSessionAliveService.start(), + ]); + } + await PostLoginService.exec(); + await this.registerRememberMeOption(rememberMe); + } catch (error) { + if (!(error instanceof UserAlreadyLoggedInError)) { + throw error; + } + } + + + await this.redirectToApp(temporaryAccount.account.domain); + // Remove from the local storage the account recovery used to initiate/complete the account recovery. + await AccountLocalStorage.deleteByUserIdAndType(temporaryAccount.account.userId, AccountAccountRecoveryEntity.TYPE_ACCOUNT_ACCOUNT_RECOVERY); + // Remove account temporary when an account recovery process is finished + await AccountTemporarySessionStorageService.remove(); + } + + /** + * Redirect the user to the application + * @param {string} url The url + * @returns {Promise} + */ + async redirectToApp(url) { + browser.tabs.update(this.worker.tab.id, {url}); + } + + /** + * Handles the registration of the rememberMe choice from the user so it can be used next time. + * @param {boolean} rememberMe + * @returns {Promise} + */ + async registerRememberMeOption(rememberMe) { + const duration = rememberMe ? -1 : 0; + const userRememberMeLatestChoiceEntity = new UserRememberMeLatestChoiceEntity({duration}); + await this.userRememberMeLatestChoiceLocalStorage.set(userRememberMeLatestChoiceEntity); + } +} + +export default AccountRecoveryLoginController; diff --git a/src/all/background_page/controller/accountRecovery/accountRecoveryLoginController.test.js b/src/all/background_page/controller/accountRecovery/accountRecoveryLoginController.test.js new file mode 100644 index 00000000..6e11dc90 --- /dev/null +++ b/src/all/background_page/controller/accountRecovery/accountRecoveryLoginController.test.js @@ -0,0 +1,213 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) 2022 Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 3.9.0 + */ +import "../../../../../test/mocks/mockSsoDataStorage"; +import "../../../../../test/mocks/mockCryptoKey"; +import MockExtension from "../../../../../test/mocks/mockExtension"; +import AccountEntity from "../../model/entity/account/accountEntity"; +import {defaultAccountDto} from "../../model/entity/account/accountEntity.test.data"; +import {defaultApiClientOptions} from "passbolt-styleguide/src/shared/lib/apiClient/apiClientOptions.test.data"; +import SsoDataStorage from "../../service/indexedDB_storage/ssoDataStorage"; +import GenerateSsoKitService from "../../service/sso/generateSsoKitService"; +import AccountRecoveryLoginController from "./accountRecoveryLoginController"; +import {enableFetchMocks} from "jest-fetch-mock"; +import {anonymousOrganizationSettings} from "../../model/entity/organizationSettings/organizationSettingsEntity.test.data"; +import {mockApiResponse} from "../../../../../test/mocks/mockApiResponse"; +import {defaultEmptySettings, withAzureSsoSettings} from "../sso/getCurrentSsoSettingsController.test.data"; +import {clientSsoKit} from "../../model/entity/sso/ssoKitClientPart.test.data"; +import PostLoginService from "../../service/auth/postLoginService"; +import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; +import each from "jest-each"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import AccountLocalStorage from "../../service/local_storage/accountLocalStorage"; +import AccountAccountRecoveryEntity from "../../model/entity/account/accountAccountRecoveryEntity"; +import {defaultAccountAccountRecoveryDto} from "../../model/entity/account/accountAccountRecoveryEntity.test.data"; +import AccountRecoverEntity from "../../model/entity/account/accountRecoverEntity"; +import {withSecurityTokenAccountRecoverDto} from "../../model/entity/account/accountRecoverEntity.test.data"; + +beforeEach(async() => { + enableFetchMocks(); + jest.clearAllMocks(); + await MockExtension.withConfiguredAccount(); +}); + +describe("AccountRecoveryLoginController", () => { + describe("AccountRecoveryLoginController::exec", () => { + const passphrase = "ada@passbolt.com"; + const mockOrganisationSettings = (withSsoEnabled = true) => { + const organizationSettings = anonymousOrganizationSettings(); + organizationSettings.passbolt.plugins.sso = { + enabled: withSsoEnabled + }; + fetch.doMockOnceIf(new RegExp('/settings.json'), () => mockApiResponse(organizationSettings, {servertime: Date.now() / 1000})); + }; + + const mockOrganisationSettingsSsoSettings = ssoSettings => { + fetch.doMockOnceIf(new RegExp('/sso/settings/current.json'), () => mockApiResponse(ssoSettings)); + }; + + each([ + {scenario: 'remember me true', passphrase: passphrase, rememberMe: true}, + {scenario: 'remember me false', passphrase: passphrase, rememberMe: false}, + ]).describe("Should sign-in the user.", test => { + it(`Sign in with ${test.scenario}`, async() => { + mockOrganisationSettings(false); + + const account = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new AccountRecoveryLoginController({tab: {id: 1}, port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + jest.spyOn(PassphraseStorageService, "set"); + jest.spyOn(PostLoginService, "exec"); + jest.spyOn(browser.tabs, "update"); + jest.spyOn(AccountTemporarySessionStorageService, "remove"); + + expect.assertions(6); + + await controller.exec(test.passphrase, test.rememberMe); + expect(controller.authVerifyLoginChallengeService.verifyAndValidateLoginChallenge).toHaveBeenCalledWith(account.userKeyFingerprint, account.userPrivateArmoredKey, test.passphrase); + if (test.rememberMe) { + expect(PassphraseStorageService.set).toHaveBeenCalledWith(test.passphrase, -1); + } else { + expect(PassphraseStorageService.set).not.toHaveBeenCalled(); + } + + expect(browser.tabs.update).toHaveBeenCalledWith(1, {url: account.domain}); + expect(PostLoginService.exec).toHaveBeenCalledTimes(1); + expect(AccountTemporarySessionStorageService.remove).toHaveBeenCalledTimes(1); + // The account recovery should been removed from the account local storage. + expect(await AccountLocalStorage.get()).toHaveLength(0); + }); + }); + + it("Should throw an exception if the passphrase is not a valid.", async() => { + const account = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); + // Mock temporary account + const controller = new AccountRecoveryLoginController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + + expect.assertions(2); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const promiseMissingParameter = controller.exec(); + await expect(promiseMissingParameter).rejects.toThrowError("A passphrase is required."); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const promiseInvalidTypeParameter = controller.exec(2); + await expect(promiseInvalidTypeParameter).rejects.toThrowError("The passphrase should be a string."); + }, 10000); + + it("Should throw an exception if the provided remember me is not a valid boolean.", async() => { + const account = new AccountEntity(defaultAccountDto()); + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new AccountRecoveryLoginController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + + expect.assertions(1); + const promiseInvalidTypeParameter = controller.exec("passphrase", 42); + await expect(promiseInvalidTypeParameter).rejects.toThrowError("The rememberMe should be a boolean."); + }, 10000); + + it("Should sign-in the user and not generate an SSO kit if SSO organization settings is disabled.", async() => { + expect.assertions(1); + SsoDataStorage.setMockedData(null); + jest.spyOn(GenerateSsoKitService, "generate"); + mockOrganisationSettings(false); + + const account = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new AccountRecoveryLoginController({tab: {id: 1}, port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + + await controller.exec(passphrase, true); + expect(GenerateSsoKitService.generate).not.toHaveBeenCalled(); + }); + + it("Should sign-in the user and not generate an SSO kit if SSO organization settings is enabled and a kit already exists.", async() => { + expect.assertions(1); + SsoDataStorage.setMockedData(clientSsoKit()); + jest.spyOn(GenerateSsoKitService, "generate"); + mockOrganisationSettings(true); + mockOrganisationSettingsSsoSettings(withAzureSsoSettings()); + + const account = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new AccountRecoveryLoginController({tab: {id: 1}, port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + + await controller.exec(passphrase, true); + expect(GenerateSsoKitService.generate).not.toHaveBeenCalled(); + }); + + it("Should sign-in the user and generate an SSO kit if SSO organization settings is enabled and a kit is not available.", async() => { + expect.assertions(2); + SsoDataStorage.setMockedData(null); + const ssoSettingsDto = withAzureSsoSettings(); + jest.spyOn(GenerateSsoKitService, "generate"); + mockOrganisationSettings(true); + mockOrganisationSettingsSsoSettings(ssoSettingsDto); + + const account = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new AccountRecoveryLoginController({tab: {id: 1}, port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + + await controller.exec(passphrase, true); + expect(GenerateSsoKitService.generate).toHaveBeenCalledTimes(1); + expect(GenerateSsoKitService.generate).toHaveBeenCalledWith(passphrase, ssoSettingsDto.provider); + }); + + it("Should sign-in the user and flush SSO kit data if a kit is available locally and the SSO is not configured for the organisation.", async() => { + expect.assertions(1); + SsoDataStorage.setMockedData(clientSsoKit()); + mockOrganisationSettings(true); + mockOrganisationSettingsSsoSettings(defaultEmptySettings()); + + const account = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new AccountRecoveryLoginController({tab: {id: 1}, port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + + await controller.exec(passphrase, true); + expect(SsoDataStorage.flush).toHaveBeenCalledTimes(1); + }); + + it("Should sign-in the user and flush SSO kit data if a kit is available locally and the organization settings is disabled.", async() => { + expect.assertions(1); + SsoDataStorage.setMockedData(clientSsoKit()); + mockOrganisationSettings(false); + + const account = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: account})); + const controller = new AccountRecoveryLoginController({tab: {id: 1}, port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + jest.spyOn(controller.authVerifyLoginChallengeService, "verifyAndValidateLoginChallenge").mockImplementationOnce(jest.fn()); + + await controller.exec(passphrase, true); + expect(SsoDataStorage.flush).toHaveBeenCalledTimes(1); + }); + + it("Should raise an error if no account has been found.", async() => { + const account = new AccountRecoverEntity(withSecurityTokenAccountRecoverDto()); + const controller = new AccountRecoveryLoginController({port: {_port: {name: "test"}}}, null, defaultApiClientOptions(), account); + expect.assertions(1); + try { + await controller.exec(passphrase, true); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); + }); +}); diff --git a/src/all/background_page/controller/accountRecovery/continueAccountRecoveryController.js b/src/all/background_page/controller/accountRecovery/continueAccountRecoveryController.js index fb38a2fc..de307d72 100644 --- a/src/all/background_page/controller/accountRecovery/continueAccountRecoveryController.js +++ b/src/all/background_page/controller/accountRecovery/continueAccountRecoveryController.js @@ -14,6 +14,9 @@ import AccountRecoveryModel from "../../model/accountRecovery/accountRecoveryModel"; import WorkerService from "../../service/worker/workerService"; +import AccountAccountRecoveryEntity from "../../model/entity/account/accountAccountRecoveryEntity"; +import AccountTemporaryEntity from "../../model/entity/account/accountTemporaryEntity"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; class ContinueAccountRecoveryController { /** @@ -52,7 +55,9 @@ class ContinueAccountRecoveryController { */ async exec() { try { - await this.accountRecoveryModel.continue(this.account.userId, this.account.authenticationTokenToken); + const accountTemporary = await this._buildTemporaryAccountEntity(); + await this.accountRecoveryModel.continue(accountTemporary.account.userId, accountTemporary.account.authenticationTokenToken); + await AccountTemporarySessionStorageService.set(accountTemporary); } catch (error) { /* * Something went wrong. @@ -63,6 +68,19 @@ class ContinueAccountRecoveryController { throw error; } } + + /** + * Build the account temporary. + * @returns {Promise} + * @private + */ + async _buildTemporaryAccountEntity() { + const accountTemporaryDto = { + account: this.account.toDto(AccountAccountRecoveryEntity.ALL_CONTAIN_OPTIONS), + worker_id: this.worker.port._port.name + }; + return new AccountTemporaryEntity(accountTemporaryDto); + } } export default ContinueAccountRecoveryController; diff --git a/src/all/background_page/controller/accountRecovery/continueAccountRecoveryController.test.js b/src/all/background_page/controller/accountRecovery/continueAccountRecoveryController.test.js index dab414f8..e1a8249f 100644 --- a/src/all/background_page/controller/accountRecovery/continueAccountRecoveryController.test.js +++ b/src/all/background_page/controller/accountRecovery/continueAccountRecoveryController.test.js @@ -19,6 +19,8 @@ import ContinueAccountRecoveryController from "./continueAccountRecoveryControll import AccountAccountRecoveryEntity from "../../model/entity/account/accountAccountRecoveryEntity"; import {defaultAccountAccountRecoveryDto} from "../../model/entity/account/accountAccountRecoveryEntity.test.data"; import WorkerService from "../../service/worker/workerService"; +import {v4 as uuidv4} from "uuid"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(() => { enableFetchMocks(); @@ -27,21 +29,26 @@ beforeEach(() => { describe("ContinueAccountRecoveryController", () => { describe("ContinueAccountRecoveryController::exec", () => { it("Should continue the account recovery if the user can continue.", async() => { + const workerId = uuidv4(); + const mockedWorker = {tab: {id: "tabID"}, port: {_port: {name: workerId}}}; const accountRecovery = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); // Mock API fetch account recovery requests response. const url = new RegExp(`/account-recovery/continue/${accountRecovery.userId}/${accountRecovery.authenticationTokenToken}.json`); fetch.doMockIf(url, () => mockApiResponse()); + jest.spyOn(AccountTemporarySessionStorageService, "set"); - const controller = new ContinueAccountRecoveryController(null, null, defaultApiClientOptions(), accountRecovery); + const controller = new ContinueAccountRecoveryController(mockedWorker, null, defaultApiClientOptions(), accountRecovery); const promise = controller.exec(); - expect.assertions(1); + expect.assertions(2); await expect(promise).resolves.not.toThrow(); + await expect(AccountTemporarySessionStorageService.set).toHaveBeenCalledTimes(1); }); it("Should not continue the account recovery if the API return an error.", async() => { - const mockedWorker = {tab: {id: "tabID"}}; + const workerId = uuidv4(); + const mockedWorker = {tab: {id: "tabID"}, port: {_port: {name: workerId}}}; const accountRecovery = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); // Mock API fetch account recovery requests response. diff --git a/src/all/background_page/controller/accountRecovery/recoverAccountController.js b/src/all/background_page/controller/accountRecovery/recoverAccountController.js index 7924b90f..722b4f77 100644 --- a/src/all/background_page/controller/accountRecovery/recoverAccountController.js +++ b/src/all/background_page/controller/accountRecovery/recoverAccountController.js @@ -18,13 +18,14 @@ import AccountRecoveryModel from "../../model/accountRecovery/accountRecoveryMod import DecryptPrivateKeyService from "../../service/crypto/decryptPrivateKeyService"; import EncryptPrivateKeyService from "../../service/crypto/encryptPrivateKeyService"; import AccountModel from "../../model/account/accountModel"; -import AccountLocalStorage from "../../service/local_storage/accountLocalStorage"; import SetupModel from "../../model/setup/setupModel"; import DecryptResponseDataService from "../../service/accountRecovery/decryptResponseDataService"; import AccountAccountRecoveryEntity from "../../model/entity/account/accountAccountRecoveryEntity"; import AccountEntity from "../../model/entity/account/accountEntity"; import UpdateSsoCredentialsService from "../../service/account/updateSsoCredentialsService"; import SsoDataStorage from "../../service/indexedDB_storage/ssoDataStorage"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; +import FindAccountTemporaryService from "../../service/account/findAccountTemporaryService"; class RecoverAccountController { /** @@ -32,16 +33,16 @@ class RecoverAccountController { * @param {Worker} worker * @param {string} requestId uuid * @param {ApiClientOptions} apiClientOptions - * @param {AccountAccountRecoveryEntity} account The account completing the account recovery. */ - constructor(worker, requestId, apiClientOptions, account) { + constructor(worker, requestId, apiClientOptions) { this.worker = worker; this.requestId = requestId; - this.account = account; this.accountRecoveryModel = new AccountRecoveryModel(apiClientOptions); this.setupModel = new SetupModel(apiClientOptions); this.accountModel = new AccountModel(apiClientOptions); this.updateSsoCredentialsService = new UpdateSsoCredentialsService(apiClientOptions); + // The temporary account stored in the session storage + this.temporaryAccount = null; } /** @@ -65,6 +66,7 @@ class RecoverAccountController { * @return {Promise} */ async exec(passphrase) { + this.temporaryAccount = await FindAccountTemporaryService.exec(this.worker.port._port.name); if (typeof passphrase === "undefined") { throw new Error("A passphrase is required."); } @@ -72,16 +74,19 @@ class RecoverAccountController { throw new Error("The passphrase should be a string."); } - const request = await this._findAndAssertRequest(); + const request = await this._findAndAssertRequest(this.temporaryAccount.account); const recoveredArmoredPrivateKey = await this._recoverPrivateKey(request.accountRecoveryPrivateKey, request.accountRecoveryResponses.items[0], passphrase); await this._completeRecover(recoveredArmoredPrivateKey); - const account = await this._addRecoveredAccountToStorage(this.account); + const account = await this._addRecoveredAccountToStorage(this.temporaryAccount.account); this._updateWorkerAccount(account); await this._refreshSsoKit(passphrase); + // Update all data in the temporary account stored + await AccountTemporarySessionStorageService.set(this.temporaryAccount); } /** * Find the account recovery request. + * @param {AccountAccountRecoveryEntity} account The account * @return {Promise} * @throw {Error} If the request id does not match the request id associated to the account recovery material stored on the extension * @throw {Error} If the request does not have a private key defined @@ -89,14 +94,14 @@ class RecoverAccountController { * @throw {Error} If the request responses is empty * @private */ - async _findAndAssertRequest() { + async _findAndAssertRequest(account) { const accountRecoveryRequest = await this.accountRecoveryModel.findRequestByIdAndUserIdAndAuthenticationToken( - this.account.accountRecoveryRequestId, - this.account.userId, - this.account.authenticationTokenToken + account.accountRecoveryRequestId, + account.userId, + account.authenticationTokenToken ); - if (accountRecoveryRequest.id !== this.account.accountRecoveryRequestId) { + if (accountRecoveryRequest.id !== account.accountRecoveryRequestId) { throw new Error("The account recovery request id should match the request id associated to the account being recovered."); } @@ -124,13 +129,13 @@ class RecoverAccountController { * @private */ async _recoverPrivateKey(privateKey, response, passphrase) { - const key = await OpenpgpAssertion.readKeyOrFail(this.account.userPrivateArmoredKey); + const key = await OpenpgpAssertion.readKeyOrFail(this.temporaryAccount.account.userPrivateArmoredKey); const requestPrivateKeyDecrypted = await DecryptPrivateKeyService.decrypt(key, passphrase); /* * @todo Additional check could be done to ensure the recovered key is the same than the one the user was previously using. * If the user is in the case lost passphrase, a key should still be referenced in the storage of the extension. */ - const privateKeyPasswordDecryptedData = await DecryptResponseDataService.decrypt(response, requestPrivateKeyDecrypted, this.account.userId, this.account.domain); + const privateKeyPasswordDecryptedData = await DecryptResponseDataService.decrypt(response, requestPrivateKeyDecrypted, this.temporaryAccount.account.userId, this.temporaryAccount.account.domain); const privateKeyData = await OpenpgpAssertion.readMessageOrFail(privateKey.data); const decryptedRecoveredPrivateArmoredKey = await DecryptMessageService.decryptSymmetrically(privateKeyData, privateKeyPasswordDecryptedData.privateKeySecret); const decryptedRecoveredPrivateKey = await OpenpgpAssertion.readKeyOrFail(decryptedRecoveredPrivateArmoredKey); @@ -145,10 +150,10 @@ class RecoverAccountController { */ async _completeRecover(recoveredPrivateKey) { OpenpgpAssertion.assertPrivateKey(recoveredPrivateKey); - this.account.userPrivateArmoredKey = recoveredPrivateKey.armor(); - this.account.userPublicArmoredKey = recoveredPrivateKey.toPublic().armor(); - this.account.userKeyFingerprint = recoveredPrivateKey.getFingerprint().toUpperCase(); - await this.setupModel.completeRecover(this.account); + this.temporaryAccount.account.userPrivateArmoredKey = recoveredPrivateKey.armor(); + this.temporaryAccount.account.userPublicArmoredKey = recoveredPrivateKey.toPublic().armor(); + this.temporaryAccount.account.userKeyFingerprint = recoveredPrivateKey.getFingerprint().toUpperCase(); + await this.setupModel.completeRecover(this.temporaryAccount.account); } /** @@ -160,9 +165,6 @@ class RecoverAccountController { async _addRecoveredAccountToStorage(accountAccountRecovery) { const account = new AccountEntity(accountAccountRecovery.toDto(AccountAccountRecoveryEntity.ALL_CONTAIN_OPTIONS)); await this.accountModel.add(account); - // Remove from the local storage the account recovery used to initiate/complete the account recovery. - await AccountLocalStorage.deleteByUserIdAndType(account.userId, AccountAccountRecoveryEntity.TYPE_ACCOUNT_ACCOUNT_RECOVERY); - return account; } @@ -177,9 +179,9 @@ class RecoverAccountController { * @private */ _updateWorkerAccount(account) { - this.account.userPublicArmoredKey = account.userPublicArmoredKey; - this.account.userPrivateArmoredKey = account.userPrivateArmoredKey; - this.account.userKeyFingerprint = account.userKeyFingerprint; + this.temporaryAccount.account.userPublicArmoredKey = account.userPublicArmoredKey; + this.temporaryAccount.account.userPrivateArmoredKey = account.userPrivateArmoredKey; + this.temporaryAccount.account.userKeyFingerprint = account.userKeyFingerprint; } /** diff --git a/src/all/background_page/controller/accountRecovery/recoverAccountController.test.js b/src/all/background_page/controller/accountRecovery/recoverAccountController.test.js index 4c835e12..c43cb498 100644 --- a/src/all/background_page/controller/accountRecovery/recoverAccountController.test.js +++ b/src/all/background_page/controller/accountRecovery/recoverAccountController.test.js @@ -28,12 +28,12 @@ import { approvedAccountRecoveryRequestWithoutPrivateKeyDto, approvedAccountRecoveryRequestWithoutResponsesDto } from "../../model/entity/accountRecovery/accountRecoveryRequestEntity.test.data"; -import AccountLocalStorage from "../../service/local_storage/accountLocalStorage"; import InvalidMasterPasswordError from "../../error/invalidMasterPasswordError"; import {OpenpgpAssertion} from "../../utils/openpgp/openpgpAssertions"; import SsoDataStorage from "../../service/indexedDB_storage/ssoDataStorage"; import GenerateSsoKitService from "../../service/sso/generateSsoKitService"; import {anonymousOrganizationSettings} from "../../model/entity/organizationSettings/organizationSettingsEntity.test.data"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(() => { enableFetchMocks(); @@ -62,11 +62,14 @@ describe("RecoverAccountController", () => { fetch.doMockOnce(() => mockApiResponse()); // Mock API organisation settings mockOrganisationSettings(); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: accountRecovery})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); - const controller = new RecoverAccountController(null, null, apiClientOptions, accountRecovery); + const controller = new RecoverAccountController({port: {_port: {name: "test"}}}, null, apiClientOptions); await controller.exec(passphrase); - expect.assertions(10); + expect.assertions(9); // The user account should have been configured (legacy). const user = User.getInstance().get(); @@ -87,9 +90,6 @@ describe("RecoverAccountController", () => { expect(keyringPrivateKeyFingerprint).toStrictEqual(pgpKeys.ada.fingerprint); expect(userPublicKeyFingerprint).toStrictEqual(pgpKeys.ada.fingerprint); expect(userPublicKeyFingerprint).toStrictEqual(keyringPrivateKeyFingerprint); - - // The account recovery should been removed from the account local storage. - expect(await AccountLocalStorage.get()).toHaveLength(0); }); each([ @@ -97,7 +97,9 @@ describe("RecoverAccountController", () => { {expectedError: "The passphrase should be a string.", passphrase: 42} ]).describe("Should assert the signed-in user passphrase parameter.", scenario => { it(`Should validate the scenario: ${scenario.expectedError}`, async() => { - const controller = new RecoverAccountController(null, null, apiClientOptions, accountRecovery); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: accountRecovery})); + const controller = new RecoverAccountController({port: {_port: {name: "test"}}}, null, apiClientOptions); const promise = controller.exec(scenario.passphrase); expect.assertions(1); await expect(promise).rejects.toThrowError(scenario.expectedError); @@ -113,8 +115,10 @@ describe("RecoverAccountController", () => { it(`Should validate the scenario: ${scenario.expectedError}`, async() => { // Mock API fetch account recovery request get response. fetch.doMockOnce(() => mockApiResponse(scenario.findRequestMock)); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: accountRecovery})); - const controller = new RecoverAccountController(null, null, apiClientOptions, accountRecovery); + const controller = new RecoverAccountController({port: {_port: {name: "test"}}}, null, apiClientOptions); const promise = controller.exec(passphrase); expect.assertions(1); await expect(promise).rejects.toThrowError(scenario.expectedError); @@ -124,8 +128,10 @@ describe("RecoverAccountController", () => { it("Should assert the account recovery user private key can be decrypted.", async() => { // Mock API fetch account recovery request get response. fetch.doMockOnce(() => mockApiResponse(accountRecoveryRequestDto)); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: accountRecovery})); - const controller = new RecoverAccountController(null, null, apiClientOptions, accountRecovery); + const controller = new RecoverAccountController({port: {_port: {name: "test"}}}, null, apiClientOptions); const promise = controller.exec("wrong passphrase"); expect.assertions(1); await expect(promise).rejects.toThrowError(InvalidMasterPasswordError); @@ -134,13 +140,15 @@ describe("RecoverAccountController", () => { it("Should not add the account to the local storage if the complete API request fails.", async() => { const accountRecovery = new AccountAccountRecoveryEntity(defaultAccountAccountRecoveryDto()); const accountRecoveryRequestDto = approvedAccountRecoveryRequestDto({id: accountRecovery.accountRecoveryRequestId}); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: accountRecovery})); // Mock API fetch account recovery request get response. fetch.doMockOnce(() => mockApiResponse(accountRecoveryRequestDto)); // Mock API complete request. fetch.doMockOnce(() => Promise.reject(new Error("Unable to reach the server, an unexpected error occurred"))); - const controller = new RecoverAccountController(null, null, apiClientOptions, accountRecovery); + const controller = new RecoverAccountController({port: {_port: {name: "test"}}}, null, apiClientOptions); const promise = controller.exec(passphrase); expect.assertions(2); @@ -167,10 +175,13 @@ describe("RecoverAccountController", () => { mockOrganisationSettings(true); // Mock configured SSO settings fetch.doMockOnce(() => mockApiResponse({provider: expetedProvider})); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: accountRecovery})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); jest.spyOn(GenerateSsoKitService, "generate"); - const controller = new RecoverAccountController(null, null, apiClientOptions, accountRecovery); + const controller = new RecoverAccountController({port: {_port: {name: "test"}}}, null, apiClientOptions); await controller.exec(passphrase); expect(SsoDataStorage.flush).toHaveBeenCalled(); @@ -189,10 +200,13 @@ describe("RecoverAccountController", () => { fetch.doMockOnce(() => mockApiResponse()); // Mock API organisation settings mockOrganisationSettings(); + // Mock temporary account + jest.spyOn(AccountTemporarySessionStorageService, "get").mockImplementationOnce(() => ({account: accountRecovery})); + jest.spyOn(AccountTemporarySessionStorageService, "set").mockImplementationOnce(() => jest.fn()); jest.spyOn(GenerateSsoKitService, "generate"); - const controller = new RecoverAccountController(null, null, apiClientOptions, accountRecovery); + const controller = new RecoverAccountController({port: {_port: {name: "test"}}}, null, apiClientOptions); await controller.exec(passphrase); expect.assertions(2); @@ -200,5 +214,15 @@ describe("RecoverAccountController", () => { expect(SsoDataStorage.flush).toHaveBeenCalled(); expect(GenerateSsoKitService.generate).not.toHaveBeenCalled(); }); + + it("Should raise an error if no account has been found.", async() => { + const controller = new RecoverAccountController({port: {_port: {name: "test"}}}, null, apiClientOptions); + expect.assertions(1); + try { + await controller.exec(); + } catch (error) { + expect(error.message).toEqual("You have already started the process on another tab."); + } + }); }); }); diff --git a/src/all/background_page/controller/auth/authLoginController.test.js b/src/all/background_page/controller/auth/authLoginController.test.js index 6ecee3a6..871a31e6 100644 --- a/src/all/background_page/controller/auth/authLoginController.test.js +++ b/src/all/background_page/controller/auth/authLoginController.test.js @@ -28,6 +28,7 @@ import {clientSsoKit} from "../../model/entity/sso/ssoKitClientPart.test.data"; import PostLoginService from "../../service/auth/postLoginService"; import PassphraseStorageService from "../../service/session_storage/passphraseStorageService"; import each from "jest-each"; +import AccountTemporarySessionStorageService from "../../service/sessionStorage/accountTemporarySessionStorageService"; beforeEach(async() => { enableFetchMocks(); @@ -66,6 +67,7 @@ describe("AuthLoginController", () => { jest.spyOn(PassphraseStorageService, "set"); jest.spyOn(PostLoginService, "exec"); jest.spyOn(browser.tabs, "update"); + jest.spyOn(AccountTemporarySessionStorageService, "remove"); expect.assertions(4); diff --git a/src/all/background_page/event/accountRecoveryEvents.js b/src/all/background_page/event/accountRecoveryEvents.js index c9e3716a..a1af9992 100644 --- a/src/all/background_page/event/accountRecoveryEvents.js +++ b/src/all/background_page/event/accountRecoveryEvents.js @@ -21,7 +21,7 @@ import GetOrganizationSettingsController from "../controller/organizationSetting import GetAndInitializeAccountLocaleController from "../controller/account/getAndInitializeAccountLocaleController"; import GetExtensionVersionController from "../controller/extension/getExtensionVersionController"; import GetAccountController from "../controller/account/getAccountController"; -import AuthLoginController from "../controller/auth/authLoginController"; +import AccountRecoveryLoginController from "../controller/accountRecovery/accountRecoveryLoginController"; import ReloadTabController from "../controller/tab/reloadTabController"; /** @@ -62,13 +62,13 @@ const listen = function(worker, apiClientOptions, account) { }); worker.port.on('passbolt.account-recovery.recover-account', async(requestId, passphrase) => { - const controller = new RecoverAccountController(worker, requestId, apiClientOptions, account); + const controller = new RecoverAccountController(worker, requestId, apiClientOptions); await controller._exec(passphrase); }); worker.port.on('passbolt.account-recovery.sign-in', async(requestId, passphrase, rememberMe) => { - const controller = new AuthLoginController(worker, requestId, apiClientOptions, account); - await controller._exec(passphrase, rememberMe, true); + const controller = new AccountRecoveryLoginController(worker, requestId, apiClientOptions, account); + await controller._exec(passphrase, rememberMe); }); worker.port.on('passbolt.account-recovery.request-help-credentials-lost', async requestId => { diff --git a/src/all/background_page/event/recoverEvents.js b/src/all/background_page/event/recoverEvents.js index 4521bdb7..0f5bb287 100644 --- a/src/all/background_page/event/recoverEvents.js +++ b/src/all/background_page/event/recoverEvents.js @@ -34,7 +34,6 @@ import ReloadTabController from "../controller/tab/reloadTabController"; const listen = (worker, apiClientOptions, account) => { - worker.port.on('passbolt.recover.first-install', async requestId => { const controller = new IsExtensionFirstInstallController(worker, requestId); await controller._exec(); diff --git a/src/all/background_page/model/entity/account/accountAccountRecoveryEntity.js b/src/all/background_page/model/entity/account/accountAccountRecoveryEntity.js index 4adb62ed..f13f617c 100644 --- a/src/all/background_page/model/entity/account/accountAccountRecoveryEntity.js +++ b/src/all/background_page/model/entity/account/accountAccountRecoveryEntity.js @@ -137,8 +137,8 @@ class AccountAccountRecoveryEntity extends AbstractAccountEntity { if (contains.account_recovery_request_id) { result.account_recovery_request_id = this.accountRecoveryRequestId; } - if (contains.security_token && this._security_token) { - result.security_token = this._security_token.toDto(); + if (contains.security_token && this.securityToken) { + result.security_token = this.securityToken.toDto(); } if (contains.account_recovery_request && this.accountRecoveryRequest) { result.account_recovery_request = this._account_recovery_request.toDto(AccountRecoveryRequestEntity.ALL_CONTAIN_OPTIONS); diff --git a/src/all/locales/en-UK/common.json b/src/all/locales/en-UK/common.json index b916ed70..81ad1d0b 100644 --- a/src/all/locales/en-UK/common.json +++ b/src/all/locales/en-UK/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Updating group ...", "Updating password": "Updating password", "Updating resource": "Updating resource", - "Updating users' key...": "Updating users' key..." + "Updating users' key...": "Updating users' key...", + "You have already started the process on another tab.": "You have already started the process on another tab." } From 28004033d9d65d065edf164531ec109106481b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Loegel?= Date: Wed, 24 Apr 2024 13:50:36 +0000 Subject: [PATCH 51/56] PB-22623: add polling mechanism to ensure SW doesn't go to sleep --- src/chrome-mv3/offscreens/fetch.js | 1 - .../service/network/fetchOffscreenService.js | 71 ++++- .../fetchOffscreenService.test.data.js | 133 +++++++++ .../network/fetchOffscreenService.test.js | 282 ++++++++++++++++++ .../requestFetchOffscreenService.test.js | 1 - .../network/responseFetchOffscreenService.js | 9 +- .../responseFetchOffscreenService.test.js | 2 +- test/mocks/mockAlarms.js | 2 +- 8 files changed, 494 insertions(+), 7 deletions(-) create mode 100644 src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.data.js create mode 100644 src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.js diff --git a/src/chrome-mv3/offscreens/fetch.js b/src/chrome-mv3/offscreens/fetch.js index d9c044fd..be7c63b6 100644 --- a/src/chrome-mv3/offscreens/fetch.js +++ b/src/chrome-mv3/offscreens/fetch.js @@ -12,7 +12,6 @@ * @since 4.7.0 */ - import FetchOffscreenService from "./service/network/fetchOffscreenService"; chrome.runtime.onMessage.addListener(FetchOffscreenService.handleFetchRequest); diff --git a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js index 58771108..246f9c90 100644 --- a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js +++ b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js @@ -16,10 +16,28 @@ import Validator from "validator"; export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN = "fetch-offscreen"; export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER = "service-worker-fetch-offscreen-response-handler"; +export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_POLLING_HANDLER = "service-worker-fetch-offscreen-polling-handler"; export const FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS = "success"; export const FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR = "error"; +const POLLING_COUNTER_UPDATE_LOCK = "POLLING_COUNTER_UPDATE_LOCK"; +const POLLING_PERIOD = 25_000; export default class FetchOffscreenService { + /** + * The count of pending request under fetch process. + * It is used to know when to stop polling. + * @type {number} + * @private + */ + static pendingRequestsCount = 0; + + /** + * The 'interval' id of the current polling mechanism + * @type {number|null} + * @private + */ + static pollingIntervalId = null; + /** * Handle fetch request. * @param {object} message Browser runtime.onMessage listener message. @@ -37,12 +55,14 @@ export default class FetchOffscreenService { } const {id, resource, options} = message?.data || {}; + await FetchOffscreenService.increaseAwaitingRequests(); try { const response = await fetch(resource, options); await FetchOffscreenService.handleSuccessResponse(id, response); } catch (error) { await FetchOffscreenService.handleErrorResponse(id, error); } + await FetchOffscreenService.decreaseAwaitingRequests(); } /** @@ -53,11 +73,11 @@ export default class FetchOffscreenService { static async validateMessageData(messageData = {}) { let error; - if (!messageData.id || !Validator.isUUID(messageData.id)) { + if (!messageData.id || typeof messageData.id !== "string" || !Validator.isUUID(messageData.id)) { error = new Error("FetchOffscreenService: message.id should be a valid uuid."); } else if (typeof messageData.resource !== "string") { error = new Error("FetchOffscreenService: message.resource should be a valid valid."); - } else if (typeof messageData.options !== "undefined" && !(messageData.options instanceof Object)) { + } else if (typeof messageData.options === "undefined" || !(messageData.options instanceof Object)) { error = new Error("FetchOffscreenService: message.options should be an object."); } @@ -119,4 +139,51 @@ export default class FetchOffscreenService { text: await response.text() }; } + + /** + * Increases the awaiting requests counter. + * If the polling mechanism is not started, it starts it. + * @return {Promise} + * @private + */ + static increaseAwaitingRequests() { + return navigator.locks.request(POLLING_COUNTER_UPDATE_LOCK, () => { + FetchOffscreenService.pendingRequestsCount++; + if (!FetchOffscreenService.pollingIntervalId) { + FetchOffscreenService.pollingIntervalId = setInterval(FetchOffscreenService.pollServiceWorker, POLLING_PERIOD); + } + }); + } + + /** + * Sends a message to the service worker to keep it awake. + * This ensures that it is not stopped and will be awake to receive the fetch response as intended. + * + * @returns {Promise} + * @private + */ + static async pollServiceWorker() { + await chrome.runtime.sendMessage({ + target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_POLLING_HANDLER, + }); + } + + /** + * Decreases the awaiting request counter. + * If the counter reaches 0, then the polling mechanism is halted. + * This method is meant to be called after a finished fetch (successful or not). + * + * @returns {Promise} + * @private + */ + static async decreaseAwaitingRequests() { + return navigator.locks.request(POLLING_COUNTER_UPDATE_LOCK, () => { + FetchOffscreenService.pendingRequestsCount--; + if (FetchOffscreenService.pendingRequestsCount === 0) { + // there is no more pending request, halt the service worker polling + clearInterval(FetchOffscreenService.pollingIntervalId); + FetchOffscreenService.pollingIntervalId = null; + } + }); + } } diff --git a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.data.js b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.data.js new file mode 100644 index 00000000..2e744aff --- /dev/null +++ b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.data.js @@ -0,0 +1,133 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import {fetchOptionsHeaders} from "../../../serviceWorker/service/network/requestFetchOffscreenService.test.data"; +import {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} from "./fetchOffscreenService"; + +export const defaultFetchMessage = message => ({ + target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN, + data: { + id: crypto.randomUUID(), + resource: "https://www.passbolt.test/settings.json?api-version=v2", + options: { + credentials: "include", + headers: fetchOptionsHeaders(), + body: { + prop1: "value 1", + prop2: "value 2" + } + } + }, + ...message +}); + +export const defaultFetchResponse = () => (new Response( + "{\n \"header\": {\n \"id\": \"0682ab8f-ecba-4336-a628-8b6cac609f49\",\n \"status\": \"success\",\n \"servertime\": 1713863028,\n \"action\": \"bef9f3ca-86ef-5c6a-9b38-320e03ceb5df\",\n \"message\": \"The operation was successful.\",\n \"url\": \"\\/settings.json?api-version=v2\",\n \"code\": 200\n },\n \"body\": {\n \"app\": {\n \"url\": \"https:\\/\\/www.passbolt.test\\/\",\n \"locale\": \"en-UK\"\n },\n \"passbolt\": {\n \"legal\": {\n \"privacy_policy\": {\n \"url\": \"\"\n },\n \"terms\": {\n \"url\": \"https:\\/\\/www.passbolt.com\\/terms\"\n }\n },\n \"edition\": \"pro\",\n \"plugins\": {\n \"jwtAuthentication\": {\n \"enabled\": true\n },\n \"accountRecoveryRequestHelp\": {\n \"enabled\": true\n },\n \"accountRecovery\": {\n \"enabled\": true\n },\n \"selfRegistration\": {\n \"enabled\": true\n },\n \"sso\": {\n \"enabled\": true\n },\n \"mfaPolicies\": {\n \"enabled\": true\n },\n \"ssoRecover\": {\n \"enabled\": true\n },\n \"userPassphrasePolicies\": {\n \"enabled\": true\n },\n \"inFormIntegration\": {\n \"enabled\": true\n },\n \"locale\": {\n \"options\": [\n {\n \"locale\": \"de-DE\",\n \"label\": \"Deutsch\"\n },\n {\n \"locale\": \"en-UK\",\n \"label\": \"English\"\n },\n {\n \"locale\": \"es-ES\",\n \"label\": \"Espa\\u00f1ol\"\n },\n {\n \"locale\": \"fr-FR\",\n \"label\": \"Fran\\u00e7ais\"\n },\n {\n \"locale\": \"it-IT\",\n \"label\": \"Italiano (beta)\"\n },\n {\n \"locale\": \"ja-JP\",\n \"label\": \"\\u65e5\\u672c\\u8a9e\"\n },\n {\n \"locale\": \"ko-KR\",\n \"label\": \"\\ud55c\\uad6d\\uc5b4 (beta)\"\n },\n {\n \"locale\": \"lt-LT\",\n \"label\": \"Lietuvi\\u0173\"\n },\n {\n \"locale\": \"nl-NL\",\n \"label\": \"Nederlands\"\n },\n {\n \"locale\": \"pl-PL\",\n \"label\": \"Polski\"\n },\n {\n \"locale\": \"pt-BR\",\n \"label\": \"Portugu\\u00eas Brasil (beta)\"\n },\n {\n \"locale\": \"ro-RO\",\n \"label\": \"Rom\\u00e2n\\u0103 (beta)\"\n },\n {\n \"locale\": \"ru-RU\",\n \"label\": \"P\\u0443\\u0441\\u0441\\u043a\\u0438\\u0439 (beta)\"\n },\n {\n \"locale\": \"sv-SE\",\n \"label\": \"Svenska\"\n }\n ]\n },\n \"rememberMe\": {\n \"options\": {\n \"300\": \"5 minutes\",\n \"900\": \"15 minutes\",\n \"1800\": \"30 minutes\",\n \"3600\": \"1 hour\",\n \"-1\": \"until I log out\"\n }\n }\n }\n }\n }\n}", + { + "status": 200, + "statusText": "OK", + "headers": [ + [ + "access-control-expose-headers", + "X-GPGAuth-Verify-Response, X-GPGAuth-Progress, X-GPGAuth-User-Auth-Token, X-GPGAuth-Authenticated, X-GPGAuth-Refer, X-GPGAuth-Debug, X-GPGAuth-Error, X-GPGAuth-Pubkey, X-GPGAuth-Logout-Url, X-GPGAuth-Version" + ], + [ + "cache-control", + "no-store, no-cache, must-revalidate" + ], + [ + "content-security-policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self' https://*.duosecurity.com;" + ], + [ + "content-type", + "application/json" + ], + [ + "date", + "Tue, 23 Apr 2024 09:03:48 GMT" + ], + [ + "expires", + "Thu, 19 Nov 1981 08:52:00 GMT" + ], + [ + "pragma", + "no-cache" + ], + [ + "referrer-policy", + "same-origin" + ], + [ + "server", + "nginx/1.24.0 (Ubuntu)" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-download-options", + "noopen" + ], + [ + "x-frame-options", + "sameorigin" + ], + [ + "x-gpgauth-authenticated", + "false" + ], + [ + "x-gpgauth-debug", + "There is no user associated with this key. No key id set." + ], + [ + "x-gpgauth-error", + "true" + ], + [ + "x-gpgauth-login-url", + "/auth/login" + ], + [ + "x-gpgauth-logout-url", + "/auth/logout" + ], + [ + "x-gpgauth-progress", + "stage0" + ], + [ + "x-gpgauth-pubkey-url", + "/auth/verify.json" + ], + [ + "x-gpgauth-verify-url", + "/auth/verify" + ], + [ + "x-gpgauth-version", + "1.3.0" + ], + [ + "x-permitted-cross-domain-policies", + "all" + ] + ], + "redirected": false, + "url": "https://www.passbolt.test/settings.json?api-version=v2", + "ok": true, + } +)); diff --git a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.js b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.js new file mode 100644 index 00000000..9caa4ee9 --- /dev/null +++ b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.js @@ -0,0 +1,282 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.7.0 + */ +import {v4 as uuid} from 'uuid'; +import {enableFetchMocks} from "jest-fetch-mock"; +import FetchOffscreenService from './fetchOffscreenService'; +import each from "jest-each"; +import {defaultFetchMessage, defaultFetchResponse} from "./fetchOffscreenService.test.data"; + +beforeEach(() => { + enableFetchMocks(); + fetch.resetMocks(); + jest.clearAllMocks(); + // Flush runtime memory cache. + FetchOffscreenService.pollingIntervalId = null; + FetchOffscreenService.pendingRequestsCount = 0; +}); + +describe("FetchOffscreenService", () => { + describe("::handleFetchRequest", () => { + it("should not proceed message if the message doesn't have a target", async() => { + expect.assertions(1); + const spyOnFetch = jest.spyOn(self, "fetch"); + await FetchOffscreenService.handleFetchRequest({}); + + expect(spyOnFetch).not.toHaveBeenCalled(); + }); + + it("should not proceed message if targetting another process", async() => { + expect.assertions(1); + const spyOnFetch = jest.spyOn(self, "fetch"); + await FetchOffscreenService.handleFetchRequest({target: "wrong-target"}); + expect(spyOnFetch).not.toHaveBeenCalled(); + }); + + it("should increase and decrease the pending request count", async() => { + expect.assertions(5); + const message = defaultFetchMessage(); + const spyOnFetch = jest.spyOn(self, "fetch").mockImplementation(async() => ({header: {}, body: {}})); + const spyOnIncreaseRequestCount = jest.spyOn(FetchOffscreenService, "increaseAwaitingRequests"); + const spyOnDecreaseRequestCount = jest.spyOn(FetchOffscreenService, "decreaseAwaitingRequests"); + await FetchOffscreenService.handleFetchRequest(message); + + expect(spyOnFetch).toHaveBeenCalledTimes(1); + expect(spyOnFetch).toHaveBeenCalledWith(message.data.resource, message.data.options); + expect(spyOnIncreaseRequestCount).toHaveBeenCalledTimes(1); + expect(spyOnDecreaseRequestCount).toHaveBeenCalledTimes(1); + expect(FetchOffscreenService.pendingRequestsCount).toStrictEqual(0); + }); + + it("should handle a successful response", async() => { + expect.assertions(3); + const message = defaultFetchMessage(); + const expectedResponse = {header: {}, body: {}}; + const spyOnFetch = jest.spyOn(self, "fetch").mockImplementation(async() => expectedResponse); + const spyOnSuccessResponse = jest.spyOn(FetchOffscreenService, "handleSuccessResponse"); + const spyOnErrorResponse = jest.spyOn(FetchOffscreenService, "handleErrorResponse"); + await FetchOffscreenService.handleFetchRequest(message); + + expect(spyOnFetch).toHaveBeenCalledTimes(1); + expect(spyOnSuccessResponse).toHaveBeenCalledWith(message.data.id, expectedResponse); + expect(spyOnErrorResponse).not.toHaveBeenCalledWith(); + }); + + it("should handle a erroneous response", async() => { + expect.assertions(3); + const message = defaultFetchMessage(); + const expectedError = new Error("Something went wrong!"); + const spyOnFetch = jest.spyOn(self, "fetch").mockImplementation(async() => { throw expectedError; }); + const spyOnSuccessResponse = jest.spyOn(FetchOffscreenService, "handleSuccessResponse"); + const spyOnErrorResponse = jest.spyOn(FetchOffscreenService, "handleErrorResponse"); + await FetchOffscreenService.handleFetchRequest(message); + + expect(spyOnFetch).toHaveBeenCalledTimes(1); + expect(spyOnSuccessResponse).not.toHaveBeenCalledWith(); + expect(spyOnErrorResponse).toHaveBeenCalledWith(message.data.id, expectedError); + }); + }); + + describe("::validateMessageData", () => { + it("should validate if the message data respects the format", async() => { + const message = defaultFetchMessage(); + const validation = await FetchOffscreenService.validateMessageData(message.data); + expect(validation).toBeTruthy(); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + }); + + each([ + {scenario: "undefined", id: undefined}, + {scenario: "null", id: null}, + {scenario: "invalid string", id: "invalid"}, + {scenario: "boolean", id: true}, + {scenario: "object", id: {data: crypto.randomUUID()}}, + ]).describe("should fail if message id is not valid", _props => { + it(`should trow if message id: ${_props.scenario}`, async() => { + const message = defaultFetchMessage(); + message.data.id = _props.id; + const spyOnErrorResponse = jest.spyOn(FetchOffscreenService, "handleErrorResponse"); + const validation = await FetchOffscreenService.validateMessageData(message.data); + expect(validation).toBeFalsy(); + expect(spyOnErrorResponse).toHaveBeenCalledWith(message.data.id, expect.any(Error)); + }); + }); + + each([ + {scenario: "undefined", resource: undefined}, + {scenario: "null", resource: null}, + {scenario: "integer", resource: 42}, + {scenario: "boolean", resource: true}, + {scenario: "object", resource: {data: "resource"}}, + ]).describe("should fail if message resource is not valid", _props => { + it(`should fail if message resource: ${_props.scenario}`, async() => { + const message = defaultFetchMessage(); + message.data.resource = _props.resource; + const spyOnErrorResponse = jest.spyOn(FetchOffscreenService, "handleErrorResponse"); + const validation = await FetchOffscreenService.validateMessageData(message.data); + expect(validation).toBeFalsy(); + expect(spyOnErrorResponse).toHaveBeenCalledWith(message.data.id, expect.any(Error)); + }); + }); + + each([ + {scenario: "undefined", options: undefined}, + {scenario: "null", options: null}, + {scenario: "integer", options: 42}, + {scenario: "boolean", options: true}, + {scenario: "string", options: "string"}, + ]).describe("should fail if message options is not valid", _props => { + it(`should fail if message options: ${_props.scenario}`, async() => { + const message = defaultFetchMessage(); + message.data.options = _props.options; + const spyOnErrorResponse = jest.spyOn(FetchOffscreenService, "handleErrorResponse"); + const validation = await FetchOffscreenService.validateMessageData(message.data); + expect(validation).toBeFalsy(); + expect(spyOnErrorResponse).toHaveBeenCalledWith(message.data.id, expect.any(Error)); + }); + }); + }); + + describe("::handleSuccessResponse", () => { + it("should send a message through the chrome.runtime", async() => { + expect.assertions(2); + + const message = defaultFetchMessage(); + + const fetchResponse = { + headers: new Array([]), + status: 200, + statusText: "OK", + text: async() => "", + ok: true, + url: message.data.resource, + redirected: false, + }; + await FetchOffscreenService.handleSuccessResponse(message.data.id, fetchResponse); + + const expectedCall = { + target: "service-worker-fetch-offscreen-response-handler", + id: message.data.id, + type: "success", + data: await FetchOffscreenService.serializeResponse(fetchResponse), + }; + + expect(chrome.runtime.sendMessage).toHaveBeenCalledTimes(1); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(expectedCall); + }); + }); + + describe("::handleErrorResponse", () => { + it("should send an error message through the chrome.runtime", async() => { + expect.assertions(2); + + const dataFetch = { + id: uuid(), + }; + + const error = new Error("Something went wrong"); + error.name = "A fake error name"; + const spyOnChromeRuntime = jest.spyOn(chrome.runtime, "sendMessage"); + await FetchOffscreenService.handleErrorResponse(dataFetch.id, error); + + const expectedCall = { + target: "service-worker-fetch-offscreen-response-handler", + id: dataFetch.id, + type: "error", + data: { + name: error.name, + message: error.message + } + }; + + expect(spyOnChromeRuntime).toHaveBeenCalledTimes(1); + expect(spyOnChromeRuntime).toHaveBeenCalledWith(expectedCall); + }); + }); + + describe("::serializeResponse", () => { + it("should serialize fetch response object", async() => { + expect.assertions(7); + const response = defaultFetchResponse(); + const serializedResponse = await FetchOffscreenService.serializeResponse(response); + expect(serializedResponse.status).toEqual(response.status); + expect(serializedResponse.statusText).toEqual(response.statusText); + expect(serializedResponse.headers).toEqual(Array.from(response.headers.entries())); + expect(serializedResponse.redirected).toEqual(response.redirected); + expect(serializedResponse.url).toEqual(response.url); + expect(serializedResponse.ok).toEqual(response.ok); + expect(serializedResponse.text).toEqual(await defaultFetchResponse().text()); + }); + }); + + describe("::increaseAwaitingRequests", () => { + it("should increase the pending requests count and not start the polling if already started", async() => { + expect.assertions(2); + jest.useFakeTimers(); + + FetchOffscreenService.pollingIntervalId = 42; + await FetchOffscreenService.increaseAwaitingRequests(); + const spyOnInterval = jest.spyOn(self, "setInterval"); + + expect(FetchOffscreenService.pendingRequestsCount).toStrictEqual(1); + expect(spyOnInterval).not.toHaveBeenCalled(); + }); + + it("should increase the pending requests count and start the polling if not started already", async() => { + expect.assertions(3); + jest.useFakeTimers(); + + const spyOnInterval = jest.spyOn(self, "setInterval"); + FetchOffscreenService.pollingIntervalId = null; + FetchOffscreenService.pendingRequestsCount = 10; + await FetchOffscreenService.increaseAwaitingRequests(); + + expect(FetchOffscreenService.pendingRequestsCount).toStrictEqual(11); + expect(FetchOffscreenService.pollingIntervalId).not.toBeNull(); + expect(spyOnInterval).toHaveBeenCalled(); + }); + }); + + describe("::decreaseAwaitingRequests", () => { + it("should decrease the pending requests count and keep the polling if pending requests are left", async() => { + expect.assertions(2); + jest.useFakeTimers(); + + FetchOffscreenService.pendingRequestsCount = 10; + FetchOffscreenService.pollingIntervalId = 42; + await FetchOffscreenService.decreaseAwaitingRequests(); + expect(FetchOffscreenService.pendingRequestsCount).toStrictEqual(9); + expect(FetchOffscreenService.pollingIntervalId).not.toBeNull(); + }); + + it("should decrease the pending requests count and stop the polling if no pending requests are left", async() => { + expect.assertions(2); + jest.useFakeTimers(); + + FetchOffscreenService.pendingRequestsCount = 1; + FetchOffscreenService.pollingIntervalId = 42; + await FetchOffscreenService.decreaseAwaitingRequests(); + expect(FetchOffscreenService.pendingRequestsCount).toStrictEqual(0); + expect(FetchOffscreenService.pollingIntervalId).toBeNull(); + }); + }); + + describe("::pollServiceWorker", () => { + it("should send a poll message to the service worker", async() => { + expect.assertions(1); + await FetchOffscreenService.pollServiceWorker(); + const target = "service-worker-fetch-offscreen-polling-handler"; + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({target}); + }); + }); +}); diff --git a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js index e2bbc799..e878b496 100644 --- a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js +++ b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js @@ -22,7 +22,6 @@ import { import {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} from "../../../offscreens/service/network/fetchOffscreenService"; import {fetchOptionsWithBodyFormData, fetchOptionWithBodyData} from "./requestFetchOffscreenService.test.data"; - beforeEach(() => { enableFetchMocks(); fetch.resetMocks(); diff --git a/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.js b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.js index c6976049..f49fca1b 100644 --- a/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.js +++ b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.js @@ -16,7 +16,8 @@ import {assertUuid} from "../../../../all/background_page/utils/assertions"; import { FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR, FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS, - SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER + SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER, + SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_POLLING_HANDLER, } from "../../../offscreens/service/network/fetchOffscreenService"; import {RequestFetchOffscreenService} from "./requestFetchOffscreenService"; @@ -27,6 +28,12 @@ export default class ResponseFetchOffscreenService { * @return {void} */ static handleFetchResponse(message) { + // This is a polling message for long request to keep the service worker alive. + if (message.target === SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_POLLING_HANDLER) { + console.debug("ResponseFetchOffscreenService: polled"); + return; + } + // Return early if this message isn't meant for the offscreen document. if (message.target !== SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER) { console.debug("ResponseFetchOffscreenService: received message not specific to the service worker fetch offscreen response handler."); diff --git a/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.js b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.js index cdd97a2c..5a200b63 100644 --- a/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.js +++ b/src/chrome-mv3/serviceWorker/service/network/responseFetchOffscreenService.test.js @@ -66,7 +66,7 @@ describe("ResponseFetchOffscreenService", () => { }); it("should validate message id", () => { - expect(() => defaultResponseMessage({id: crypto.randomUUID()})).not.toThrow(); + expect(() => ResponseFetchOffscreenService.assertMessage(defaultResponseMessage({id: crypto.randomUUID()}))).not.toThrow(); }); each([ diff --git a/test/mocks/mockAlarms.js b/test/mocks/mockAlarms.js index 451b810d..2b2f1cbb 100644 --- a/test/mocks/mockAlarms.js +++ b/test/mocks/mockAlarms.js @@ -25,7 +25,7 @@ class MockAlarms { } /** - * Register a new alarm by mocking the mecanism with setInterval and setTimeout. + * Register a new alarm by mocking the mechanism with setInterval and setTimeout. * @param {string} alarmName the name of the alarm passed as the callback parameter when the alarm triggers * @param {object} options the options to define when the alarm triggers and at which frequency * @return {Promise} a promise is return to simulate the chrome.alarm API From 204d5a7a7ea142243d5f34dd02f4bd1252acc5bb Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Wed, 24 Apr 2024 16:01:11 +0200 Subject: [PATCH 52/56] PB-33153 Styleguide version bump to v4.7.0 --- package-lock.json | 540 ++++++++++++---------------------------------- package.json | 2 +- 2 files changed, 136 insertions(+), 406 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3557a6a..ecd36635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "locutus": "~2.0.9", "openpgp": "^5.11.1", "papaparse": "^5.2.0", - "passbolt-styleguide": "^4.7.0-alpha-4", + "passbolt-styleguide": "^4.7.0", "react": "17.0.2", "react-dom": "17.0.2", "secrets-passbolt": "github:passbolt/secrets.js#v2.0.1", @@ -92,6 +92,7 @@ "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, "dependencies": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -449,6 +450,7 @@ "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -494,6 +496,7 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -3442,117 +3445,6 @@ "node": ">=14.16" } }, - "node_modules/@testing-library/dom": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", - "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/@testing-library/dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, - "node_modules/@testing-library/dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -3562,11 +3454,6 @@ "node": ">= 10" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4613,6 +4500,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -4723,18 +4611,11 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dependencies": { - "deep-equal": "^2.0.5" - } - }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, "dependencies": { "call-bind": "^1.0.5", "is-array-buffer": "^3.0.4" @@ -5018,6 +4899,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.6.tgz", "integrity": "sha512-j1QzY8iPNPG4o4xmO3ptzpRxTciqD3MgEHtifP/YnJpIo58Xu+ne4BejlbkuaLfXn/nz6HFiw29bLpj2PNMdGg==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -5928,6 +5810,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -5988,6 +5871,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -6295,6 +6179,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -6302,7 +6187,8 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true }, "node_modules/colorette": { "version": "2.0.20", @@ -7080,37 +6966,6 @@ } } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -7178,6 +7033,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.2.tgz", "integrity": "sha512-SRtsSqsDbgpJBbW3pABMCOt6rQyeM8s8RiyeSN8jYG8sYmt/kGJejbydttUsnDs1tadr19tvhT4ShwMyoqAm4g==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.2", @@ -7201,6 +7057,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -7346,11 +7203,6 @@ "node": ">=6.0.0" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -7731,29 +7583,11 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "engines": { "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-iterator-helpers": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", @@ -7900,6 +7734,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, "engines": { "node": ">=0.8.0" } @@ -8945,6 +8780,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, "dependencies": { "is-callable": "^1.1.3" } @@ -9160,6 +8996,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9186,6 +9023,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9292,6 +9130,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -9544,6 +9383,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -10100,6 +9940,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -10108,6 +9949,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, "engines": { "node": ">=4" } @@ -10116,6 +9958,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, "dependencies": { "get-intrinsic": "^1.2.2" }, @@ -10127,6 +9970,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -10138,6 +9982,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -10149,6 +9994,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -10199,6 +10045,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -10772,6 +10619,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.0", @@ -10827,6 +10675,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -10842,6 +10691,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.1" @@ -10878,6 +10728,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, "dependencies": { "has-bigints": "^1.0.1" }, @@ -10901,6 +10752,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -10922,6 +10774,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -10957,6 +10810,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -11067,6 +10921,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11123,6 +10978,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -11185,6 +11041,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -11212,6 +11069,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11220,6 +11078,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -11243,6 +11102,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -11257,6 +11117,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -11319,6 +11180,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11339,6 +11201,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -11380,7 +11243,8 @@ "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "node_modules/isexe": { "version": "2.0.0", @@ -13872,14 +13736,6 @@ "node": ">=12" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -14595,21 +14451,7 @@ "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -14618,6 +14460,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -14626,6 +14469,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.5", "define-properties": "^1.2.1", @@ -15185,11 +15029,10 @@ } }, "node_modules/passbolt-styleguide": { - "version": "4.7.0-alpha-4", - "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0-alpha-4.tgz", - "integrity": "sha512-RB8o4zfwKpXvp3IDjwJtTvzhTsyaIrvGenXl7A6NOZWlvwbPuIlYv8ndnin8izaTqq5pR1m4vVVuG1XBcraMzg==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0.tgz", + "integrity": "sha512-8myUPLOQIWUoeTqoWJSGjvQKIOzAF4h0nW1fYqNUALMFuBcC7JjIUrpD7qAqp1tOgCKU5SoSoe22GSf4z7xKag==", "dependencies": { - "@testing-library/dom": "^8.11.3", "debounce-promise": "^3.1.2", "grapheme-splitter": "^1.0.4", "html5-qrcode": "^2.3.8", @@ -16295,6 +16138,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -16862,6 +16706,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "dev": true, "dependencies": { "define-data-property": "^1.1.2", "es-errors": "^1.3.0", @@ -16878,6 +16723,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, "dependencies": { "define-data-property": "^1.0.1", "functions-have-names": "^1.2.3", @@ -16967,6 +16813,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.6", "es-errors": "^1.3.0", @@ -17204,17 +17051,6 @@ "node": ">=8" } }, - "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dependencies": { - "internal-slot": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -17615,6 +17451,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -19321,6 +19158,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, "dependencies": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -19362,6 +19200,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, "dependencies": { "is-map": "^2.0.1", "is-set": "^2.0.1", @@ -19381,6 +19220,7 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", + "dev": true, "dependencies": { "available-typed-arrays": "^1.0.6", "call-bind": "^1.0.5", @@ -19797,6 +19637,7 @@ "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, "requires": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -20055,7 +19896,8 @@ "@babel/helper-validator-identifier": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true }, "@babel/helper-validator-option": { "version": "7.23.5", @@ -20089,6 +19931,7 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -22081,99 +21924,12 @@ "defer-to-connect": "^2.0.1" } }, - "@testing-library/dom": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", - "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", - "requires": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "requires": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "dependencies": { - "ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" - } - } - }, - "react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "dev": true }, - "@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" - }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -23016,6 +22772,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -23116,18 +22873,11 @@ "sprintf-js": "~1.0.2" } }, - "aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "requires": { - "deep-equal": "^2.0.5" - } - }, "array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, "requires": { "call-bind": "^1.0.5", "is-array-buffer": "^3.0.4" @@ -23348,7 +23098,8 @@ "available-typed-arrays": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.6.tgz", - "integrity": "sha512-j1QzY8iPNPG4o4xmO3ptzpRxTciqD3MgEHtifP/YnJpIo58Xu+ne4BejlbkuaLfXn/nz6HFiw29bLpj2PNMdGg==" + "integrity": "sha512-j1QzY8iPNPG4o4xmO3ptzpRxTciqD3MgEHtifP/YnJpIo58Xu+ne4BejlbkuaLfXn/nz6HFiw29bLpj2PNMdGg==", + "dev": true }, "await-lock": { "version": "2.2.2", @@ -24066,6 +23817,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", + "dev": true, "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -24100,6 +23852,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -24326,6 +24079,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, "requires": { "color-name": "1.1.3" } @@ -24333,7 +24087,8 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true }, "colorette": { "version": "2.0.20", @@ -24965,31 +24720,6 @@ "dev": true, "requires": {} }, - "deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "requires": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - } - }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -25044,6 +24774,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.2.tgz", "integrity": "sha512-SRtsSqsDbgpJBbW3pABMCOt6rQyeM8s8RiyeSN8jYG8sYmt/kGJejbydttUsnDs1tadr19tvhT4ShwMyoqAm4g==", + "dev": true, "requires": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.2", @@ -25061,6 +24792,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "requires": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -25172,11 +24904,6 @@ "esutils": "^2.0.2" } }, - "dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" - }, "dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -25494,23 +25221,8 @@ "es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" - }, - "es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - } + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true }, "es-iterator-helpers": { "version": "1.0.15", @@ -25629,7 +25341,8 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true }, "escodegen": { "version": "2.1.0", @@ -26414,6 +26127,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, "requires": { "is-callable": "^1.1.3" } @@ -26580,7 +26294,8 @@ "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true }, "function.prototype.name": { "version": "1.1.6", @@ -26597,7 +26312,8 @@ "functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==" + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true }, "fx-runner": { "version": "1.4.0", @@ -26682,6 +26398,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -26868,6 +26585,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, "requires": { "get-intrinsic": "^1.1.3" } @@ -27300,17 +27018,20 @@ "has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==" + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true }, "has-property-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, "requires": { "get-intrinsic": "^1.2.2" } @@ -27318,17 +27039,20 @@ "has-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==" + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true }, "has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true }, "has-tostringtag": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "requires": { "has-symbols": "^1.0.3" } @@ -27364,6 +27088,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, "requires": { "function-bind": "^1.1.2" } @@ -27813,6 +27538,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, "requires": { "es-errors": "^1.3.0", "hasown": "^2.0.0", @@ -27850,6 +27576,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, "requires": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -27859,6 +27586,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.1" @@ -27883,6 +27611,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, "requires": { "has-bigints": "^1.0.1" } @@ -27900,6 +27629,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, "requires": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -27914,7 +27644,8 @@ "is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true }, "is-ci": { "version": "3.0.1", @@ -27938,6 +27669,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, "requires": { "has-tostringtag": "^1.0.0" } @@ -28005,7 +27737,8 @@ "is-map": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", - "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==" + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true }, "is-mergeable-object": { "version": "1.1.1", @@ -28041,6 +27774,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, "requires": { "has-tostringtag": "^1.0.0" } @@ -28082,6 +27816,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, "requires": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -28099,12 +27834,14 @@ "is-set": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", - "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==" + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true }, "is-shared-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, "requires": { "call-bind": "^1.0.2" } @@ -28119,6 +27856,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, "requires": { "has-tostringtag": "^1.0.0" } @@ -28127,6 +27865,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, "requires": { "has-symbols": "^1.0.2" } @@ -28170,7 +27909,8 @@ "is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", - "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==" + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true }, "is-weakref": { "version": "1.0.2", @@ -28185,6 +27925,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -28214,7 +27955,8 @@ "isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "isexe": { "version": "2.0.0", @@ -30137,11 +29879,6 @@ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==" }, - "lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==" - }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -30697,26 +30434,20 @@ "object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==" - }, - "object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true }, "object.assign": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, "requires": { "call-bind": "^1.0.5", "define-properties": "^1.2.1", @@ -31136,11 +30867,10 @@ } }, "passbolt-styleguide": { - "version": "4.7.0-alpha-4", - "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0-alpha-4.tgz", - "integrity": "sha512-RB8o4zfwKpXvp3IDjwJtTvzhTsyaIrvGenXl7A6NOZWlvwbPuIlYv8ndnin8izaTqq5pR1m4vVVuG1XBcraMzg==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/passbolt-styleguide/-/passbolt-styleguide-4.7.0.tgz", + "integrity": "sha512-8myUPLOQIWUoeTqoWJSGjvQKIOzAF4h0nW1fYqNUALMFuBcC7JjIUrpD7qAqp1tOgCKU5SoSoe22GSf4z7xKag==", "requires": { - "@testing-library/dom": "^8.11.3", "debounce-promise": "^3.1.2", "grapheme-splitter": "^1.0.4", "html5-qrcode": "^2.3.8", @@ -32013,6 +31743,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -32424,6 +32155,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "dev": true, "requires": { "define-data-property": "^1.1.2", "es-errors": "^1.3.0", @@ -32437,6 +32169,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, "requires": { "define-data-property": "^1.0.1", "functions-have-names": "^1.2.3", @@ -32508,6 +32241,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", + "dev": true, "requires": { "call-bind": "^1.0.6", "es-errors": "^1.3.0", @@ -32688,14 +32422,6 @@ } } }, - "stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "requires": { - "internal-slot": "^1.0.4" - } - }, "stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -33036,6 +32762,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -34328,6 +34055,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, "requires": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -34360,6 +34088,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, "requires": { "is-map": "^2.0.1", "is-set": "^2.0.1", @@ -34376,6 +34105,7 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", + "dev": true, "requires": { "available-typed-arrays": "^1.0.6", "call-bind": "^1.0.5", diff --git a/package.json b/package.json index fd14eb0b..63df5580 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "locutus": "~2.0.9", "openpgp": "^5.11.1", "papaparse": "^5.2.0", - "passbolt-styleguide": "^4.7.0-alpha-4", + "passbolt-styleguide": "^4.7.0", "react": "17.0.2", "react-dom": "17.0.2", "secrets-passbolt": "github:passbolt/secrets.js#v2.0.1", From 6444dd40214001e29b1732a7c32d63636b87495b Mon Sep 17 00:00:00 2001 From: Crowdin Date: Thu, 25 Apr 2024 03:13:54 +0000 Subject: [PATCH 53/56] New Crowdin updates --- src/all/locales/de-DE/common.json | 3 ++- src/all/locales/es-ES/common.json | 3 ++- src/all/locales/fr-FR/common.json | 3 ++- src/all/locales/it-IT/common.json | 3 ++- src/all/locales/ja-JP/common.json | 3 ++- src/all/locales/ko-KR/common.json | 3 ++- src/all/locales/lt-LT/common.json | 3 ++- src/all/locales/nl-NL/common.json | 3 ++- src/all/locales/pl-PL/common.json | 3 ++- src/all/locales/pt-BR/common.json | 3 ++- src/all/locales/ro-RO/common.json | 3 ++- src/all/locales/ru-RU/common.json | 3 ++- src/all/locales/sv-SE/common.json | 3 ++- 13 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/all/locales/de-DE/common.json b/src/all/locales/de-DE/common.json index 74769c07..95cfe5f3 100644 --- a/src/all/locales/de-DE/common.json +++ b/src/all/locales/de-DE/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Gruppe wird aktualisiert ...", "Updating password": "Passwort wird aktualisiert", "Updating resource": "Ressource wird aktualisiert", - "Updating users' key...": "Aktualisiere Benutzer-Schlüssel..." + "Updating users' key...": "Aktualisiere Benutzer-Schlüssel...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/es-ES/common.json b/src/all/locales/es-ES/common.json index 973505ed..3be89893 100644 --- a/src/all/locales/es-ES/common.json +++ b/src/all/locales/es-ES/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Actualizando grupo ...", "Updating password": "Actualizando contraseña", "Updating resource": "Actualizando recurso", - "Updating users' key...": "Actualizando la clave de usuarios..." + "Updating users' key...": "Actualizando la clave de usuarios...", + "You have already started the process on another tab.": "Ya ha iniciado el proceso en otra pestaña." } diff --git a/src/all/locales/fr-FR/common.json b/src/all/locales/fr-FR/common.json index 7b3f58c2..186f8cba 100644 --- a/src/all/locales/fr-FR/common.json +++ b/src/all/locales/fr-FR/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Mise à jour du groupe ...", "Updating password": "Mise à jour du mot de passe", "Updating resource": "Mise à jour de la ressource", - "Updating users' key...": "Mise à jour de la clé d'utilisateurs..." + "Updating users' key...": "Mise à jour de la clé d'utilisateurs...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/it-IT/common.json b/src/all/locales/it-IT/common.json index 77bf2980..7a3f5c89 100644 --- a/src/all/locales/it-IT/common.json +++ b/src/all/locales/it-IT/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Aggiornamento del gruppo...", "Updating password": "Aggiornamento password", "Updating resource": "Aggiornamento risorse", - "Updating users' key...": "Aggiornamento della chiave degli utenti..." + "Updating users' key...": "Aggiornamento della chiave degli utenti...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/ja-JP/common.json b/src/all/locales/ja-JP/common.json index 97110f74..bc4f579a 100644 --- a/src/all/locales/ja-JP/common.json +++ b/src/all/locales/ja-JP/common.json @@ -90,5 +90,6 @@ "Updating group ...": "グループを更新中...", "Updating password": "パスワードを更新中", "Updating resource": "リソースの更新", - "Updating users' key...": "Updating users' key..." + "Updating users' key...": "Updating users' key...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/ko-KR/common.json b/src/all/locales/ko-KR/common.json index 0293be64..201a3130 100644 --- a/src/all/locales/ko-KR/common.json +++ b/src/all/locales/ko-KR/common.json @@ -90,5 +90,6 @@ "Updating group ...": "그룹 업데이트 중 ...", "Updating password": "암호 업데이트중", "Updating resource": "리소스 업데이트 중", - "Updating users' key...": "사용자 키를 업데이트하는 중..." + "Updating users' key...": "사용자 키를 업데이트하는 중...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/lt-LT/common.json b/src/all/locales/lt-LT/common.json index d0723190..f0624509 100644 --- a/src/all/locales/lt-LT/common.json +++ b/src/all/locales/lt-LT/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Grupė atnaujinima ...", "Updating password": "Atnaujinti slaptažodį", "Updating resource": "Atnaujinamas šaltinis\n", - "Updating users' key...": "Updating users' key..." + "Updating users' key...": "Updating users' key...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/nl-NL/common.json b/src/all/locales/nl-NL/common.json index 96894c5f..450d0e54 100644 --- a/src/all/locales/nl-NL/common.json +++ b/src/all/locales/nl-NL/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Groep bijwerken ...", "Updating password": "Wachtwoord wordt bijgewerkt", "Updating resource": "Middel bijwerken", - "Updating users' key...": "Gebruikerssleutel wordt bijgewerkt..." + "Updating users' key...": "Gebruikerssleutel wordt bijgewerkt...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/pl-PL/common.json b/src/all/locales/pl-PL/common.json index 7bb32894..fe9674ae 100644 --- a/src/all/locales/pl-PL/common.json +++ b/src/all/locales/pl-PL/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Aktualizowanie grupy ...", "Updating password": "Aktualizowanie hasła", "Updating resource": "Aktualizowanie zasobu", - "Updating users' key...": "Aktualizowanie klucza użytkowników..." + "Updating users' key...": "Aktualizowanie klucza użytkowników...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/pt-BR/common.json b/src/all/locales/pt-BR/common.json index 6db6c908..816d7f74 100644 --- a/src/all/locales/pt-BR/common.json +++ b/src/all/locales/pt-BR/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Atualizando grupo ...", "Updating password": "Atualizando senha", "Updating resource": "Atualizando recurso", - "Updating users' key...": "Atualizando a chave dos usuários..." + "Updating users' key...": "Atualizando a chave dos usuários...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/ro-RO/common.json b/src/all/locales/ro-RO/common.json index 5eaa3806..c9f2a233 100644 --- a/src/all/locales/ro-RO/common.json +++ b/src/all/locales/ro-RO/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Se actualizează grupul ...", "Updating password": "Se actualizează parola", "Updating resource": "Se actualizează resursa", - "Updating users' key...": "Se actualizează cheia utilizatorilor..." + "Updating users' key...": "Se actualizează cheia utilizatorilor...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/ru-RU/common.json b/src/all/locales/ru-RU/common.json index c41fbcab..a9099a66 100644 --- a/src/all/locales/ru-RU/common.json +++ b/src/all/locales/ru-RU/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Обновление группы ...", "Updating password": "Обновление пароля", "Updating resource": "Обновление ресурсов", - "Updating users' key...": "Обновление ключа пользователя..." + "Updating users' key...": "Обновление ключа пользователя...", + "You have already started the process on another tab.": "You have already started the process on another tab." } diff --git a/src/all/locales/sv-SE/common.json b/src/all/locales/sv-SE/common.json index 386c25fc..6290f287 100644 --- a/src/all/locales/sv-SE/common.json +++ b/src/all/locales/sv-SE/common.json @@ -90,5 +90,6 @@ "Updating group ...": "Uppdaterar grupp ...", "Updating password": "Uppdaterar lösenord", "Updating resource": "Updating resource", - "Updating users' key...": "Updating users' key..." + "Updating users' key...": "Updating users' key...", + "You have already started the process on another tab.": "You have already started the process on another tab." } From 9ba229dc7afd3b40a1e3b1e5b2a0b6d4997e339d Mon Sep 17 00:00:00 2001 From: Cedric Alfonsi Date: Thu, 25 Apr 2024 15:26:52 +0000 Subject: [PATCH 54/56] PB-33155 Browser extension version bump to v4.7.0-rc.0 --- CHANGELOG.md | 63 ++++++++++++++++++++++++++++++++++++- RELEASE_NOTES.md | 79 +++++++++++++++++++++++++++++++++++------------ package-lock.json | 4 +-- package.json | 2 +- 4 files changed, 125 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6068c42e..564f1d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,65 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [4.7.0] - 2024-04-26 +### Added +- PB-32931 As administrator, I see SSO and Directory Sync health checks in Passbolt API Status page +- PB-33065 As an administrator I can add a fallback property to map my organisation AD user username +- PB-33070 Request passphrase when exporting account kit + +### Fixed +- PB-32420 Fix double calls to PwnedPassword API service +- PB-32631 Fix healthCheck Entity to support air gapped instances +- PB-33066 As AD, I should not see directorySync and SSO checks if they are disabled +- PB-33067 After an unexpected error during setup, recover or account recovery, only the iframe reload and the port cannot reconnect + +### Maintenance +- PB-22623 Start service worker in an insecure environment +- PB-22640 As a signed-in user the inform call to action should remain after the port is disconnected only for MV3 +- PB-22644 The passbolt icon should detect if the user is still connected after the service worker awake +- PB-23928 Handle when the extension is updated, the webIntegration should be destroy and injected again +- PB-29622 Simulate user keyboard input for autofill event +- PB-29946 When the service worker is shutdown and a navigation is detected the service worker do not reconnect port and stay in error mode +- PB-29965 Use a dedicated service to verify the server +- PB-29966 Update apiClient to support form data body and custom header +- PB-29967 Use a dedicated service to do the step challenge with the server +- PB-29968 use a dedicated service to check the user authentication status +- PB-29969 Use a dedicated service to logout the user +- PB-29988 Update the alarm in the class StartLoopAuthSessionCheckService to use the property periodInMinutes +- PB-29989 Put the alarm listener at the top level for the StartLoopAuthSessionCheckService to check the authentication status +- PB-29990 Move PassphraseStorageService keep alive alarm listener in top level +- PB-30272 Add message service in the app content script in order to reconnect the port from a message sent by the service worker +- PB-30273 On the post logout event the service worker should reconnect port that needs to receive the post logout message +- PB-30274 Add message service in the browser integration content script in order to reconnect the port from a message sent by the service worker +- PB-30310 Improve invalid groups users sanitization strategy +- PB-30335 Use timeout instead alarms for service worker +- PB-30336 Use timeout instead alarms for promise timeout service +- PB-30337 Put the alarm listener at the top level for the passphraseStorageService to flush passphrase after a time duration +- PB-30341 Remove alarms for toolbar controller +- PB-30342 Use timeout instead of alarm for the resource in progress cache service to flush the resource not consumed +- PB-30374 Check if AuthService from styleguide is still used in the Bext otherwise remove it +- PB-30375 Improve CI unit test performance by running them in band +- PB-32291 Cleanup legacy code and unused passbolt.auth.is-authenticated related elements +- PB-32335 Split PassphraseStorageService to put the KeepSessionAlive feature on its own service +- PB-32345 Ensures on the desktop app during import account that the file to import is taken into account +- PB-32597 Ensure ToolbarController are set on index.js +- PB-32598 Ensure add listener from authentication event controller are set on index.js +- PB-32599 Ensure add listener from StartLoopAuthSessionCheckService are set on index.js +- PB-32604 Ensure add listener from on extension update available controller are set on index.js +- PB-32602 Ensure add listener from user.js are set on index.js +- PB-32603 Ensure add listener from ResourceInProgressCacheService are set on index.js +- PB-32915 Update code to remove the destruction of the public web sign-in on port disconnected +- PB-32916 Update code to remove the destruction of the setup on port disconnected +- PB-32917 Update code to remove the destruction of the recover on port disconnected +- PB-33018 Automate browser extension npm publication +- PB-33024 Ensure only stable tags of the styleguide are published to npm +- PB-33024 Ensure only stable tag of the browser extension are sent for review or publish to the store +- PB-33061 Create account temporary storage +- PB-33062 Use temporary account storage for setup process +- PB-33063 Use temporary account storage for recover process +- PB-33064 Use temporary account storage for account recovery process +- PB-33068 Remove beta information for the windows app + ## [4.6.2] - 2024-03-29 ### Fixed - PB-32394 As a user defining my passphrase while activating my account I want to know if my passphrase is part of a dictionary on form submission @@ -1527,7 +1586,9 @@ self registration settings option in the left-side bar - AP: User with plugin installed - LU: Logged in user -[Unreleased]: https://github.com/passbolt/passbolt_browser_extension/compare/v4.6.0...HEAD +[Unreleased]: https://github.com/passbolt/passbolt_browser_extension/compare/v4.7.0...HEAD +[4.7.0]: https://github.com/passbolt/passbolt_browser_extension/compare/v4.6.2...v4.7.0 +[4.6.2]: https://github.com/passbolt/passbolt_browser_extension/compare/v4.6.0...v4.6.2 [4.6.0]: https://github.com/passbolt/passbolt_browser_extension/compare/v4.5.2...v4.6.0 [4.5.2]: https://github.com/passbolt/passbolt_browser_extension/compare/v4.5.1...v.4.5.2 [4.5.1]: https://github.com/passbolt/passbolt_browser_extension/compare/v4.5.0...v.4.5.1 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e169da32..09fa95d4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,23 +1,64 @@ -Song: https://youtu.be/3WOZwwRH6XU?si=jvTiezg7eEEpEh-S +Song: https://www.youtube.com/watch?v=3L4YrGaR8E4 -Version 4.6.2 is a targeted maintenance release of the browser extension, focusing on refining passwords strength verification process. This update ensures a balance between adhering to security best practices and maintaining user-friendliness. +Passbolt is pleased to announce that the v4.7.0 Release Candidate is officially available for testing. This is a maintenance release containing bug fixes for issues reported by the community and preparing the browser extension migration to the manifest version 3. As always, your feedback is invaluable, please share and report any issues you come across. -We extend our gratitude to the community for their insights to help us build passbolt. +Thank you for your support! ♥️ + +## [4.7.0] - 2024-04-26 +### Added +- PB-32931 As administrator, I see SSO and Directory Sync health checks in Passbolt API Status page +- PB-33065 As an administrator I can add a fallback property to map my organisation AD user username +- PB-33070 Request passphrase when exporting account kit -## [4.6.2] - 2024-03-29 ### Fixed -- PB-32394 As a user defining my passphrase while activating my account I want to know if my passphrase is part of a dictionary on form submission -- PB-32396 As a user defining my new passphrase while changing it I want to know if my new passphrase is part of a dictionary on form submission -- PB-32401 As an administrator defining the passphrase of the generated organization account recovery key I want to know if the passphrase is part of a dictionary on form submission -- PB-32407 As a user editing a password I am invited to confirm its edition when this one very weak in a separate dialog on form submission -- PB-32395 As a user defining my passphrase while requesting an account recovery I want to know if my new passphrase is part of a dictionary on form submission -- PB-32397 As a user verifying my private key passphrase while activation my account I do not want to know if my passphrase is part of a dictionary at this stage -- PB-32399 As a user confirming my passphrase while completing an account recovery (Admin approved) I do not want to know if my passphrase is part of a dictionary on form submission -- PB-32398 As a user confirming my passphrase while importing my private key during an account recover I do not want to know if my passphrase is part of a dictionary on form submission -- PB-32404 As a user creating a password from the quickaccess I am invited to confirm its creation when this one is part of a dictionary in a separate dialog on form submission -- PB-32403 As a user updating a password I am invited to confirm its edition when this one is part of a dictionary in a separate dialog on form submission -- PB-32405 As a user auto-saving a password from the quickaccess I should not be notified if the password is part of an exposed dictionary -- PB-32402 As a user creating a password I am invited to confirm its creation when this one is part of a dictionary in a separate dialog on form submission -- PB-32400 As a user confirming my passphrase while importing an account kit on the desktop app I do not want to know if my passphrase is part of a dictionary on form submission -- PB-32406 As a user creating a password I am invited to confirm its creation when this one very weak in a separate dialog on form submission -- PB-32427 As a user creating a password from the quickaccess I am invited to confirm its creation when this one is VERY WEAK in a separate page on form submission +- PB-32420 Fix double calls to PwnedPassword API service +- PB-32631 Fix healthCheck Entity to support air gapped instances +- PB-33066 As AD, I should not see directorySync and SSO checks if they are disabled +- PB-33067 After an unexpected error during setup, recover or account recovery, only the iframe reload and the port cannot reconnect + +### Maintenance +- PB-22623 Start service worker in an insecure environment +- PB-22640 As a signed-in user the inform call to action should remain after the port is disconnected only for MV3 +- PB-22644 The passbolt icon should detect if the user is still connected after the service worker awake +- PB-23928 Handle when the extension is updated, the webIntegration should be destroy and injected again +- PB-29622 Simulate user keyboard input for autofill event +- PB-29946 When the service worker is shutdown and a navigation is detected the service worker do not reconnect port and stay in error mode +- PB-29965 Use a dedicated service to verify the server +- PB-29966 Update apiClient to support form data body and custom header +- PB-29967 Use a dedicated service to do the step challenge with the server +- PB-29968 use a dedicated service to check the user authentication status +- PB-29969 Use a dedicated service to logout the user +- PB-29988 Update the alarm in the class StartLoopAuthSessionCheckService to use the property periodInMinutes +- PB-29989 Put the alarm listener at the top level for the StartLoopAuthSessionCheckService to check the authentication status +- PB-29990 Move PassphraseStorageService keep alive alarm listener in top level +- PB-30272 Add message service in the app content script in order to reconnect the port from a message sent by the service worker +- PB-30273 On the post logout event the service worker should reconnect port that needs to receive the post logout message +- PB-30274 Add message service in the browser integration content script in order to reconnect the port from a message sent by the service worker +- PB-30310 Improve invalid groups users sanitization strategy +- PB-30335 Use timeout instead alarms for service worker +- PB-30336 Use timeout instead alarms for promise timeout service +- PB-30337 Put the alarm listener at the top level for the passphraseStorageService to flush passphrase after a time duration +- PB-30341 Remove alarms for toolbar controller +- PB-30342 Use timeout instead of alarm for the resource in progress cache service to flush the resource not consumed +- PB-30374 Check if AuthService from styleguide is still used in the Bext otherwise remove it +- PB-30375 Improve CI unit test performance by running them in band +- PB-32291 Cleanup legacy code and unused passbolt.auth.is-authenticated related elements +- PB-32335 Split PassphraseStorageService to put the KeepSessionAlive feature on its own service +- PB-32345 Ensures on the desktop app during import account that the file to import is taken into account +- PB-32597 Ensure ToolbarController are set on index.js +- PB-32598 Ensure add listener from authentication event controller are set on index.js +- PB-32599 Ensure add listener from StartLoopAuthSessionCheckService are set on index.js +- PB-32604 Ensure add listener from on extension update available controller are set on index.js +- PB-32602 Ensure add listener from user.js are set on index.js +- PB-32603 Ensure add listener from ResourceInProgressCacheService are set on index.js +- PB-32915 Update code to remove the destruction of the public web sign-in on port disconnected +- PB-32916 Update code to remove the destruction of the setup on port disconnected +- PB-32917 Update code to remove the destruction of the recover on port disconnected +- PB-33018 Automate browser extension npm publication +- PB-33024 Ensure only stable tags of the styleguide are published to npm +- PB-33024 Ensure only stable tag of the browser extension are sent for review or publish to the store +- PB-33061 Create account temporary storage +- PB-33062 Use temporary account storage for setup process +- PB-33063 Use temporary account storage for recover process +- PB-33064 Use temporary account storage for account recovery process +- PB-33068 Remove beta information for the windows app \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ecd36635..8fa3fe73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "passbolt-browser-extension", - "version": "4.7.0-alpha.1", + "version": "4.7.0-rc.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "passbolt-browser-extension", - "version": "4.7.0-alpha.1", + "version": "4.7.0-rc.0", "license": "AGPL-3.0", "dependencies": { "await-lock": "^2.1.0", diff --git a/package.json b/package.json index 63df5580..d19a7e7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passbolt-browser-extension", - "version": "4.7.0-alpha.1", + "version": "4.7.0-rc.0", "license": "AGPL-3.0", "copyright": "Copyright 2022 Passbolt SA", "description": "Passbolt web extension for the open source password manager for teams", From 79b2fe4fffc211f7e72323f22c1ce6208aa97d2e Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Fri, 26 Apr 2024 17:01:12 +0200 Subject: [PATCH 55/56] PB-33177 Fix keep session alive alarm --- src/all/background_page/service/alarm/globalAlarmService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/all/background_page/service/alarm/globalAlarmService.js b/src/all/background_page/service/alarm/globalAlarmService.js index 14ef1554..bc15d127 100644 --- a/src/all/background_page/service/alarm/globalAlarmService.js +++ b/src/all/background_page/service/alarm/globalAlarmService.js @@ -19,7 +19,7 @@ import PassphraseStorageService from "../session_storage/passphraseStorageServic const topLevelAlarmMapping = { [StartLoopAuthSessionCheckService.ALARM_NAME]: StartLoopAuthSessionCheckService.handleAuthStatusCheckAlarm, [PassphraseStorageService.ALARM_NAME]: PassphraseStorageService.handleFlushEvent, - [KeepSessionAliveService.ALARM_NAME]: KeepSessionAliveService.stop, + [KeepSessionAliveService.ALARM_NAME]: KeepSessionAliveService.handleKeepSessionAlive, }; /** From acce932a1152cbf382d71ebddfd06b93b7d10f9c Mon Sep 17 00:00:00 2001 From: Benjamin Monnot Date: Mon, 29 Apr 2024 09:51:29 +0200 Subject: [PATCH 56/56] PB-33180 Browser extension version bump to v4.7.0 --- RELEASE_NOTES.md | 12 ++++++++++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 09fa95d4..80d3edc7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,16 @@ Song: https://www.youtube.com/watch?v=3L4YrGaR8E4 -Passbolt is pleased to announce that the v4.7.0 Release Candidate is officially available for testing. This is a maintenance release containing bug fixes for issues reported by the community and preparing the browser extension migration to the manifest version 3. As always, your feedback is invaluable, please share and report any issues you come across. +Passbolt v4.7 is a maintenance release that resolves multiple issues identified by the community. +Furthermore, this release supports the commitment to improving customization options and integration features, making it easier for organizations to tailor the system to their specific needs. -Thank you for your support! ♥️ +A key enhancement in this release is the ability for administrators to use custom SSL certificates for SMTP and Users directory server connections (PRO only). +These long-awaited features are particularly beneficial for organizations operating in air-gapped environments or those using their own root CAs, enabling passbolt to more securely integrate with internal communication tools. +All of these customizations are visible in the API status report of the administration workspace, providing a clear and manageable overview for administrators. + +Moreover, the integration with user directories has been enhanced, now enabling the synchronization of user accounts using multiple fields as email identifiers. +This allows organizations with heterogeneous data environments to synchronize more seamlessly with Passbolt. +This improvement is part of a broader initiative aimed at modernizing the integration with your user directories. +Stay tuned, more enhancements are planned for future releases. ## [4.7.0] - 2024-04-26 ### Added diff --git a/package-lock.json b/package-lock.json index 8fa3fe73..4ea53390 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "passbolt-browser-extension", - "version": "4.7.0-rc.0", + "version": "4.7.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "passbolt-browser-extension", - "version": "4.7.0-rc.0", + "version": "4.7.0", "license": "AGPL-3.0", "dependencies": { "await-lock": "^2.1.0", diff --git a/package.json b/package.json index d19a7e7b..b86962fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passbolt-browser-extension", - "version": "4.7.0-rc.0", + "version": "4.7.0", "license": "AGPL-3.0", "copyright": "Copyright 2022 Passbolt SA", "description": "Passbolt web extension for the open source password manager for teams",