diff --git a/.changeset/loose-knives-retire.md b/.changeset/loose-knives-retire.md new file mode 100644 index 0000000000000..8bb9dcbb35d14 --- /dev/null +++ b/.changeset/loose-knives-retire.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the behavior when the login token expires to redirect the user to the login page diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index becb48a4a8137..75a7e4b832d39 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -15,6 +15,8 @@ declare module 'meteor/accounts-base' { callback?: (error?: Error | Meteor.Error | Meteor.TypedError) => void, ): string; + function _expireTokens(oldestValidDate?: Date, userId?: string): Promise; + function _bcryptRounds(): number; function _getLoginToken(connectionId: string): string | undefined; diff --git a/apps/meteor/server/services/meteor/userReactivity.ts b/apps/meteor/server/services/meteor/userReactivity.ts index eb1b10eacee62..bc52cf641c352 100644 --- a/apps/meteor/server/services/meteor/userReactivity.ts +++ b/apps/meteor/server/services/meteor/userReactivity.ts @@ -1,5 +1,49 @@ +import { api } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; import { MongoInternals } from 'meteor/mongo'; +const { _expireTokens: expireTokensOriginal } = Accounts; + +Accounts._expireTokens = async () => { + // TODO need to get the real expiration time from the settings + const oldestValidDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + + const users = await Users.find( + { + $or: [ + { 'services.resume.loginTokens.when': { $lt: oldestValidDate } }, + { 'services.resume.loginTokens.when': { $lt: +oldestValidDate } }, + ], + }, + { projection: { services: 1 } }, + ).toArray(); + + const updates: { _id: string; validTokens: any[] }[] = []; + + users.forEach(({ _id, services }) => { + const loginTokens = services?.resume?.loginTokens; + + // remove all tokens that are expired + const validTokens = loginTokens?.filter((token: any) => token.when >= oldestValidDate) || []; + + updates.push({ _id, validTokens }); + }); + + await expireTokensOriginal.call(Accounts); + + updates.forEach(({ _id, validTokens }) => { + void api.broadcast('watch.users', { + clientAction: 'updated', + id: _id, + diff: { + 'services.resume.loginTokens': validTokens, + }, + unset: {}, + }); + }); +}; + type Callbacks = { added(id: string, record: object): void; changed(id: string, record: object): void; @@ -82,7 +126,7 @@ export const processOnChange = (diff: Record, id: string): void => const cbs = userCallbacks.get(id); if (cbs) { [...cbs] - .filter(({ hashedToken }) => tokens === undefined || !tokens.includes(hashedToken)) + .filter(({ hashedToken }) => !tokens?.includes(hashedToken)) .forEach((item) => { item.callbacks.removed(id); cbs.delete(item); diff --git a/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts b/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts new file mode 100644 index 0000000000000..2a0dddeda7a99 --- /dev/null +++ b/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts @@ -0,0 +1,101 @@ +import { MongoClient } from 'mongodb'; + +import { URL_MONGODB } from './config/constants'; +import injectInitialData from './fixtures/inject-initial-data'; +import { restoreState, Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { test, expect } from './utils/test'; + +const removeTokensFromdb = async (username: string): Promise => { + const connection = await MongoClient.connect(URL_MONGODB); + + await connection + .db() + .collection('users') + .updateOne({ username }, { $set: { 'services.resume.loginTokens': [] } }); + + await connection.close(); +}; + +test.use({ storageState: Users.user1.state }); + +test.describe('Session Expiration Redirect', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto(`/channel/${targetChannel}`); + }); + test.afterEach(async ({ page }) => { + await injectInitialData(); + await restoreState(page, Users.user1); + }); + + test('should redirect to login page when server-side token is deleted and user tries to interact', async ({ page }) => { + await test.step('expect user to be logged in initially', async () => { + await expect(page.locator('#main-content')).toBeVisible(); + + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + expect(userId).not.toBeNull(); + expect(loginToken).not.toBeNull(); + }); + + await test.step('delete login tokens from database (simulating server-side expiration)', async () => { + await removeTokensFromdb(Users.user1.data.username); + }); + + await test.step('open room search messages (without page reload)', async () => { + await poHomeChannel.roomToolbar.btnSearchMessages.click(); + }); + + await test.step('should redirect to login page', async () => { + await expect(page.getByRole('form', { name: 'Login' })).toBeVisible({ timeout: 10000 }); + }); + + await test.step('verify localStorage was cleared', async () => { + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + const loginTokenExpires = await page.evaluate(() => localStorage.getItem('Meteor.loginTokenExpires')); + + expect(userId).toBeNull(); + expect(loginToken).toBeNull(); + expect(loginTokenExpires).toBeNull(); + }); + }); + + test('should redirect to login page when trying to send message with expired token', async ({ page }) => { + await test.step('type message', async () => { + await poHomeChannel.composer.inputMessage.fill('Test message'); + }); + + await test.step('delete login tokens from database', async () => { + await removeTokensFromdb(Users.user1.data.username); + }); + + await test.step('try to send a message (should trigger auth error)', async () => { + await poHomeChannel.composer.btnSend.click(); + }); + + await test.step('expect automatic redirect to login page', async () => { + await expect(page.getByRole('form', { name: 'Login' })).toBeVisible({ timeout: 10000 }); + }); + + await test.step('verify localStorage was cleared', async () => { + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + expect(userId).toBeNull(); + expect(loginToken).toBeNull(); + }); + }); +}); diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index e0e41503a3dea..aae9118f565ae 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -254,7 +254,7 @@ export type EventSignatures = { } | { clientAction: 'updated'; - diff: Record; + diff: Record; unset: Record; } ),