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:
17
apps/meteor/server/lib/2fa/MethodInvocationOverride.js
Normal file
17
apps/meteor/server/lib/2fa/MethodInvocationOverride.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DDP } from 'meteor/ddp';
|
||||
import { DDPCommon } from 'meteor/ddp-common';
|
||||
|
||||
class MethodInvocation extends DDPCommon.MethodInvocation {
|
||||
constructor(options) {
|
||||
const result = super(options);
|
||||
const currentInvocation = DDP._CurrentInvocation.get();
|
||||
|
||||
if (currentInvocation) {
|
||||
this.twoFactorChecked = currentInvocation.twoFactorChecked;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
DDPCommon.MethodInvocation = MethodInvocation;
|
||||
70
apps/meteor/server/lib/2fa/code/EmailCheck.spec.ts
Normal file
70
apps/meteor/server/lib/2fa/code/EmailCheck.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
150
apps/meteor/server/lib/2fa/code/EmailCheck.ts
Normal file
150
apps/meteor/server/lib/2fa/code/EmailCheck.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
apps/meteor/server/lib/2fa/code/EmailCheckForOAuth.ts
Normal file
38
apps/meteor/server/lib/2fa/code/EmailCheckForOAuth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
apps/meteor/server/lib/2fa/code/ICodeCheck.ts
Normal file
22
apps/meteor/server/lib/2fa/code/ICodeCheck.ts
Normal 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>;
|
||||
}
|
||||
48
apps/meteor/server/lib/2fa/code/PasswordCheckFallback.ts
Normal file
48
apps/meteor/server/lib/2fa/code/PasswordCheckFallback.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
apps/meteor/server/lib/2fa/code/TOTPCheck.ts
Normal file
43
apps/meteor/server/lib/2fa/code/TOTPCheck.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
apps/meteor/server/lib/2fa/code/TOTPCheckForOAuth.ts
Normal file
36
apps/meteor/server/lib/2fa/code/TOTPCheckForOAuth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
193
apps/meteor/server/lib/2fa/code/checkCodeForUser.spec.ts
Normal file
193
apps/meteor/server/lib/2fa/code/checkCodeForUser.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
135
apps/meteor/server/lib/2fa/code/index.spec.ts
Normal file
135
apps/meteor/server/lib/2fa/code/index.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
268
apps/meteor/server/lib/2fa/code/index.ts
Normal file
268
apps/meteor/server/lib/2fa/code/index.ts
Normal 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;
|
||||
}
|
||||
84
apps/meteor/server/lib/2fa/functions/resetTOTP.ts
Normal file
84
apps/meteor/server/lib/2fa/functions/resetTOTP.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { i18n } from '../../i18n';
|
||||
import { isUserIdFederated } from '../../isUserIdFederated';
|
||||
import * as Mailer from '../../notifications/email/api';
|
||||
import { notifyOnUserChange } from '../../notifyListener';
|
||||
|
||||
const sendResetNotification = async function (uid: string): Promise<void> {
|
||||
const user = await Users.findOneById<Pick<IUser, 'language' | 'emails'>>(uid, {
|
||||
projection: { language: 1, emails: 1 },
|
||||
});
|
||||
if (!user) {
|
||||
throw new Meteor.Error('invalid-user');
|
||||
}
|
||||
|
||||
const language = user.language || settings.get('Language') || 'en';
|
||||
const addresses = user.emails?.filter(({ verified }) => Boolean(verified)).map((e) => e.address);
|
||||
if (!addresses?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const t = i18n.getFixedT(language);
|
||||
const text = `
|
||||
${t('Your_TOTP_has_been_reset')}
|
||||
|
||||
${t('TOTP_Reset_Other_Key_Warning')}
|
||||
`;
|
||||
const html = `
|
||||
<p>${t('Your_TOTP_has_been_reset')}</p>
|
||||
<p>${t('TOTP_Reset_Other_Key_Warning')}</p>
|
||||
`;
|
||||
|
||||
const from = settings.get('From_Email');
|
||||
const subject = t('TOTP_reset_email');
|
||||
|
||||
for (const address of addresses) {
|
||||
try {
|
||||
await Mailer.send({
|
||||
to: address,
|
||||
from,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${message}`, {
|
||||
function: 'resetUserTOTP',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function resetTOTP(userId: string, notifyUser = false): Promise<boolean> {
|
||||
if (notifyUser) {
|
||||
await sendResetNotification(userId);
|
||||
}
|
||||
|
||||
const isUserFederated = await isUserIdFederated(userId);
|
||||
if (isUserFederated) {
|
||||
throw new Meteor.Error('error-not-allowed', 'Federated Users cant have TOTP', { function: 'resetTOTP' });
|
||||
}
|
||||
|
||||
const result = await Users.resetTOTPById(userId);
|
||||
|
||||
if (result?.modifiedCount === 1) {
|
||||
await Users.unsetLoginTokens(userId);
|
||||
|
||||
void notifyOnUserChange({
|
||||
clientAction: 'updated',
|
||||
id: userId,
|
||||
diff: {
|
||||
'services.resume.loginTokens': [],
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
154
apps/meteor/server/lib/2fa/functions/totp.ts
Normal file
154
apps/meteor/server/lib/2fa/functions/totp.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../notifyListener';
|
||||
import { TOTP } from '../lib/totp';
|
||||
|
||||
const requireUser = async (userId: string | null) => {
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(userId);
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const enableTotp = async (userId: string | null): Promise<{ secret: string; url: string }> => {
|
||||
const user = await requireUser(userId);
|
||||
|
||||
if (!user.username) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
|
||||
if (user.services?.totp?.enabled) {
|
||||
throw new Meteor.Error('error-2fa-already-enabled');
|
||||
}
|
||||
|
||||
const secret = TOTP.generateSecret();
|
||||
|
||||
await Users.disable2FAAndSetTempSecretByUserId(user._id, secret.base32);
|
||||
|
||||
return {
|
||||
secret: secret.base32,
|
||||
url: TOTP.generateOtpauthURL(secret, user.username),
|
||||
};
|
||||
};
|
||||
|
||||
export const disableTotp = async (userId: string | null, code: string): Promise<boolean> => {
|
||||
const user = await requireUser(userId);
|
||||
|
||||
if (!user.services?.totp?.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const verified = await TOTP.verify({
|
||||
secret: user.services.totp.secret,
|
||||
token: code,
|
||||
userId: user._id,
|
||||
backupTokens: user.services.totp.hashedBackup,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { modifiedCount } = await Users.disable2FAByUserId(user._id);
|
||||
|
||||
if (!modifiedCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': false } });
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const validateTotpTempToken = async (userId: string | null, userToken: string, authToken?: string): Promise<{ codes: string[] }> => {
|
||||
const user = await requireUser(userId);
|
||||
|
||||
if (!user.services?.totp?.tempSecret) {
|
||||
throw new Meteor.Error('invalid-totp');
|
||||
}
|
||||
|
||||
const verified = await TOTP.verify({
|
||||
secret: user.services.totp.tempSecret,
|
||||
token: userToken,
|
||||
});
|
||||
if (!verified) {
|
||||
throw new Meteor.Error('invalid-totp');
|
||||
}
|
||||
|
||||
const { codes, hashedCodes } = TOTP.generateCodes();
|
||||
|
||||
await Users.enable2FAAndSetSecretAndCodesByUserId(user._id, user.services.totp.tempSecret, hashedCodes);
|
||||
|
||||
if (authToken) {
|
||||
const hashedToken = Accounts._hashLoginToken(authToken);
|
||||
|
||||
const { modifiedCount } = await Users.removeNonPATLoginTokensExcept(user._id, hashedToken);
|
||||
|
||||
if (modifiedCount > 0) {
|
||||
void notifyOnUserChangeAsync(async () => {
|
||||
const refreshed = await Users.findOneById(user._id, {
|
||||
projection: { 'services.resume.loginTokens': 1, 'services.totp': 1 },
|
||||
});
|
||||
return {
|
||||
clientAction: 'updated',
|
||||
id: user._id,
|
||||
diff: {
|
||||
'services.resume.loginTokens': refreshed?.services?.resume?.loginTokens,
|
||||
...(refreshed?.services?.totp && { 'services.totp.enabled': refreshed.services.totp.enabled }),
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': true } });
|
||||
}
|
||||
} else {
|
||||
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': true } });
|
||||
}
|
||||
|
||||
return { codes };
|
||||
};
|
||||
|
||||
export const regenerateTotpCodes = async (userId: string | null, userToken: string): Promise<{ codes: string[] } | undefined> => {
|
||||
const user = await requireUser(userId);
|
||||
|
||||
if (!user.services?.totp?.enabled) {
|
||||
throw new Meteor.Error('invalid-totp');
|
||||
}
|
||||
|
||||
const verified = await TOTP.verify({
|
||||
secret: user.services.totp.secret,
|
||||
token: userToken,
|
||||
userId: user._id,
|
||||
backupTokens: user.services.totp.hashedBackup,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { codes, hashedCodes } = TOTP.generateCodes();
|
||||
|
||||
await Users.update2FABackupCodesByUserId(user._id, hashedCodes);
|
||||
|
||||
return { codes };
|
||||
};
|
||||
|
||||
export const codesRemainingTotp = async (userId: string | null): Promise<{ remaining: number }> => {
|
||||
const user = await requireUser(userId);
|
||||
|
||||
if (!user.services?.totp?.enabled) {
|
||||
throw new Meteor.Error('invalid-totp');
|
||||
}
|
||||
|
||||
return {
|
||||
remaining: user.services.totp.hashedBackup?.length ?? 0,
|
||||
};
|
||||
};
|
||||
78
apps/meteor/server/lib/2fa/lib/totp.ts
Normal file
78
apps/meteor/server/lib/2fa/lib/totp.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { SHA256 } from '@rocket.chat/sha256';
|
||||
import speakeasy from 'speakeasy';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
export const TOTP = {
|
||||
generateSecret(): speakeasy.GeneratedSecret {
|
||||
return speakeasy.generateSecret();
|
||||
},
|
||||
|
||||
generateOtpauthURL(secret: speakeasy.GeneratedSecret, username: string): string {
|
||||
return speakeasy.otpauthURL({
|
||||
secret: secret.ascii,
|
||||
label: `Rocket.Chat:${username}`,
|
||||
});
|
||||
},
|
||||
|
||||
async verify({
|
||||
secret,
|
||||
token,
|
||||
backupTokens,
|
||||
userId,
|
||||
}: {
|
||||
secret: string;
|
||||
token: string;
|
||||
backupTokens?: string[];
|
||||
userId?: string;
|
||||
}): Promise<boolean> {
|
||||
// validates a backup code
|
||||
if (token.length === 8 && backupTokens) {
|
||||
const hashedCode = SHA256(token);
|
||||
const usedCode = backupTokens.indexOf(hashedCode);
|
||||
|
||||
if (usedCode !== -1 && userId) {
|
||||
backupTokens.splice(usedCode, 1);
|
||||
|
||||
// mark the code as used (remove it from the list)
|
||||
await Users.update2FABackupCodesByUserId(userId, backupTokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxDelta = settings.get<number>('Accounts_TwoFactorAuthentication_MaxDelta');
|
||||
if (maxDelta) {
|
||||
const verifiedDelta = speakeasy.totp.verifyDelta({
|
||||
secret,
|
||||
encoding: 'base32',
|
||||
token,
|
||||
window: maxDelta,
|
||||
});
|
||||
|
||||
return verifiedDelta !== undefined;
|
||||
}
|
||||
|
||||
return speakeasy.totp.verify({
|
||||
secret,
|
||||
encoding: 'base32',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
generateCodes(): { codes: string[]; hashedCodes: string[] } {
|
||||
// generate 12 backup codes
|
||||
const codes = [];
|
||||
const hashedCodes = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const code = Random.id(8);
|
||||
codes.push(code);
|
||||
hashedCodes.push(SHA256(code));
|
||||
}
|
||||
|
||||
return { codes, hashedCodes };
|
||||
},
|
||||
};
|
||||
110
apps/meteor/server/lib/2fa/loginHandler.ts
Normal file
110
apps/meteor/server/lib/2fa/loginHandler.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { OAuth } from 'meteor/oauth';
|
||||
|
||||
import { checkCodeForUser } from './code';
|
||||
import { callbacks } from '../callbacks';
|
||||
|
||||
const isMeteorError = (error: any): error is Meteor.Error => {
|
||||
return error?.meteorError !== undefined;
|
||||
};
|
||||
|
||||
const isCredentialWithError = (credential: any): credential is { error: Error } => {
|
||||
return credential?.error !== undefined;
|
||||
};
|
||||
|
||||
Accounts.registerLoginHandler('totp', function (options) {
|
||||
if (!options.totp?.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error - not sure how to type this yet
|
||||
return Accounts._runLoginHandlers(this, options.totp.login);
|
||||
});
|
||||
|
||||
callbacks.add(
|
||||
'onValidateLogin',
|
||||
async (login) => {
|
||||
if (
|
||||
!login.user ||
|
||||
login.type === 'resume' ||
|
||||
login.type === 'proxy' ||
|
||||
login.type === 'cas' ||
|
||||
(login.type === 'password' && login.methodName === 'resetPassword') ||
|
||||
login.methodName === 'verifyEmail'
|
||||
) {
|
||||
return login;
|
||||
}
|
||||
|
||||
const [loginArgs] = login.methodArguments;
|
||||
const { totp } = loginArgs;
|
||||
|
||||
await checkCodeForUser({
|
||||
user: login.user,
|
||||
code: totp?.code,
|
||||
options: { disablePasswordFallback: true },
|
||||
});
|
||||
|
||||
return login;
|
||||
},
|
||||
callbacks.priority.MEDIUM,
|
||||
'2fa',
|
||||
);
|
||||
|
||||
const copyTo = <T extends Error>(from: T, to: T): T => {
|
||||
Object.getOwnPropertyNames(to).forEach((key) => {
|
||||
const idx: keyof T = key as keyof T;
|
||||
to[idx] = from[idx];
|
||||
});
|
||||
|
||||
return to;
|
||||
};
|
||||
|
||||
const recreateError = (errorDoc: Error | Meteor.Error): Error | Meteor.Error => {
|
||||
if (isMeteorError(errorDoc)) {
|
||||
const error = new Meteor.Error('');
|
||||
return copyTo(errorDoc, error);
|
||||
}
|
||||
|
||||
const error = new Error();
|
||||
return copyTo(errorDoc, error);
|
||||
};
|
||||
|
||||
OAuth._retrievePendingCredential = async function (key, ...args): Promise<string | Error | void> {
|
||||
const credentialSecret = args.length > 0 && args[0] !== undefined ? args[0] : undefined;
|
||||
check(key, String);
|
||||
|
||||
const pendingCredential = await OAuth._pendingCredentials.findOneAsync({
|
||||
key,
|
||||
credentialSecret,
|
||||
});
|
||||
|
||||
if (!pendingCredential) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCredentialWithError(pendingCredential.credential)) {
|
||||
await OAuth._pendingCredentials.removeAsync({
|
||||
_id: pendingCredential._id,
|
||||
});
|
||||
return recreateError(pendingCredential.credential.error);
|
||||
}
|
||||
|
||||
// Work-around to make the credentials reusable for 2FA
|
||||
const future = new Date();
|
||||
future.setMinutes(future.getMinutes() + 2);
|
||||
|
||||
await OAuth._pendingCredentials.updateAsync(
|
||||
{
|
||||
_id: pendingCredential._id,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
createdAt: future,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return OAuth.openSecret(pendingCredential.credential);
|
||||
};
|
||||
51
apps/meteor/server/lib/2fa/twoFactorRequired.ts
Normal file
51
apps/meteor/server/lib/2fa/twoFactorRequired.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import type { ITwoFactorOptions } from './code';
|
||||
import { checkCodeForUser } from './code';
|
||||
|
||||
export type AuthenticatedContext = {
|
||||
userId: string;
|
||||
token: string;
|
||||
connection: {
|
||||
id: string;
|
||||
clientAddress: string;
|
||||
httpHeaders: Record<string, string>;
|
||||
};
|
||||
twoFactorChecked?: boolean;
|
||||
};
|
||||
|
||||
export const twoFactorRequired = <TFunction extends (this: any, ...args: any) => Promise<any>>(
|
||||
fn: ThisParameterType<TFunction> extends AuthenticatedContext
|
||||
? TFunction
|
||||
: (this: AuthenticatedContext, ...args: Parameters<TFunction>) => ReturnType<TFunction>,
|
||||
options?: ITwoFactorOptions,
|
||||
) =>
|
||||
async function (this, ...args) {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'twoFactorRequired' });
|
||||
}
|
||||
|
||||
// get two factor options from last item of args and remove it
|
||||
const twoFactor = args.pop();
|
||||
if (twoFactor) {
|
||||
if (twoFactor.twoFactorCode && twoFactor.twoFactorMethod) {
|
||||
await checkCodeForUser({
|
||||
user: this.userId,
|
||||
connection: this.connection || undefined,
|
||||
code: twoFactor.twoFactorCode,
|
||||
method: twoFactor.twoFactorMethod,
|
||||
options,
|
||||
});
|
||||
this.twoFactorChecked = true;
|
||||
} else {
|
||||
// if it was not two factor options, put it back
|
||||
args.push(twoFactor);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.twoFactorChecked) {
|
||||
await checkCodeForUser({ user: this.userId, connection: this.connection || undefined, options });
|
||||
}
|
||||
|
||||
return fn.apply(this, args);
|
||||
} as (this: ThisParameterType<TFunction>, ...args: Parameters<TFunction>) => ReturnType<TFunction>;
|
||||
Reference in New Issue
Block a user