Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
49051b1
wip: extract required fields from DDP connection in auth hooks
nazabucciarelli Jan 19, 2026
3ca9f16
wip: flag commit
nazabucciarelli Jan 20, 2026
0b0a9a2
run linter and fix DeviceLoginPayload type issue
nazabucciarelli Jan 21, 2026
7bbe051
add LogoutSesionPayload type use
nazabucciarelli Jan 21, 2026
d02ff34
add properties funneling on sau events
nazabucciarelli Jan 21, 2026
3d6bfed
move types to sau folder, enhance socket.disconnected parameters
nazabucciarelli Jan 22, 2026
83f3da5
Merge branch 'develop' into chore/ddp-headers
nazabucciarelli Jan 22, 2026
4aea9c5
prettier fix
nazabucciarelli Jan 22, 2026
11eaf8c
edit logger message
nazabucciarelli Jan 22, 2026
668c2a3
remove unreachable code
nazabucciarelli Jan 23, 2026
fe4ed1f
Merge branch 'develop' into chore/ddp-headers
nazabucciarelli Jan 23, 2026
ba3990c
add forgotten loginToken
nazabucciarelli Jan 23, 2026
3c5c1e0
Merge branch 'chore/ddp-headers' of github.com:RocketChat/Rocket.Chat…
nazabucciarelli Jan 23, 2026
ecf59a7
remove use of created types
nazabucciarelli Jan 23, 2026
e76d829
add optional operator to loginToken
nazabucciarelli Jan 23, 2026
4ed51c6
add optional to loginToken
nazabucciarelli Jan 26, 2026
b94212c
Merge branch 'develop' into chore/ddp-headers
nazabucciarelli Jan 26, 2026
7586e19
modify instanceId param
nazabucciarelli Jan 26, 2026
9c2b6db
Merge branch 'chore/ddp-headers' of github.com:RocketChat/Rocket.Chat…
nazabucciarelli Jan 26, 2026
5cf8c84
Merge branch 'develop' into chore/ddp-headers
nazabucciarelli Jan 26, 2026
eac64ef
rollback deno.lock changes by accident
nazabucciarelli Jan 26, 2026
b65be50
Merge branch 'chore/ddp-headers' of github.com:RocketChat/Rocket.Chat…
nazabucciarelli Jan 26, 2026
9ce09a4
make code cleaner
nazabucciarelli Jan 26, 2026
f86de12
Apply suggestion from @KevLehman
nazabucciarelli Jan 26, 2026
73403e2
enhance getHeader method and add unit tests
nazabucciarelli Jan 26, 2026
2a47751
Merge branch 'chore/ddp-headers' of github.com:RocketChat/Rocket.Chat…
nazabucciarelli Jan 26, 2026
64cf87c
rollback deno.lock (again)
nazabucciarelli Jan 27, 2026
6d3d051
change hashLoginToken method
nazabucciarelli Jan 27, 2026
cef0138
improve getHeader tool
nazabucciarelli Jan 27, 2026
2361dad
add loginToken data to device-management hook
nazabucciarelli Jan 27, 2026
fa604e3
overload getHeader, update tests
nazabucciarelli Jan 27, 2026
497e50d
remove getClientAddress, make getHeader generic
nazabucciarelli Jan 27, 2026
0700e17
change ?? by ||
nazabucciarelli Jan 27, 2026
a9830a4
Merge branch 'develop' into chore/ddp-headers
nazabucciarelli Jan 27, 2026
1616785
move loginToken conditional upstream preventing device-login duplicat…
nazabucciarelli Jan 27, 2026
5b2120c
Merge branch 'develop' into chore/ddp-headers
jessicaschelly Feb 10, 2026
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
82 changes: 33 additions & 49 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 } 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 All @@ -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
*/
Expand Down Expand Up @@ -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);
});
}

Expand All @@ -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;
}
Expand All @@ -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;
}

Expand All @@ -157,14 +165,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 }: HandleSessionArgs,
params: Pick<ISession, 'mostImportantRole' | 'loginAt' | 'day' | 'month' | 'year' | 'roles'>,
): Promise<void> {
const data = this._getConnectionInfo(connection, params);

if (!data) {
return;
}
const data: Omit<ISession, '_id' | '_updatedAt' | 'createdAt' | 'searchTerm'> = {
userId,
...(loginToken && { loginToken }),
ip: clientAddress,
host,
sessionId: connectionId,
instanceId,
type: 'session',
...(loginToken && this._getUserAgentInfo(userAgent)),
...params,
};

const searchTerm = this._getSearchTerm(data);

Expand Down Expand Up @@ -221,37 +235,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
16 changes: 4 additions & 12 deletions apps/meteor/ee/server/lib/deviceManagement/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Expand All @@ -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,
Expand All @@ -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)),
};
Expand All @@ -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;
}

Expand Down
50 changes: 23 additions & 27 deletions apps/meteor/server/hooks/sauMonitorHooks.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 });
});
3 changes: 1 addition & 2 deletions apps/meteor/server/services/device-management/events.ts
Original file line number Diff line number Diff line change
@@ -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 };
}>();
12 changes: 9 additions & 3 deletions apps/meteor/server/services/device-management/service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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'),
});
}
});
}
}
17 changes: 12 additions & 5 deletions apps/meteor/server/services/sauMonitor/events.ts
Original file line number Diff line number Diff line change
@@ -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 };
}>();
23 changes: 15 additions & 8 deletions apps/meteor/server/services/sauMonitor/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 });
});
}
}
Loading
Loading