Files
upstream-mirror 30810eecd7
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 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

269 lines
8.0 KiB
TypeScript

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;
}