Skip to content

Commit

Permalink
Fix error handling in Web MFA flow.
Browse files Browse the repository at this point in the history
  • Loading branch information
Joerger committed Dec 14, 2024
1 parent a338f48 commit ccb1aaf
Show file tree
Hide file tree
Showing 10 changed files with 300 additions and 294 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import styled from 'styled-components';
import { Alert, OutlineDanger } from 'design/Alert/Alert';
import { ButtonPrimary, ButtonSecondary } from 'design/Button';
import Dialog from 'design/Dialog';
import Flex from 'design/Flex';
import { RadioGroup } from 'design/RadioGroup';
import { StepComponentProps, StepSlider, StepHeader } from 'design/StepSlider';
import React, { useEffect, useState } from 'react';
import { StepComponentProps, StepHeader, StepSlider } from 'design/StepSlider';
import React, { useCallback, useEffect, useState } from 'react';
import FieldInput from 'shared/components/FieldInput';
import Validation, { Validator } from 'shared/components/Validation';
import {
Expand All @@ -32,19 +31,22 @@ import {
requiredPassword,
} from 'shared/components/Validation/rules';
import { useAsync } from 'shared/hooks/useAsync';
import styled from 'styled-components';

import Box from 'design/Box';

import Indicator from 'design/Indicator';

import useReAuthenticate, {
ReauthState,
} from 'teleport/components/ReAuthenticate/useReAuthenticate';
import { ChangePasswordReq } from 'teleport/services/auth';
import auth, { MfaChallengeScope } from 'teleport/services/auth/auth';
import {
DeviceType,
MfaOption,
WebauthnAssertionResponse,
} from 'teleport/services/mfa';
import useReAuthenticate from 'teleport/components/ReAuthenticate/useReAuthenticate';

export interface ChangePasswordWizardProps {
hasPasswordless: boolean;
Expand All @@ -59,46 +61,15 @@ export function ChangePasswordWizard({
}: ChangePasswordWizardProps) {
const [webauthnResponse, setWebauthnResponse] =
useState<WebauthnAssertionResponse>();
const { getMfaChallengeOptions, submitWithMfa, submitWithPasswordless } =
useReAuthenticate({
challengeScope: MfaChallengeScope.CHANGE_PASSWORD,
onMfaResponse: mfaResponse => {
setWebauthnResponse(mfaResponse.webauthn_response);
},
});

// Attempt to get an MFA challenge for an existing device. If the challenge is
// empty, the user has no existing device (e.g. SSO user) and can register their
// first device without re-authentication.
const [reauthOptions, initReauthOptions] = useAsync(async () => {
let mfaOptions = await getMfaChallengeOptions();
const reauthOptions = getReauthOptions(mfaOptions, hasPasswordless);
setReauthMethod(reauthOptions[0].value);
return reauthOptions;
const reauthState = useReAuthenticate({
challengeScope: MfaChallengeScope.CHANGE_PASSWORD,
onMfaResponse: async mfaResponse =>
setWebauthnResponse(mfaResponse.webauthn_response),
});

useEffect(() => {
initReauthOptions();
}, []);

const [reauthMethod, setReauthMethod] = useState<ReauthenticationMethod>();

// Handle potential error states first.
switch (reauthOptions.status) {
case 'processing':
return (
<Box textAlign="center" m={10}>
<Indicator />
</Box>
);
case 'error':
return <Alert children={reauthOptions.statusText} />;
case 'success':
break;
default:
return null;
}

return (
<Dialog
open={true}
Expand All @@ -110,12 +81,11 @@ export function ChangePasswordWizard({
flows={wizardFlows}
currFlow={'withReauthentication'}
// Step properties
reauthOptions={reauthOptions.data}
hasPasswordless={hasPasswordless}
reauthMethod={reauthMethod}
setReauthMethod={setReauthMethod}
reauthState={reauthState}
webauthnResponse={webauthnResponse}
onReauthMethodChange={setReauthMethod}
submitWithPasswordless={submitWithPasswordless}
submitWithMfa={submitWithMfa}
onClose={onClose}
onSuccess={onSuccess}
/>
Expand Down Expand Up @@ -165,11 +135,10 @@ export type ChangePasswordWizardStepProps = StepComponentProps &
ChangePasswordStepProps;

interface ReauthenticateStepProps {
reauthOptions: ReauthenticationOption[];
hasPasswordless: boolean;
reauthMethod: ReauthenticationMethod;
onReauthMethodChange(method: ReauthenticationMethod): void;
submitWithPasswordless(): Promise<void>;
submitWithMfa(mfaType?: DeviceType): Promise<void>;
setReauthMethod(method: ReauthenticationMethod): void;
reauthState: ReauthState;
onClose(): void;
}

Expand All @@ -178,35 +147,84 @@ export function ReauthenticateStep({
refCallback,
stepIndex,
flowLength,
reauthOptions,
hasPasswordless,
reauthMethod,
onReauthMethodChange,
submitWithPasswordless,
submitWithMfa,
setReauthMethod,
reauthState: {
initAttempt,
mfaOptions,
submitWithMfa,
clearSubmitAttempt,
submitAttempt,
},
onClose,
}: ChangePasswordWizardStepProps) {
const [reauthAttempt, reauthenticate] = useAsync(
const [reauthOptions, initReauthOptions] = useAsync(
useCallback(async () => {
const reauthOptions = getReauthOptions(mfaOptions, hasPasswordless);
setReauthMethod(reauthOptions[0].value);
return reauthOptions;
}, [hasPasswordless, mfaOptions, setReauthMethod])
);

useEffect(() => {
initReauthOptions();
}, [initReauthOptions]);

const reauthenticate = useCallback(
async (reauthMethod: ReauthenticationMethod) => {
switch (reauthMethod) {
case 'passwordless':
await submitWithPasswordless();
break;
case 'totp':
// totp is handled in the ChangePasswordStep
break;
default:
await submitWithMfa(reauthMethod);
break;
}
next();
}
// totp is handled in the ChangePasswordStep
if (reauthMethod === 'totp') next();

const mfaType =
reauthMethod === 'passwordless' ? 'webauthn' : reauthMethod;
const deviceUsage =
reauthMethod === 'passwordless' ? 'passwordless' : 'mfa';

submitWithMfa(mfaType, deviceUsage).then(([, err]) => {
if (!err) throw next();
});
},
[submitWithMfa, next]
);

const onReauthenticate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
reauthenticate(reauthMethod);
};

// Handle potential error states first.
switch (initAttempt.status) {
case 'processing':
return (
<Box textAlign="center" m={10}>
<Indicator />
</Box>
);
case 'error':
return <Alert children={initAttempt.statusText} />;
case 'success':
break;
default:
return null;
}

// Handle potential error states first.
switch (reauthOptions.status) {
case 'processing':
return (
<Box textAlign="center" m={10}>
<Indicator />
</Box>
);
case 'error':
return <Alert children={reauthOptions.statusText} />;
case 'success':
break;
default:
return null;
}

return (
<StepContainer ref={refCallback} data-testid="reauthenticate-step">
<Box mb={4}>
Expand All @@ -216,20 +234,23 @@ export function ReauthenticateStep({
title="Verify Identity"
/>
</Box>
{reauthAttempt.status === 'error' && (
<OutlineDanger>{reauthAttempt.statusText}</OutlineDanger>
{submitAttempt.status === 'error' && (
<OutlineDanger>{submitAttempt.statusText}</OutlineDanger>
)}
<Box mb={2}>Verification Method</Box>
<form onSubmit={e => onReauthenticate(e)}>
<RadioGroup
name="mfaOption"
options={reauthOptions}
options={reauthOptions.data}
value={reauthMethod}
autoFocus
flexDirection="row"
gap={3}
mb={4}
onChange={onReauthMethodChange}
onChange={o => {
setReauthMethod(o as DeviceType);
clearSubmitAttempt();
}}
/>
<Flex gap={2}>
<ButtonPrimary type="submit" block={true}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import Image from 'design/Image';
import Indicator from 'design/Indicator';
import { RadioGroup } from 'design/RadioGroup';
import { StepComponentProps, StepSlider } from 'design/StepSlider';
import React, { useState, useEffect, FormEvent } from 'react';
import React, { FormEvent, useEffect, useState } from 'react';
import FieldInput from 'shared/components/FieldInput';
import Validation, { Validator } from 'shared/components/Validation';
import { requiredField } from 'shared/components/Validation/rules';
Expand Down Expand Up @@ -76,13 +76,17 @@ export function AddAuthDeviceWizard({
const [privilegeToken, setPrivilegeToken] = useState();
const [credential, setCredential] = useState<Credential>(null);

const { attempt, clearAttempt, getMfaChallengeOptions, submitWithMfa } =
useReAuthenticate({
challengeScope: MfaChallengeScope.MANAGE_DEVICES,
onMfaResponse: mfaResponse => {
auth.createPrivilegeToken(mfaResponse).then(setPrivilegeToken);
},
});
const reauthState = useReAuthenticate({
challengeScope: MfaChallengeScope.MANAGE_DEVICES,
onMfaResponse: mfaResponse =>
// TODO(Joerger): Instead of getting a privilege token, we should get
// // a register challenge with the mfa response directly. For good UX, this would
// // require some refactoring to the flow so the user can choose a device type before
// // completing an mfa check and getting an otp/webauthn register challenge, or
// // allowing the backend to return a flexible register challenge
// await auth.createPrivilegeToken(mfaResponse).then(setPrivilegeToken);
auth.createPrivilegeToken(mfaResponse).then(setPrivilegeToken),
});

// Choose a new device type from the options available for the given 2fa type.
// irrelevant if usage === 'passkey'.
Expand All @@ -91,40 +95,31 @@ export function AddAuthDeviceWizard({
registerMfaOptions[0].value
);

// Attempt to get an MFA challenge for an existing device. If the challenge is
// empty, the user has no existing device (e.g. SSO user) and can register their
// first device without re-authentication.
const [reauthMfaOptions, getMfaOptions] = useAsync(async () => {
const reauthMfaOptions = await getMfaChallengeOptions();

// registering first device does not require reauth, just get a privilege token.
//
// TODO(Joerger): v19.0.0
// Registering first device does not require a privilege token anymore,
// but the existing web register endpoint requires privilege token.
// We have a new endpoint "/v1/webapi/users/privilege" which does not
// require token, but can't be used until v19 for backwards compatibility.
if (reauthMfaOptions.length === 0) {
await auth.createPrivilegeToken().then(setPrivilegeToken);
}

return reauthMfaOptions;
});

// If the user has no mfa devices registered, they can create a privilege token
// without an mfa response.
//
// TODO(Joerger): v19.0.0
// A user without devices can register their first device without a privilege token
// too, but the existing web register endpoint requires privilege token.
// We have a new endpoint "/v1/webapi/users/devices" which does not
// require token, but can't be used until v19 for backwards compatibility.
// Once in use, we can leave privilege token empty here.
useEffect(() => {
getMfaOptions();
}, []);
if (reauthState.mfaOptions?.length === 0) {
auth.createPrivilegeToken().then(setPrivilegeToken);
}
}, [reauthState.mfaOptions]);

// Handle potential error states first.
switch (reauthMfaOptions.status) {
switch (reauthState.initAttempt.status) {
case 'processing':
return (
<Box textAlign="center" m={10}>
<Indicator />
</Box>
);
case 'error':
return <Alert children={reauthMfaOptions.statusText} />;
return <Alert children={reauthState.initAttempt.statusText} />;
case 'success':
break;
default:
Expand All @@ -141,16 +136,13 @@ export function AddAuthDeviceWizard({
<StepSlider
flows={wizardFlows}
currFlow={
reauthMfaOptions.data.length > 0
reauthState.mfaOptions.length > 0
? 'withReauthentication'
: 'withoutReauthentication'
}
// Step properties
mfaRegisterOptions={registerMfaOptions}
mfaChallengeOptions={reauthMfaOptions.data}
reauthAttempt={attempt}
clearReauthAttempt={clearAttempt}
submitWithMfa={submitWithMfa}
reauthState={reauthState}
usage={usage}
privilegeToken={privilegeToken}
credential={credential}
Expand Down
Loading

0 comments on commit ccb1aaf

Please sign in to comment.