diff --git a/apps/meteor/app/statistics/server/lib/SAUMonitor.ts b/apps/meteor/app/statistics/server/lib/SAUMonitor.ts index 3beb3639cf3b0..d712b5b623e9e 100644 --- a/apps/meteor/app/statistics/server/lib/SAUMonitor.ts +++ b/apps/meteor/app/statistics/server/lib/SAUMonitor.ts @@ -1,4 +1,4 @@ -import type { ISession, ISessionDevice, ISocketConnectionLogged, IUser } from '@rocket.chat/core-typings'; +import type { ISession, ISessionDevice, IUser } from '@rocket.chat/core-typings'; import { cronJobs } from '@rocket.chat/cron'; import { Logger } from '@rocket.chat/logger'; import { Sessions, Users, aggregates } from '@rocket.chat/models'; @@ -8,7 +8,6 @@ import UAParser from 'ua-parser-js'; import { UAParserMobile, UAParserDesktop } from './UAParserCustom'; import { getMostImportantRole } from '../../../../lib/roles/getMostImportantRole'; -import { getClientAddress } from '../../../../server/lib/getClientAddress'; import { sauEvents } from '../../../../server/services/sauMonitor/events'; type DateObj = { day: number; month: number; year: number }; @@ -32,6 +31,16 @@ const getUserRoles = mem( const isProdEnv = process.env.NODE_ENV === 'production'; +type HandleSessionArgs = { + userId: string; + instanceId: string; + userAgent: string; + loginToken?: string; + connectionId: string; + clientAddress: string; + host: string; +}; + /** * Server Session Monitor for SAU(Simultaneously Active Users) based on Meteor server sessions */ @@ -97,12 +106,12 @@ export class SAUMonitorClass { return; } - sauEvents.on('socket.disconnected', async ({ id, instanceId }) => { + sauEvents.on('sau.socket.disconnected', async ({ connectionId, instanceId }) => { if (!this.isRunning()) { return; } - await Sessions.closeByInstanceIdAndSessionId(instanceId, id); + await Sessions.closeByInstanceIdAndSessionId(instanceId, connectionId); }); } @@ -111,7 +120,7 @@ export class SAUMonitorClass { return; } - sauEvents.on('accounts.login', async ({ userId, connection }) => { + sauEvents.on('sau.accounts.login', async ({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }) => { if (!this.isRunning()) { return; } @@ -121,23 +130,22 @@ export class SAUMonitorClass { const mostImportantRole = getMostImportantRole(roles); const loginAt = new Date(); - const params = { userId, roles, mostImportantRole, loginAt, ...getDateObj() }; - await this._handleSession(connection, params); + const params = { roles, mostImportantRole, loginAt, ...getDateObj() }; + await this._handleSession({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }, params); }); - sauEvents.on('accounts.logout', async ({ userId, connection }) => { + sauEvents.on('sau.accounts.logout', async ({ userId, sessionId }) => { if (!this.isRunning()) { return; } if (!userId) { - logger.warn({ msg: "Received 'accounts.logout' event without 'userId'" }); + logger.warn({ msg: "Received 'sau.accounts.logout' event without 'userId'" }); return; } - const { id: sessionId } = connection; if (!sessionId) { - logger.warn({ msg: "Received 'accounts.logout' event without 'sessionId'" }); + logger.warn({ msg: "Received 'sau.accounts.logout' event without 'sessionId'" }); return; } @@ -157,14 +165,20 @@ export class SAUMonitorClass { } private async _handleSession( - connection: ISocketConnectionLogged, - params: Pick, + { userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }: HandleSessionArgs, + params: Pick, ): Promise { - const data = this._getConnectionInfo(connection, params); - - if (!data) { - return; - } + const data: Omit = { + userId, + ...(loginToken && { loginToken }), + ip: clientAddress, + host, + sessionId: connectionId, + instanceId, + type: 'session', + ...(loginToken && this._getUserAgentInfo(userAgent)), + ...params, + }; const searchTerm = this._getSearchTerm(data); @@ -221,37 +235,7 @@ export class SAUMonitorClass { .join(''); } - private _getConnectionInfo( - connection: ISocketConnectionLogged, - params: Pick, - ): Omit | undefined { - if (!connection) { - return; - } - - const ip = getClientAddress(connection); - - const host = connection.httpHeaders?.host ?? ''; - - return { - type: 'session', - sessionId: connection.id, - instanceId: connection.instanceId, - ...(connection.loginToken && { loginToken: connection.loginToken }), - ip, - host, - ...this._getUserAgentInfo(connection), - ...params, - }; - } - - private _getUserAgentInfo(connection: ISocketConnectionLogged): { device: ISessionDevice } | undefined { - if (!connection?.httpHeaders?.['user-agent']) { - return; - } - - const uaString = connection.httpHeaders['user-agent']; - + private _getUserAgentInfo(uaString: string): { device: ISessionDevice } | undefined { // TODO define a type for "result" below // | UAParser.IResult // | { device: { type: string; model?: string }; browser: undefined; os: undefined; app: { name: string; version: string } } diff --git a/apps/meteor/ee/server/lib/deviceManagement/session.ts b/apps/meteor/ee/server/lib/deviceManagement/session.ts index c5b286c9dff2c..a613fbb0a11d0 100644 --- a/apps/meteor/ee/server/lib/deviceManagement/session.ts +++ b/apps/meteor/ee/server/lib/deviceManagement/session.ts @@ -32,17 +32,13 @@ const uaParser = async ( }; export const listenSessionLogin = () => { - return deviceManagementEvents.on('device-login', async ({ userId, connection }) => { + return deviceManagementEvents.on('device-login', async ({ userId, userAgent, clientAddress }) => { const deviceEnabled = settings.get('Device_Management_Enable_Login_Emails'); if (!deviceEnabled) { return; } - if (connection.loginToken) { - return; - } - const user = await Users.findOneByIdWithEmailAddress(userId, { projection: { 'name': 1, 'username': 1, 'emails': 1, 'settings.preferences.receiveLoginDetectionEmail': 1 }, }); @@ -67,11 +63,7 @@ export const listenSessionLogin = () => { emails: [{ address: email }], } = user; - const userAgentString = - connection.httpHeaders instanceof Headers - ? (connection.httpHeaders.get('user-agent') ?? '') - : (connection.httpHeaders['user-agent'] ?? ''); - const { browser, os, device, cpu, app } = await uaParser(userAgentString); + const { browser, os, device, cpu, app } = await uaParser(userAgent); const mailData = { name, @@ -81,7 +73,7 @@ export const listenSessionLogin = () => { deviceInfo: `${device.type || t('Device_Management_Device_Unknown')} ${device.vendor || ''} ${device.model || ''} ${ cpu.architecture || '' }`, - ipInfo: connection.clientAddress, + ipInfo: clientAddress, userAgent: '', date: moment().format(String(dateFormat)), }; @@ -105,7 +97,7 @@ export const listenSessionLogin = () => { mailData.deviceInfo = `Desktop App ${cpu.architecture || ''}`; break; default: - mailData.userAgent = connection.httpHeaders['user-agent'] || ''; + mailData.userAgent = userAgent || ''; break; } diff --git a/apps/meteor/server/hooks/sauMonitorHooks.ts b/apps/meteor/server/hooks/sauMonitorHooks.ts index 3137ab41237a1..ca985869af877 100644 --- a/apps/meteor/server/hooks/sauMonitorHooks.ts +++ b/apps/meteor/server/hooks/sauMonitorHooks.ts @@ -1,6 +1,6 @@ -import type { IncomingHttpHeaders } from 'http'; - +import { hashLoginToken } from '@rocket.chat/account-utils'; import { InstanceStatus } from '@rocket.chat/instance-status'; +import { getHeader } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -19,45 +19,41 @@ Accounts.onLogin((info: ILoginAttempt) => { } const { resume } = methodArguments.find((arg) => 'resume' in arg) ?? {}; + const loginToken = resume ? hashLoginToken(resume) : ''; + const instanceId = InstanceStatus.id(); + const clientAddress = info.connection.clientAddress || getHeader(httpHeaders, 'x-real-ip'); + const userAgent = getHeader(httpHeaders, 'user-agent'); + const host = getHeader(httpHeaders, 'host'); - const eventObject = { + sauEvents.emit('sau.accounts.login', { userId: info.user._id, - connection: { - ...info.connection, - ...(resume && { loginToken: Accounts._hashLoginToken(resume) }), - instanceId: InstanceStatus.id(), - httpHeaders: httpHeaders as IncomingHttpHeaders, - }, - }; - sauEvents.emit('accounts.login', eventObject); - deviceManagementEvents.emit('device-login', eventObject); + instanceId, + userAgent, + loginToken, + connectionId: info.connection.id, + clientAddress, + host, + }); + + if (!loginToken) { + deviceManagementEvents.emit('device-login', { userId: info.user._id, userAgent, clientAddress }); + } }); Accounts.onLogout((info) => { - const { httpHeaders } = info.connection; - if (!info.user) { return; } - sauEvents.emit('accounts.logout', { - userId: info.user._id, - connection: { instanceId: InstanceStatus.id(), ...info.connection, httpHeaders: httpHeaders as IncomingHttpHeaders }, - }); + + sauEvents.emit('sau.accounts.logout', { userId: info.user._id, sessionId: info.connection.id }); }); Meteor.onConnection((connection) => { connection.onClose(async () => { - const { httpHeaders } = connection; - sauEvents.emit('socket.disconnected', { - instanceId: InstanceStatus.id(), - ...connection, - httpHeaders: httpHeaders as IncomingHttpHeaders, - }); + sauEvents.emit('sau.socket.disconnected', { connectionId: connection.id, instanceId: InstanceStatus.id() }); }); }); Meteor.onConnection((connection) => { - const { httpHeaders } = connection; - - sauEvents.emit('socket.connected', { instanceId: InstanceStatus.id(), ...connection, httpHeaders: httpHeaders as IncomingHttpHeaders }); + sauEvents.emit('sau.socket.connected', { instanceId: InstanceStatus.id(), connectionId: connection.id }); }); diff --git a/apps/meteor/server/services/device-management/events.ts b/apps/meteor/server/services/device-management/events.ts index 5f99c63da472f..488b2e958d435 100644 --- a/apps/meteor/server/services/device-management/events.ts +++ b/apps/meteor/server/services/device-management/events.ts @@ -1,6 +1,5 @@ -import type { ISocketConnectionLogged } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; export const deviceManagementEvents = new Emitter<{ - 'device-login': { userId: string; connection: ISocketConnectionLogged }; + 'device-login': { userId: string; userAgent: string; clientAddress: string }; }>(); diff --git a/apps/meteor/server/services/device-management/service.ts b/apps/meteor/server/services/device-management/service.ts index 75f3639088922..b3f1402bc786a 100644 --- a/apps/meteor/server/services/device-management/service.ts +++ b/apps/meteor/server/services/device-management/service.ts @@ -1,5 +1,6 @@ import type { IDeviceManagementService } from '@rocket.chat/core-services'; import { ServiceClassInternal } from '@rocket.chat/core-services'; +import { getHeader } from '@rocket.chat/tools'; import { deviceManagementEvents } from './events'; @@ -9,9 +10,14 @@ export class DeviceManagementService extends ServiceClassInternal implements IDe constructor() { super(); - this.onEvent('accounts.login', async (data) => { - // TODO need to add loginToken to data - deviceManagementEvents.emit('device-login', data); + this.onEvent('accounts.login', async ({ userId, connection }) => { + if (!connection.loginToken) { + deviceManagementEvents.emit('device-login', { + userId, + userAgent: getHeader(connection.httpHeaders, 'user-agent'), + clientAddress: connection.clientAddress || getHeader(connection.httpHeaders, 'x-real-ip'), + }); + } }); } } diff --git a/apps/meteor/server/services/sauMonitor/events.ts b/apps/meteor/server/services/sauMonitor/events.ts index 44b971a91a70e..134092ca717f5 100644 --- a/apps/meteor/server/services/sauMonitor/events.ts +++ b/apps/meteor/server/services/sauMonitor/events.ts @@ -1,9 +1,16 @@ -import type { ISocketConnection, ISocketConnectionLogged } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; export const sauEvents = new Emitter<{ - 'accounts.login': { userId: string; connection: ISocketConnectionLogged }; - 'accounts.logout': { userId: string; connection: ISocketConnection }; - 'socket.connected': ISocketConnection; - 'socket.disconnected': ISocketConnection; + 'sau.accounts.login': { + userId: string; + instanceId: string; + connectionId: string; + loginToken?: string; + clientAddress: string; + userAgent: string; + host: string; + }; + 'sau.accounts.logout': { userId: string; sessionId: string }; + 'sau.socket.connected': { instanceId: string; connectionId: string }; + 'sau.socket.disconnected': { instanceId: string; connectionId: string }; }>(); diff --git a/apps/meteor/server/services/sauMonitor/service.ts b/apps/meteor/server/services/sauMonitor/service.ts index a0b36bb201633..d4b48924caab4 100644 --- a/apps/meteor/server/services/sauMonitor/service.ts +++ b/apps/meteor/server/services/sauMonitor/service.ts @@ -2,6 +2,7 @@ import { ServiceClassInternal } from '@rocket.chat/core-services'; import type { ISAUMonitorService } from '@rocket.chat/core-services'; +import { getHeader } from '@rocket.chat/tools'; import { sauEvents } from './events'; @@ -11,22 +12,28 @@ export class SAUMonitorService extends ServiceClassInternal implements ISAUMonit constructor() { super(); - this.onEvent('accounts.login', async (data) => { - sauEvents.emit('accounts.login', data); + this.onEvent('accounts.login', async ({ userId, connection }) => { + sauEvents.emit('sau.accounts.login', { + userId, + instanceId: connection.instanceId, + connectionId: connection.id, + loginToken: connection.loginToken, + clientAddress: connection.clientAddress || getHeader(connection.httpHeaders, 'x-real-ip'), + userAgent: getHeader(connection.httpHeaders, 'user-agent'), + host: getHeader(connection.httpHeaders, 'host'), + }); }); - this.onEvent('accounts.logout', async (data) => { - sauEvents.emit('accounts.logout', data); + this.onEvent('accounts.logout', async ({ userId, connection }) => { + sauEvents.emit('sau.accounts.logout', { userId, sessionId: connection.id }); }); this.onEvent('socket.disconnected', async (data) => { - // console.log('socket.disconnected', data); - sauEvents.emit('socket.disconnected', data); + sauEvents.emit('sau.socket.disconnected', { instanceId: data.instanceId, connectionId: data.id }); }); this.onEvent('socket.connected', async (data) => { - // console.log('socket.connected', data); - sauEvents.emit('socket.connected', data); + sauEvents.emit('sau.socket.connected', { instanceId: data.instanceId, connectionId: data.id }); }); } } diff --git a/packages/tools/src/getHeader.spec.ts b/packages/tools/src/getHeader.spec.ts new file mode 100644 index 0000000000000..71589322fc79f --- /dev/null +++ b/packages/tools/src/getHeader.spec.ts @@ -0,0 +1,65 @@ +import type { IncomingHttpHeaders } from 'http'; + +import { getHeader } from './getHeader'; + +describe('getHeader', () => { + describe('default mode (string)', () => { + it('returns empty string when headers is undefined', () => { + expect(getHeader(undefined as unknown as IncomingHttpHeaders, 'origin')).toBe(''); + }); + + it('returns empty string when header does not exist', () => { + expect(getHeader({ origin: 'localhost:3000' }, 'host')).toBe(''); + }); + + it('returns header value when it exists', () => { + expect(getHeader({ origin: 'localhost:3000' }, 'origin')).toBe('localhost:3000'); + }); + + it('returns empty string when headers is empty object', () => { + expect(getHeader({}, 'origin')).toBe(''); + }); + + it('returns empty string when value is undefined', () => { + expect(getHeader({ origin: undefined }, 'origin')).toBe(''); + }); + }); + + describe('generic array mode (string[])', () => { + it('returns the correct array when header is array', () => { + expect(getHeader({ 'x-forwarded-for': ['127.0.0.1', '10.0.0.1'] }, 'x-forwarded-for')).toStrictEqual([ + '127.0.0.1', + '10.0.0.1', + ]); + }); + + it('returns empty array when value is empty array', () => { + expect(getHeader({ 'x-forwarded-for': [] }, 'x-forwarded-for')).toStrictEqual([]); + }); + + it('returns string even when T is string[] (by design)', () => { + expect(getHeader({ origin: 'localhost:3000' }, 'origin')).toEqual('localhost:3000'); + }); + }); + + describe('IncomingHttpHeaders support', () => { + it('works with IncomingHttpHeaders', () => { + const headers: IncomingHttpHeaders = { + connection: 'keep-alive', + }; + + expect(getHeader(headers, 'connection')).toBe('keep-alive'); + expect(getHeader(headers, 'origin')).toBe(''); + }); + }); + + describe('Headers support', () => { + it('works with Headers', () => { + const headers = new Headers(); + headers.set('host', 'localhost:3000'); + + expect(getHeader(headers, 'host')).toBe('localhost:3000'); + expect(getHeader(headers, 'origin')).toBe(''); + }); + }); +}); diff --git a/packages/tools/src/getHeader.ts b/packages/tools/src/getHeader.ts new file mode 100644 index 0000000000000..bceaf6d364b07 --- /dev/null +++ b/packages/tools/src/getHeader.ts @@ -0,0 +1,15 @@ +import type { IncomingHttpHeaders } from 'http'; + +type HeaderLike = IncomingHttpHeaders | Headers | Record; + +export const getHeader = (headers: HeaderLike, key: string): T => { + if (!headers) { + return '' as T; + } + + if (headers instanceof Headers) { + return (headers.get(key) ?? '') as T; + } + + return (headers[key] ?? '') as T; +}; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 5f60085511b71..7889c9caf5caa 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -15,3 +15,4 @@ export * from './isRecord'; export * from './validateEmail'; export * from './truncateString'; export * from './isTruthy'; +export * from './getHeader';