Skip to content
Closed
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
5 changes: 3 additions & 2 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -807,8 +807,9 @@ func (h *Handler) bindDefaultEndpoints() {
// Fetches the user's preferences
h.GET("/webapi/user/preferences", h.WithAuth(h.getUserPreferences))

// Updates the user's preferences
h.PUT("/webapi/user/preferences", h.WithAuth(h.updateUserPreferences))
// Updates the user's preferences. This leverages WithAuthCookieAndCSRF rather than WithAuth because
// we update preferences during the onboarding flow, before a bearer token is set.
h.PUT("/webapi/user/preferences", h.WithAuthCookieAndCSRF(h.updateUserPreferences))
Comment on lines 810 to 812
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love thoughts on this change. Are we still secure if leveraging WithAuthCookieAndCSRF?

We set a bearer token on redirect to /web at the end of the registration. Until then, there is no bearer token to check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh, i'm not sure either... i would love to know a definite answer though, because i have questions, like why not all protected endpoints use that too then? (perhaps these endpoints where it's used, the consequence aren't as high?) and also why didn't we pass the access token instead of the csrf token in the form? (this form is located where user was already authenticated)

but i took a look at WithAuthCookieAndCSRF and it's intended for non-ajax request (forms) so I would generally stay away from it.

i wonder if we can use local storage to temporarily store the user preference, then on init, we can update user preference there, and then delete that storage key?

}

// GetProxyClient returns authenticated auth server client
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ limitations under the License.
import React from 'react';
import { Card } from 'design';

import { Props, NewCredentials, SliderProps } from './NewCredentials';
import { NewCredentials, SliderProps } from './NewCredentials';
import { NewMfaDevice } from './NewMfaDevice';
import { NewCredentialsProps } from './types';

export default {
title: 'Teleport/Welcome/Form',
Expand Down Expand Up @@ -184,7 +185,7 @@ const sliderProps: SliderProps & {
password: '',
updatePassword: () => null,
};
const props: Props = {
const props: NewCredentialsProps = {
auth2faType: 'off',
primaryAuthType: 'local',
isPasswordlessEnabled: true,
Expand Down Expand Up @@ -245,4 +246,6 @@ const props: Props = {
'IYKEEEFCiCAh5KMwdgQ8OCEhRJAQ8v8AAAD//1QuL6EmJFBiAAAAAElFTkSuQmCC',
},
isDashboard: false,
displayOnboardingQuestionnaire: false,
setDisplayOnboardingQuestionnaire: () => {},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Copyright 2023 Gravitational, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Attempt } from 'shared/hooks/useAttemptNext';

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

import { RecoveryCodes, ResetToken } from 'teleport/services/auth';
import { NewCredentialsProps } from 'teleport/Welcome/NewCredentials/types';
import { NewCredentials } from 'teleport/Welcome/NewCredentials/NewCredentials';
import { mockUserContextProviderWith } from 'teleport/User/testHelpers/mockUserContextWith';
import { makeTestUserContext } from 'teleport/User/testHelpers/makeTestUserContext';

const attempt: Attempt = { status: '' };
const failedAttempt: Attempt = { status: 'failed' };
const processingAttempt: Attempt = { status: 'processing' };
const successAttempt: Attempt = { status: 'success', statusText: 'hey' };

const resetToken: ResetToken = {
tokenId: 'tokenId',
qrCode: 'qrCode',
user: 'user',
};
const recoveryCodes: RecoveryCodes = {
createdDate: new Date(),
};

const makeProps = (): NewCredentialsProps => {
return {
auth2faType: 'off',
primaryAuthType: 'sso',
isPasswordlessEnabled: false,
fetchAttempt: attempt,
submitAttempt: attempt,
clearSubmitAttempt: () => {},
onSubmit: () => {},
onSubmitWithWebauthn: () => {},
resetToken: resetToken,
recoveryCodes: recoveryCodes,
redirect: () => {},
success: false,
finishedRegister: () => {},
privateKeyPolicyEnabled: false,
displayOnboardingQuestionnaire: false,
setDisplayOnboardingQuestionnaire: () => {},
resetMode: false,
isDashboard: false,
};
};

test('renders expired for failed fetch attempt', () => {
const props = makeProps();
props.fetchAttempt = failedAttempt;
render(<NewCredentials {...props} />);

expect(screen.getByText(/Invitation Code Expired/i)).toBeInTheDocument();
});

const nullCases: {
attempt: Attempt;
}[] = [{ attempt: processingAttempt }, { attempt: attempt }];

test.each(nullCases)('renders $attempt as null', testCase => {
const props = makeProps();
props.fetchAttempt = testCase.attempt;
const { container } = render(<NewCredentials {...props} />);

expect(container).toBeEmptyDOMElement();
});

test('renders Reset Complete for success and private key policy enabled during reset', () => {
const props = makeProps();
props.fetchAttempt = successAttempt;
props.success = true;
props.privateKeyPolicyEnabled = true;
props.resetMode = true;
render(<NewCredentials {...props} />);

expect(screen.getByText(/Reset Complete/i)).toBeInTheDocument();
});

test('renders Registration Complete for success and private key policy enabled during registration', () => {
const props = makeProps();
props.fetchAttempt = { status: 'success' };
props.success = true;
props.privateKeyPolicyEnabled = true;
props.resetMode = false;
render(<NewCredentials {...props} />);

expect(screen.getByText(/Registration Complete/i)).toBeInTheDocument();
});

test('renders Register Success on success', () => {
const props = makeProps();
props.fetchAttempt = { status: 'success' };
props.privateKeyPolicyEnabled = false;
props.recoveryCodes = undefined;
props.success = true;
render(<NewCredentials {...props} />);

expect(
screen.getByText(/Proceed to access your account./i)
).toBeInTheDocument();
expect(screen.getByText(/Go to Cluster/i)).toBeInTheDocument();
});

test('renders recovery codes', () => {
const props = makeProps();
props.fetchAttempt = { status: 'success' };
props.success = false;
props.recoveryCodes = {
codes: ['foo', 'bar'],
createdDate: new Date(),
};
render(<NewCredentials {...props} />);

expect(screen.getByText(/Backup & Recovery Codes/i)).toBeInTheDocument();
});

test('renders credential flow for passwordless', () => {
const props = makeProps();
props.fetchAttempt = { status: 'success' };
props.success = false;
props.recoveryCodes = undefined;
props.primaryAuthType = 'passwordless';
render(<NewCredentials {...props} />);

expect(screen.getByText(/Set A Passwordless Device/i)).toBeInTheDocument();
});

test('renders credential flow for local', () => {
const props = makeProps();
props.fetchAttempt = { status: 'success' };
props.success = false;
props.recoveryCodes = undefined;
props.primaryAuthType = 'local';
render(<NewCredentials {...props} />);

expect(screen.getByText(/Set A Password/i)).toBeInTheDocument();
});

test('renders credential flow for sso', () => {
const props = makeProps();
props.fetchAttempt = { status: 'success' };
props.success = false;
props.recoveryCodes = undefined;
props.primaryAuthType = 'sso';
render(<NewCredentials {...props} />);

expect(screen.getByText(/Set A Password/i)).toBeInTheDocument();
});

test('renders questionnaire', () => {
mockUserContextProviderWith(makeTestUserContext());

const props = makeProps();
props.fetchAttempt = { status: 'success' };
props.success = true;
props.recoveryCodes = undefined;
props.displayOnboardingQuestionnaire = true;
render(<NewCredentials {...props} />);

expect(screen.getByText(/Tell us about yourself/i)).toBeInTheDocument();
});
47 changes: 28 additions & 19 deletions web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ limitations under the License.
import React, { useState } from 'react';
import { Card } from 'design';
import { PrimaryAuthType } from 'shared/services';

import { NewFlow, StepComponentProps, StepSlider } from 'design/StepSlider';

import RecoveryCodes from 'teleport/components/RecoveryCodes';
import { PrivateKeyLoginDisabledCard } from 'teleport/components/PrivateKeyPolicy';

import { Questionnaire } from 'teleport/Welcome/Questionnaire/Questionnaire';
import cfg from 'teleport/config';

import useToken, { State } from '../useToken';
import useToken from '../useToken';

import { Expired } from './Expired';
import { NewCredentialsProps } from './types';
import { RegisterSuccess } from './Success';
import { NewMfaDevice } from './NewMfaDevice';
import { NewPasswordlessDevice } from './NewPasswordlessDevice';
Expand All @@ -54,7 +54,7 @@ export function Container({ tokenId = '', resetMode = false }) {
);
}

export function NewCredentials(props: State & Props) {
export function NewCredentials(props: NewCredentialsProps) {
const {
fetchAttempt,
recoveryCodes,
Expand All @@ -66,8 +66,20 @@ export function NewCredentials(props: State & Props) {
finishedRegister,
privateKeyPolicyEnabled,
isDashboard,
displayOnboardingQuestionnaire,
setDisplayOnboardingQuestionnaire,
} = props;

// Check which flow to render as default.
const [password, setPassword] = useState('');
const [newFlow, setNewFlow] = useState<NewFlow<LoginFlow>>();
const [flow, setFlow] = useState<LoginFlow>(() => {
if (primaryAuthType === 'sso' || primaryAuthType === 'local') {
return 'local';
}
return 'passwordless';
});

if (fetchAttempt.status === 'failed') {
return <Expired resetMode={resetMode} />;
}
Expand All @@ -84,6 +96,17 @@ export function NewCredentials(props: State & Props) {
);
}

if (success && !resetMode && displayOnboardingQuestionnaire) {
// todo (michellescripts) check cluster config to determine if all or partial questions are asked
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up PR

return (
<Questionnaire
full={true}
username={resetToken.user}
onSubmit={() => setDisplayOnboardingQuestionnaire(false)}
/>
);
}

if (success) {
return (
<RegisterSuccess
Expand All @@ -106,16 +129,7 @@ export function NewCredentials(props: State & Props) {
);
}

// Check which flow to render as default.
const [password, setPassword] = useState('');
const [newFlow, setNewFlow] = useState<NewFlow<LoginFlow>>();
const [flow, setFlow] = useState<LoginFlow>(() => {
if (primaryAuthType === 'sso' || primaryAuthType === 'local') {
return 'local';
}
return 'passwordless';
});

// display credentials flow
function onSwitchFlow(flow: LoginFlow) {
setFlow(flow);
}
Expand Down Expand Up @@ -143,8 +157,3 @@ export function NewCredentials(props: State & Props) {
</Card>
);
}

export type Props = State & {
resetMode?: boolean;
isDashboard: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ import createMfaOptions from 'shared/utils/createMfaOptions';
import { useRefAutoFocus } from 'shared/hooks';
import { Auth2faType } from 'shared/services';

import { Props as CredentialsProps, SliderProps } from './NewCredentials';
import { UseTokenState } from 'teleport/Welcome/NewCredentials/types';

import { SliderProps } from './NewCredentials';
import secKeyGraphic from './sec-key-with-bg.png';

export function NewMfaDevice(props: Props) {
Expand All @@ -48,7 +50,7 @@ export function NewMfaDevice(props: Props) {
} = props;
const [otp, setOtp] = useState('');
const mfaOptions = createMfaOptions({
auth2faType: auth2faType,
auth2faType: auth2faType as Auth2faType,
});
const [mfaType, setMfaType] = useState(mfaOptions[0]);
const [deviceName, setDeviceName] = useState(() =>
Expand Down Expand Up @@ -241,7 +243,7 @@ function getDefaultDeviceName(mfaType: Auth2faType) {
return '';
}

type Props = CredentialsProps &
type Props = UseTokenState &
SliderProps & {
password: string;
updatePassword(pwd: string): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
} from 'shared/components/Validation/rules';
import { useRefAutoFocus } from 'shared/hooks';

import { Props as CredentialsProps, SliderProps } from './NewCredentials';
import { UseTokenState } from './types';
import { SliderProps } from './NewCredentials';

export function NewPassword(props: Props) {
const {
Expand Down Expand Up @@ -143,7 +144,7 @@ export function NewPassword(props: Props) {
);
}

type Props = CredentialsProps &
type Props = UseTokenState &
SliderProps & {
password: string;
updatePassword(pwd: string): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import Validation, { Validator } from 'shared/components/Validation';
import { requiredField } from 'shared/components/Validation/rules';
import { useRefAutoFocus } from 'shared/hooks';

import { Props, SliderProps } from './NewCredentials';
import { UseTokenState } from './types';
import { SliderProps } from './NewCredentials';

export function NewPasswordlessDevice(props: Props & SliderProps) {
export function NewPasswordlessDevice(props: UseTokenState & SliderProps) {
const {
submitAttempt,
onSubmitWithWebauthn,
Expand Down
8 changes: 2 additions & 6 deletions web/packages/teleport/src/Welcome/NewCredentials/Success.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,15 @@ import { ButtonPrimary, Card, Flex, Image, Text } from 'design';

import { CaptureEvent, userEventService } from 'teleport/services/userEvent';

import { RegisterSuccessProps } from './types';
import shieldCheck from './shield-check.png';

export function RegisterSuccess({
redirect,
resetMode = false,
username = '',
isDashboard,
}: {
redirect(): void;
resetMode: boolean;
username?: string;
isDashboard: boolean;
}) {
}: RegisterSuccessProps) {
const actionTxt = resetMode ? 'reset' : 'registration';

const handleRedirect = () => {
Expand Down
Loading