diff --git a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.story.tsx b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.story.tsx index 3ec850a0aeba7..3876fdd06b8fb 100644 --- a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.story.tsx +++ b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.story.tsx @@ -23,13 +23,18 @@ import Dialog from 'design/Dialog'; import { createTeleportContext } from 'teleport/mocks/contexts'; import { ContextProvider } from 'teleport'; -import { MfaDevice, WebauthnAssertionResponse } from 'teleport/services/mfa'; +import { + MFA_OPTION_SSO_DEFAULT, + MFA_OPTION_TOTP, + WebauthnAssertionResponse, +} from 'teleport/services/mfa'; import { ChangePasswordStep, ChangePasswordWizardStepProps, + REAUTH_OPTION_PASSKEY, + REAUTH_OPTION_WEBAUTHN, ReauthenticateStep, - createReauthOptions, } from './ChangePasswordWizard'; export default { @@ -60,44 +65,16 @@ export function ChangePasswordWithPasswordlessVerification() { } export function ChangePasswordWithMfaDeviceVerification() { - return ; + return ; } export function ChangePasswordWithOtpVerification() { - return ; + return ; } -const devices: MfaDevice[] = [ - { - id: '1', - description: 'Hardware Key', - name: 'touch_id', - registeredDate: new Date(1628799417000), - lastUsedDate: new Date(1628799417000), - type: 'webauthn', - usage: 'passwordless', - }, - { - id: '2', - description: 'Hardware Key', - name: 'solokey', - registeredDate: new Date(1623722252000), - lastUsedDate: new Date(1623981452000), - type: 'webauthn', - usage: 'mfa', - }, - { - id: '3', - description: 'Authenticator App', - name: 'iPhone', - registeredDate: new Date(1618711052000), - lastUsedDate: new Date(1626472652000), - type: 'totp', - usage: 'mfa', - }, -]; - -const defaultReauthOptions = createReauthOptions('optional', true, devices); +export function ChangePasswordWithSsoVerification() { + return ; +} const stepProps = { // StepComponentProps @@ -109,14 +86,20 @@ const stepProps = { refCallback: () => {}, // Shared props - reauthMethod: 'mfaDevice', + reauthMethod: 'passwordless', onClose() {}, onSuccess() {}, // ReauthenticateStepProps - reauthOptions: defaultReauthOptions, - onReauthMethodChange() {}, - onWebauthnResponse() {}, + reauthOptions: [ + REAUTH_OPTION_PASSKEY, + REAUTH_OPTION_WEBAUTHN, + MFA_OPTION_TOTP, + MFA_OPTION_SSO_DEFAULT, + ], + onReauthMethodChange: () => {}, + submitWithPasswordless: async () => {}, + submitWithMfa: async () => {}, // ChangePasswordStepProps webauthnResponse: {} as WebauthnAssertionResponse, diff --git a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.test.tsx b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.test.tsx index d23469ead98fd..333d98f240fe8 100644 --- a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.test.tsx +++ b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.test.tsx @@ -24,11 +24,19 @@ import { userEvent, UserEvent } from '@testing-library/user-event'; import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; -import { MfaChallengeResponse } from 'teleport/services/mfa'; +import { + MFA_OPTION_SSO_DEFAULT, + MFA_OPTION_TOTP, + MFA_OPTION_WEBAUTHN, + MfaChallengeResponse, + SSOChallenge, +} from 'teleport/services/mfa'; import { ChangePasswordWizardProps, - createReauthOptions, + getReauthOptions, + REAUTH_OPTION_PASSKEY, + REAUTH_OPTION_WEBAUTHN, } from './ChangePasswordWizard'; import { ChangePasswordWizard } from '.'; @@ -56,25 +64,10 @@ function twice(arr) { return [...arr, ...arr]; } -// Repeat devices twice to make sure we support multiple devices of the same -// type and purpose. -const deviceCases = { - all: twice([ - { type: 'totp', usage: 'mfa' }, - { type: 'webauthn', usage: 'mfa' }, - { type: 'webauthn', usage: 'passwordless' }, - ]), - authApps: twice([{ type: 'totp', usage: 'mfa' }]), - mfaDevices: twice([{ type: 'webauthn', usage: 'mfa' }]), - passkeys: twice([{ type: 'webauthn', usage: 'passwordless' }]), -}; - function TestWizard(props: Partial = {}) { return ( {}} onSuccess={onSuccess} {...props} @@ -86,7 +79,11 @@ beforeEach(() => { user = userEvent.setup(); onSuccess = jest.fn(); - jest.spyOn(auth, 'getMfaChallenge').mockResolvedValueOnce(undefined); + jest.spyOn(auth, 'getMfaChallenge').mockResolvedValueOnce({ + totpChallenge: true, + webauthnPublicKey: {} as PublicKeyCredentialRequestOptions, + ssoChallenge: {} as SSOChallenge, + }); jest .spyOn(auth, 'getMfaChallengeResponse') .mockResolvedValueOnce(dummyChallengeResponse); @@ -402,121 +399,20 @@ describe('with OTP MFA reauthentication', () => { }); }); -describe('without reauthentication', () => { - it('changes password', async () => { - render(); - - const changePasswordStep = within( - screen.getByTestId('change-password-step') - ); - await user.type( - changePasswordStep.getByLabelText('Current Password'), - 'current-pass' - ); - await user.type( - changePasswordStep.getByLabelText('New Password'), - 'new-pass1234' - ); - await user.type( - changePasswordStep.getByLabelText('Confirm Password'), - 'new-pass1234' - ); - await user.click(changePasswordStep.getByText('Save Changes')); - expect(auth.getMfaChallenge).not.toHaveBeenCalled(); - expect(auth.changePassword).toHaveBeenCalledWith({ - oldPassword: 'current-pass', - newPassword: 'new-pass1234', - webauthnResponse: undefined, - secondFactorToken: '', - }); - expect(onSuccess).toHaveBeenCalled(); - }); - - it('cancels changing password', async () => { - render(); - - const changePasswordStep = within( - screen.getByTestId('change-password-step') - ); - await user.type( - changePasswordStep.getByLabelText('New Password'), - 'new-pass1234' - ); - await user.type( - changePasswordStep.getByLabelText('Confirm Password'), - 'new-pass1234' - ); - await user.click(changePasswordStep.getByText('Cancel')); - expect(auth.changePassword).not.toHaveBeenCalled(); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it('validates the password form', async () => { - render(); - - const changePasswordStep = within( - screen.getByTestId('change-password-step') - ); - await user.type( - changePasswordStep.getByLabelText('New Password'), - 'new-pass123' - ); - await user.type( - changePasswordStep.getByLabelText('Confirm Password'), - 'new-pass123' - ); - await user.click(changePasswordStep.getByText('Save Changes')); - expect(auth.changePassword).not.toHaveBeenCalled(); - expect(onSuccess).not.toHaveBeenCalled(); - expect(changePasswordStep.getByLabelText('New Password')).toBeInvalid(); - expect( - changePasswordStep.getByLabelText('New Password') - ).toHaveAccessibleDescription('Enter at least 12 characters'); - expect(changePasswordStep.getByLabelText('Current Password')).toBeInvalid(); - expect( - changePasswordStep.getByLabelText('Current Password') - ).toHaveAccessibleDescription('Current Password is required'); - - await user.type( - changePasswordStep.getByLabelText('Current Password'), - 'current-pass' - ); - await user.type( - changePasswordStep.getByLabelText('New Password'), - 'new-pass1234' - ); - await user.click(changePasswordStep.getByText('Save Changes')); - expect(auth.changePassword).not.toHaveBeenCalled(); - expect(onSuccess).not.toHaveBeenCalled(); - expect(changePasswordStep.getByLabelText('Confirm Password')).toBeInvalid(); - expect( - changePasswordStep.getByLabelText('Confirm Password') - ).toHaveAccessibleDescription('Password does not match'); - }); -}); - test.each` - auth2faType | passwordless | deviceCase | methods - ${'otp'} | ${false} | ${'all'} | ${['otp']} - ${'off'} | ${false} | ${'all'} | ${[]} - ${'optional'} | ${false} | ${'all'} | ${['mfaDevice', 'otp']} - ${'on'} | ${false} | ${'all'} | ${['mfaDevice', 'otp']} - ${'webauthn'} | ${false} | ${'all'} | ${['mfaDevice']} - ${'optional'} | ${true} | ${'all'} | ${['passwordless', 'mfaDevice', 'otp']} - ${'on'} | ${true} | ${'all'} | ${['passwordless', 'mfaDevice', 'otp']} - ${'webauthn'} | ${true} | ${'all'} | ${['passwordless', 'mfaDevice']} - ${'optional'} | ${true} | ${'authApps'} | ${['otp']} - ${'optional'} | ${true} | ${'mfaDevices'} | ${['mfaDevice']} - ${'optional'} | ${true} | ${'passkeys'} | ${['passwordless']} + mfaOptions | hasPasswordless | reauthOptions + ${[MFA_OPTION_TOTP]} | ${false} | ${[MFA_OPTION_TOTP]} + ${[MFA_OPTION_WEBAUTHN]} | ${false} | ${[REAUTH_OPTION_WEBAUTHN]} + ${[MFA_OPTION_SSO_DEFAULT]} | ${false} | ${[MFA_OPTION_SSO_DEFAULT]} + ${[MFA_OPTION_TOTP, MFA_OPTION_WEBAUTHN, MFA_OPTION_SSO_DEFAULT]} | ${false} | ${[REAUTH_OPTION_PASSKEY, MFA_OPTION_TOTP, REAUTH_OPTION_WEBAUTHN, MFA_OPTION_SSO_DEFAULT]} + ${[MFA_OPTION_WEBAUTHN]} | ${true} | ${[REAUTH_OPTION_PASSKEY, REAUTH_OPTION_WEBAUTHN]} + ${[MFA_OPTION_TOTP, MFA_OPTION_WEBAUTHN, MFA_OPTION_SSO_DEFAULT]} | ${true} | ${[REAUTH_OPTION_PASSKEY, MFA_OPTION_TOTP, REAUTH_OPTION_WEBAUTHN, MFA_OPTION_SSO_DEFAULT]} `( - 'createReauthOptions: auth2faType=$auth2faType, passwordless=$passwordless, devices=$deviceCase', - ({ auth2faType, passwordless, methods, deviceCase }) => { - const devices = deviceCases[deviceCase]; - const reauthMethods = createReauthOptions( - auth2faType, - passwordless, - devices - ).map(o => o.value); - expect(reauthMethods).toEqual(methods); + 'getReauthOptions: mfaOptions=$mfaOptions, passwordless=$passwordless, devices=$deviceCase', + ({ mfaOptions, hasPasswordless, reauthOptions }) => { + const gotReauthOptions = getReauthOptions(mfaOptions, hasPasswordless).map( + o => o.value + ); + expect(gotReauthOptions).toEqual(reauthOptions); } ); diff --git a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.tsx b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.tsx index 3a66e65a070ce..55051b9e9cec6 100644 --- a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.tsx +++ b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.tsx @@ -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 { @@ -32,43 +32,71 @@ 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, WebauthnAssertionResponse } from 'teleport/services/mfa'; +import { + DeviceType, + MfaOption, + WebauthnAssertionResponse, +} from 'teleport/services/mfa'; +import useReAuthenticate from 'teleport/components/ReAuthenticate/useReAuthenticate'; +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. */ - devices: MfaDevice[]; + hasPasswordless: boolean; onClose(): void; onSuccess(): void; } export function ChangePasswordWizard({ - auth2faType, - passwordlessEnabled, - devices, + hasPasswordless, onClose, onSuccess, }: ChangePasswordWizardProps) { - const reauthOptions = createReauthOptions( - auth2faType, - passwordlessEnabled, - devices - ); - const [reauthMethod, setReauthMethod] = useState( - reauthOptions[0]?.value - ); const [webauthnResponse, setWebauthnResponse] = useState(); - const reauthRequired = reauthOptions.length > 0; + 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; + }); + + useEffect(() => { + initReauthOptions(); + }, []); + + const [reauthMethod, setReauthMethod] = useState(); + + // Handle potential error states first. + switch (reauthOptions.status) { + case 'processing': + return ( + + + + ); + case 'error': + return ; + case 'success': + break; + default: + return null; + } return ( @@ -95,56 +122,41 @@ export function ChangePasswordWizard({ ); } -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' }); - } +export const REAUTH_OPTION_WEBAUTHN: ReauthenticationOption = { + value: 'webauthn', + label: 'Security Key', +}; - const mfaEnabled = auth2faType === 'on' || auth2faType === 'optional'; +export const REAUTH_OPTION_PASSKEY: ReauthenticationOption = { + value: 'passwordless', + label: 'Passkey', +}; - if ( - (auth2faType === 'webauthn' || mfaEnabled) && - 'mfaDevice' in methodsAllowedByDevices - ) { - options.push({ value: 'mfaDevice', label: 'MFA Device' }); - } +export function getReauthOptions( + mfaOptions: MfaOption[], + hasPasswordless: boolean +) { + // Be more specific about the WebAuthn device type (it's not a passkey). + const reauthOptions = mfaOptions.map((o: ReauthenticationOption) => + o.value === 'webauthn' ? REAUTH_OPTION_WEBAUTHN : o + ); - if ( - (auth2faType === 'otp' || mfaEnabled) && - 'otp' in methodsAllowedByDevices - ) { - options.push({ value: 'otp', label: 'Authenticator App' }); + // Add passwordless as the default if available. + if (hasPasswordless) { + reauthOptions.unshift(REAUTH_OPTION_PASSKEY); } - 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'; + return reauthOptions; } const wizardFlows = { withReauthentication: [ReauthenticateStep, ChangePasswordStep], - withoutReauthentication: [ChangePasswordStep], }; export type ChangePasswordWizardStepProps = StepComponentProps & @@ -155,7 +167,8 @@ interface ReauthenticateStepProps { reauthOptions: ReauthenticationOption[]; reauthMethod: ReauthenticationMethod; onReauthMethodChange(method: ReauthenticationMethod): void; - onWebauthnResponse(res: WebauthnAssertionResponse): void; + submitWithPasswordless(): Promise; + submitWithMfa(mfaType?: DeviceType): Promise; onClose(): void; } @@ -167,29 +180,24 @@ export function ReauthenticateStep({ reauthOptions, reauthMethod, onReauthMethodChange, - onWebauthnResponse, + 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, - 'webauthn' - ); - - // TODO(Joerger): handle non-webauthn response. - onWebauthnResponse(response.webauthn_response); + const [reauthAttempt, reauthenticate] = useAsync( + async (reauthMethod: ReauthenticationMethod) => { + switch (reauthMethod) { + case 'passwordless': + await submitWithPasswordless(); + break; + default: + await submitWithMfa(reauthMethod); + break; } next(); } ); + const onReauthenticate = (e: React.FormEvent) => { e.preventDefault(); reauthenticate(reauthMethod); @@ -204,8 +212,8 @@ export function ReauthenticateStep({ title="Verify Identity" /> - {reauthenticateAttempt.status === 'error' && ( - {reauthenticateAttempt.statusText} + {reauthAttempt.status === 'error' && ( + {reauthAttempt.statusText} )} Verification Method
onReauthenticate(e)}> @@ -252,9 +260,9 @@ export function ChangePasswordStep({ 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) => { - setAuthCode(e.target.value); + setOtpCode(e.target.value); }; const [changePasswordAttempt, changePassword] = useAsync( async (req: ChangePasswordReq) => { @@ -269,7 +277,7 @@ export function ChangePasswordStep({ setOldPassword(''); setNewPassword(''); setNewPassConfirmed(''); - setAuthCode(''); + setOtpCode(''); } async function onSubmit( @@ -282,8 +290,10 @@ export function ChangePasswordStep({ await changePassword({ oldPassword, newPassword, - secondFactorToken: authCode, - webauthnResponse, + mfaResponse: { + totp_code: otpCode, + webauthn_response: webauthnResponse, + }, }); } @@ -328,14 +338,14 @@ export function ChangePasswordStep({ type="password" placeholder="Confirm Password" /> - {reauthMethod === 'otp' && ( + {reauthMethod === 'totp' && ( { act(() => { 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' }) ); diff --git a/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.tsx b/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.tsx index 847197e2c12c7..396c594e86f6e 100644 --- a/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.tsx @@ -268,7 +268,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 ( diff --git a/web/packages/teleport/src/Account/ManageDevices/wizards/ReauthenticateStep.tsx b/web/packages/teleport/src/Account/ManageDevices/wizards/ReauthenticateStep.tsx index b7e703daef72c..c36e133e73c7b 100644 --- a/web/packages/teleport/src/Account/ManageDevices/wizards/ReauthenticateStep.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/wizards/ReauthenticateStep.tsx @@ -46,9 +46,9 @@ export function ReauthenticateStep({ refCallback, stepIndex, flowLength, - reauthAttempt: attempt, mfaChallengeOptions, - clearReauthAttempt: clearAttempt, + reauthAttempt, + clearReauthAttempt, submitWithMfa, onClose, }: ReauthenticateStepProps) { @@ -68,7 +68,7 @@ export function ReauthenticateStep({ submitWithMfa(mfaOption, otpCode).then(next); }; - const errorMessage = getReauthenticationErrorMessage(attempt); + const errorMessage = getReauthenticationErrorMessage(reauthAttempt); return (
@@ -94,7 +94,7 @@ export function ReauthenticateStep({ mb={4} onChange={o => { setMfaOption(o as DeviceType); - clearAttempt(); + clearReauthAttempt(); }} /> {mfaOption === 'totp' && ( @@ -106,7 +106,7 @@ export function ReauthenticateStep({ value={otpCode} placeholder="123 456" onChange={onOtpCodeChanged} - readonly={attempt.status === 'processing'} + readonly={reauthAttempt.status === 'processing'} /> )} diff --git a/web/packages/teleport/src/Account/PasswordBox.tsx b/web/packages/teleport/src/Account/PasswordBox.tsx index f4ce622668517..bef6f8220745a 100644 --- a/web/packages/teleport/src/Account/PasswordBox.tsx +++ b/web/packages/teleport/src/Account/PasswordBox.tsx @@ -78,9 +78,10 @@ export function PasswordBox({ {dialogOpen && ( dev.usage === 'passwordless') + } onClose={() => setDialogOpen(false)} onSuccess={onSuccess} /> diff --git a/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.story.tsx b/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.story.tsx index e3e784e691c44..bf107c81164ea 100644 --- a/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.story.tsx +++ b/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.story.tsx @@ -52,5 +52,6 @@ const props: State = { MFA_OPTION_SSO_DEFAULT, ], submitWithMfa: () => null, + submitWithPasswordless: () => null, onClose: () => null, }; diff --git a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts index 4d9e991277827..1e7d1337db3f2 100644 --- a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts +++ b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts @@ -102,6 +102,21 @@ export default function useReAuthenticate(props: ReauthProps): ReauthState { .catch(handleError); } + function submitWithPasswordless() { + setAttempt({ status: 'processing' }); + // Always get a new passwordless challenge, the challenge stored in state is for mfa + // and will also be overwritten in the backend by the passwordless challenge. + return auth + .getMfaChallenge({ + scope: props.challengeScope, + userVerificationRequirement: 'required', + }) + .then(chal => auth.getMfaChallengeResponse(chal, 'webauthn')) + .then(props.onMfaResponse) + .finally(clearMfaChallenge) + .catch(handleError); + } + function clearAttempt() { setAttempt({ status: '' }); } @@ -112,6 +127,7 @@ export default function useReAuthenticate(props: ReauthProps): ReauthState { getMfaChallenge, getMfaChallengeOptions, submitWithMfa, + submitWithPasswordless, }; } @@ -128,4 +144,5 @@ export type ReauthState = { getMfaChallenge: () => Promise; getMfaChallengeOptions: () => Promise; submitWithMfa: (mfaType?: DeviceType, totp_code?: string) => Promise; + submitWithPasswordless: () => Promise; }; diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts index c26a5fe0641f4..a818eda3e1f5b 100644 --- a/web/packages/teleport/src/services/auth/auth.ts +++ b/web/packages/teleport/src/services/auth/auth.ts @@ -210,17 +210,12 @@ const auth = { }); }, - changePassword({ - oldPassword, - newPassword, - secondFactorToken, - webauthnResponse, - }: ChangePasswordReq) { + changePassword({ oldPassword, newPassword, mfaResponse }: ChangePasswordReq) { const data = { old_password: base64EncodeUnicode(oldPassword), new_password: base64EncodeUnicode(newPassword), - second_factor_token: secondFactorToken, - webauthnAssertionResponse: webauthnResponse, + second_factor_token: mfaResponse.totp_code, + webauthnAssertionResponse: mfaResponse.webauthn_response, }; return api.put(cfg.api.changeUserPasswordPath, data); diff --git a/web/packages/teleport/src/services/auth/types.ts b/web/packages/teleport/src/services/auth/types.ts index 7c74f666d9db1..57fb003ab7975 100644 --- a/web/packages/teleport/src/services/auth/types.ts +++ b/web/packages/teleport/src/services/auth/types.ts @@ -18,7 +18,7 @@ import { EventMeta } from 'teleport/services/userEvent'; -import { DeviceUsage, WebauthnAssertionResponse } from '../mfa'; +import { DeviceUsage, MfaChallengeResponse } from '../mfa'; import { IsMfaRequiredRequest, MfaChallengeScope } from './auth'; @@ -73,8 +73,7 @@ export type CreateAuthenticateChallengeRequest = { export type ChangePasswordReq = { oldPassword: string; newPassword: string; - secondFactorToken: string; - webauthnResponse?: WebauthnAssertionResponse; + mfaResponse?: MfaChallengeResponse; }; export type CreateNewHardwareDeviceRequest = { diff --git a/web/packages/teleport/src/services/mfa/mfaOptions.ts b/web/packages/teleport/src/services/mfa/mfaOptions.ts index 5f63b7ecdb12c..4137c3562d5f4 100644 --- a/web/packages/teleport/src/services/mfa/mfaOptions.ts +++ b/web/packages/teleport/src/services/mfa/mfaOptions.ts @@ -32,7 +32,7 @@ export function getMfaChallengeOptions(mfaChallenge: MfaAuthenticateChallenge) { } if (mfaChallenge?.ssoChallenge) { - mfaOptions.push(getSsoOption(mfaChallenge.ssoChallenge)); + mfaOptions.push(getSsoMfaOption(mfaChallenge.ssoChallenge)); } return mfaOptions; @@ -67,18 +67,18 @@ export const MFA_OPTION_TOTP: MfaOption = { label: 'Authenticator App', }; -// used in tests, returned by getSsoOptions(null). +// SSO MFA option used in tests. export const MFA_OPTION_SSO_DEFAULT: MfaOption = { value: 'sso', label: 'SSO', }; -const getSsoOption = (ssoChallenge: SSOChallenge): MfaOption => { +const getSsoMfaOption = (ssoChallenge: SSOChallenge): MfaOption => { return { value: 'sso', label: - ssoChallenge.device?.displayName || - ssoChallenge.device?.connectorId || + ssoChallenge?.device?.displayName || + ssoChallenge?.device?.connectorId || 'SSO', }; };