diff --git a/web/packages/teleport/src/Account/Account.story.tsx b/web/packages/teleport/src/Account/Account.story.tsx index fa9ccb5d17439..8a5a3372a6a3e 100644 --- a/web/packages/teleport/src/Account/Account.story.tsx +++ b/web/packages/teleport/src/Account/Account.story.tsx @@ -161,7 +161,6 @@ const props: AccountProps = { residentKey: false, }, ], - onAddPasskey: () => {}, onPasskeyAdded: () => {}, isReauthenticationRequired: false, passkeyWizardVisible: false, diff --git a/web/packages/teleport/src/Account/Account.tsx b/web/packages/teleport/src/Account/Account.tsx index a34acdea6cd77..3ebadd6aca1b6 100644 --- a/web/packages/teleport/src/Account/Account.tsx +++ b/web/packages/teleport/src/Account/Account.tsx @@ -34,8 +34,6 @@ import { MfaChallengeScope } from 'teleport/services/auth/auth'; import cfg from 'teleport/config'; -import { storageService } from 'teleport/services/storageService'; - import { AuthDeviceList } from './ManageDevices/AuthDeviceList/AuthDeviceList'; import useManageDevices, { State as ManageDevicesState, @@ -45,9 +43,6 @@ import { ActionButton, Header } from './Header'; import { PasswordBox } from './PasswordBox'; import { AddAuthDeviceWizard } from './ManageDevices/AddAuthDeviceWizard'; -const useNewAddAuthDeviceDialog = - storageService.isNewAddAuthDeviceDialogEnabled(); - export interface EnterpriseComponentProps { // TODO(bl-nero): Consider moving the notifications to its own store and // unifying them between this screen and the unified resources screen. @@ -110,7 +105,6 @@ export function Account({ setToken, onAddDevice, onRemoveDevice, - onAddPasskey, onPasskeyAdded, deviceToRemove, fetchDevices, @@ -206,11 +200,7 @@ export function Account({ ? 'Passwordless authentication is disabled' : '' } - onClick={() => - useNewAddAuthDeviceDialog - ? onAddPasskey() - : onAddDevice('passwordless') - } + onClick={() => onAddDevice('passwordless')} > Add a Passkey @@ -293,6 +283,8 @@ export function Account({ {passkeyWizardVisible && ( . + */ + +import React from 'react'; + +import { Auth2faType } from 'shared/services'; + +import Dialog from 'design/Dialog'; + +import { initialize, mswLoader } from 'msw-storybook-addon'; + +import { rest } from 'msw'; + +import { DeviceUsage } from 'teleport/services/mfa'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { ContextProvider } from 'teleport/index'; + +import cfg from 'teleport/config'; + +import { + CreateDeviceStep, + ReauthenticateStep, + SaveDeviceStep, +} from './AddAuthDeviceWizard'; + +export default { + title: 'teleport/Account/Manage Devices/Add Device Wizard', + loaders: [mswLoader], + decorators: [ + Story => { + const ctx = createTeleportContext(); + return ( + + ({ width: '650px' })}> + + + + ); + }, + ], +}; + +initialize(); + +export function Reauthenticate() { + return ; +} + +export function CreatePasskey() { + return ; +} + +export function CreateMfaHardwareDevice() { + return ( + + ); +} + +export function CreateMfaAppQrCodeLoading() { + return ; +} +CreateMfaAppQrCodeLoading.parameters = { + msw: { + handlers: [ + rest.post( + cfg.getMfaCreateRegistrationChallengeUrl('privilege-token'), + (req, res, ctx) => res(ctx.delay('infinite')) + ), + ], + }, +}; + +export function CreateMfaAppQrCodeFailed() { + return ; +} +CreateMfaAppQrCodeFailed.parameters = { + msw: { + handlers: [ + rest.post( + cfg.getMfaCreateRegistrationChallengeUrl('privilege-token'), + (req, res, ctx) => res(ctx.status(500)) + ), + ], + }, +}; + +const dummyQrCode = + 'iVBORw0KGgoAAAANSUhEUgAAAB0AAAAdAQMAAABsXfVMAAAABlBMVEUAAAD///+l2Z/dAAAAAnRSTlP//8i138cAAAAJcEhZcwAACxIAAAsSAdLdfvwAAABrSURBVAiZY/gPBAxoxAcxh3qG71fv1zN8iQ8EEReBRACQ+H4ZKPZBFCj7/3v9f4aPU9vqGX4kFtUzfG5mBLK2aNUz/PM3AsmqAk2RNQTquLYLqDdG/z/QlGAgES4CFLu4GygrXF2Pbi+IAADZqFQFAjXZWgAAAABJRU5ErkJggg=='; + +export function CreateMfaApp() { + return ; +} +CreateMfaApp.parameters = { + msw: { + handlers: [ + rest.post( + cfg.getMfaCreateRegistrationChallengeUrl('privilege-token'), + (req, res, ctx) => res(ctx.json({ totp: { qrCode: dummyQrCode } })) + ), + ], + }, +}; + +export function SavePasskey() { + return ; +} + +export function SaveMfaHardwareDevice() { + return ( + + ); +} + +export function SaveMfaAuthenticatorApp() { + return ; +} + +const stepProps = { + // StepComponentProps + next: () => {}, + prev: () => {}, + hasTransitionEnded: true, + stepIndex: 0, + flowLength: 1, + refCallback: () => {}, + + // Other props + privilegeToken: 'privilege-token', + usage: 'passwordless' as DeviceUsage, + auth2faType: 'optional' as Auth2faType, + credential: { id: 'cred-id', type: 'public-key' }, + newMfaDeviceType: 'webauthn' as Auth2faType, + onNewMfaDeviceTypeChange: () => {}, + onDeviceCreated: () => {}, + onAuthenticated: () => {}, + onClose: () => {}, + onPasskeyCreated: () => {}, + onSuccess: () => {}, +}; diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx index 03114b5efef7a..2e19beca398ab 100644 --- a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx @@ -16,29 +16,35 @@ * along with this program. If not, see . */ -import { render, screen, userEvent } from 'design/utils/testing'; +import { render, screen } from 'design/utils/testing'; import React from 'react'; import { within } from '@testing-library/react'; +import { userEvent, UserEvent } from '@testing-library/user-event'; import TeleportContext from 'teleport/teleportContext'; import { ContextProvider } from 'teleport'; -import MfaService from 'teleport/services/mfa'; -import cfg from 'teleport/config'; +import MfaService, { DeviceUsage } from 'teleport/services/mfa'; import auth from 'teleport/services/auth/auth'; import { AddAuthDeviceWizard } from '.'; const dummyCredential: Credential = { id: 'cred-id', type: 'public-key' }; +let ctx: TeleportContext; +let user: UserEvent; +let onSuccess: jest.Mock; beforeEach(() => { - jest.replaceProperty(cfg.auth, 'second_factor', 'optional'); + ctx = new TeleportContext(); + user = userEvent.setup(); + onSuccess = jest.fn(); + jest .spyOn(MfaService.prototype, 'createNewWebAuthnDevice') .mockResolvedValueOnce(dummyCredential); jest .spyOn(MfaService.prototype, 'saveNewWebAuthnDevice') - .mockResolvedValueOnce('some-credential'); + .mockResolvedValueOnce(undefined); jest .spyOn(auth, 'createPrivilegeTokenWithWebauthn') .mockResolvedValueOnce('webauthn-privilege-token'); @@ -47,22 +53,29 @@ beforeEach(() => { .mockImplementationOnce(token => Promise.resolve(`totp-privilege-token-${token}`) ); + jest.spyOn(auth, 'createMfaRegistrationChallenge').mockResolvedValueOnce({ + qrCode: 'dummy-qr-code', + webauthnPublicKey: {} as PublicKeyCredentialCreationOptions, + }); + jest + .spyOn(MfaService.prototype, 'addNewTotpDevice') + .mockResolvedValueOnce(undefined); }); afterEach(jest.resetAllMocks); function TestWizard({ - ctx, privilegeToken, - onSuccess, + usage, }: { - ctx: TeleportContext; privilegeToken?: string; - onSuccess(): void; + usage: DeviceUsage; }) { return ( {}} onSuccess={onSuccess} @@ -73,15 +86,8 @@ function TestWizard({ describe('flow without reauthentication', () => { test('adds a passkey', async () => { - const ctx = new TeleportContext(); - const user = userEvent.setup(); - const onSuccess = jest.fn(); render( - + ); const createStep = within(screen.getByTestId('create-step')); @@ -108,14 +114,67 @@ describe('flow without reauthentication', () => { }); expect(onSuccess).toHaveBeenCalled(); }); + + test('adds a WebAuthn MFA', async () => { + render(); + + const createStep = within(screen.getByTestId('create-step')); + await user.click(createStep.getByLabelText('Hardware Device')); + await user.click( + createStep.getByRole('button', { name: 'Create an MFA method' }) + ); + expect(ctx.mfaService.createNewWebAuthnDevice).toHaveBeenCalledWith({ + tokenId: 'privilege-token', + deviceUsage: 'mfa', + }); + + const saveStep = within(screen.getByTestId('save-step')); + await user.type(saveStep.getByLabelText('MFA Method Name'), 'new-mfa'); + await user.click( + saveStep.getByRole('button', { name: 'Save the MFA method' }) + ); + expect(ctx.mfaService.saveNewWebAuthnDevice).toHaveBeenCalledWith({ + credential: dummyCredential, + addRequest: { + deviceName: 'new-mfa', + deviceUsage: 'mfa', + tokenId: 'privilege-token', + }, + }); + expect(onSuccess).toHaveBeenCalled(); + }); + + test('adds an authenticator app', async () => { + render(); + + const createStep = within(screen.getByTestId('create-step')); + await user.click(createStep.getByLabelText('Authenticator App')); + expect(createStep.getByRole('img')).toHaveAttribute( + 'src', + 'data:image/png;base64,dummy-qr-code' + ); + await user.click( + createStep.getByRole('button', { name: 'Create an MFA method' }) + ); + + const saveStep = within(screen.getByTestId('save-step')); + await user.type(saveStep.getByLabelText('MFA Method Name'), 'new-mfa'); + await user.type(saveStep.getByLabelText(/Authenticator Code/), '345678'); + await user.click( + saveStep.getByRole('button', { name: 'Save the MFA method' }) + ); + expect(ctx.mfaService.addNewTotpDevice).toHaveBeenCalledWith({ + tokenId: 'privilege-token', + secondFactorToken: '345678', + deviceName: 'new-mfa', + }); + expect(onSuccess).toHaveBeenCalled(); + }); }); describe('flow with reauthentication', () => { test('adds a passkey with WebAuthn reauthentication', async () => { - const ctx = new TeleportContext(); - const user = userEvent.setup(); - const onSuccess = jest.fn(); - render(); + render(); const reauthenticateStep = within( screen.getByTestId('reauthenticate-step') @@ -148,10 +207,7 @@ describe('flow with reauthentication', () => { }); test('adds a passkey with OTP reauthentication', async () => { - const ctx = new TeleportContext(); - const user = userEvent.setup(); - const onSuccess = jest.fn(); - render(); + render(); const reauthenticateStep = within( screen.getByTestId('reauthenticate-step') diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx index aa426ac0e8cad..41c709fc020b4 100644 --- a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx @@ -19,34 +19,47 @@ import { OutlineDanger } from 'design/Alert/Alert'; import { ButtonPrimary, ButtonSecondary } from 'design/Button'; import Dialog from 'design/Dialog'; +import Flex from 'design/Flex'; +import Image from 'design/Image'; +import Indicator from 'design/Indicator'; +import Link from 'design/Link'; import { SingleRowBox } from 'design/MultiRowBox'; import { RadioGroup } from 'design/RadioGroup'; import { StepComponentProps, StepSlider } from 'design/StepSlider'; import Text from 'design/Text'; -import React, { useState, FormEvent } from 'react'; +import React, { useState, useEffect, FormEvent } from 'react'; import FieldInput from 'shared/components/FieldInput'; import Validation, { Validator } from 'shared/components/Validation'; import { requiredField } from 'shared/components/Validation/rules'; +import { useAsync } from 'shared/hooks/useAsync'; import useAttempt from 'shared/hooks/useAttemptNext'; import { Auth2faType } from 'shared/services'; -import createMfaOptions from 'shared/utils/createMfaOptions'; +import createMfaOptions, { MfaOption } from 'shared/utils/createMfaOptions'; import useReAuthenticate from 'teleport/components/ReAuthenticate/useReAuthenticate'; -import { MfaChallengeScope } from 'teleport/services/auth/auth'; +import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; +import { DeviceUsage } from 'teleport/services/mfa'; import useTeleport from 'teleport/useTeleport'; interface AddAuthDeviceWizardProps { + /** Indicates usage of the device to be added: MFA or a passkey. */ + usage: DeviceUsage; + /** MFA type setting, as configured in the cluster's configuration. */ + auth2faType: Auth2faType; + /** + * A privilege token that may have been created previously; if present, the + * reauthentication step will be skipped. + */ privilegeToken?: string; onClose(): void; onSuccess(): void; } -/** - * A wizard for adding MFA and passkey devices. Currently only supports - * passkeys. - */ +/** A wizard for adding MFA and passkey devices. */ export function AddAuthDeviceWizard({ privilegeToken: privilegeTokenProp = '', + usage, + auth2faType, onClose, onSuccess, }: AddAuthDeviceWizardProps) { @@ -54,6 +67,14 @@ export function AddAuthDeviceWizard({ const [privilegeToken, setPrivilegeToken] = useState(privilegeTokenProp); const [credential, setCredential] = useState(null); + const mfaOptions = createMfaOptions({ + auth2faType, + required: true, + }); + + /** A new MFA device type, irrelevant if usage === 'passkey'. */ + const [newMfaDeviceType, setNewMfaDeviceType] = useState(mfaOptions[0].value); + return ( ); } const wizardFlows = { - withReauthentication: [ - ReauthenticateStep, - CreatePasskeyStep, - SavePasskeyStep, - ], - withoutReauthentication: [CreatePasskeyStep, SavePasskeyStep], + withReauthentication: [ReauthenticateStep, CreateDeviceStep, SaveDeviceStep], + withoutReauthentication: [CreateDeviceStep, SaveDeviceStep], }; interface ReauthenticateStepProps extends StepComponentProps { + auth2faType: Auth2faType; onAuthenticated(privilegeToken: string): void; onClose(): void; } -function ReauthenticateStep({ +export function ReauthenticateStep({ next, refCallback, stepIndex, flowLength, + auth2faType, onClose, onAuthenticated: onAuthenticatedProp, }: ReauthenticateStepProps) { @@ -105,20 +128,13 @@ function ReauthenticateStep({ onAuthenticatedProp(privilegeToken); next(); }; - const { - attempt, - auth2faType, - preferredMfaType, - clearAttempt, - submitWithTotp, - submitWithWebauthn, - } = useReAuthenticate({ - onAuthenticated, - challengeScope, - }); + const { attempt, clearAttempt, submitWithTotp, submitWithWebauthn } = + useReAuthenticate({ + onAuthenticated, + challengeScope, + }); const mfaOptions = createMfaOptions({ auth2faType, - preferredType: preferredMfaType, required: true, }); @@ -167,6 +183,7 @@ function ReauthenticateStep({ name="mfaOption" options={mfaOptions} value={mfaOption} + autoFocus flexDirection="row" gap={3} onChange={o => { @@ -182,7 +199,6 @@ function ReauthenticateStep({ autoComplete="one-time-code" value={authCode} placeholder="123 456" - autoFocus onChange={onAuthCodeChanged} readonly={attempt.status === 'processing'} /> @@ -196,34 +212,45 @@ function ReauthenticateStep({ ); } -interface CreatePasskeyStepProps extends StepComponentProps { +interface CreateDeviceStepProps extends StepComponentProps { + usage: DeviceUsage; + auth2faType: Auth2faType; privilegeToken: string; + newMfaDeviceType: Auth2faType; + onNewMfaDeviceTypeChange(o: Auth2faType): void; onClose(): void; - onPasskeyCreated(c: Credential): void; + onDeviceCreated(c: Credential): void; } -function CreatePasskeyStep({ +export function CreateDeviceStep({ prev, next, refCallback, stepIndex, flowLength, + usage, + auth2faType, privilegeToken, + newMfaDeviceType, + onNewMfaDeviceTypeChange, onClose, - onPasskeyCreated, -}: CreatePasskeyStepProps) { + onDeviceCreated, +}: CreateDeviceStepProps) { const ctx = useTeleport(); const createPasskeyAttempt = useAttempt(); - const onCreate = () => { - createPasskeyAttempt.run(async () => { - const credential = await ctx.mfaService.createNewWebAuthnDevice({ - tokenId: privilegeToken, - deviceUsage: 'passwordless', + if (usage === 'passwordless' || newMfaDeviceType === 'webauthn') { + createPasskeyAttempt.run(async () => { + const credential = await ctx.mfaService.createNewWebAuthnDevice({ + tokenId: privilegeToken, + deviceUsage: usage, + }); + onDeviceCreated(credential); + next(); }); - onPasskeyCreated(credential); + } else { next(); - }); + } }; return ( @@ -231,12 +258,24 @@ function CreatePasskeyStep({ Step {stepIndex + 1} of {flowLength} - Create a Passkey + + {usage === 'passwordless' ? 'Create a Passkey' : 'Create an MFA Method'} + {createPasskeyAttempt.attempt.status === 'failed' && ( {createPasskeyAttempt.attempt.statusText} )} - - Create a passkey + {usage === 'passwordless' && } + {usage === 'mfa' && ( + + )} + + {usage === 'passwordless' ? 'Create a passkey' : 'Create an MFA method'} + {stepIndex === 0 ? ( Cancel ) : ( @@ -246,51 +285,163 @@ function CreatePasskeyStep({ ); } +function CreateMfaBox({ + auth2faType, + newMfaDeviceType, + privilegeToken, + onNewMfaDeviceTypeChange, +}: { + auth2faType: Auth2faType; + newMfaDeviceType: Auth2faType; + privilegeToken: string; + onNewMfaDeviceTypeChange(o: Auth2faType): void; +}) { + const mfaOptions = createMfaOptions({ + auth2faType, + required: true, + }).map((o: MfaOption) => + // Be more specific about the WebAuthn device type (it's not a passkey). + o.value === 'webauthn' ? { ...o, label: 'Hardware Device' } : o + ); + + return ( + <> + Multi-factor type + { + onNewMfaDeviceTypeChange(o as Auth2faType); + }} + /> + {newMfaDeviceType === 'otp' && ( + + )} + + ); +} + +function QrCodeBox({ privilegeToken }: { privilegeToken: string }) { + const [fetchQrCodeAttempt, fetchQrCode] = useAsync((privilegeToken: string) => + auth.createMfaRegistrationChallenge(privilegeToken, 'totp') + ); + + useEffect(() => { + fetchQrCode(privilegeToken); + }, []); + + return ( + props.theme.colors.interactive.tonal.neutral[0]}; + `} + > + + {fetchQrCodeAttempt.status === 'error' && ( + + Could not load the QR code. {fetchQrCodeAttempt.statusText} + + )} + {fetchQrCodeAttempt.status === 'processing' && } + {fetchQrCodeAttempt.status === 'success' && ( + + )} + + + Scan the QR Code with any authenticator app. +
+ We recommend{' '} + + Authy + + . +
+
+ ); +} + interface SaveKeyStepProps extends StepComponentProps { privilegeToken: string; credential: Credential; + usage: DeviceUsage; + newMfaDeviceType: Auth2faType; onSuccess(): void; } -function SavePasskeyStep({ +export function SaveDeviceStep({ refCallback, prev, stepIndex, flowLength, privilegeToken, credential, + usage, + newMfaDeviceType, onSuccess, }: SaveKeyStepProps) { const ctx = useTeleport(); const saveAttempt = useAttempt(); const [deviceName, setDeviceName] = useState(''); + const [authCode, setAuthCode] = useState(''); const onSave = (e: FormEvent, validator: Validator) => { e.preventDefault(); if (!validator.validate()) return; - saveAttempt.run(async () => { - await ctx.mfaService.saveNewWebAuthnDevice({ - addRequest: { + if (usage === 'passwordless' || newMfaDeviceType === 'webauthn') { + saveAttempt.run(async () => { + await ctx.mfaService.saveNewWebAuthnDevice({ + addRequest: { + tokenId: privilegeToken, + deviceUsage: usage, + deviceName, + }, + credential, + }); + onSuccess(); + }); + } else { + saveAttempt.run(async () => { + await ctx.mfaService.addNewTotpDevice({ tokenId: privilegeToken, - deviceUsage: 'passwordless', + secondFactorToken: authCode, deviceName, - }, - credential, + }); + onSuccess(); }); - onSuccess(); - }); + } }; const onNameChange = (e: React.ChangeEvent) => { setDeviceName(e.target.value); }; + const onAuthCodeChanged = (e: React.ChangeEvent) => { + setAuthCode(e.target.value); + }; + return (
Step {stepIndex + 1} of {flowLength} - Save the Passkey + + {usage === 'passwordless' ? 'Save the Passkey' : 'Save the MFA method'} + {saveAttempt.attempt.status === 'failed' && ( {saveAttempt.attempt.statusText} )} @@ -298,7 +449,11 @@ function SavePasskeyStep({ {({ validator }) => (
onSave(e, validator)}> - Save the Passkey + + {usage === 'mfa' && newMfaDeviceType === 'otp' && ( + + )} + + {usage === 'passwordless' + ? 'Save the Passkey' + : 'Save the MFA method'} + Back )} diff --git a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts index e743caf2280a6..9335729de8b1d 100644 --- a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts +++ b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts @@ -23,6 +23,10 @@ import Ctx from 'teleport/teleportContext'; import cfg from 'teleport/config'; import auth from 'teleport/services/auth'; import { DeviceUsage, MfaDevice } from 'teleport/services/mfa'; +import { storageService } from 'teleport/services/storageService'; + +const useNewAddAuthDeviceDialog = + storageService.isNewAddAuthDeviceDialogEnabled(); export default function useManageDevices(ctx: Ctx) { const [devices, setDevices] = useState([]); @@ -58,29 +62,19 @@ export default function useManageDevices(ctx: Ctx) { } function onAddDevice(restrictUsage?: DeviceUsage) { + const showDialog = useNewAddAuthDeviceDialog + ? setPasskeyWizardVisible + : setIsDialogVisible; setRestrictNewDeviceUsage(restrictUsage); if (devices.length === 0) { createRestrictedTokenAttempt.run(() => auth.createRestrictedPrivilegeToken().then(token => { setToken(token); - setIsDialogVisible(true); - }) - ); - } else { - setIsDialogVisible(true); - } - } - - function onAddPasskey() { - if (devices.length === 0) { - createRestrictedTokenAttempt.run(() => - auth.createRestrictedPrivilegeToken().then(token => { - setToken(token); - setPasskeyWizardVisible(true); + showDialog(true); }) ); } else { - setPasskeyWizardVisible(true); + showDialog(true); } } @@ -122,7 +116,6 @@ export default function useManageDevices(ctx: Ctx) { setToken, onAddDevice, onRemoveDevice, - onAddPasskey, onPasskeyAdded, deviceToRemove, fetchDevices,