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
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:
27
apps/meteor/server/lib/auth/ILoginAttempt.ts
Normal file
27
apps/meteor/server/lib/auth/ILoginAttempt.ts
Normal 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;
|
||||
}
|
||||
31
apps/meteor/server/lib/auth/generatePassword.ts
Normal file
31
apps/meteor/server/lib/auth/generatePassword.ts
Normal 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 });
|
||||
};
|
||||
37
apps/meteor/server/lib/auth/logLoginAttempts.ts
Normal file
37
apps/meteor/server/lib/auth/logLoginAttempts.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
16
apps/meteor/server/lib/auth/loginErrorMessageOverride.ts
Normal file
16
apps/meteor/server/lib/auth/loginErrorMessageOverride.ts
Normal 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;
|
||||
};
|
||||
69
apps/meteor/server/lib/auth/oauth2-server/addOAuthApp.ts
Normal file
69
apps/meteor/server/lib/auth/oauth2-server/addOAuthApp.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
4
apps/meteor/server/lib/auth/oauth2-server/index.ts
Normal file
4
apps/meteor/server/lib/auth/oauth2-server/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import './oauth2-server';
|
||||
import './addOAuthApp';
|
||||
import '../../../meteor-methods/auth/updateOAuthApp';
|
||||
import '../../../meteor-methods/auth/deleteOAuthApp';
|
||||
85
apps/meteor/server/lib/auth/oauth2-server/oauth2-server.ts
Normal file
85
apps/meteor/server/lib/auth/oauth2-server/oauth2-server.ts
Normal 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);
|
||||
17
apps/meteor/server/lib/auth/oauth2-server/parseUriList.ts
Normal file
17
apps/meteor/server/lib/auth/oauth2-server/parseUriList.ts
Normal 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
|
||||
};
|
||||
64
apps/meteor/server/lib/auth/passwordPolicy.ts
Normal file
64
apps/meteor/server/lib/auth/passwordPolicy.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
173
apps/meteor/server/lib/auth/restrictLoginAttempts.ts
Normal file
173
apps/meteor/server/lib/auth/restrictLoginAttempts.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
531
apps/meteor/server/lib/auth/startup.js
Normal file
531
apps/meteor/server/lib/auth/startup.js
Normal 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);
|
||||
});
|
||||
23
apps/meteor/server/lib/auth/token-login.js
Normal file
23
apps/meteor/server/lib/auth/token-login.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user