Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 29 additions & 48 deletions apps/meteor/app/statistics/server/lib/SAUMonitor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ISession, ISessionDevice, ISocketConnectionLogged, IUser } from '@rocket.chat/core-typings';
import type { ISession, ISessionDevice, IUser, LoginSessionPayload, LogoutSessionPayload } 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';
Expand All @@ -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 };
Expand Down Expand Up @@ -111,21 +110,24 @@ export class SAUMonitorClass {
return;
}

sauEvents.on('accounts.login', async ({ userId, connection }) => {
if (!this.isRunning()) {
return;
}
sauEvents.on(
'sau.accounts.login',
async ({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }: LoginSessionPayload) => {
if (!this.isRunning()) {
return;
}

const roles = await getUserRoles(userId);
const roles = await getUserRoles(userId);

const mostImportantRole = getMostImportantRole(roles);
const mostImportantRole = getMostImportantRole(roles);

const loginAt = new Date();
const params = { userId, roles, mostImportantRole, loginAt, ...getDateObj() };
await this._handleSession(connection, params);
});
const loginAt = new Date();
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 }: LogoutSessionPayload) => {
if (!this.isRunning()) {
return;
}
Expand All @@ -135,7 +137,6 @@ export class SAUMonitorClass {
return;
}

const { id: sessionId } = connection;
if (!sessionId) {
logger.warn({ msg: "Received 'accounts.logout' event without 'sessionId'" });
return;
Expand All @@ -157,10 +158,20 @@ export class SAUMonitorClass {
}

private async _handleSession(
connection: ISocketConnectionLogged,
params: Pick<ISession, 'userId' | 'mostImportantRole' | 'loginAt' | 'day' | 'month' | 'year' | 'roles'>,
{ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }: LoginSessionPayload,
params: Pick<ISession, 'mostImportantRole' | 'loginAt' | 'day' | 'month' | 'year' | 'roles'>,
): Promise<void> {
const data = this._getConnectionInfo(connection, params);
const data: Omit<ISession, '_id' | '_updatedAt' | 'createdAt' | 'searchTerm'> = {
userId,
loginToken,
ip: clientAddress,
host,
sessionId: connectionId,
instanceId,
type: 'session',
...this._getUserAgentInfo(userAgent),
...params,
};

if (!data) {
return;
Expand Down Expand Up @@ -221,37 +232,7 @@ export class SAUMonitorClass {
.join('');
}

private _getConnectionInfo(
connection: ISocketConnectionLogged,
params: Pick<ISession, 'userId' | 'mostImportantRole' | 'loginAt' | 'day' | 'month' | 'year' | 'roles'>,
): Omit<ISession, '_id' | '_updatedAt' | 'createdAt' | 'searchTerm'> | 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 } }
Expand Down
14 changes: 5 additions & 9 deletions apps/meteor/ee/server/lib/deviceManagement/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ const uaParser = async (
};

export const listenSessionLogin = () => {
return deviceManagementEvents.on('device-login', async ({ userId, connection }) => {
return deviceManagementEvents.on('device-login', async ({ userId, userAgent, loginToken, clientAddress }) => {
const deviceEnabled = settings.get('Device_Management_Enable_Login_Emails');

if (!deviceEnabled) {
return;
}

if (connection.loginToken) {
if (loginToken) {
return;
}

Expand Down Expand Up @@ -67,11 +67,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,
Expand All @@ -81,7 +77,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)),
};
Expand All @@ -105,7 +101,7 @@ export const listenSessionLogin = () => {
mailData.deviceInfo = `Desktop App ${cpu.architecture || ''}`;
break;
default:
mailData.userAgent = connection.httpHeaders['user-agent'] || '';
mailData.userAgent = userAgent || '';
break;
}

Expand Down
57 changes: 39 additions & 18 deletions apps/meteor/server/hooks/sauMonitorHooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { IncomingHttpHeaders } from 'http';

import type { LoginSessionPayload, LogoutSessionPayload, DeviceLoginPayload } from '@rocket.chat/core-typings';
import { InstanceStatus } from '@rocket.chat/instance-status';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';

import type { ILoginAttempt } from '../../app/authentication/server/ILoginAttempt';
import { getClientAddress } from '../lib/getClientAddress';
import { getHeader } from '../lib/getHeader';
import { deviceManagementEvents } from '../services/device-management/events';
import { sauEvents } from '../services/sauMonitor/events';

Expand All @@ -19,36 +22,51 @@ Accounts.onLogin((info: ILoginAttempt) => {
}

const { resume } = methodArguments.find((arg) => 'resume' in arg) ?? {};
const loginToken = resume ? Accounts._hashLoginToken(resume) : '';
const instanceId = InstanceStatus.id();
const userId = info.user._id;
const connectionId = info.connection.id;
const clientAddress = getClientAddress(info.connection);
const userAgent = getHeader(httpHeaders, 'user-agent');
const host = getHeader(httpHeaders, 'host');

const eventObject = {
userId: info.user._id,
connection: {
...info.connection,
...(resume && { loginToken: Accounts._hashLoginToken(resume) }),
instanceId: InstanceStatus.id(),
httpHeaders: httpHeaders as IncomingHttpHeaders,
},
const loginEventObject: LoginSessionPayload = {
userId,
instanceId,
userAgent,
loginToken,
connectionId,
clientAddress,
host,
};
sauEvents.emit('accounts.login', eventObject);
deviceManagementEvents.emit('device-login', eventObject);
sauEvents.emit('sau.accounts.login', loginEventObject);

const deviceLoginEventObject: DeviceLoginPayload = {
userId,
userAgent,
loginToken,
clientAddress,
};
deviceManagementEvents.emit('device-login', deviceLoginEventObject);
});

Accounts.onLogout((info) => {
const { httpHeaders } = info.connection;

if (!info.user) {
return;
}
sauEvents.emit('accounts.logout', {

const logoutEventObject: LogoutSessionPayload = {
userId: info.user._id,
connection: { instanceId: InstanceStatus.id(), ...info.connection, httpHeaders: httpHeaders as IncomingHttpHeaders },
});
sessionId: info.connection.id,
};

sauEvents.emit('sau.accounts.logout', logoutEventObject);
});

Meteor.onConnection((connection) => {
connection.onClose(async () => {
const { httpHeaders } = connection;
sauEvents.emit('socket.disconnected', {
sauEvents.emit('sau.socket.disconnected', {
instanceId: InstanceStatus.id(),
...connection,
httpHeaders: httpHeaders as IncomingHttpHeaders,
Expand All @@ -58,6 +76,9 @@ Meteor.onConnection((connection) => {

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(),
...connection,
httpHeaders: httpHeaders as IncomingHttpHeaders,
});
});
11 changes: 11 additions & 0 deletions apps/meteor/server/lib/getHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const getHeader = (headers: unknown, key: string): string => {
if (!headers) {
return '';
}

if (typeof (headers as any).get === 'function') {
return (headers as Headers).get(key) ?? '';
}

return (headers as Record<string, string | undefined>)[key] || '';
};
4 changes: 2 additions & 2 deletions apps/meteor/server/services/device-management/events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ISocketConnectionLogged } from '@rocket.chat/core-typings';
import type { DeviceLoginPayload } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';

export const deviceManagementEvents = new Emitter<{
'device-login': { userId: string; connection: ISocketConnectionLogged };
'device-login': DeviceLoginPayload;
}>();
8 changes: 6 additions & 2 deletions apps/meteor/server/services/device-management/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ import type { IDeviceManagementService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';

import { deviceManagementEvents } from './events';
import { getClientAddress } from '../../lib/getClientAddress';
import { getHeader } from '../../lib/getHeader';

export class DeviceManagementService extends ServiceClassInternal implements IDeviceManagementService {
protected name = 'device-management';

constructor() {
super();

this.onEvent('accounts.login', async (data) => {
this.onEvent('accounts.login', async ({ userId, connection }) => {
const clientAddress = getClientAddress(connection);
const userAgent = getHeader(connection.httpHeaders, 'user-agent');
// TODO need to add loginToken to data
deviceManagementEvents.emit('device-login', data);
deviceManagementEvents.emit('device-login', { userId, userAgent, clientAddress });
});
}
}
10 changes: 5 additions & 5 deletions apps/meteor/server/services/sauMonitor/events.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { ISocketConnection, ISocketConnectionLogged } from '@rocket.chat/core-typings';
import type { ISocketConnection, LoginSessionPayload, LogoutSessionPayload } 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': LoginSessionPayload;
'sau.accounts.logout': LogoutSessionPayload;
'sau.socket.connected': ISocketConnection;
'sau.socket.disconnected': ISocketConnection;
Comment on lines +1 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update remaining listeners to sau.socket.disconnected.

Event keys changed here, but SAUMonitorClass._handleOnConnection still listens to socket.disconnected (Line 99 in apps/meteor/app/statistics/server/lib/SAUMonitor.ts). That listener will stop firing, leaving sessions open.

🔧 Suggested fix
-		sauEvents.on('socket.disconnected', async ({ id, instanceId }) => {
+		sauEvents.on('sau.socket.disconnected', async ({ id, instanceId }) => {
🤖 Prompt for AI Agents
In `@apps/meteor/server/services/sauMonitor/events.ts` around lines 1 - 8, The SAU
event keys were renamed to namespaced keys but
SAUMonitorClass._handleOnConnection still subscribes to the old
'socket.disconnected' event; update that listener to use the new
'sau.socket.disconnected' key so it matches the sauEvents emitter (reference the
sauEvents Emitter and the _handleOnConnection method in SAUMonitorClass),
ensuring the handler remains registered and sessions are closed correctly.

}>();
21 changes: 15 additions & 6 deletions apps/meteor/server/services/sauMonitor/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,40 @@

import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { ISAUMonitorService } from '@rocket.chat/core-services';
import { InstanceStatus } from '@rocket.chat/instance-status';

import { sauEvents } from './events';
import { getClientAddress } from '../../lib/getClientAddress';
import { getHeader } from '../../lib/getHeader';

export class SAUMonitorService extends ServiceClassInternal implements ISAUMonitorService {
protected name = 'sau-monitor';

constructor() {
super();

this.onEvent('accounts.login', async (data) => {
sauEvents.emit('accounts.login', data);
this.onEvent('accounts.login', async ({ userId, connection }) => {
const instanceId = InstanceStatus.id();
const connectionId = connection.id;
const clientAddress = getClientAddress(connection);
const userAgent = getHeader(connection.httpHeaders, 'user-agent');
const host = getHeader(connection.httpHeaders, 'host');

sauEvents.emit('sau.accounts.login', { userId, instanceId, connectionId, clientAddress, userAgent, 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', data);
});

this.onEvent('socket.connected', async (data) => {
// console.log('socket.connected', data);
sauEvents.emit('socket.connected', data);
sauEvents.emit('sau.socket.connected', data);
});
}
}
6 changes: 6 additions & 0 deletions packages/core-typings/src/DeviceLoginPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type DeviceLoginPayload = {
userId: string;
userAgent: string;
loginToken?: string;
clientAddress: string;
};
9 changes: 9 additions & 0 deletions packages/core-typings/src/LoginSessionPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type LoginSessionPayload = {
userId: string;
instanceId: string;
userAgent: string;
loginToken?: string;
connectionId: string;
clientAddress: string;
host: string;
};
4 changes: 4 additions & 0 deletions packages/core-typings/src/LogoutSessionPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type LogoutSessionPayload = {
userId?: string;
sessionId?: string;
};
4 changes: 4 additions & 0 deletions packages/core-typings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,8 @@ export * from './IAbacAttribute';
export * from './Abac';
export * from './ServerAudit/IAuditServerAbacAction';

export * from './LoginSessionPayload';
export * from './DeviceLoginPayload';
export * from './LogoutSessionPayload';

export { schemas } from './Ajv';
Loading