Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/auth/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ func (a *Server) checkOTP(user string, otpToken string) (*types.MFADevice, error
}
return dev, nil
}
// This message is relied upon by the Web UI in
// web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx/RequthenticateStep().
// Please keep these in sync.
return nil, trace.AccessDenied("invalid totp token")
}

Expand Down
12 changes: 12 additions & 0 deletions web/packages/design/src/Alert/Alert.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ const kind = props => {
background: theme.colors.success.main,
color: theme.colors.text.primaryInverse,
};
case 'outline-danger':
return {
background: fade(theme.colors.error.main, 0.1),
border: `${theme.radii[1]}px solid ${theme.colors.error.main}`,
borderRadius: `${theme.radii[3]}px`,
boxShadow: 'none',
justifyContent: 'normal',
};
case 'outline-info':
return {
background: fade(theme.colors.link, 0.1),
Expand Down Expand Up @@ -90,6 +98,7 @@ Alert.propTypes = {
'info',
'warning',
'success',
'outline-danger',
'outline-info',
]),
...color.propTypes,
Expand All @@ -108,4 +117,7 @@ export const Danger = props => <Alert kind="danger" {...props} />;
export const Info = props => <Alert kind="info" {...props} />;
export const Warning = props => <Alert kind="warning" {...props} />;
export const Success = props => <Alert kind="success" {...props} />;
export const OutlineDanger = props => (
<Alert kind="outline-danger" {...props} />
);
export const OutlineInfo = props => <Alert kind="outline-info" {...props} />;
4 changes: 4 additions & 0 deletions web/packages/design/src/StepSlider/StepSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ export function StepSlider<T>(props: Props<T>) {
rootRef.current.style.height = `${height}px`;
}}
hasTransitionEnded={hasTransitionEnded}
stepIndex={step}
flowLength={flows[currFlow].length}
{...stepProps}
/>
);
Expand Down Expand Up @@ -329,6 +331,8 @@ export type StepComponentProps = {
// prev goes back a step in the flow.
prev(): void;
hasTransitionEnded: boolean;
stepIndex: number;
flowLength: number;
};

// NewFlow defines fields for a new flow.
Expand Down
5 changes: 5 additions & 0 deletions web/packages/teleport/src/Account/Account.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,9 @@ const props: AccountProps = {
residentKey: false,
},
],
onAddPasskey: () => {},
onPasskeyAdded: () => {},
isReauthenticationRequired: false,
passkeyWizardVisible: false,
closePasskeyWizard: () => {},
};
29 changes: 28 additions & 1 deletion web/packages/teleport/src/Account/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ 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,
} from './ManageDevices/useManageDevices';
import AddDevice from './ManageDevices/AddDevice';
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
Expand Down Expand Up @@ -104,6 +110,8 @@ export function Account({
setToken,
onAddDevice,
onRemoveDevice,
onAddPasskey,
onPasskeyAdded,
deviceToRemove,
fetchDevices,
removeDevice,
Expand All @@ -112,9 +120,11 @@ export function Account({
isReAuthenticateVisible,
isAddDeviceVisible,
isRemoveDeviceVisible,
passkeyWizardVisible,
hideReAuthenticate,
hideAddDevice,
hideRemoveDevice,
closePasskeyWizard,
isSso,
canAddMFA,
canAddPasskeys,
Expand Down Expand Up @@ -170,6 +180,11 @@ export function Account({
addNotification('info', 'Your password has been changed.');
}

function onAddPasskeySuccess() {
addNotification('info', 'Passkey successfully saved.');
onPasskeyAdded();
}

return (
<Relative>
<FeatureBox gap={4} mt={4}>
Expand All @@ -191,7 +206,11 @@ export function Account({
? 'Passwordless authentication is disabled'
: ''
}
onClick={() => onAddDevice('passwordless')}
onClick={() =>
useNewAddAuthDeviceDialog
? onAddPasskey()
: onAddDevice('passwordless')
}
>
<Icon.Add size={20} />
Add a Passkey
Expand Down Expand Up @@ -272,6 +291,14 @@ export function Account({
/>
)}

{passkeyWizardVisible && (
<AddAuthDeviceWizard
privilegeToken={token}
onClose={closePasskeyWizard}
onSuccess={onAddPasskeySuccess}
/>
)}

{/* Note: Although notifications appear on top, we deliberately place the
container on the bottom to avoid manipulating z-index. The stacking
context from one of the buttons appears on top otherwise.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { render, screen, userEvent } from 'design/utils/testing';
import React from 'react';

import { within } from '@testing-library/react';

import TeleportContext from 'teleport/teleportContext';
import { ContextProvider } from 'teleport';
import MfaService from 'teleport/services/mfa';
import cfg from 'teleport/config';
import auth from 'teleport/services/auth/auth';

import { AddAuthDeviceWizard } from '.';

const dummyCredential: Credential = { id: 'cred-id', type: 'public-key' };

beforeEach(() => {
jest.replaceProperty(cfg.auth, 'second_factor', 'optional');
jest
.spyOn(MfaService.prototype, 'createNewWebAuthnDevice')
.mockResolvedValueOnce(dummyCredential);
jest
.spyOn(MfaService.prototype, 'saveNewWebAuthnDevice')
.mockResolvedValueOnce('some-credential');
jest
.spyOn(auth, 'createPrivilegeTokenWithWebauthn')
.mockResolvedValueOnce('webauthn-privilege-token');
jest
.spyOn(auth, 'createPrivilegeTokenWithTotp')
.mockImplementationOnce(token =>
Promise.resolve(`totp-privilege-token-${token}`)
);
});

afterEach(jest.resetAllMocks);

function TestWizard({
ctx,
privilegeToken,
onSuccess,
}: {
ctx: TeleportContext;
privilegeToken?: string;
onSuccess(): void;
}) {
return (
<ContextProvider ctx={ctx}>
<AddAuthDeviceWizard
privilegeToken={privilegeToken}
onClose={() => {}}
onSuccess={onSuccess}
/>
</ContextProvider>
);
}

describe('flow without reauthentication', () => {
test('adds a passkey', async () => {
const ctx = new TeleportContext();
const user = userEvent.setup();
const onSuccess = jest.fn();
render(
<TestWizard
ctx={ctx}
onSuccess={onSuccess}
privilegeToken="privilege-token"
/>
);

const createStep = within(screen.getByTestId('create-step'));
await user.click(
createStep.getByRole('button', { name: 'Create a passkey' })
);
expect(ctx.mfaService.createNewWebAuthnDevice).toHaveBeenCalledWith({
tokenId: 'privilege-token',
deviceUsage: 'passwordless',
});

const saveStep = within(screen.getByTestId('save-step'));
await user.type(saveStep.getByLabelText('Passkey Nickname'), 'new-passkey');
await user.click(
saveStep.getByRole('button', { name: 'Save the Passkey' })
);
expect(ctx.mfaService.saveNewWebAuthnDevice).toHaveBeenCalledWith({
credential: dummyCredential,
addRequest: {
deviceName: 'new-passkey',
deviceUsage: 'passwordless',
tokenId: 'privilege-token',
},
});
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(<TestWizard ctx={ctx} onSuccess={onSuccess} />);

const reauthenticateStep = within(
screen.getByTestId('reauthenticate-step')
);
await user.click(reauthenticateStep.getByText('Verify my identity'));

const createStep = within(screen.getByTestId('create-step'));
await user.click(
createStep.getByRole('button', { name: 'Create a passkey' })
);
expect(ctx.mfaService.createNewWebAuthnDevice).toHaveBeenCalledWith({
tokenId: 'webauthn-privilege-token',
deviceUsage: 'passwordless',
});

const saveStep = within(screen.getByTestId('save-step'));
await user.type(saveStep.getByLabelText('Passkey Nickname'), 'new-passkey');
await user.click(
saveStep.getByRole('button', { name: 'Save the Passkey' })
);
expect(ctx.mfaService.saveNewWebAuthnDevice).toHaveBeenCalledWith({
credential: dummyCredential,
addRequest: {
deviceName: 'new-passkey',
deviceUsage: 'passwordless',
tokenId: 'webauthn-privilege-token',
},
});
expect(onSuccess).toHaveBeenCalled();
});

test('adds a passkey with OTP reauthentication', async () => {
const ctx = new TeleportContext();
const user = userEvent.setup();
const onSuccess = jest.fn();
render(<TestWizard ctx={ctx} onSuccess={onSuccess} />);

const reauthenticateStep = within(
screen.getByTestId('reauthenticate-step')
);
await user.click(reauthenticateStep.getByText('Authenticator App'));
await user.type(
reauthenticateStep.getByLabelText('Authenticator Code'),
'654987'
);
await user.click(reauthenticateStep.getByText('Verify my identity'));

const createStep = within(screen.getByTestId('create-step'));
await user.click(
createStep.getByRole('button', { name: 'Create a passkey' })
);
expect(ctx.mfaService.createNewWebAuthnDevice).toHaveBeenCalledWith({
tokenId: 'totp-privilege-token-654987',
deviceUsage: 'passwordless',
});

const saveStep = within(screen.getByTestId('save-step'));
await user.type(saveStep.getByLabelText('Passkey Nickname'), 'new-passkey');
await user.click(
saveStep.getByRole('button', { name: 'Save the Passkey' })
);
expect(ctx.mfaService.saveNewWebAuthnDevice).toHaveBeenCalledWith({
credential: dummyCredential,
addRequest: {
deviceName: 'new-passkey',
deviceUsage: 'passwordless',
tokenId: 'totp-privilege-token-654987',
},
});
expect(onSuccess).toHaveBeenCalled();
});
});
Loading