Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ddp-migrate-batch5-totp-caller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Migrates the `TwoFactorTOTP` account settings page from the five `2fa:*` DDP methods to the new TOTP REST endpoints. DDP methods stay registered for external SDK/mobile clients with deprecation logs pointing at the new routes until 9.0.0.
16 changes: 16 additions & 0 deletions .changeset/rest-users-totp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---

Adds five new REST endpoints covering the TOTP 2FA flows that previously only existed as DDP methods:

- `POST /v1/users.enableTotp` → `{ secret, url }` (replaces `2fa:enable`)
- `POST /v1/users.disableTotp` body `{ code }` → `{ disabled }` (replaces `2fa:disable`)
- `POST /v1/users.validateTotp` body `{ code }` → `{ codes }` (replaces `2fa:validateTempToken`; also rotates non-PAT login tokens server-side)
- `POST /v1/users.regenerateTotpCodes` body `{ code }` → `{ codes }` (replaces `2fa:regenerateCodes`)
- `GET /v1/users.totpCodesRemaining` → `{ remaining }` (replaces `2fa:checkCodesRemaining`)

`users.enableTotp` and `users.validateTotp` require two-factor verification (`twoFactorRequired`) so enrolling a new TOTP device confirms the account owner's identity first — closing a 2FA-enrollment bypass where a hijacked session could register an attacker-controlled TOTP without verifying the existing 2FA. All five endpoints are rate-limited.

The legacy DDP methods stay registered with deprecation logs pointing at the new routes until 9.0.0 removes them.
38 changes: 21 additions & 17 deletions apps/meteor/client/views/account/security/TwoFactorTOTP.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box, Button, TextInput, Margins, Field, FieldRow, FieldLabel, ToggleSwitch } from '@rocket.chat/fuselage';
import { useStableCallback, useSafely } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useToastMessageDispatch, useUser, useMethod } from '@rocket.chat/ui-contexts';
import { useSetModal, useToastMessageDispatch, useUser, useEndpoint } from '@rocket.chat/ui-contexts';
import type { ComponentPropsWithoutRef, ChangeEvent } from 'react';
import { useState, useCallback, useEffect, useId } from 'react';
import { useForm } from 'react-hook-form';
Expand All @@ -17,17 +17,22 @@ type TwoFactorTOTPFormData = {

export type TwoFactorTOTPProps = ComponentPropsWithoutRef<typeof Box>;

const isInvalidTotpError = (error: unknown): boolean => {
const { error: errorCode, errorType } = (error ?? {}) as { error?: string; errorType?: string };
return errorCode === 'invalid-totp' || errorType === 'invalid-totp';
};

const TwoFactorTOTP = (props: TwoFactorTOTPProps) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const user = useUser();
const setModal = useSetModal();

const enableTotpFn = useMethod('2fa:enable');
const disableTotpFn = useMethod('2fa:disable');
const verifyCodeFn = useMethod('2fa:validateTempToken');
const checkCodesRemainingFn = useMethod('2fa:checkCodesRemaining');
const regenerateCodesFn = useMethod('2fa:regenerateCodes');
const enableTotpFn = useEndpoint('POST', '/v1/users.enableTotp');
const disableTotpFn = useEndpoint('POST', '/v1/users.disableTotp');
const verifyCodeFn = useEndpoint('POST', '/v1/users.validateTotp');
const checkCodesRemainingFn = useEndpoint('GET', '/v1/users.totpCodesRemaining');
const regenerateCodesFn = useEndpoint('POST', '/v1/users.regenerateTotpCodes');

const [registeringTotp, setRegisteringTotp] = useSafely(useState(false));
const [qrCode, setQrCode] = useSafely(useState<string>());
Expand Down Expand Up @@ -73,9 +78,9 @@ const TwoFactorTOTP = (props: TwoFactorTOTPProps) => {

const onDisable = async (authCode: string): Promise<void> => {
try {
const result = await disableTotpFn(authCode);
const { disabled } = await disableTotpFn({ code: authCode });

if (!result) {
if (!disabled) {
dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });

return;
Expand Down Expand Up @@ -106,17 +111,16 @@ const TwoFactorTOTP = (props: TwoFactorTOTPProps) => {
const handleVerifyCode = useCallback(
async ({ authCode }: TwoFactorTOTPFormData) => {
try {
const result = await verifyCodeFn(authCode);

if (!result) {
return dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });
}
const result = await verifyCodeFn({ code: authCode });

setRegisteringTotp(false);
setModal(<BackupCodesModal codes={result.codes} onClose={closeModal} />);

dispatchToastMessage({ type: 'success', message: t('Two-factor_authentication_enabled') });
} catch (error) {
if (isInvalidTotpError(error)) {
return dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });
}
dispatchToastMessage({ type: 'error', message: error });
}
},
Expand All @@ -126,13 +130,13 @@ const TwoFactorTOTP = (props: TwoFactorTOTPProps) => {
const handleRegenerateCodes = useCallback(() => {
const onRegenerate = async (authCode: string): Promise<void> => {
try {
const result = await regenerateCodesFn(authCode);
const { codes } = await regenerateCodesFn({ code: authCode });

if (!result) {
setModal(<BackupCodesModal codes={codes} onClose={closeModal} />);
} catch (error) {
if (isInvalidTotpError(error)) {
return dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });
}
setModal(<BackupCodesModal codes={result.codes} onClose={closeModal} />);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
Expand Down
161 changes: 161 additions & 0 deletions apps/meteor/server/api/v1/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { removePersonalAccessTokenOfUser } from '../../../imports/personal-acces
import { runUserLogoutCleanUp } from '../../hooks/userLogoutCleanUp';
import { getUserForCheck, emailCheck } from '../../lib/2fa/code';
import { resetTOTP } from '../../lib/2fa/functions/resetTOTP';
import { codesRemainingTotp, disableTotp, enableTotp, regenerateTotpCodes, validateTotpTempToken } from '../../lib/2fa/functions/totp';
import { UserChangedAuditStore } from '../../lib/auditServerEvents/userChanged';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { i18n } from '../../lib/i18n';
Expand Down Expand Up @@ -2125,6 +2126,166 @@ API.v1.post(
return API.v1.success();
},
);
API.v1
.post(
'users.enableTotp',
{
authRequired: true,
twoFactorRequired: true,
twoFactorOptions: { disableRememberMe: true },
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
response: {
200: ajv.compile<{ secret: string; url: string }>({
type: 'object',
properties: {
secret: { type: 'string' },
url: { type: 'string' },
success: { type: 'boolean', enum: [true] },
},
required: ['secret', 'url', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
return API.v1.success(await enableTotp(this.userId));
},
)
Comment thread
ggazzo marked this conversation as resolved.
.post(
'users.disableTotp',
{
authRequired: true,
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
body: ajv.compile<{ code: string }>({
type: 'object',
properties: { code: { type: 'string', minLength: 1 } },
required: ['code'],
additionalProperties: false,
}),
response: {
200: ajv.compile<{ disabled: boolean }>({
type: 'object',
properties: {
disabled: { type: 'boolean' },
success: { type: 'boolean', enum: [true] },
},
required: ['disabled', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const disabled = await disableTotp(this.userId, this.bodyParams.code);
return API.v1.success({ disabled });
},
)
.post(
'users.validateTotp',
{
authRequired: true,
twoFactorRequired: true,
twoFactorOptions: { disableRememberMe: true },
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
body: ajv.compile<{ code: string }>({
type: 'object',
properties: { code: { type: 'string', minLength: 1 } },
required: ['code'],
additionalProperties: false,
}),
response: {
200: ajv.compile<{ codes: string[] }>({
type: 'object',
properties: {
codes: { type: 'array', items: { type: 'string' } },
success: { type: 'boolean', enum: [true] },
},
required: ['codes', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const result = await validateTotpTempToken(this.userId, this.bodyParams.code, this.request.headers.get('x-auth-token') ?? undefined);
return API.v1.success(result);
},
)
.post(
'users.regenerateTotpCodes',
{
authRequired: true,
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
body: ajv.compile<{ code: string }>({
type: 'object',
properties: { code: { type: 'string', minLength: 1 } },
required: ['code'],
additionalProperties: false,
}),
response: {
200: ajv.compile<{ codes: string[] }>({
type: 'object',
properties: {
codes: { type: 'array', items: { type: 'string' } },
success: { type: 'boolean', enum: [true] },
},
required: ['codes', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const result = await regenerateTotpCodes(this.userId, this.bodyParams.code);
if (!result) {
return API.v1.failure('invalid-totp');
}
return API.v1.success(result);
},
)
.get(
'users.totpCodesRemaining',
{
authRequired: true,
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
response: {
200: ajv.compile<{ remaining: number }>({
type: 'object',
properties: {
remaining: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['remaining', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
return API.v1.success(await codesRemainingTotp(this.userId));
},
);

settings.watch<number>('Rate_Limiter_Limit_RegisterUser', (value) => {
const userRegisterRoute = '/api/v1/users.registerpost';
Expand Down
Loading
Loading