chore: Move 2fa challenge handler to rest api package (#29263)

pull/28975/head^2
Guilherme Gazzo 3 years ago committed by GitHub
parent 6c8dac195d
commit 40cebcc0f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      .changeset/seven-rules-search.md
  2. 6
      .changeset/shy-maps-admire.md
  3. 34
      apps/meteor/app/2fa/client/overrideMeteorCall.ts
  4. 7
      apps/meteor/app/2fa/server/code/index.ts
  5. 35
      apps/meteor/app/utils/client/lib/RestApiClient.ts
  6. 24
      apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx
  7. 9
      apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx
  8. 30
      apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx
  9. 23
      apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx
  10. 117
      apps/meteor/client/lib/2fa/process2faReturn.ts
  11. 163
      packages/api-client/__tests__/2fahandling.spec.ts
  12. 9
      packages/api-client/jest.config.ts
  13. 3
      packages/api-client/package.json
  14. 4
      packages/api-client/src/RestClientInterface.ts
  15. 24
      packages/api-client/src/errors.ts
  16. 47
      packages/api-client/src/index.ts
  17. 29
      yarn.lock

@ -0,0 +1,5 @@
---
'@rocket.chat/api-client': minor
---
Moved from patch monkey solution to official one

@ -0,0 +1,6 @@
---
'@rocket.chat/meteor': minor
'@rocket.chat/api-client': minor
---
ask for totp if the provided one is invalid

@ -3,7 +3,6 @@ import { Meteor } from 'meteor/meteor';
import { t } from '../../utils/lib/i18n';
import { process2faReturn, process2faAsyncReturn } from '../../../client/lib/2fa/process2faReturn';
import { isTotpInvalidError } from '../../../client/lib/2fa/utils';
import { dispatchToastMessage } from '../../../client/lib/toast';
const { call, callAsync } = Meteor;
@ -35,23 +34,6 @@ const callWithoutTotp = (methodName: string, args: unknown[], callback: Callback
});
});
const callAsyncWithTotp =
(methodName: string, args: unknown[]) =>
async (twoFactorCode: string, twoFactorMethod: string): Promise<unknown> => {
try {
const result = await callAsync(methodName, ...args, { twoFactorCode, twoFactorMethod });
return result;
} catch (error: unknown) {
if (isTotpInvalidError(error)) {
dispatchToastMessage({ type: 'error', message: t('TOTP Invalid [totp-invalid]') });
throw new Error(twoFactorMethod === 'password' ? t('Invalid_password') : t('Invalid_two_factor_code'));
}
throw error;
}
};
Meteor.call = function (methodName: string, ...args: unknown[]): unknown {
const callback = args.length > 0 && typeof args[args.length - 1] === 'function' ? (args.pop() as Callback) : (): void => undefined;
@ -59,11 +41,13 @@ Meteor.call = function (methodName: string, ...args: unknown[]): unknown {
};
Meteor.callAsync = async function _callAsyncWithTotp(methodName: string, ...args: unknown[]): Promise<unknown> {
const promise = callAsync(methodName, ...args);
return process2faAsyncReturn({
promise,
onCode: callAsyncWithTotp(methodName, args),
emailOrUsername: undefined,
});
try {
return await callAsync(methodName, ...args);
} catch (error: unknown) {
return process2faAsyncReturn({
error,
onCode: (twoFactorCode, twoFactorMethod) => Meteor.callAsync(methodName, ...args, { twoFactorCode, twoFactorMethod }),
emailOrUsername: undefined,
});
}
};

@ -34,7 +34,7 @@ function getMethodByNameOrFirstActiveForUser(user: IUser, name?: string): ICodeC
return Array.from(checkMethods.values()).find((method) => method.isEnabled(user));
}
function getAvailableMethodNames(user: IUser): string[] | [] {
function getAvailableMethodNames(user: IUser): string[] {
return (
Array.from(checkMethods)
.filter(([, method]) => method.isEnabled(user))
@ -205,9 +205,9 @@ export async function checkCodeForUser({ user, code, method, options = {}, conne
const data = await selectedMethod.processInvalidCode(existingUser);
if (!code) {
const availableMethods = getAvailableMethodNames(existingUser);
const availableMethods = getAvailableMethodNames(existingUser);
if (!code) {
throw new Meteor.Error('totp-required', 'TOTP Required', {
method: selectedMethod.name,
...data,
@ -220,6 +220,7 @@ export async function checkCodeForUser({ user, code, method, options = {}, conne
throw new Meteor.Error('totp-invalid', 'TOTP Invalid', {
method: selectedMethod.name,
...data,
availableMethods,
});
}

@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
import { baseURI } from '../../../../client/lib/baseURI';
import { process2faReturn } from '../../../../client/lib/2fa/process2faReturn';
import { invokeTwoFactorModal } from '../../../../client/lib/2fa/process2faReturn';
class RestApiClient extends RestClient {
getCredentials():
@ -28,31 +28,24 @@ export const APIClient = new RestApiClient({
baseUrl: baseURI.replace(/\/$/, ''),
});
APIClient.handleTwoFactorChallenge(invokeTwoFactorModal);
/**
* The original rest api code throws the Response object, which is very useful
* for the client sometimes, if the developer wants to access more information about the error
* unfortunately/fortunately Rocket.Chat expects an error object (from Response.json()
* This middleware will throw the error object instead.
* */
APIClient.use(async function (request, next) {
try {
return await next(...request);
} catch (error) {
if (!(error instanceof Response)) {
throw error;
if (error instanceof Response) {
const e = await error.json();
throw e;
}
const e = await error.json();
return new Promise(async (resolve, reject) => {
await process2faReturn({
error: e,
result: null,
emailOrUsername: undefined,
originalCallback: () => reject(e),
onCode(code, method) {
return resolve(
next(request[0], request[1], {
...request[2],
headers: { ...request[2]?.headers, 'x-2fa-code': code, 'x-2fa-method': method },
}),
);
},
});
});
throw error;
}
});

@ -1,5 +1,5 @@
import { Box, TextInput } from '@rocket.chat/fuselage';
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import { Box, FieldGroup, TextInput, Field } from '@rocket.chat/fuselage';
import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react';
import React, { useState } from 'react';
@ -12,9 +12,10 @@ type TwoFactorEmailModalProps = {
onConfirm: OnConfirm;
onClose: () => void;
emailOrUsername: string;
invalidAttempt?: boolean;
};
const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername }: TwoFactorEmailModalProps): ReactElement => {
const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttempt }: TwoFactorEmailModalProps): ReactElement => {
const dispatchToastMessage = useToastMessageDispatch();
const t = useTranslation();
const [code, setCode] = useState<string>('');
@ -43,6 +44,8 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername }: TwoFactorE
setCode(currentTarget.value);
};
const id = useUniqueId();
return (
<GenericModal
wrapperFunction={(props) => <Box is='form' onSubmit={onConfirmEmailCode} {...props} />}
@ -54,10 +57,17 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername }: TwoFactorE
icon='info'
confirmDisabled={!code}
>
<Box mbe='x16'>{t('Verify_your_email_with_the_code_we_sent')}</Box>
<Box mbe='x4' display='flex' justifyContent='stretch'>
<TextInput ref={ref} value={code} onChange={onChange} placeholder={t('Enter_authentication_code')} />
</Box>
<FieldGroup>
<Field>
<Field.Label alignSelf='stretch' htmlFor={id}>
{t('Verify_your_email_with_the_code_we_sent')}
</Field.Label>
<Field.Row>
<TextInput id={id} ref={ref} value={code} onChange={onChange} placeholder={t('Enter_authentication_code')} />
</Field.Row>
{invalidAttempt && <Field.Error>{t('Invalid_password')}</Field.Error>}
</Field>
</FieldGroup>
<Box display='flex' justifyContent='end' is='a' onClick={onClickResendCode}>
{t('Cloud_resend_email')}
</Box>

@ -17,6 +17,7 @@ export type OnConfirm = (code: string, method: Method) => void;
type TwoFactorModalProps = {
onConfirm: OnConfirm;
onClose: () => void;
invalidAttempt?: boolean;
} & (
| {
method: 'totp' | 'password';
@ -27,7 +28,7 @@ type TwoFactorModalProps = {
}
);
const TwoFactorModal = ({ onConfirm, onClose, ...props }: TwoFactorModalProps): ReactElement => {
const TwoFactorModal = ({ onConfirm, onClose, invalidAttempt, ...props }: TwoFactorModalProps): ReactElement => {
const logoutOtherSessions = useEndpoint('POST', '/v1/users.logoutOtherClients');
const confirm = (code: any, method: Method): void => {
@ -35,17 +36,17 @@ const TwoFactorModal = ({ onConfirm, onClose, ...props }: TwoFactorModalProps):
logoutOtherSessions();
};
if (props.method === Method.TOTP) {
return <TwoFactorTotp onConfirm={confirm} onClose={onClose} />;
return <TwoFactorTotp onConfirm={confirm} onClose={onClose} invalidAttempt={invalidAttempt} />;
}
if (props.method === Method.EMAIL) {
const { emailOrUsername } = props;
return <TwoFactorEmail onConfirm={confirm} onClose={onClose} emailOrUsername={emailOrUsername} />;
return <TwoFactorEmail onConfirm={confirm} onClose={onClose} emailOrUsername={emailOrUsername} invalidAttempt={invalidAttempt} />;
}
if (props.method === Method.PASSWORD) {
return <TwoFactorPassword onConfirm={confirm} onClose={onClose} />;
return <TwoFactorPassword onConfirm={confirm} onClose={onClose} invalidAttempt={invalidAttempt} />;
}
throw new Error('Invalid Two Factor method');

@ -1,5 +1,5 @@
import { Box, PasswordInput } from '@rocket.chat/fuselage';
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import { Box, PasswordInput, FieldGroup, Field } from '@rocket.chat/fuselage';
import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement, ChangeEvent, Ref, SyntheticEvent } from 'react';
import React, { useState } from 'react';
@ -11,9 +11,10 @@ import { Method } from './TwoFactorModal';
type TwoFactorPasswordModalProps = {
onConfirm: OnConfirm;
onClose: () => void;
invalidAttempt?: boolean;
};
const TwoFactorPasswordModal = ({ onConfirm, onClose }: TwoFactorPasswordModalProps): ReactElement => {
const TwoFactorPasswordModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorPasswordModalProps): ReactElement => {
const t = useTranslation();
const [code, setCode] = useState<string>('');
const ref = useAutoFocus();
@ -27,6 +28,8 @@ const TwoFactorPasswordModal = ({ onConfirm, onClose }: TwoFactorPasswordModalPr
setCode(currentTarget.value);
};
const id = useUniqueId();
return (
<GenericModal
wrapperFunction={(props) => <Box is='form' onSubmit={onConfirmTotpCode} {...props} />}
@ -38,10 +41,23 @@ const TwoFactorPasswordModal = ({ onConfirm, onClose }: TwoFactorPasswordModalPr
icon='info'
confirmDisabled={!code}
>
<Box mbe='x16'>{t('For_your_security_you_must_enter_your_current_password_to_continue')}</Box>
<Box mbe='x16' display='flex' justifyContent='stretch'>
<PasswordInput ref={ref as Ref<HTMLInputElement>} value={code} onChange={onChange} placeholder={t('Password')}></PasswordInput>
</Box>
<FieldGroup>
<Field>
<Field.Label alignSelf='stretch' htmlFor={id}>
{t('For_your_security_you_must_enter_your_current_password_to_continue')}
</Field.Label>
<Field.Row>
<PasswordInput
id={id}
ref={ref as Ref<HTMLInputElement>}
value={code}
onChange={onChange}
placeholder={t('Password')}
></PasswordInput>
</Field.Row>
{invalidAttempt && <Field.Error>{t('Invalid_password')}</Field.Error>}
</Field>
</FieldGroup>
</GenericModal>
);
};

@ -1,5 +1,5 @@
import { Box, TextInput } from '@rocket.chat/fuselage';
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import { Box, TextInput, Field, FieldGroup } from '@rocket.chat/fuselage';
import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react';
import React, { useState } from 'react';
@ -11,9 +11,10 @@ import { Method } from './TwoFactorModal';
type TwoFactorTotpModalProps = {
onConfirm: OnConfirm;
onClose: () => void;
invalidAttempt?: boolean;
};
const TwoFactorTotpModal = ({ onConfirm, onClose }: TwoFactorTotpModalProps): ReactElement => {
const TwoFactorTotpModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorTotpModalProps): ReactElement => {
const t = useTranslation();
const [code, setCode] = useState<string>('');
const ref = useAutoFocus<HTMLInputElement>();
@ -27,6 +28,7 @@ const TwoFactorTotpModal = ({ onConfirm, onClose }: TwoFactorTotpModalProps): Re
setCode(currentTarget.value);
};
const id = useUniqueId();
return (
<GenericModal
wrapperFunction={(props) => <Box is='form' onSubmit={onConfirmTotpCode} {...props} />}
@ -38,10 +40,17 @@ const TwoFactorTotpModal = ({ onConfirm, onClose }: TwoFactorTotpModalProps): Re
icon='info'
confirmDisabled={!code}
>
<Box mbe='x16'>{t('Open_your_authentication_app_and_enter_the_code')}</Box>
<Box mbe='x16' display='flex' justifyContent='stretch'>
<TextInput ref={ref} value={code} onChange={onChange} placeholder={t('Enter_authentication_code')}></TextInput>
</Box>
<FieldGroup>
<Field>
<Field.Label alignSelf='stretch' htmlFor={id}>
{t('Open_your_authentication_app_and_enter_the_code')}
</Field.Label>
<Field.Row>
<TextInput id={id} ref={ref} value={code} onChange={onChange} placeholder={t('Enter_authentication_code')}></TextInput>
</Field.Row>
{invalidAttempt && <Field.Error>{t('Invalid_password')}</Field.Error>}
</Field>
</FieldGroup>
</GenericModal>
);
};

@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor';
import TwoFactorModal from '../../components/TwoFactorModal';
import { imperativeModal } from '../imperativeModal';
import { isTotpRequiredError } from './utils';
import { isTotpInvalidError, isTotpRequiredError } from './utils';
const twoFactorMethods = ['totp', 'email', 'password'] as const;
@ -49,7 +49,7 @@ export async function process2faReturn({
onCode: (code: string, method: string) => void;
emailOrUsername: string | null | undefined;
}): Promise<void> {
if (!isTotpRequiredError(error) || !hasRequiredTwoFactorMethod(error)) {
if (!(isTotpRequiredError(error) || isTotpInvalidError(error)) || !hasRequiredTwoFactorMethod(error)) {
originalCallback(error, result);
return;
}
@ -57,66 +57,83 @@ export async function process2faReturn({
const props = {
method: error.details.method,
emailOrUsername: emailOrUsername || error.details.emailOrUsername || Meteor.user()?.username,
// eslint-disable-next-line no-nested-ternary
invalidAttempt: isTotpInvalidError(error),
};
assertModalProps(props);
imperativeModal.open({
component: TwoFactorModal,
props: {
...props,
onConfirm: (code: string, method: string): void => {
imperativeModal.close();
onCode(method === 'password' ? SHA256(code) : code, method);
},
onClose: (): void => {
imperativeModal.close();
originalCallback(new Meteor.Error('totp-canceled'));
},
},
});
try {
const code = await invokeTwoFactorModal(props);
onCode(code, props.method);
} catch (error) {
process2faReturn({
error,
result,
originalCallback,
onCode,
emailOrUsername,
});
}
}
export async function process2faAsyncReturn({
promise,
error,
onCode,
emailOrUsername,
}: {
promise: Promise<unknown>;
error: unknown;
onCode: (code: string, method: string) => unknown | Promise<unknown>;
emailOrUsername: string | null | undefined;
}): Promise<unknown> {
// if the promise is rejected, we need to check if it's a 2fa error
return promise.catch(async (error) => {
// if it's not a 2fa error, we reject the promise
if (!isTotpRequiredError(error) || !hasRequiredTwoFactorMethod(error)) {
throw error;
}
const props = {
method: error.details.method,
emailOrUsername: emailOrUsername || error.details.emailOrUsername || Meteor.user()?.username,
};
assertModalProps(props);
return new Promise<unknown>((resolve, reject) => {
imperativeModal.open({
component: TwoFactorModal,
props: {
...props,
onConfirm: (code: string, method: string): void => {
imperativeModal.close();
// once we have the code, we resolve the promise with the result of the `onCode` callback
resolve(onCode(method === 'password' ? SHA256(code) : code, method));
},
onClose: (): void => {
imperativeModal.close();
reject(new Meteor.Error('totp-canceled'));
},
// if it's not a 2fa error, we reject the promise
if (!(isTotpRequiredError(error) || isTotpInvalidError(error)) || !hasRequiredTwoFactorMethod(error)) {
throw error;
}
const props = {
method: error.details.method,
emailOrUsername: emailOrUsername || error.details.emailOrUsername || Meteor.user()?.username,
// eslint-disable-next-line no-nested-ternary
invalidAttempt: isTotpInvalidError(error),
};
assertModalProps(props);
try {
const code = await invokeTwoFactorModal(props);
return onCode(code, props.method);
} catch (error) {
return process2faAsyncReturn({
error,
onCode,
emailOrUsername,
});
}
}
export const invokeTwoFactorModal = async (props: {
method: 'totp' | 'email' | 'password';
emailOrUsername?: string | undefined;
invalidAttempt?: boolean;
}) => {
assertModalProps(props);
return new Promise<string>((resolve, reject) => {
imperativeModal.open({
component: TwoFactorModal,
props: {
...props,
onConfirm: (code: string, method: string): void => {
imperativeModal.close();
resolve(method === 'password' ? SHA256(code) : code);
},
onClose: (): void => {
imperativeModal.close();
reject(new Meteor.Error('totp-canceled'));
},
});
},
});
});
}
};

@ -0,0 +1,163 @@
import fetchMock from 'jest-fetch-mock';
import { RestClient } from '../src/index';
beforeAll(() => {
fetchMock.enableMocks();
});
afterAll(() => {
fetchMock.disableMocks();
});
beforeEach(() => {
fetchMock.mockIf(/^https?:\/\/example.com.*$/, async (req) => {
if (req.headers.get('x-2fa-code') === '2FA_CODE') {
return {
status: 200,
body: JSON.stringify({
status: 'success',
data: {
userId: 'foo',
email: 'foo',
username: 'foo',
},
}),
};
}
if (req.headers.get('x-2fa-code') === 'WRONG_2FA_CODE') {
return {
status: 400,
body: JSON.stringify({
errorType: 'totp-invalid',
message: 'Invalid TOTP provided',
details: {
method: 'totp',
},
}),
};
}
return {
status: 400,
body: JSON.stringify({
errorType: 'totp-required',
details: {
method: 'totp',
},
}),
};
});
fetchMock.doMock();
});
const isResponse = (e: any): e is Response => {
expect(e).toBeInstanceOf(Response);
return true;
};
test('if the 2fa handler is not provided, it should throw an error', async () => {
const client = new RestClient({
baseUrl: 'https://example.com',
});
try {
await client.post('/v1/login', { user: 'foo', username: 'foo', email: 'foo', password: 'foo', code: 'foo' });
} catch (error) {
if (!isResponse(error)) {
throw error;
}
expect(error.status).toBe(400);
const body = error.body && (await JSON.parse(error.body.toString()));
expect(body).toMatchObject({
errorType: 'totp-required',
details: {
method: 'totp',
},
});
}
});
test('if the 2fa handler is provided, and fails if should throw the error thrown by the handler', async () => {
const fn = jest.fn();
const client = new RestClient({
baseUrl: 'https://example.com',
});
client.handleTwoFactorChallenge((e) => {
fn(e);
throw new Error('foo');
});
await expect(client.post('/v1/login', { user: 'foo', username: 'foo', email: 'foo', password: 'foo', code: 'foo' })).rejects.toThrow(
new Error('foo'),
);
expect(fn).toHaveBeenCalledTimes(1);
});
test('if the 2fa handler is provided it should resolves', async () => {
const fn = jest.fn();
const client = new RestClient({
baseUrl: 'https://example.com',
});
client.handleTwoFactorChallenge(() => {
fn();
return Promise.resolve('2FA_CODE');
});
const result = await client.post('/v1/login', { user: 'foo', username: 'foo', email: 'foo', password: 'foo', code: 'foo' });
expect(result).toMatchObject({
status: 'success',
data: {
userId: 'foo',
email: 'foo',
username: 'foo',
},
});
expect(fn).toHaveBeenCalledTimes(1);
});
test.only('should be ask for 2fa code again if the code is wrong', async () => {
const fn = jest.fn();
const client = new RestClient({
baseUrl: 'https://example.com',
});
let retries = 0;
client.handleTwoFactorChallenge(() => {
fn();
if (!retries) {
retries++;
return Promise.resolve('WRONG_2FA_CODE');
}
return Promise.resolve('2FA_CODE');
});
const result = await client.post('/v1/login', { user: 'foo', username: 'foo', email: 'foo', password: 'foo', code: 'foo' });
expect(result).toMatchObject({
status: 'success',
data: {
userId: 'foo',
email: 'foo',
username: 'foo',
},
});
expect(fn).toHaveBeenCalledTimes(2);
});

@ -0,0 +1,9 @@
export default {
preset: 'ts-jest',
errorOnDeprecated: true,
testEnvironment: 'jsdom',
modulePathIgnorePatterns: ['<rootDir>/dist/'],
moduleNameMapper: {
'\\.css$': 'identity-obj-proxy',
},
};

@ -7,13 +7,14 @@
"@types/strict-uri-encode": "^2.0.0",
"eslint": "^8.29.0",
"jest": "~29.5.0",
"jest-fetch-mock": "^3.0.3",
"ts-jest": "~29.0.5",
"typescript": "~5.0.2"
},
"scripts": {
"lint": "eslint --ext .js,.jsx,.ts,.tsx .",
"lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix",
"test": "jest",
"testunit": "jest",
"build": "tsc -p tsconfig.json",
"dev": "tsc --watch --preserveWatchOutput -p tsconfig.json"
},

@ -84,4 +84,8 @@ export interface RestClientInterface {
use(middleware: Middleware<RestClientInterface['send']>): void;
send(endpoint: string, method: string, options?: Omit<RequestInit, 'method'>): Promise<Response>;
handleTwoFactorChallenge(
cb: (args: { method: 'totp' | 'email' | 'password'; emailOrUsername?: string; invalidAttempt?: boolean }) => Promise<string>,
): void;
}

@ -0,0 +1,24 @@
const twoFactorMethods = ['totp', 'email', 'password'] as const;
type TwoFactorMethod = (typeof twoFactorMethods)[number];
export const isTotpRequiredError = (error: unknown): error is { error: 'totp-required' } | { errorType: 'totp-required' } =>
typeof error === 'object' &&
((error as { error?: unknown } | undefined)?.error === 'totp-required' ||
(error as { errorType?: unknown } | undefined)?.errorType === 'totp-required');
export const isTotpInvalidError = (error: unknown): error is { error: 'totp-invalid' } | { errorType: 'totp-invalid' } =>
(error as { error?: unknown } | undefined)?.error === 'totp-invalid' ||
(error as { errorType?: unknown } | undefined)?.errorType === 'totp-invalid';
export const isTwoFactorMethod = (method: string): method is TwoFactorMethod => twoFactorMethods.includes(method as TwoFactorMethod);
export const hasRequiredTwoFactorMethod = (error: unknown): error is { details: { method: TwoFactorMethod; emailOrUsername?: string } } => {
const details = error && typeof error === 'object' && 'details' in error && (error.details as unknown);
return (
typeof details === 'object' &&
details !== null &&
typeof (details as { method: unknown }).method === 'string' &&
isTwoFactorMethod((details as { method: string }).method)
);
};

@ -10,6 +10,7 @@ import type {
} from '@rocket.chat/rest-typings';
import type { Middleware, RestClientInterface } from './RestClientInterface';
import { hasRequiredTwoFactorMethod, isTotpInvalidError, isTotpRequiredError } from './errors';
export { RestClientInterface };
@ -49,6 +50,12 @@ const checkIfIsFormData = (data: any = {}): boolean => {
};
export class RestClient implements RestClientInterface {
private twoFactorHandler?: (args: {
method: 'totp' | 'email' | 'password';
emailOrUsername?: string;
invalidAttempt?: boolean;
}) => Promise<string>;
private readonly baseUrl: string;
private headers: Record<string, string> = {};
@ -218,11 +225,39 @@ export class RestClient implements RestClientInterface {
...options,
headers: { ...this.getCredentialsAsHeaders(), ...this.headers, ...headers },
method,
}).then(function (response) {
if (!response.ok) {
}).then(async (response) => {
if (response.ok) {
return response;
}
if (response.status !== 400) {
return Promise.reject(response);
}
return response;
const error = await response.json();
if ((isTotpRequiredError(error) || isTotpInvalidError(error)) && hasRequiredTwoFactorMethod(error) && this.twoFactorHandler) {
const method2fa = 'details' in error ? error.details.method : 'password';
const code = await this.twoFactorHandler({
method: method2fa,
emailOrUsername: error.details.emailOrUsername,
invalidAttempt: isTotpInvalidError(error),
});
return this.send(endpoint, method, {
...options,
headers: {
...this.getCredentialsAsHeaders(),
...this.headers,
...headers,
'x-2fa-code': code,
'x-2fa-method': method2fa,
},
});
}
return Promise.reject(response);
});
}
@ -274,4 +309,10 @@ export class RestClient implements RestClientInterface {
return middleware(context, pipe(fn));
} as RestClientInterface['send'];
}
handleTwoFactorChallenge(
cb: (args: { method: 'totp' | 'email' | 'password'; emailOrUsername?: string; invalidAttempt?: boolean }) => Promise<string>,
): void {
this.twoFactorHandler = cb;
}
}

@ -6792,6 +6792,7 @@ __metadata:
eslint: ^8.29.0
filter-obj: ^3.0.0
jest: ~29.5.0
jest-fetch-mock: ^3.0.3
query-string: ^7.1.1
split-on-first: ^3.0.0
strict-uri-encode: ^2.0.0
@ -16821,6 +16822,15 @@ __metadata:
languageName: node
linkType: hard
"cross-fetch@npm:^3.0.4":
version: 3.1.6
resolution: "cross-fetch@npm:3.1.6"
dependencies:
node-fetch: ^2.6.11
checksum: 704b3519ab7de488328cc49a52cf1aa14132ec748382be5b9557b22398c33ffa7f8c2530e8a97ed8cb55da52b0a9740a9791d361271c4591910501682d981d9c
languageName: node
linkType: hard
"cross-spawn@npm:^5.0.1, cross-spawn@npm:^5.1.0":
version: 5.1.0
resolution: "cross-spawn@npm:5.1.0"
@ -24642,6 +24652,16 @@ __metadata:
languageName: node
linkType: hard
"jest-fetch-mock@npm:^3.0.3":
version: 3.0.3
resolution: "jest-fetch-mock@npm:3.0.3"
dependencies:
cross-fetch: ^3.0.4
promise-polyfill: ^8.1.3
checksum: fb052f7e0ef1c8192a9c15efdd1b18d281ab68fc6b1648b30bff8880fe24418bdf12190ea79b1996932dc15417c3c01f5b2d77ef7104a7e7943e7cbe8d61071d
languageName: node
linkType: hard
"jest-get-type@npm:^27.5.1":
version: 27.5.1
resolution: "jest-get-type@npm:27.5.1"
@ -28149,7 +28169,7 @@ __metadata:
languageName: node
linkType: hard
"node-fetch@npm:^2.5.0":
"node-fetch@npm:^2.5.0, node-fetch@npm:^2.6.11":
version: 2.6.11
resolution: "node-fetch@npm:2.6.11"
dependencies:
@ -31332,6 +31352,13 @@ __metadata:
languageName: node
linkType: hard
"promise-polyfill@npm:^8.1.3":
version: 8.3.0
resolution: "promise-polyfill@npm:8.3.0"
checksum: 206373802076c77def0805758d0a8ece64120dfa6603f092404a1004211f8f2f67f33cadbc35953fc2a8ed0b0d38c774e88bdf01e20ce7a920723a60df84b7a5
languageName: node
linkType: hard
"promise-retry@npm:^2.0.1":
version: 2.0.1
resolution: "promise-retry@npm:2.0.1"

Loading…
Cancel
Save