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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (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,70 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsMock = sinon.stub();
const { EmailCheck } = proxyquire.noCallThru().load('./EmailCheck', {
'@rocket.chat/models': {
Users: {},
},
'meteor/accounts-base': {
Accounts: {
_bcryptRounds: () => '123',
},
},
'../../i18n': {
i18n: {
t: (key: string) => key,
},
},
'../../notifications/email/api': {
send: () => undefined,
},
'../../../settings': {
settings: {
get: settingsMock,
},
},
});
const normalUserMock = { services: { email2fa: { enabled: true } }, emails: [{ email: 'abc@gmail.com', verified: true }] };
const normalUserWithUnverifiedEmailMock = {
services: { email2fa: { enabled: true } },
emails: [{ email: 'abc@gmail.com', verified: false }],
};
const OAuthUserMock = { services: { google: {} }, emails: [{ email: 'abc@gmail.com', verified: true }] };
describe('EmailCheck', () => {
let emailCheck: typeof EmailCheck;
beforeEach(() => {
settingsMock.reset();
emailCheck = new EmailCheck();
});
it('should return EmailCheck is enabled for a normal user', () => {
settingsMock.returns(true);
const isEmail2FAEnabled = emailCheck.isEnabled(normalUserMock);
expect(isEmail2FAEnabled).to.be.equal(true);
});
it('should return EmailCheck is not enabled for a normal user with unverified email', () => {
settingsMock.returns(true);
const isEmail2FAEnabled = emailCheck.isEnabled(normalUserWithUnverifiedEmailMock);
expect(isEmail2FAEnabled).to.be.equal(false);
});
it('should return EmailCheck is not enabled for a OAuth user with setting being false', () => {
settingsMock.returns(true);
const isEmail2FAEnabled = emailCheck.isEnabled(OAuthUserMock);
expect(isEmail2FAEnabled).to.be.equal(false);
});
});

View File

@@ -0,0 +1,150 @@
import { isOAuthUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import bcrypt from 'bcrypt';
import { Accounts } from 'meteor/accounts-base';
import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck';
import { settings } from '../../../settings';
import { i18n } from '../../i18n';
import * as Mailer from '../../notifications/email/api';
export class EmailCheck implements ICodeCheck {
public readonly name: string = 'email';
private getUserVerifiedEmails(user: TwoFactorUser): string[] {
if (!Array.isArray(user.emails)) {
return [];
}
return user.emails.filter(({ verified }) => verified).map((e) => e.address);
}
public isEnabled(user: TwoFactorUser): boolean {
if (!settings.get('Accounts_TwoFactorAuthentication_By_Email_Enabled')) {
return false;
}
if (!settings.get('Accounts_twoFactorAuthentication_email_available_for_OAuth_users') && isOAuthUser(user)) {
return false;
}
if (!user.services?.email2fa?.enabled) {
return false;
}
return this.getUserVerifiedEmails(user).length > 0;
}
private async send2FAEmail(address: string, random: string, user: TwoFactorUser): Promise<void> {
const language = user.language || settings.get('Language') || 'en';
const t = i18n.getFixedT(language);
await Mailer.send({
to: address,
from: settings.get('From_Email'),
subject: 'Authentication code',
replyTo: undefined,
data: {
code: random.replace(/^(\d{3})/, '$1-'),
},
headers: undefined,
text: `
${t('Here_is_your_authentication_code')}
__code__
${t('Do_not_provide_this_code_to_anyone')}
${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')}
`,
html: `
<p>${t('Here_is_your_authentication_code')}</p>
<p style="font-size: 30px;">
<b>__code__</b>
</p>
<p>${t('Do_not_provide_this_code_to_anyone')}</p>
<p>${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')}</p>
`,
});
}
public async verify(user: TwoFactorUser, codeFromEmail: string): Promise<boolean> {
if (!this.isEnabled(user)) {
return false;
}
if (!user.services?.emailCode) {
return false;
}
// Remove non digits
codeFromEmail = codeFromEmail.replace(/([^\d])/g, '');
const { code, expire } = user.services.emailCode;
if (expire < new Date()) {
return false;
}
if (await bcrypt.compare(codeFromEmail, code)) {
await Users.removeEmailCodeOfUserId(user._id);
return true;
}
await Users.incrementInvalidEmailCodeAttempt(user._id);
return false;
}
public async sendEmailCode(user: TwoFactorUser): Promise<void> {
const emails = this.getUserVerifiedEmails(user);
const random = Random._randomString(6, '0123456789');
const encryptedRandom = await bcrypt.hash(random, Accounts._bcryptRounds());
const expire = new Date();
const expirationInSeconds = parseInt(settings.get('Accounts_TwoFactorAuthentication_By_Email_Code_Expiration'), 10);
expire.setSeconds(expire.getSeconds() + expirationInSeconds);
await Users.addEmailCodeByUserId(user._id, encryptedRandom, expire);
for (const address of emails) {
await this.send2FAEmail(address, random, user);
}
}
public async processInvalidCode(user: TwoFactorUser): Promise<IProcessInvalidCodeResult> {
await Users.removeExpiredEmailCodeOfUserId(user._id);
// Generate new code if the there isn't any code with more than 5 minutes to expire
const expireWithDelta = new Date();
expireWithDelta.setMinutes(expireWithDelta.getMinutes() - 5);
const emails = this.getUserVerifiedEmails(user);
const emailOrUsername = user.username || emails[0];
const hasValidCode =
user.services?.emailCode?.expire &&
user.services?.emailCode?.expire > expireWithDelta &&
!(await this.maxFaildedAttemtpsReached(user));
if (hasValidCode) {
return {
emailOrUsername,
codeGenerated: false,
codeExpires: user.services?.emailCode?.expire,
};
}
await this.sendEmailCode(user);
return {
codeGenerated: true,
emailOrUsername,
};
}
public async maxFaildedAttemtpsReached(user: TwoFactorUser) {
const maxAttempts = settings.get<number>('Accounts_TwoFactorAuthentication_Max_Invalid_Email_Code_Attempts');
return Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts);
}
}

View File

@@ -0,0 +1,38 @@
import { TwoFactorChallenges } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { EmailCheck } from './EmailCheck';
import type { TwoFactorUser } from './ICodeCheck';
export class EmailCheckForOAuth extends EmailCheck {
public override readonly name = 'email-oauth';
public readonly method = 'email';
public async sendTwoFactorChallenge(user: TwoFactorUser): Promise<string> {
const challengeId = await TwoFactorChallenges.createTwoFactorChallenge(user._id, 'email');
await this.sendEmailCode(user);
return challengeId;
}
public async verifyEmailTwoFactorChallenge(user: TwoFactorUser, challengeId: string, code: string): Promise<boolean> {
const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId);
if (!challenge) {
return false;
}
if (challenge.expireAt && challenge.expireAt < new Date()) {
throw new Meteor.Error('error-challenge-expired', 'challenge expired');
}
const isCodeValid = await this.verify(user, code);
if (!isCodeValid) {
return false;
}
await TwoFactorChallenges.removeByPendingChallengeId(challengeId);
return true;
}
}

View File

@@ -0,0 +1,22 @@
import type { IUser } from '@rocket.chat/core-typings';
/** The user fields the 2FA checks read; `getUserForCheck` projects exactly these. */
export type TwoFactorUser = Pick<IUser, '_id' | 'username' | 'emails' | 'language' | 'createdAt' | 'services'>;
export interface IProcessInvalidCodeResult {
codeGenerated: boolean;
codeExpires?: Date;
emailOrUsername?: string;
}
export interface ICodeCheck {
readonly name: string;
isEnabled(user: TwoFactorUser, force?: boolean): boolean;
verify(user: TwoFactorUser, code: string, force?: boolean): Promise<boolean>;
processInvalidCode(user: TwoFactorUser): Promise<IProcessInvalidCodeResult>;
maxFaildedAttemtpsReached(user: TwoFactorUser): Promise<boolean>;
}

View File

@@ -0,0 +1,48 @@
import { Accounts } from 'meteor/accounts-base';
import type { Meteor } from 'meteor/meteor';
import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck';
import { settings } from '../../../settings';
export class PasswordCheckFallback implements ICodeCheck {
public readonly name = 'password';
public isEnabled(user: TwoFactorUser, force: boolean): boolean {
if (force) {
return true;
}
// TODO: Remove this setting for version 4.0 forcing the
// password fallback for who has password set.
if (settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) {
return user.services?.password?.bcrypt != null;
}
return false;
}
public async verify(user: TwoFactorUser, code: string, force: boolean): Promise<boolean> {
if (!this.isEnabled(user, force)) {
return false;
}
const passCheck = await Accounts._checkPasswordAsync(user as Meteor.User, {
digest: code.toLowerCase(),
algorithm: 'sha-256',
});
if (passCheck.error) {
return false;
}
return true;
}
public async processInvalidCode(): Promise<IProcessInvalidCodeResult> {
return {
codeGenerated: false,
};
}
public async maxFaildedAttemtpsReached(_user: TwoFactorUser): Promise<boolean> {
return false;
}
}

View File

@@ -0,0 +1,43 @@
import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck';
import { settings } from '../../../settings';
import { TOTP } from '../lib/totp';
export class TOTPCheck implements ICodeCheck {
public readonly name: string = 'totp';
public isEnabled(user: TwoFactorUser): boolean {
if (!settings.get('Accounts_TwoFactorAuthentication_By_TOTP_Enabled')) {
return false;
}
return user.services?.totp?.enabled === true;
}
public async verify(user: TwoFactorUser, code: string): Promise<boolean> {
if (!this.isEnabled(user)) {
return false;
}
if (!user.services?.totp?.secret) {
return false;
}
return TOTP.verify({
secret: user.services?.totp?.secret,
token: code,
userId: user._id,
backupTokens: user.services?.totp?.hashedBackup,
});
}
public async processInvalidCode(): Promise<IProcessInvalidCodeResult> {
// Nothing to do
return {
codeGenerated: false,
};
}
public async maxFaildedAttemtpsReached(_user: TwoFactorUser): Promise<boolean> {
return false;
}
}

View File

@@ -0,0 +1,36 @@
import { TwoFactorChallenges } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import type { TwoFactorUser } from './ICodeCheck';
import { TOTPCheck } from './TOTPCheck';
export class TOTPCheckForOAuth extends TOTPCheck {
public override readonly name = 'totp-oauth';
public readonly method = 'totp';
public async sendTwoFactorChallenge(user: TwoFactorUser): Promise<string> {
return TwoFactorChallenges.createTwoFactorChallenge(user._id, 'totp');
}
public async verifyEmailTwoFactorChallenge(user: TwoFactorUser, challengeId: string, code: string): Promise<boolean> {
const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId);
if (!challenge) {
return false;
}
if (challenge.expireAt && challenge.expireAt < new Date()) {
throw new Meteor.Error('error-challenge-expired', 'challenge expired');
}
const isCodeValid = await this.verify(user, code);
if (!isCodeValid) {
return false;
}
await TwoFactorChallenges.removeByPendingChallengeId(challengeId);
return true;
}
}

View File

@@ -0,0 +1,193 @@
import { expect } from 'chai';
import { before, after, beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsGet = sinon.stub();
const totpEnabled = sinon.stub().returns(false);
class MeteorError extends Error {
error: string;
details?: unknown;
constructor(error: string, reason?: string, details?: unknown) {
super(reason ?? error);
this.error = error;
this.details = details;
}
}
// Disabled second-factor methods so the password fallback is the only candidate.
class TOTPCheck {
name = 'totp';
isEnabled(): boolean {
return totpEnabled();
}
async processInvalidCode(): Promise<{ codeGenerated: boolean }> {
return { codeGenerated: false };
}
}
class EmailCheck {
name = 'email';
isEnabled(): boolean {
return false;
}
}
class PasswordCheckFallback {
name = 'password';
isEnabled(): boolean {
return true;
}
async verify(): Promise<boolean> {
return false;
}
async processInvalidCode(): Promise<{ codeGenerated: boolean }> {
return { codeGenerated: false };
}
async maxFaildedAttemtpsReached(): Promise<boolean> {
return false;
}
}
const { checkCodeForUser, getFingerprintFromConnection } = proxyquire.noCallThru().load('./index', {
'@rocket.chat/models': {
Users: {
findOneById: async () => null,
setTwoFactorAuthorizationHashAndUntilForUserIdAndToken: async () => undefined,
},
},
'meteor/accounts-base': {
Accounts: {
_getLoginToken: () => 'token-hash',
},
},
'meteor/meteor': {
Meteor: { Error: MeteorError },
},
'./TOTPCheck': { TOTPCheck },
'./EmailCheck': { EmailCheck },
'./PasswordCheckFallback': { PasswordCheckFallback },
'../../shared/getModifiedHttpHeaders': {
normalizeHeaders: (headers: unknown) => headers,
},
'../../../settings': {
settings: { get: settingsGet },
},
});
const REMEMBER_FOR_SECONDS = 1800;
const buildUser = (createdAt: Date) => ({
_id: 'user-id',
createdAt,
services: {
password: { bcrypt: 'hashed' },
resume: { loginTokens: [{ hashedToken: 'token-hash' }] },
},
});
const connection = {
id: 'connection-id',
httpHeaders: { 'user-agent': 'agent' },
clientAddress: '127.0.0.1',
};
describe('checkCodeForUser - post-registration grace window', () => {
let originalTestMode: string | undefined;
before(() => {
originalTestMode = process.env.TEST_MODE;
delete process.env.TEST_MODE;
settingsGet.callsFake((key: string) => {
switch (key) {
case 'Accounts_TwoFactorAuthentication_Enabled':
return true;
case 'Accounts_TwoFactorAuthentication_RememberFor':
return REMEMBER_FOR_SECONDS;
case 'Accounts_TwoFactorAuthentication_Enforce_Password_Fallback':
return true;
default:
return false;
}
});
});
after(() => {
if (originalTestMode === undefined) {
delete process.env.TEST_MODE;
} else {
process.env.TEST_MODE = originalTestMode;
}
});
beforeEach(() => {
totpEnabled.reset();
totpEnabled.returns(false);
});
it('should not prompt a freshly registered user even when disableRememberMe is set (Setup Wizard regression)', async () => {
const user = buildUser(new Date());
const result = await checkCodeForUser({
user,
options: { disableRememberMe: true },
connection,
});
expect(result).to.be.equal(true);
});
it('should still prompt a freshly registered user who already has a 2FA method configured', async () => {
totpEnabled.returns(true);
const user = buildUser(new Date());
await expect(
checkCodeForUser({
user,
options: { disableRememberMe: true },
connection,
}),
).to.be.rejectedWith('TOTP Required');
});
it('should still prompt when the post-registration grace window has expired and disableRememberMe is set', async () => {
const user = buildUser(new Date(Date.now() - (REMEMBER_FOR_SECONDS + 60) * 1000));
await expect(
checkCodeForUser({
user,
options: { disableRememberMe: true },
connection,
}),
).to.be.rejectedWith('TOTP Required');
});
it('should ignore a remembered authorization when disableRememberMe is set and grace window expired', async () => {
const user = buildUser(new Date(Date.now() - (REMEMBER_FOR_SECONDS + 60) * 1000));
// A previously remembered 2FA authorization that is still valid in time.
user.services.resume.loginTokens[0] = {
hashedToken: 'token-hash',
twoFactorAuthorizedUntil: new Date(Date.now() + 60 * 1000),
twoFactorAuthorizedHash: getFingerprintFromConnection(connection),
} as (typeof user.services.resume.loginTokens)[number];
await expect(
checkCodeForUser({
user,
options: { disableRememberMe: true },
connection,
}),
).to.be.rejectedWith('TOTP Required');
});
});

View File

@@ -0,0 +1,135 @@
import { expect } from 'chai';
import { afterEach, beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsMock = sinon.stub();
const getLoginTokenStub = sinon.stub();
class TOTPCheckMock {
name = 'totp';
isEnabled() {
return true;
}
async processInvalidCode() {
return {};
}
async verify() {
return false;
}
async maxFaildedAttemtpsReached() {
return false;
}
}
class DisabledCheckMock {
name = 'email';
isEnabled() {
return false;
}
}
class MeteorErrorMock extends Error {
error: string;
details: unknown;
constructor(error: string, reason?: string, details?: unknown) {
super(reason);
this.error = error;
this.details = details;
}
}
const { checkCodeForUser } = proxyquire.noCallThru().load('./index', {
'./TOTPCheck': { TOTPCheck: TOTPCheckMock },
'./EmailCheck': { EmailCheck: DisabledCheckMock },
'./PasswordCheckFallback': { PasswordCheckFallback: class extends DisabledCheckMock {} },
'../../shared/getModifiedHttpHeaders': { normalizeHeaders: (headers: unknown) => headers },
'../../../settings': { settings: { get: settingsMock } },
'@rocket.chat/models': {
Users: {
findOneById: async () => null,
setTwoFactorAuthorizationHashAndUntilForUserIdAndToken: async () => undefined,
},
},
'meteor/accounts-base': { Accounts: { _getLoginToken: getLoginTokenStub } },
'meteor/meteor': { Meteor: { Error: MeteorErrorMock } },
});
const HASHED_TOKEN = 'hashed-login-token';
const userWithBypassToken = {
_id: 'user-id',
services: { resume: { loginTokens: [{ hashedToken: HASHED_TOKEN, bypassTwoFactor: true }] } },
} as any;
const connection = {
id: 'connection-id',
clientAddress: '127.0.0.1',
httpHeaders: {},
} as any;
describe('checkCodeForUser - bypassTwoFactor token resolution (SUP-1064)', () => {
let testMode: string | undefined;
beforeEach(() => {
// TEST_MODE short-circuits the whole check; remove it so the token resolution path runs.
testMode = process.env.TEST_MODE;
delete process.env.TEST_MODE;
settingsMock.reset();
settingsMock.withArgs('Accounts_TwoFactorAuthentication_Enabled').returns(true);
getLoginTokenStub.reset();
});
afterEach(() => {
if (testMode !== undefined) {
process.env.TEST_MODE = testMode;
}
});
it('should honor a bypassTwoFactor token resolved from the REST connection (connection.token)', async () => {
// REST: the token is not registered in account data, only carried on the connection.
getLoginTokenStub.returns(undefined);
const authorized = await checkCodeForUser({
user: userWithBypassToken,
connection: { ...connection, token: HASHED_TOKEN },
options: {},
});
expect(authorized).to.be.equal(true);
});
it('should honor a bypassTwoFactor token resolved from account data (DDP, _getLoginToken)', async () => {
// DDP: the token is registered in Accounts._accountData and read via _getLoginToken.
getLoginTokenStub.returns(HASHED_TOKEN);
const authorized = await checkCodeForUser({
user: userWithBypassToken,
connection: { ...connection, token: undefined },
options: {},
});
expect(authorized).to.be.equal(true);
});
it('should still require a second factor when the token cannot be resolved from either source', async () => {
// Regression guard: this is the buggy state (#38017) the fix addresses.
getLoginTokenStub.returns(undefined);
await expect(
checkCodeForUser({
user: userWithBypassToken,
connection: { ...connection, token: undefined },
options: {},
}),
).to.be.rejectedWith('TOTP Required');
});
});

View File

@@ -0,0 +1,268 @@
import crypto from 'node:crypto';
import type { IUser, IMethodConnection } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { EmailCheck } from './EmailCheck';
import type { ICodeCheck, TwoFactorUser } from './ICodeCheck';
import { PasswordCheckFallback } from './PasswordCheckFallback';
import { TOTPCheck } from './TOTPCheck';
import { settings } from '../../../settings';
import { normalizeHeaders } from '../../shared/getModifiedHttpHeaders';
export interface ITwoFactorOptions {
disablePasswordFallback?: boolean;
disableRememberMe?: boolean;
requireSecondFactor?: boolean; // whether any two factor should be required
}
const totpCheck = new TOTPCheck();
export const emailCheck = new EmailCheck();
const passwordCheckFallback = new PasswordCheckFallback();
const checkMethods = new Map<string, ICodeCheck>();
checkMethods.set(totpCheck.name, totpCheck);
checkMethods.set(emailCheck.name, emailCheck);
function getMethodByNameOrFirstActiveForUser(user: TwoFactorUser, name?: string): ICodeCheck | undefined {
if (name && checkMethods.has(name)) {
return checkMethods.get(name);
}
return Array.from(checkMethods.values()).find((method) => method.isEnabled(user));
}
function getAvailableMethodNames(user: TwoFactorUser): string[] {
return (
Array.from(checkMethods)
.filter(([, method]) => method.isEnabled(user))
.map(([name]) => name) || []
);
}
export async function getUserForCheck(userId: string): Promise<TwoFactorUser | null> {
return Users.findOneById(userId, {
projection: {
username: 1,
emails: 1,
language: 1,
createdAt: 1,
services: 1,
},
});
}
export function getFingerprintFromConnection(connection: IMethodConnection): string {
const data = JSON.stringify({
userAgent: connection.httpHeaders['user-agent'],
clientAddress: connection.clientAddress,
});
return crypto.createHash('sha256').update(data).digest('hex');
}
export function getRememberDate(from: Date = new Date()): Date | undefined {
const rememberFor = settings.get<number>('Accounts_TwoFactorAuthentication_RememberFor');
if (rememberFor <= 0) {
return;
}
const expires = new Date(from);
expires.setSeconds(expires.getSeconds() + rememberFor);
return expires;
}
function isAuthorizedForToken(connection: IMethodConnection, user: TwoFactorUser, options: ITwoFactorOptions): boolean {
// Resolve the current login token from both transports:
// - DDP: the login flow registers it in `Accounts._accountData`, read via `_getLoginToken`.
// - REST: it is not registered in account data, so it is carried on `connection.token`.
// Both are needed; REST is the fallback that fixes `bypassTwoFactor` PATs (SUP-1064).
const currentToken = Accounts._getLoginToken(connection.id) || connection.token;
const tokenObject = user.services?.resume?.loginTokens?.find((i) => i.hashedToken === currentToken);
if (!tokenObject) {
return false;
}
// if any two factor is required, early abort
if (options.requireSecondFactor) {
return false;
}
if ('bypassTwoFactor' in tokenObject && tokenObject.bypassTwoFactor === true) {
return true;
}
// Skip 2FA for a freshly registered user who has not set up any 2FA method yet,
// until the grace period that starts at registration expires.
// (e.g. the Setup Wizard saving settings between steps before any 2FA is configured)
const rememberPeriodEnd = user.createdAt && getRememberDate(user.createdAt);
const isWithinRememberPeriod = rememberPeriodEnd && rememberPeriodEnd >= new Date();
const hasNoTwoFactorMethod = getAvailableMethodNames(user).length === 0;
if (isWithinRememberPeriod && hasNoTwoFactorMethod) {
return true;
}
if (options.disableRememberMe === true) {
return false;
}
if (!tokenObject.twoFactorAuthorizedUntil || !tokenObject.twoFactorAuthorizedHash) {
return false;
}
if (tokenObject.twoFactorAuthorizedUntil < new Date()) {
return false;
}
if (tokenObject.twoFactorAuthorizedHash !== getFingerprintFromConnection(connection)) {
return false;
}
return true;
}
export async function rememberAuthorizationByToken(token: string, userId: IUser['_id'], connection: IMethodConnection): Promise<void> {
const user = await Users.findOneByIdAndLoginHashedToken(userId, token, { projection: { _id: 1, services: 1 } });
if (!user) {
throw new Meteor.Error('error-user-not-found', 'user not found');
}
const expires = getRememberDate();
if (!expires) {
return;
}
await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken(user._id, token, getFingerprintFromConnection(connection), expires);
}
async function rememberAuthorization(connection: IMethodConnection, user: TwoFactorUser): Promise<void> {
// Same dual-transport resolution as `isAuthorizedForToken`: DDP reads from `Accounts._accountData`
// via `_getLoginToken`, REST falls back to the token carried on `connection.token`.
const currentToken = Accounts._getLoginToken(connection.id) || connection.token;
const expires = getRememberDate();
if (!expires) {
return;
}
if (!currentToken) {
return;
}
await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken(
user._id,
currentToken,
getFingerprintFromConnection(connection),
expires,
);
}
interface ICheckCodeForUser {
user: TwoFactorUser | string;
code?: string;
method?: string;
options?: ITwoFactorOptions;
connection?: IMethodConnection;
}
export const getSecondFactorMethod = (
user: TwoFactorUser,
method: string | undefined,
options: ITwoFactorOptions,
): ICodeCheck | undefined => {
// try first getting one of the available methods or the one that was already provided
const selectedMethod = getMethodByNameOrFirstActiveForUser(user, method);
if (selectedMethod) {
return selectedMethod;
}
// if none found but a second factor is required, chose the password check
if (options.requireSecondFactor) {
return passwordCheckFallback;
}
// check if password fallback is enabled
if (!options.disablePasswordFallback && passwordCheckFallback.isEnabled(user, !!options.requireSecondFactor)) {
return passwordCheckFallback;
}
};
export async function checkCodeForUser({ user, code, method, options = {}, connection }: ICheckCodeForUser): Promise<boolean> {
if (process.env.TEST_MODE && !options.requireSecondFactor) {
return true;
}
if (!settings.get('Accounts_TwoFactorAuthentication_Enabled')) {
return true;
}
let existingUser: TwoFactorUser | null;
if (typeof user === 'string') {
existingUser = await getUserForCheck(user);
} else {
existingUser = user;
}
if (!existingUser) {
throw new Meteor.Error('totp-user-not-found', 'TOTP User not found');
}
const headers = normalizeHeaders(connection?.httpHeaders);
if (!code && !method && headers?.['x-2fa-code'] && headers['x-2fa-method']) {
code = headers['x-2fa-code'];
method = headers['x-2fa-method'];
}
if (connection && isAuthorizedForToken(connection, existingUser, options)) {
return true;
}
// select a second factor method or return if none is found/available
const selectedMethod = getSecondFactorMethod(existingUser, method, options);
if (!selectedMethod) {
return true;
}
const data = await selectedMethod.processInvalidCode(existingUser);
const availableMethods = getAvailableMethodNames(existingUser);
if (!code) {
throw new Meteor.Error('totp-required', 'TOTP Required', {
method: selectedMethod.name,
...data,
availableMethods,
});
}
const valid = await selectedMethod.verify(existingUser, code, options.requireSecondFactor);
if (!valid) {
const tooManyFailedAttempts = await selectedMethod.maxFaildedAttemtpsReached(existingUser);
if (tooManyFailedAttempts) {
throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached', {
method: selectedMethod.name,
...data,
availableMethods,
});
}
throw new Meteor.Error('totp-invalid', 'TOTP Invalid', {
method: selectedMethod.name,
...data,
availableMethods,
});
}
if (options.disableRememberMe !== true && connection) {
await rememberAuthorization(connection, existingUser);
}
return true;
}