Skip to content

Commit

Permalink
Refactor ChangePasswordWizard to use useReauthenticate.
Browse files Browse the repository at this point in the history
  • Loading branch information
Joerger committed Dec 3, 2024
1 parent 81466bc commit b4e63c1
Show file tree
Hide file tree
Showing 10 changed files with 133 additions and 125 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,9 @@ import Dialog from 'design/Dialog';
import { createTeleportContext } from 'teleport/mocks/contexts';
import { ContextProvider } from 'teleport';

import { MfaDevice } from 'teleport/services/mfa';
import { getMfaRegisterOptions, MfaDevice } from 'teleport/services/mfa';

import {
ChangePasswordStep,
ReauthenticateStep,
createReauthOptions,
} from './ChangePasswordWizard';
import { ChangePasswordStep, ReauthenticateStep } from './ChangePasswordWizard';

export default {
title: 'teleport/Account/Manage Devices/Change Password Wizard',
Expand Down Expand Up @@ -96,8 +92,6 @@ const devices: MfaDevice[] = [
},
];

const defaultReauthOptions = createReauthOptions('optional', true, devices);

const stepProps = {
// StepComponentProps
next() {},
Expand All @@ -108,8 +102,8 @@ const stepProps = {
refCallback: () => {},

// Other props
reauthOptions: defaultReauthOptions,
reauthMethod: defaultReauthOptions[0].value,
reauthOptions: getMfaRegisterOptions('on'),
reauthMethod: 'webauthn',
credential: { id: '', type: '' },
onReauthMethodChange() {},
onAuthenticated() {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,7 @@ import auth, { MfaChallengeScope } from 'teleport/services/auth/auth';

import { MfaChallengeResponse } from 'teleport/services/mfa';

import {
ChangePasswordWizardProps,
createReauthOptions,
} from './ChangePasswordWizard';
import { ChangePasswordWizardProps } from './ChangePasswordWizard';

import { ChangePasswordWizard } from '.';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
*/

import styled from 'styled-components';
import { OutlineDanger } from 'design/Alert/Alert';
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, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import FieldInput from 'shared/components/FieldInput';
import Validation, { Validator } from 'shared/components/Validation';
import {
Expand All @@ -32,17 +32,21 @@ import {
requiredPassword,
} from 'shared/components/Validation/rules';
import { useAsync } from 'shared/hooks/useAsync';
import { Auth2faType } from 'shared/services';

import Box from 'design/Box';

import { ChangePasswordReq } from 'teleport/services/auth';
import auth, { MfaChallengeScope } from 'teleport/services/auth/auth';
import { MfaDevice } from 'teleport/services/mfa';
import {
DeviceType,
MfaDevice,
WebauthnAssertionResponse,
} from 'teleport/services/mfa';
import useReAuthenticate from 'teleport/components/ReAuthenticate/useReAuthenticate';
import { Attempt } from 'shared/hooks/useAttemptNext';
import Indicator from 'design/Indicator';

export interface ChangePasswordWizardProps {
/** MFA type setting, as configured in the cluster's configuration. */
auth2faType: Auth2faType;
/** Determines whether the cluster allows passwordless login. */
passwordlessEnabled: boolean;
/** A list of available authentication devices. */
Expand All @@ -52,22 +56,66 @@ export interface ChangePasswordWizardProps {
}

export function ChangePasswordWizard({
auth2faType,
passwordlessEnabled,
devices,
onClose,
onSuccess,
}: ChangePasswordWizardProps) {
const reauthOptions = createReauthOptions(
auth2faType,
passwordlessEnabled,
devices
);
const [reauthMethod, setReauthMethod] = useState<ReauthenticationMethod>(
reauthOptions[0]?.value
);
const [credential, setCredential] = useState<Credential | undefined>();
const reauthRequired = reauthOptions.length > 0;
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, getReauthOptions] = useAsync(async () => {
let reauthOptions: ReauthenticationOption[] =
await getMfaChallengeOptions();

// Be more specific about the WebAuthn device type (it's not a passkey).
reauthOptions = reauthOptions.map((o: ReauthenticationOption) =>
o.value === 'webauthn' ? { ...o, label: 'Security Key' } : o
);

// Add passwordless as the default if available.
if (
passwordlessEnabled &&
devices.some(dev => dev.usage === 'passwordless')
) {
reauthOptions.unshift({ value: 'passwordless', label: 'Passkey' });
}

setReauthMethod(reauthOptions[0].value);
return reauthOptions;
});

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

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
Expand All @@ -78,72 +126,29 @@ export function ChangePasswordWizard({
>
<StepSlider
flows={wizardFlows}
currFlow={
reauthRequired ? 'withReauthentication' : 'withoutReauthentication'
}
currFlow={'withReauthentication'}
// Step properties
reauthOptions={reauthOptions}
reauthOptions={reauthOptions.data}
reauthMethod={reauthMethod}
credential={credential}
webauthnResponse={webauthnResponse}
onReauthMethodChange={setReauthMethod}
onAuthenticated={setCredential}
submitWithPasswordless={submitWithPasswordless}
submitWithMfa={submitWithMfa}
onClose={onClose}
onSuccess={onSuccess}
/>
</Dialog>
);
}

type ReauthenticationMethod = 'passwordless' | 'mfaDevice' | 'otp';
type ReauthenticationMethod = 'passwordless' | DeviceType;
type ReauthenticationOption = {
value: ReauthenticationMethod;
label: string;
};

export function createReauthOptions(
auth2faType: Auth2faType,
passwordlessEnabled: boolean,
devices: MfaDevice[]
) {
const options: ReauthenticationOption[] = [];

const methodsAllowedByDevices = {};
for (const d of devices) {
methodsAllowedByDevices[reauthMethodForDevice(d)] = true;
}

if (passwordlessEnabled && 'passwordless' in methodsAllowedByDevices) {
options.push({ value: 'passwordless', label: 'Passkey' });
}

const mfaEnabled = auth2faType === 'on' || auth2faType === 'optional';

if (
(auth2faType === 'webauthn' || mfaEnabled) &&
'mfaDevice' in methodsAllowedByDevices
) {
options.push({ value: 'mfaDevice', label: 'MFA Device' });
}

if (
(auth2faType === 'otp' || mfaEnabled) &&
'otp' in methodsAllowedByDevices
) {
options.push({ value: 'otp', label: 'Authenticator App' });
}

return options;
}

/** Returns the reauthentication method supported by a given device. */
function reauthMethodForDevice(d: MfaDevice): ReauthenticationMethod {
if (d.usage === 'passwordless') return 'passwordless';
return d.type === 'totp' ? 'otp' : 'mfaDevice';
}

const wizardFlows = {
withReauthentication: [ReauthenticateStep, ChangePasswordStep],
withoutReauthentication: [ChangePasswordStep],
};

type ChangePasswordWizardStepProps = StepComponentProps &
Expand All @@ -154,7 +159,8 @@ interface ReauthenticateStepProps {
reauthOptions: ReauthenticationOption[];
reauthMethod: ReauthenticationMethod;
onReauthMethodChange(method: ReauthenticationMethod): void;
onAuthenticated(res: Credential): void;
submitWithPasswordless(): Promise<void>;
submitWithMfa(mfaType?: DeviceType): Promise<void>;
onClose(): void;
}

Expand All @@ -166,26 +172,24 @@ export function ReauthenticateStep({
reauthOptions,
reauthMethod,
onReauthMethodChange,
onAuthenticated,
submitWithPasswordless,
submitWithMfa,
onClose,
}: ChangePasswordWizardStepProps) {
const [reauthenticateAttempt, reauthenticate] = useAsync(
async (m: ReauthenticationMethod) => {
if (m === 'passwordless' || m === 'mfaDevice') {
const challenge = await auth.getMfaChallenge({
scope: MfaChallengeScope.CHANGE_PASSWORD,
userVerificationRequirement:
m === 'passwordless' ? 'required' : 'discouraged',
});

const response = await auth.getMfaChallengeResponse(challenge);

// TODO(Joerger): handle non-webauthn response.
onAuthenticated(response.webauthn_response);
const [reauthAttempt, reauthenticate] = useAsync(
async (reauthMethod: ReauthenticationMethod) => {
switch (reauthMethod) {
case 'passwordless':
await submitWithPasswordless();
break;
case 'webauthn':
await submitWithMfa('webauthn');
break;
}
next();
}
);

const onReauthenticate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
reauthenticate(reauthMethod);
Expand All @@ -200,8 +204,8 @@ export function ReauthenticateStep({
title="Verify Identity"
/>
</Box>
{reauthenticateAttempt.status === 'error' && (
<OutlineDanger>{reauthenticateAttempt.statusText}</OutlineDanger>
{reauthAttempt.status === 'error' && (
<OutlineDanger>{reauthAttempt.statusText}</OutlineDanger>
)}
<Box mb={2}>Verification Method</Box>
<form onSubmit={e => onReauthenticate(e)}>
Expand Down Expand Up @@ -229,7 +233,7 @@ export function ReauthenticateStep({
}

interface ChangePasswordStepProps {
credential: Credential;
webauthnResponse: WebauthnAssertionResponse;
reauthMethod: ReauthenticationMethod;
onClose(): void;
onSuccess(): void;
Expand All @@ -240,17 +244,17 @@ export function ChangePasswordStep({
prev,
stepIndex,
flowLength,
credential,
webauthnResponse,
reauthMethod,
onClose,
onSuccess,
}: ChangePasswordWizardStepProps) {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPassConfirmed, setNewPassConfirmed] = useState('');
const [authCode, setAuthCode] = useState('');
const [otpCode, setOtpCode] = useState('');
const onAuthCodeChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
setAuthCode(e.target.value);
setOtpCode(e.target.value);
};
const [changePasswordAttempt, changePassword] = useAsync(
async (req: ChangePasswordReq) => {
Expand All @@ -265,7 +269,7 @@ export function ChangePasswordStep({
setOldPassword('');
setNewPassword('');
setNewPassConfirmed('');
setAuthCode('');
setOtpCode('');
}

async function onSubmit(
Expand All @@ -278,8 +282,10 @@ export function ChangePasswordStep({
await changePassword({
oldPassword,
newPassword,
secondFactorToken: authCode,
credential,
mfaResponse: {
totp_code: otpCode,
webauthn_response: webauthnResponse,
},
});
}

Expand Down Expand Up @@ -324,14 +330,14 @@ export function ChangePasswordStep({
type="password"
placeholder="Confirm Password"
/>
{reauthMethod === 'otp' && (
{reauthMethod === 'totp' && (
<FieldInput
label="Authenticator Code"
helperText="Enter the code generated by your authenticator app"
rule={requiredField('Authenticator code is required')}
inputMode="numeric"
autoComplete="one-time-code"
value={authCode}
value={otpCode}
placeholder="123 456"
onChange={onAuthCodeChanged}
readonly={changePasswordAttempt.status === 'processing'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('flow without reauthentication', () => {
render(<TestWizard usage="mfa" privilegeToken="privilege-token" />);

const createStep = within(screen.getByTestId('create-step'));
await user.click(createStep.getByLabelText('Hardware Device'));
await user.click(createStep.getByLabelText('Security Key'));
await user.click(
createStep.getByRole('button', { name: 'Create an MFA method' })
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ function CreateMfaBox({
}) {
// Be more specific about the WebAuthn device type (it's not a passkey).
mfaRegisterOptions = mfaRegisterOptions.map((o: MfaOption) =>
o.value === 'webauthn' ? { ...o, label: 'Hardware Device' } : o
o.value === 'webauthn' ? { ...o, label: 'Security Key' } : o
);

return (
Expand Down
Loading

0 comments on commit b4e63c1

Please sign in to comment.