diff --git a/.changeset/ddp-migrate-batch5-totp-caller.md b/.changeset/ddp-migrate-batch5-totp-caller.md new file mode 100644 index 0000000000000..8d72c63d6a706 --- /dev/null +++ b/.changeset/ddp-migrate-batch5-totp-caller.md @@ -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. diff --git a/.changeset/rest-users-totp.md b/.changeset/rest-users-totp.md new file mode 100644 index 0000000000000..5c6187caa81eb --- /dev/null +++ b/.changeset/rest-users-totp.md @@ -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. diff --git a/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx b/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx index 89d7a95f8e606..0f36ee463b4cc 100644 --- a/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx +++ b/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx @@ -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'; @@ -17,17 +17,22 @@ type TwoFactorTOTPFormData = { export type TwoFactorTOTPProps = ComponentPropsWithoutRef; +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()); @@ -73,9 +78,9 @@ const TwoFactorTOTP = (props: TwoFactorTOTPProps) => { const onDisable = async (authCode: string): Promise => { 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; @@ -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(); 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 }); } }, @@ -126,13 +130,13 @@ const TwoFactorTOTP = (props: TwoFactorTOTPProps) => { const handleRegenerateCodes = useCallback(() => { const onRegenerate = async (authCode: string): Promise => { try { - const result = await regenerateCodesFn(authCode); + const { codes } = await regenerateCodesFn({ code: authCode }); - if (!result) { + setModal(); + } catch (error) { + if (isInvalidTotpError(error)) { return dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); } - setModal(); - } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } }; diff --git a/apps/meteor/server/api/v1/users.ts b/apps/meteor/server/api/v1/users.ts index 551841632a3ee..5f2e55d3abfbe 100644 --- a/apps/meteor/server/api/v1/users.ts +++ b/apps/meteor/server/api/v1/users.ts @@ -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'; @@ -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)); + }, + ) + .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('Rate_Limiter_Limit_RegisterUser', (value) => { const userRegisterRoute = '/api/v1/users.registerpost'; diff --git a/apps/meteor/server/lib/2fa/functions/totp.ts b/apps/meteor/server/lib/2fa/functions/totp.ts new file mode 100644 index 0000000000000..d0d1a4d731917 --- /dev/null +++ b/apps/meteor/server/lib/2fa/functions/totp.ts @@ -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 => { + 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, + }; +}; diff --git a/apps/meteor/server/meteor-methods/auth/checkCodesRemaining.ts b/apps/meteor/server/meteor-methods/auth/checkCodesRemaining.ts index f5f5b0e1f2758..9982c4f0efa34 100644 --- a/apps/meteor/server/meteor-methods/auth/checkCodesRemaining.ts +++ b/apps/meteor/server/meteor-methods/auth/checkCodesRemaining.ts @@ -1,6 +1,9 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; +import { codesRemainingTotp } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -10,24 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async '2fa:checkCodesRemaining'() { - if (!Meteor.userId()) { - throw new Meteor.Error('not-authorized'); - } - - const user = await Meteor.userAsync(); - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: '2fa:checkCodesRemaining', - }); - } - - if (!user.services?.totp?.enabled) { - throw new Meteor.Error('invalid-totp'); - } - - return { - remaining: user.services.totp.hashedBackup.length, - }; + methodDeprecationLogger.method('2fa:checkCodesRemaining', '9.0.0', '/v1/users.totpCodesRemaining'); + return codesRemainingTotp(Meteor.userId()); }, }); diff --git a/apps/meteor/server/meteor-methods/auth/disable.ts b/apps/meteor/server/meteor-methods/auth/disable.ts index 22426f60a5595..56cd832d5fd7f 100644 --- a/apps/meteor/server/meteor-methods/auth/disable.ts +++ b/apps/meteor/server/meteor-methods/auth/disable.ts @@ -1,9 +1,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { TOTP } from '../../lib/2fa/lib/totp'; -import { notifyOnUserChange } from '../../lib/notifyListener'; +import { disableTotp } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,42 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async '2fa:disable'(code) { - const userId = Meteor.userId(); - if (!userId) { - throw new Meteor.Error('not-authorized'); - } - - const user = await Meteor.userAsync(); - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: '2fa:disable', - }); - } - - if (!user.services?.totp?.enabled) { - return false; - } - - const verified = await TOTP.verify({ - secret: user.services.totp.secret, - token: code, - userId, - backupTokens: user.services.totp.hashedBackup, - }); - - if (!verified) { - return false; - } - - const { modifiedCount } = await Users.disable2FAByUserId(userId); - - if (!modifiedCount) { - return false; - } - - void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': false } }); - - return true; + methodDeprecationLogger.method('2fa:disable', '9.0.0', '/v1/users.disableTotp'); + return disableTotp(Meteor.userId(), code); }, }); diff --git a/apps/meteor/server/meteor-methods/auth/enable.ts b/apps/meteor/server/meteor-methods/auth/enable.ts index 3511d099fd14a..54226794e10b9 100644 --- a/apps/meteor/server/meteor-methods/auth/enable.ts +++ b/apps/meteor/server/meteor-methods/auth/enable.ts @@ -1,8 +1,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { TOTP } from '../../lib/2fa/lib/totp'; +import { enableTotp } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -13,30 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async '2fa:enable'() { - const userId = Meteor.userId(); - if (!userId) { - throw new Meteor.Error('not-authorized'); - } - - const user = await Meteor.userAsync(); - - if (!user?.username) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: '2fa:enable', - }); - } - - if (user.services?.totp?.enabled) { - throw new Meteor.Error('error-2fa-already-enabled'); - } - - const secret = TOTP.generateSecret(); - - await Users.disable2FAAndSetTempSecretByUserId(userId, secret.base32); - - return { - secret: secret.base32, - url: TOTP.generateOtpauthURL(secret, user.username), - }; + methodDeprecationLogger.method('2fa:enable', '9.0.0', '/v1/users.enableTotp'); + return enableTotp(Meteor.userId()); }, }); diff --git a/apps/meteor/server/meteor-methods/auth/regenerateCodes.ts b/apps/meteor/server/meteor-methods/auth/regenerateCodes.ts index bb012c1db87b5..1a02b60506a00 100644 --- a/apps/meteor/server/meteor-methods/auth/regenerateCodes.ts +++ b/apps/meteor/server/meteor-methods/auth/regenerateCodes.ts @@ -1,8 +1,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { TOTP } from '../../lib/2fa/lib/totp'; +import { regenerateTotpCodes } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -13,34 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async '2fa:regenerateCodes'(userToken) { - const userId = Meteor.userId(); - if (!userId) { - throw new Meteor.Error('not-authorized'); - } - - const user = await Meteor.userAsync(); - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: '2fa:regenerateCodes', - }); - } - - if (!user.services?.totp?.enabled) { - throw new Meteor.Error('invalid-totp'); - } - - const verified = await TOTP.verify({ - secret: user.services.totp.secret, - token: userToken, - userId, - backupTokens: user.services.totp.hashedBackup, - }); - - if (verified) { - const { codes, hashedCodes } = TOTP.generateCodes(); - - await Users.update2FABackupCodesByUserId(userId, hashedCodes); - return { codes }; - } + methodDeprecationLogger.method('2fa:regenerateCodes', '9.0.0', '/v1/users.regenerateTotpCodes'); + return regenerateTotpCodes(Meteor.userId(), userToken); }, }); diff --git a/apps/meteor/server/meteor-methods/auth/validateTempToken.ts b/apps/meteor/server/meteor-methods/auth/validateTempToken.ts index 2bf1e0954885d..e8ad96eae5ba5 100644 --- a/apps/meteor/server/meteor-methods/auth/validateTempToken.ts +++ b/apps/meteor/server/meteor-methods/auth/validateTempToken.ts @@ -1,10 +1,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; -import { TOTP } from '../../lib/2fa/lib/totp'; -import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../lib/notifyListener'; +import { validateTotpTempToken } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,63 +13,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async '2fa:validateTempToken'(userToken) { - const userId = Meteor.userId(); - if (!userId) { - throw new Meteor.Error('not-authorized'); - } - - const user = await Meteor.userAsync(); - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: '2fa:validateTempToken', - }); - } - - 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(userId, user.services.totp.tempSecret, hashedCodes); - - // Once the TOTP is validated we logout all other clients + methodDeprecationLogger.method('2fa:validateTempToken', '9.0.0', '/v1/users.validateTotp'); const { 'x-auth-token': xAuthToken } = this.connection?.httpHeaders ?? {}; - if (xAuthToken && this.userId) { - const hashedToken = Accounts._hashLoginToken(xAuthToken); - - const { modifiedCount } = await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken); - - if (modifiedCount > 0) { - // TODO this can be optmized so places that care about loginTokens being removed are invoked directly - // instead of having to listen to every watch.users event - void notifyOnUserChangeAsync(async () => { - if (!this.userId) { - return; - } - const user = await Users.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1, 'services.totp': 1 } }); - return { - clientAction: 'updated', - id: this.userId, - diff: { - 'services.resume.loginTokens': user?.services?.resume?.loginTokens, - ...(user?.services?.totp && { 'services.totp.enabled': user.services.totp.enabled }), - }, - }; - }); - } else { - void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': true } }); - } - } - - return { codes }; + return validateTotpTempToken(Meteor.userId(), userToken, xAuthToken); }, }); diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 69c9e0b863207..cdf53d444b3b9 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -7,6 +7,7 @@ import type { IGetRoomRoles, PaginatedResult, DefaultUserInfo } from '@rocket.ch import { assert, expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; import { MongoClient } from 'mongodb'; +import speakeasy from 'speakeasy'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials, apiEmail, apiUsername, wait, reservedWords } from '../../data/api-data'; @@ -6582,4 +6583,120 @@ describe('[Users]', () => { expect(res.body).to.have.property('success', false); })); }); + + describe('[TOTP endpoints]', () => { + let totpUser: TestUser; + let totpCredentials: Credentials; + let secret: string; + + // speakeasy verify is stateless, so the same code is accepted repeatedly within its window — safe to reuse across the flow + const totpCode = () => speakeasy.totp({ secret, encoding: 'base32' }); + + // enableTotp/validateTotp carry `twoFactorRequired` to protect users with an existing 2FA method, but the + // API e2e suite runs with TEST_MODE which bypasses the 2FA challenge (see checkCodeForUser), so these tests + // exercise the endpoint logic for a fresh user without a challenge. + before(async () => { + totpUser = await createUser({ username: Random.id(), email: `${Random.id()}@example.com`, verified: true }); + totpCredentials = await login(totpUser.username, password); + }); + + after(async () => deleteUser(totpUser)); + + describe('[/users.enableTotp]', () => { + it('should fail when unauthenticated', () => request.post(api('users.enableTotp')).expect(401)); + + it('should return a secret and an otpauth url', () => + request + .post(api('users.enableTotp')) + .set(totpCredentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('secret').that.is.a('string'); + expect(res.body) + .to.have.property('url') + .that.is.a('string') + .and.match(/^otpauth:\/\//); + secret = res.body.secret; + })); + }); + + describe('[/users.validateTotp]', () => { + it('should fail when unauthenticated', () => request.post(api('users.validateTotp')).send({ code: '000000' }).expect(401)); + + it('should fail with 400 when the code is missing', () => + request.post(api('users.validateTotp')).set(totpCredentials).send({}).expect(400)); + + it('should enable totp and return backup codes for a valid code', () => + request + .post(api('users.validateTotp')) + .set(totpCredentials) + .send({ code: totpCode() }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('codes').that.is.an('array'); + })); + }); + + describe('[/users.totpCodesRemaining]', () => { + it('should fail when unauthenticated', () => request.get(api('users.totpCodesRemaining')).expect(401)); + + it('should return the number of remaining backup codes', () => + request + .get(api('users.totpCodesRemaining')) + .set(totpCredentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('remaining').that.is.a('number'); + })); + }); + + describe('[/users.regenerateTotpCodes]', () => { + it('should fail when unauthenticated', () => request.post(api('users.regenerateTotpCodes')).send({ code: '000000' }).expect(401)); + + it('should fail with 400 when the code is missing', () => + request.post(api('users.regenerateTotpCodes')).set(totpCredentials).send({}).expect(400)); + + it('should fail with 400 when the code is invalid', () => + request + .post(api('users.regenerateTotpCodes')) + .set(totpCredentials) + .send({ code: '000000' }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + })); + + it('should return a fresh set of backup codes for a valid code', () => + request + .post(api('users.regenerateTotpCodes')) + .set(totpCredentials) + .send({ code: totpCode() }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('codes').that.is.an('array'); + })); + }); + + describe('[/users.disableTotp]', () => { + it('should fail when unauthenticated', () => request.post(api('users.disableTotp')).send({ code: '000000' }).expect(401)); + + it('should fail with 400 when the code is missing', () => + request.post(api('users.disableTotp')).set(totpCredentials).send({}).expect(400)); + + it('should disable totp for a valid code', () => + request + .post(api('users.disableTotp')) + .set(totpCredentials) + .send({ code: totpCode() }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('disabled', true); + })); + }); + }); }); diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 1f0b223fbef5e..1bd9a7c559306 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -386,6 +386,26 @@ export type UsersEndpoints = { '/v1/users.verifyEmail': { POST: (params: { token: string }) => void; }; + + '/v1/users.enableTotp': { + POST: () => { secret: string; url: string }; + }; + + '/v1/users.disableTotp': { + POST: (params: { code: string }) => { disabled: boolean }; + }; + + '/v1/users.validateTotp': { + POST: (params: { code: string }) => { codes: string[] }; + }; + + '/v1/users.regenerateTotpCodes': { + POST: (params: { code: string }) => { codes: string[] }; + }; + + '/v1/users.totpCodesRemaining': { + GET: () => { remaining: number }; + }; }; export * from './users/UserCreateParamsPOST';