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
269 lines
8.0 KiB
TypeScript
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;
|
|
}
|