snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import type { MeteorError } from '@rocket.chat/core-services';
import type { IUser, IMethodConnection } from '@rocket.chat/core-typings';
interface IMethodArgument {
user?: { username: string };
password?: {
digest: string;
algorithm: string;
};
resume?: string;
cas?: boolean;
totp?: {
code: string;
};
}
export interface ILoginAttempt {
type: string;
allowed: boolean;
methodName: string;
methodArguments: IMethodArgument[];
connection: IMethodConnection;
user?: IUser;
error?: MeteorError;
}

View File

@@ -0,0 +1,31 @@
import generator from 'generate-password';
import { passwordPolicy } from './passwordPolicy';
export const generatePassword = (): string => {
const policies = passwordPolicy.getPasswordPolicy();
const maxLength: number = (policies.policy.find(([key]) => key === 'get-password-policy-maxLength')?.[1]?.maxLength as number) || -1;
const minLength: number = (policies.policy.find(([key]) => key === 'get-password-policy-minLength')?.[1]?.minLength as number) || -1;
const length = Math.min(Math.max(minLength, 12), maxLength > 0 ? maxLength : Number.MAX_SAFE_INTEGER);
if (policies.enabled) {
for (let i = 0; i < 10; i++) {
const password = generator.generate({
length,
...(policies.policy && { numbers: true }),
...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneSpecialCharacter') && { symbols: true }),
...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneLowercase') && { lowercase: true }),
...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneUppercase') && { uppercase: true }),
strict: true,
});
if (passwordPolicy.validate(password)) {
return password;
}
}
}
return generator.generate({ length: 17 });
};

View File

@@ -0,0 +1,37 @@
import type { ILoginAttempt } from './ILoginAttempt';
import { settings } from '../../settings';
import { SystemLogger } from '../logger/system';
export const logFailedLoginAttempts = (login: ILoginAttempt): void => {
if (!settings.get('Login_Logs_Enabled')) {
return;
}
let user = 'unknown';
if (login.methodArguments[0]?.user?.username && settings.get('Login_Logs_Username')) {
user = login.methodArguments[0]?.user?.username;
}
const { connection } = login;
let { clientAddress } = connection;
if (!settings.get('Login_Logs_ClientIp')) {
clientAddress = '-';
}
let forwardedFor = connection.httpHeaders?.['x-forwarded-for'];
let realIp = connection.httpHeaders?.['x-real-ip'];
if (!settings.get('Login_Logs_ForwardedForIp')) {
forwardedFor = '-';
realIp = '-';
}
let userAgent = connection.httpHeaders?.['user-agent'];
if (!settings.get('Login_Logs_UserAgent')) {
userAgent = '-';
}
SystemLogger.info({
msg: 'Failed login detected',
user,
clientAddress,
forwardedFor,
realIp,
userAgent,
});
};

View File

@@ -0,0 +1,16 @@
// Do not disclose if user exists when password is invalid
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
const { _runLoginHandlers } = Accounts;
Accounts._options.ambiguousErrorMessages = true;
Accounts._runLoginHandlers = async function (methodInvocation, options) {
const result = await _runLoginHandlers.call(Accounts, methodInvocation, options);
if (result.error instanceof Meteor.Error) {
result.error = new Meteor.Error(401, 'User not found');
}
return result;
};

View File

@@ -0,0 +1,69 @@
import type { IOAuthApps, IUser } from '@rocket.chat/core-typings';
import { OAuthApps, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Meteor } from 'meteor/meteor';
import { parseUriList } from './parseUriList';
import type { OauthAppsAddParams } from '../../../api/v1/oauthapps';
import { hasPermissionAsync } from '../../authorization/hasPermission';
export async function addOAuthApp(applicationParams: OauthAppsAddParams, uid: IUser['_id'] | undefined): Promise<IOAuthApps> {
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthApp' });
}
const user = await Users.findOneById(uid, { projection: { username: 1 } });
if (!user?.username) {
// TODO: username is required, but not always present
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthApp' });
}
if (!(await hasPermissionAsync(uid, 'manage-oauth-apps'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addOAuthApp' });
}
if (!applicationParams.name || typeof applicationParams.name.valueOf() !== 'string' || applicationParams.name.trim() === '') {
throw new Meteor.Error('error-invalid-name', 'Invalid name', { method: 'addOAuthApp' });
}
if (
!applicationParams.redirectUri ||
typeof applicationParams.redirectUri.valueOf() !== 'string' ||
applicationParams.redirectUri.trim() === ''
) {
throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', {
method: 'addOAuthApp',
});
}
if (typeof applicationParams.active !== 'boolean') {
throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', {
method: 'addOAuthApp',
});
}
const application = {
...applicationParams,
redirectUri: parseUriList(applicationParams.redirectUri),
clientId: Random.id(),
clientSecret: Random.secret(),
_createdAt: new Date(),
_updatedAt: new Date(),
_createdBy: {
_id: user._id,
username: user.username,
},
};
if (application.redirectUri.length === 0) {
throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', {
method: 'addOAuthApp',
});
}
return {
...application,
_id: (await OAuthApps.insertOne(application)).insertedId,
};
}

View File

@@ -0,0 +1,4 @@
import './oauth2-server';
import './addOAuthApp';
import '../../../meteor-methods/auth/updateOAuthApp';
import '../../../meteor-methods/auth/deleteOAuthApp';

View File

@@ -0,0 +1,85 @@
import type { IUser } from '@rocket.chat/core-typings';
import { OAuthAccessTokens, Users } from '@rocket.chat/models';
import type { Request, Response } from 'express';
import type express from 'express';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { isPlainObject } from '../../../../lib/utils/isPlainObject';
import { API } from '../../../api';
import { OAuth2Server } from '../../../oauth2-server/oauth';
const oauth2server = new OAuth2Server({
// If you're developing something related to oauth servers, you should change this to true
debug: false,
});
// https://github.com/RocketChat/rocketchat-oauth2-server/blob/e758fd7ef69348c7ceceabe241747a986c32d036/model.coffee#L27-L27
async function getAccessToken(accessToken: string) {
return OAuthAccessTokens.findOneByAccessToken(accessToken);
}
export async function oAuth2ServerAuth(partialRequest: { authorization?: string; accessToken?: string }): Promise<IUser | undefined> {
const headerToken = partialRequest.authorization?.replace('Bearer ', '');
const incomingToken = headerToken || partialRequest.accessToken;
if (!incomingToken) {
return;
}
const accessToken = await getAccessToken(incomingToken);
// If there is no token available or the token has expired, return undefined
if (!accessToken || (accessToken.expires != null && accessToken.expires < new Date())) {
return;
}
const user = await Users.findOneActiveById(accessToken.userId);
if (!user) {
return;
}
return user;
}
oauth2server.app.disable('x-powered-by');
oauth2server.app.get('/oauth/userinfo', async (req: Request, res: Response) => {
if (req.headers.authorization == null) {
return res.status(401).send('No token');
}
const accessToken = req.headers.authorization.replace('Bearer ', '');
const token = await getAccessToken(accessToken);
if (token == null) {
return res.status(401).send('Invalid Token');
}
const user = await Users.findOneActiveById(token.userId);
if (!user) {
return res.status(401).send('Invalid Token');
}
return res.send({
sub: user._id,
name: user.name,
email: user.emails?.[0].address,
email_verified: user.emails?.[0].verified,
department: '',
birthdate: '',
preffered_username: user.username,
updated_at: user._updatedAt,
picture: `${Meteor.absoluteUrl()}avatar/${user.username}`,
});
});
API.v1.addAuthMethod((routeContext) => {
const authorization = routeContext.request.headers.get('authorization') ?? undefined;
const query = isPlainObject(routeContext.queryParams) ? routeContext.queryParams : {};
const accessToken = typeof query.access_token === 'string' ? query.access_token : undefined;
if (routeContext.queryParams && 'access_token' in routeContext.queryParams) {
delete routeContext.queryParams.access_token;
}
return oAuth2ServerAuth({ authorization, accessToken });
});
(WebApp.connectHandlers as unknown as ReturnType<typeof express>).use(oauth2server.app);

View File

@@ -0,0 +1,17 @@
export const parseUriList = (userUri: string) => {
if (userUri.indexOf('\n') < 0 && userUri.indexOf(',') < 0) {
return userUri;
}
const uriList: string[] = [];
userUri.split(/[,\n]/).forEach((item) => {
const uri = item.trim();
if (uri === '') {
return;
}
uriList.push(uri);
});
return uriList.join(','); // TODO: This is a hack because the original code was returning a string or an array of strings
};

View File

@@ -0,0 +1,64 @@
import { PasswordPolicy } from '@rocket.chat/password-policies';
import { settings } from '../../settings';
const enabled = false;
const minLength = -1;
const maxLength = -1;
const forbidRepeatingCharacters = false;
const forbidRepeatingCharactersCount = 3;
const mustContainAtLeastOneLowercase = false;
const mustContainAtLeastOneUppercase = false;
const mustContainAtLeastOneNumber = false;
const mustContainAtLeastOneSpecialCharacter = false;
export let passwordPolicy = new PasswordPolicy({
enabled,
minLength,
maxLength,
forbidRepeatingCharacters,
forbidRepeatingCharactersCount,
mustContainAtLeastOneLowercase,
mustContainAtLeastOneUppercase,
mustContainAtLeastOneNumber,
mustContainAtLeastOneSpecialCharacter,
throwError: true,
});
settings.watchMultiple(
[
'Accounts_Password_Policy_Enabled',
'Accounts_Password_Policy_MinLength',
'Accounts_Password_Policy_MaxLength',
'Accounts_Password_Policy_ForbidRepeatingCharacters',
'Accounts_Password_Policy_ForbidRepeatingCharactersCount',
'Accounts_Password_Policy_AtLeastOneLowercase',
'Accounts_Password_Policy_AtLeastOneUppercase',
'Accounts_Password_Policy_AtLeastOneNumber',
'Accounts_Password_Policy_AtLeastOneSpecialCharacter',
],
([
enabled,
minLength,
maxLength,
forbidRepeatingCharacters,
forbidRepeatingCharactersCount,
mustContainAtLeastOneLowercase,
mustContainAtLeastOneUppercase,
mustContainAtLeastOneNumber,
mustContainAtLeastOneSpecialCharacter,
]) => {
passwordPolicy = new PasswordPolicy({
enabled: Boolean(enabled),
minLength: Number(minLength),
maxLength: Number(maxLength),
forbidRepeatingCharacters: Boolean(forbidRepeatingCharacters),
forbidRepeatingCharactersCount: Number(forbidRepeatingCharactersCount),
mustContainAtLeastOneLowercase: Boolean(mustContainAtLeastOneLowercase),
mustContainAtLeastOneUppercase: Boolean(mustContainAtLeastOneUppercase),
mustContainAtLeastOneNumber: Boolean(mustContainAtLeastOneNumber),
mustContainAtLeastOneSpecialCharacter: Boolean(mustContainAtLeastOneSpecialCharacter),
throwError: true,
});
},
);

View File

@@ -0,0 +1,173 @@
import type { IServerEvent } from '@rocket.chat/core-typings';
import { ServerEventType } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Rooms, ServerEvents, Users } from '@rocket.chat/models';
import { addMinutesToADate } from '../../../lib/utils/addMinutesToADate';
import { settings } from '../../settings';
import { getClientAddress } from '../getClientAddress';
import type { ILoginAttempt } from './ILoginAttempt';
import { sendMessage } from '../messages/sendMessage';
const logger = new Logger('LoginProtection');
const notifyFailedLogin = async (ipOrUsername: string, blockedUntil: Date, failedAttempts: number): Promise<void> => {
const channelToNotify = settings.get<string>('Block_Multiple_Failed_Logins_Notify_Failed_Channel');
if (!channelToNotify) {
logger.error('Cannot notify failed logins: channel provided is invalid');
return;
}
// verify channel exists
// to avoid issues when "fname" is presented in the UI, check if the name matches it as well
const room = await Rooms.findOneByNameOrFname(channelToNotify);
if (!room) {
logger.error({ msg: 'Cannot notify failed logins: channel provided does not exist', channelToNotify });
return;
}
const rocketCat = await Users.findOneById('rocket.cat');
// send message
const message = {
attachments: [
{
fields: [
{
title: 'Failed login attempt threshold exceeded',
value: `User or IP: ${ipOrUsername}\nBlocked until: ${blockedUntil}\nFailed Attempts: ${failedAttempts}`,
short: true,
},
],
color: 'red',
},
],
};
await sendMessage(rocketCat, message, room);
};
export const isValidLoginAttemptByIp = async (ip: string): Promise<boolean> => {
const whitelist = String(settings.get('Block_Multiple_Failed_Logins_Ip_Whitelist')).split(',');
if (
!settings.get('Block_Multiple_Failed_Logins_Enabled') ||
!settings.get('Block_Multiple_Failed_Logins_By_Ip') ||
whitelist.includes(ip)
) {
return true;
}
// misconfigured
const attemptsUntilBlock = settings.get<number>('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip');
if (!attemptsUntilBlock) {
return true;
}
// if user never failed to login, then it's valid
const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts;
if (!lastFailedAttemptAt) {
return true;
}
const minutesUntilUnblock = settings.get<number>('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes');
const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1);
const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByIp(ip))?.ts;
// successful logins should reset the counter
const startTime = lastSuccessfulAttemptAt
? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime()))
: lockoutTimeStart;
const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIpSince(ip, startTime);
// if user didn't reach the threshold, then it's valid
if (failedAttemptsSinceLastLogin < attemptsUntilBlock) {
return true;
}
if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) {
const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock);
await notifyFailedLogin(ip, willBeBlockedUntil, failedAttemptsSinceLastLogin);
}
return false;
};
export const isValidAttemptByUser = async (login: ILoginAttempt): Promise<boolean> => {
if (!settings.get('Block_Multiple_Failed_Logins_Enabled') || !settings.get('Block_Multiple_Failed_Logins_By_User')) {
return true;
}
const loginUsername = login.methodArguments[0].user?.username;
if (!loginUsername) {
return true;
}
// misconfigured
const attemptsUntilBlock = settings.get<number>('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User');
if (!attemptsUntilBlock) {
return true;
}
// if user never failed to login, then it's valid
const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(loginUsername))?.ts;
if (!lastFailedAttemptAt) {
return true;
}
const minutesUntilUnblock = settings.get<number>('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes');
const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1);
const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByUsername(loginUsername))?.ts;
// succesful logins should reset the counter
const startTime = lastSuccessfulAttemptAt
? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime()))
: lockoutTimeStart;
// get total failed attempts during the lockout time
const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsernameSince(loginUsername, startTime);
// if user didn't reach the threshold, then it's valid
if (failedAttemptsSinceLastLogin < attemptsUntilBlock) {
return true;
}
if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) {
const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock);
await notifyFailedLogin(loginUsername, willBeBlockedUntil, failedAttemptsSinceLastLogin);
}
return false;
};
export const saveFailedLoginAttempts = async (login: ILoginAttempt): Promise<void> => {
const user: IServerEvent['u'] = {
_id: login.user?._id,
username: login.user?.username || login.methodArguments[0].user?.username,
};
await ServerEvents.insertOne({
ip: getClientAddress(login.connection),
t: ServerEventType.FAILED_LOGIN_ATTEMPT,
ts: new Date(),
u: user,
});
};
export const saveSuccessfulLogin = async (login: ILoginAttempt): Promise<void> => {
const user: IServerEvent['u'] = {
_id: login.user?._id,
username: login.user?.username || login.methodArguments[0].user?.username,
};
await ServerEvents.insertOne({
ip: getClientAddress(login.connection),
t: ServerEventType.LOGIN,
ts: new Date(),
u: user,
});
};

View File

@@ -0,0 +1,531 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { User } from '@rocket.chat/core-services';
import { Roles, Settings, Users } from '@rocket.chat/models';
import { escapeRegExp, escapeHTML, getLoginExpirationInDays, removeEmpty } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';
import { isValidAttemptByUser, isValidLoginAttemptByIp } from './restrictLoginAttempts';
import { parseCSV } from '../../../lib/utils/parseCSV';
import { safeHtmlDots } from '../../../lib/utils/safeHtmlDots';
import { getNewUserRoles } from '../../services/user/lib/getNewUserRoles';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { beforeCreateUserCallback } from '../callbacks/beforeCreateUserCallback';
import { getClientAddress } from '../getClientAddress';
import { getMaxLoginTokens } from '../getMaxLoginTokens';
import { i18n } from '../i18n';
import * as Mailer from '../notifications/email/api';
import { notifyOnSettingChangedById } from '../notifyListener';
import { addUserRolesAsync } from '../roles/addUserRoles';
import { joinDefaultChannels } from '../rooms/joinDefaultChannels';
import { getAvatarSuggestionForUser } from '../users/getAvatarSuggestionForUser';
import { setAvatarFromServiceWithValidation } from '../users/setUserAvatar';
import { getBaseUserFields } from '../utils/functions/getBaseUserFields';
Accounts.config({
forbidClientAccountCreation: true,
});
/**
* Accounts calls `_initServerPublications` and holds the `_defaultPublishFields`, without Object.assign its not possible
* to extend the projection
*
* the idea is to send all required fields to the client during login
* we tried `defaultFieldsSelector` , but it changes all Meteor.userAsync projections which is undesirable
*
*
* we are removing the status here because meteor send 'offline'
*/
Object.assign(Accounts._defaultPublishFields.projection, (({ status, ...rest }) => rest)(getBaseUserFields(true)));
// Override Meteor's _expireTokens to ensure the correct login expiration is used.
// If loginExpirationInDays is not set (e.g., startup was disrupted before settings watcher fired),
// read the setting directly from MongoDB before proceeding with token cleanup.
// This prevents tokens from being incorrectly deleted using Meteor's 90-day default.
const { _expireTokens } = Accounts;
Accounts._expireTokens = async function (oldestValidDate, userId) {
if (!Accounts._options.loginExpirationInDays) {
const loginExpiration = await Settings.getValueById('Accounts_LoginExpiration');
Accounts._options.loginExpirationInDays = getLoginExpirationInDays(loginExpiration);
}
return _expireTokens.call(Accounts, oldestValidDate, userId);
};
Meteor.startup(() => {
settings.watchMultiple(['Accounts_LoginExpiration', 'Site_Name', 'From_Email'], () => {
Accounts._options.loginExpirationInDays = getLoginExpirationInDays(settings.get('Accounts_LoginExpiration'));
Accounts.emailTemplates.siteName = settings.get('Site_Name');
Accounts.emailTemplates.from = `${settings.get('Site_Name')} <${settings.get('From_Email')}>`;
});
});
Accounts.emailTemplates.userToActivate = {
subject() {
const subject = i18n.t('Accounts_Admin_Email_Approval_Needed_Subject_Default');
const siteName = settings.get('Site_Name');
return `[${siteName}] ${subject}`;
},
html(options = {}) {
const email = options.reason
? 'Accounts_Admin_Email_Approval_Needed_With_Reason_Default'
: 'Accounts_Admin_Email_Approval_Needed_Default';
return Mailer.replace(i18n.t(email), {
name: escapeHTML(options.name),
email: escapeHTML(options.email),
reason: escapeHTML(options.reason),
});
},
};
Accounts.emailTemplates.userActivated = {
subject({ active, username }) {
const activated = username ? 'Activated' : 'Approved';
const action = active ? activated : 'Deactivated';
const subject = `Accounts_Email_${action}_Subject`;
const siteName = settings.get('Site_Name');
return `[${siteName}] ${i18n.t(subject)}`;
},
html({ active, name, username }) {
const activated = username ? 'Activated' : 'Approved';
const action = active ? activated : 'Deactivated';
return Mailer.replace(i18n.t(`Accounts_Email_${action}`), {
name: escapeHTML(name),
});
},
};
let verifyEmailTemplate = '';
let enrollAccountTemplate = '';
let resetPasswordTemplate = '';
Meteor.startup(() => {
Mailer.getTemplateWrapped('Verification_Email', (value) => {
verifyEmailTemplate = value;
});
Mailer.getTemplateWrapped('Accounts_Enrollment_Email', (value) => {
enrollAccountTemplate = value;
});
Mailer.getTemplateWrapped('Forgot_Password_Email', (value) => {
resetPasswordTemplate = value;
});
});
Accounts.emailTemplates.verifyEmail.html = function (userModel, url) {
const name = safeHtmlDots(userModel.name);
return Mailer.replace(verifyEmailTemplate, { Verification_Url: url, name });
};
Accounts.emailTemplates.verifyEmail.subject = function () {
const subject = settings.get('Verification_Email_Subject');
return Mailer.replace(subject || '');
};
Accounts.urls.resetPassword = function (token) {
return Meteor.absoluteUrl(`reset-password/${token}`);
};
Accounts.emailTemplates.resetPassword.subject = function (userModel) {
return Mailer.replace(settings.get('Forgot_Password_Email_Subject') || '', {
name: userModel.name,
});
};
Accounts.emailTemplates.resetPassword.html = function (userModel, url) {
return Mailer.replacekey(
Mailer.replace(resetPasswordTemplate, {
name: userModel.name,
}),
'Forgot_Password_Url',
url,
);
};
Accounts.emailTemplates.enrollAccount.subject = function (user) {
const subject = settings.get('Accounts_Enrollment_Email_Subject');
return Mailer.replace(subject, user);
};
Accounts.emailTemplates.enrollAccount.html = function (user = {} /* , url*/) {
return Mailer.replace(enrollAccountTemplate, {
name: escapeHTML(user.name),
email: user.emails && user.emails[0] && escapeHTML(user.emails[0].address),
});
};
const getLinkedInName = ({ firstName, lastName }) => {
const { preferredLocale, localized: firstNameLocalized } = firstName;
const { localized: lastNameLocalized } = lastName;
// LinkedIn new format
if (preferredLocale && firstNameLocalized && preferredLocale.language && preferredLocale.country) {
const locale = `${preferredLocale.language}_${preferredLocale.country}`;
if (firstNameLocalized[locale] && lastNameLocalized[locale]) {
return `${firstNameLocalized[locale]} ${lastNameLocalized[locale]}`;
}
if (firstNameLocalized[locale]) {
return firstNameLocalized[locale];
}
}
// LinkedIn old format
if (!lastName) {
return firstName;
}
return `${firstName} ${lastName}`;
};
const validateEmailDomain = (user) => {
if (user.type === 'visitor') {
return true;
}
let domainWhiteList = settings.get('Accounts_AllowedDomainsList');
if (_.isEmpty(domainWhiteList?.trim())) {
return true;
}
domainWhiteList = domainWhiteList.split(',').map((domain) => domain.trim());
if (user.emails && user.emails.length > 0) {
const email = user.emails[0].address;
const inWhiteList = domainWhiteList.some((domain) => email.match(`@${escapeRegExp(domain)}$`));
if (!inWhiteList) {
throw new Meteor.Error('error-invalid-domain');
}
}
return true;
};
const onCreateUserAsync = async function (options, user = {}) {
if (!options.skipBeforeCreateUserCallback) {
await beforeCreateUserCallback.run(options, user);
}
user.status = 'offline';
user.active = user.active !== undefined ? user.active : !settings.get('Accounts_ManuallyApproveNewUsers');
if (settings.get('Accounts_ManuallyApproveNewUsers') && !user.active) {
user.inactiveReason = 'pending_approval';
}
if (!user.name) {
if (options.profile) {
if (options.profile.name) {
user.name = options.profile.name;
} else if (options.profile.firstName) {
// LinkedIn format
user.name = getLinkedInName(options.profile);
}
}
}
if (user.services) {
const verified = settings.get('Accounts_Verify_Email_For_External_Accounts');
for (const service of Object.values(user.services)) {
const suggestedName = service.name || service.username;
if (!user.name && suggestedName) {
user.name = suggestedName;
}
if (!user.emails && service.email) {
user.emails = [
{
address: service.email,
verified,
},
];
}
}
}
if (!options.skipAdminEmail && !user.active) {
const destinations = [];
const usersInRole = await Roles.findUsersInRole('admin');
await usersInRole.forEach((adminUser) => {
if (Array.isArray(adminUser.emails)) {
adminUser.emails.forEach((email) => {
destinations.push(`${adminUser.name}<${email.address}>`);
});
}
});
const email = {
to: destinations,
from: settings.get('From_Email'),
subject: Accounts.emailTemplates.userToActivate.subject(),
html: Accounts.emailTemplates.userToActivate.html({
...options,
name: options.name || options.profile?.name,
email: options.email || user.emails[0].address,
}),
};
await Mailer.send(email);
}
if (!options.skipOnCreateUserCallback) {
await callbacks.run('onCreateUser', options, user);
}
if (!options.skipEmailValidation && !validateEmailDomain(user)) {
throw new Meteor.Error(403, 'User validation failed');
}
return removeEmpty(user);
};
Accounts.onCreateUser(function (...args) {
// Depends on meteor support for Async
return onCreateUserAsync.call(this, ...args);
});
const { insertUserDoc } = Accounts;
Accounts.insertUserDoc = async function (options, user) {
const globalRoles = new Set();
if (Match.test(options.globalRoles, [String]) && options.globalRoles.length > 0) {
options.globalRoles.map((role) => globalRoles.add(role));
}
if (Match.test(user.globalRoles, [String]) && user.globalRoles.length > 0) {
user.globalRoles.map((role) => globalRoles.add(role));
}
delete user.globalRoles;
// for some reason, the name is not being set in the user object but is being set in the options object
if (options.name && typeof options.name === 'string') {
user.name = options.name;
}
if (user.services && !user.services.password && !options.skipAuthServiceDefaultRoles) {
const defaultAuthServiceRoles = parseCSV(settings.get('Accounts_Registration_AuthenticationServices_Default_Roles') || '');
if (defaultAuthServiceRoles.length > 0) {
defaultAuthServiceRoles.map((role) => globalRoles.add(role));
}
}
const arrayGlobalRoles = [...globalRoles];
const roles = options.skipNewUserRolesSetting ? arrayGlobalRoles : getNewUserRoles(arrayGlobalRoles);
if (!user.type) {
user.type = 'user';
}
if (
settings.get('Accounts_TwoFactorAuthentication_Enabled') &&
settings.get('Accounts_TwoFactorAuthentication_By_Email_Enabled') &&
settings.get('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In')
) {
user.services = user.services || {};
user.services.email2fa = {
enabled: true,
changedAt: new Date(),
};
}
// Make sure that the user has the field 'roles'
if (!user.roles) {
user.roles = [];
}
const _id = await insertUserDoc.call(Accounts, options, user);
user = await Users.findOne({
_id,
});
/**
* if settings shows setup wizard to be pending
* and no admin's been found,
* and existing role list doesn't include admin
* create this user admin.
* count this as the completion of setup wizard step 1.
*/
if (!options.skipAdminCheck) {
const hasAdmin = await Users.findOneByRolesAndType('admin', 'user', { projection: { _id: 1 } });
if (!roles.includes('admin') && !hasAdmin) {
roles.push('admin');
if (settings.get('Show_Setup_Wizard') === 'pending') {
// TODO: audit
(await Settings.updateValueById('Show_Setup_Wizard', 'in_progress')).modifiedCount &&
void notifyOnSettingChangedById('Show_Setup_Wizard');
}
}
}
await addUserRolesAsync(_id, roles);
// Make user's roles to be present on callback
user = await Users.findOneById(_id, { projection: { username: 1, type: 1, roles: 1 } });
if (user.username) {
if (options.joinDefaultChannels !== false) {
await joinDefaultChannels(_id, options.joinDefaultChannelsSilenced);
}
if (!options.skipAfterCreateUserCallback && user.type !== 'visitor') {
setImmediate(() => {
return callbacks.run('afterCreateUser', user);
});
}
if (!options.skipDefaultAvatar && settings.get('Accounts_SetDefaultAvatar') === true) {
const avatarSuggestions = await getAvatarSuggestionForUser(user);
for (const service of Object.keys(avatarSuggestions)) {
const avatarData = avatarSuggestions[service];
if (service !== 'gravatar') {
await setAvatarFromServiceWithValidation(_id, avatarData.blob, '', service);
break;
}
}
}
}
if (!options.skipAppsEngineEvent) {
// `post` triggered events don't need to wait for the promise to resolve
Apps.self?.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: options.performedBy }).catch((e) => {
Apps.self?.getRocketChatLogger().error({ msg: 'Error while executing post user created event', err: e });
});
}
return _id;
};
const validateLoginAttemptAsync = async function (login) {
login = await callbacks.run('beforeValidateLogin', login);
if (!(await isValidLoginAttemptByIp(getClientAddress(login.connection)))) {
throw new Meteor.Error('error-login-blocked-for-ip', 'Login has been temporarily blocked For IP', {
function: 'Accounts.validateLoginAttempt',
});
}
if (!(await isValidAttemptByUser(login))) {
throw new Meteor.Error('error-login-blocked-for-user', 'Login has been temporarily blocked For User', {
function: 'Accounts.validateLoginAttempt',
});
}
if (login.allowed !== true) {
return login.allowed;
}
if (login.user.type === 'visitor') {
return true;
}
if (login.user.type === 'app') {
throw new Meteor.Error('error-app-user-is-not-allowed-to-login', 'App user is not allowed to login', {
function: 'Accounts.validateLoginAttempt',
});
}
if (!!login.user.active !== true) {
throw new Meteor.Error('error-user-is-not-activated', 'User is not activated', {
function: 'Accounts.validateLoginAttempt',
});
}
if (!login.user.roles || !Array.isArray(login.user.roles)) {
throw new Meteor.Error('error-user-has-no-roles', 'User has no roles', {
function: 'Accounts.validateLoginAttempt',
});
}
if (login.user.roles.includes('admin') === false && login.type === 'password' && settings.get('Accounts_EmailVerification') === true) {
const validEmail = login.user.emails.filter((email) => email.verified === true);
if (validEmail.length === 0) {
throw new Meteor.Error('error-invalid-email', 'Invalid email __email__');
}
}
login = await callbacks.run('onValidateLogin', login);
await Users.updateLastLoginById(login.user._id);
setImmediate(() => {
return callbacks.run('afterValidateLogin', login);
});
/**
* Trigger the event only when the
* user does login in Rocket.chat
*/
if (login.type !== 'resume') {
// App IPostUserLoggedIn event hook
await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedIn, login.user);
}
return true;
};
Accounts.validateLoginAttempt(function (...args) {
// Depends on meteor support for Async
return validateLoginAttemptAsync.call(this, ...args);
});
Accounts.validateNewUser((user) => {
if (user.type === 'visitor') {
return true;
}
if (
settings.get('Accounts_Registration_AuthenticationServices_Enabled') === false &&
settings.get('LDAP_Enable') === false &&
!(user.services && user.services.password)
) {
throw new Meteor.Error('registration-disabled-authentication-services', 'User registration is disabled for authentication services');
}
return true;
});
Accounts.validateNewUser((user) => {
if (user.type === 'visitor') {
return true;
}
let domainWhiteList = settings.get('Accounts_AllowedDomainsList');
if (_.isEmpty(domainWhiteList?.trim())) {
return true;
}
domainWhiteList = domainWhiteList.split(',').map((domain) => domain.trim());
if (user.emails && user.emails.length > 0) {
const email = user.emails[0].address;
const inWhiteList = domainWhiteList.some((domain) => email.match(`@${escapeRegExp(domain)}$`));
if (inWhiteList === false) {
throw new Meteor.Error('error-invalid-domain');
}
}
return true;
});
Accounts.onLogin(async ({ user }) => {
if (!user || !user.services || !user.services.resume || !user.services.resume.loginTokens || !user._id) {
return;
}
if (user.services.resume.loginTokens.length < getMaxLoginTokens()) {
return;
}
await User.ensureLoginTokensLimit(user._id);
});

View File

@@ -0,0 +1,23 @@
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { check } from 'meteor/check';
Accounts.registerLoginHandler('login-token', async (result) => {
if (!result.loginToken) {
return;
}
check(result.loginToken, String);
const user = await Users.findOne({
'services.loginToken.token': result.loginToken,
});
if (user) {
await Users.updateOne({ _id: user._id }, { $unset: { 'services.loginToken': 1 } });
return {
userId: user._id,
};
}
});