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(); 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 { 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('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 { 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 { // 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 { 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; }