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
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,6 @@ const props: State = {
username: 'teleport-username',
authType: 'local',
clusterId: 'some-cluster-id',
showMfaDialog: false,
cancelMfaDialog: () => null,
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Option } from 'shared/components/Select';
import TextSelectCopy from 'teleport/components/TextSelectCopy';
import useTeleport from 'teleport/useTeleport';
import { YamlReader } from 'teleport/Discover/Shared/SetupAccess/AccessInfo';
import ReAuthenticate from 'teleport/components/ReAuthenticate';

import {
ActionButtons,
Expand All @@ -39,6 +40,7 @@ import {
import { useTestConnection, State } from './useTestConnection';

import type { AgentStepProps } from '../../types';
import type { KubeImpersonation } from 'teleport/services/agents';

export default function Container(props: AgentStepProps) {
const ctx = useTeleport();
Expand All @@ -58,6 +60,8 @@ export function TestConnection({
authType,
username,
clusterId,
showMfaDialog,
cancelMfaDialog,
}: State) {
const userOpts = kube.users.map(l => ({ value: l, label: l }));
const groupOpts = kube.groups.map(l => ({ value: l, label: l }));
Expand Down Expand Up @@ -108,17 +112,29 @@ export function TestConnection({
return;
}

runConnectionDiagnostic({
runConnectionDiagnostic(makeTestConnRequest());
}

function makeTestConnRequest(): KubeImpersonation {
return {
namespace,
user: selectedUser?.value,
groups: selectedGroups?.map(g => g.value),
});
};
}

return (
<Validation>
{({ validator }) => (
<Box>
{showMfaDialog && (
<ReAuthenticate
onMfaResponse={res =>
runConnectionDiagnostic(makeTestConnRequest(), res)
}
onClose={cancelMfaDialog}
/>
)}
<HeaderWithBackBtn onPrev={prevStep}>
Test Connection
</HeaderWithBackBtn>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,56 @@ import type {
KubeImpersonation,
} from 'teleport/services/agents';
import type { AgentStepProps } from '../../types';
import type { MfaAuthnResponse } from 'teleport/services/mfa';

export function useTestConnection({ ctx, props }: Props) {
const { attempt, run } = useAttempt('');
const { attempt, setAttempt, handleError } = useAttempt('');
const [diagnosis, setDiagnosis] = useState<ConnectionDiagnostic>();
const [showMfaDialog, setShowMfaDialog] = useState(false);

const access = ctx.storeUser.getConnectionDiagnosticAccess();
const canTestConnection = access.create && access.edit && access.read;

function runConnectionDiagnostic(impersonate: KubeImpersonation) {
async function runConnectionDiagnostic(
impersonate: KubeImpersonation,
mfaAuthnResponse?: MfaAuthnResponse
) {
const meta = props.agentMeta as KubeMeta;
setDiagnosis(null);
run(() =>
ctx.agentService
.createConnectionDiagnostic({
resourceKind: 'kube_cluster',
resourceName: meta.kube.name,
kubeImpersonation: impersonate,
})
.then(setDiagnosis)
);
setShowMfaDialog(false);
setAttempt({ status: 'processing' });

try {
if (!mfaAuthnResponse) {
const mfaReq = {
kube: {
cluster_name: meta.kube.name,
},
};
const sessionMfa = await ctx.mfaService.isMfaRequired(mfaReq);
if (sessionMfa.required) {
setShowMfaDialog(true);
return;
}
}

const diag = await ctx.agentService.createConnectionDiagnostic({
resourceKind: 'kube_cluster',
resourceName: meta.kube.name,
kubeImpersonation: impersonate,
mfaAuthnResponse,
});

setAttempt({ status: 'success' });
setDiagnosis(diag);
} catch (err) {
handleError(err);
}
}

function cancelMfaDialog() {
setAttempt({ status: '' });
setShowMfaDialog(false);
}

const { username, authType, cluster } = ctx.storeUser.state;
Expand All @@ -61,6 +91,8 @@ export function useTestConnection({ ctx, props }: Props) {
username,
authType,
clusterId: cluster.clusterId,
showMfaDialog,
cancelMfaDialog,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,6 @@ const props: State = {
prevStep: () => null,
diagnosis: null,
canTestConnection: true,
showMfaDialog: false,
cancelMfaDialog: () => null,
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Select from 'shared/components/Select';

import useTeleport from 'teleport/useTeleport';
import { YamlReader } from 'teleport/Discover/Shared/SetupAccess/AccessInfo';
import ReAuthenticate from 'teleport/components/ReAuthenticate';

import {
HeaderWithBackBtn,
Expand Down Expand Up @@ -52,6 +53,8 @@ export function TestConnection({
nextStep,
prevStep,
canTestConnection,
showMfaDialog,
cancelMfaDialog,
}: State) {
const [usernameOpts] = useState(() =>
logins.map(l => ({ value: l, label: l }))
Expand Down Expand Up @@ -88,6 +91,12 @@ export function TestConnection({

return (
<Box>
{showMfaDialog && (
<ReAuthenticate
onMfaResponse={res => runConnectionDiagnostic(selectedOpt.value, res)}
onClose={cancelMfaDialog}
/>
)}
<HeaderWithBackBtn onPrev={prevStep}>Test Connection</HeaderWithBackBtn>
<HeaderSubtitle>
Optionally verify that you can successfully connect to the server you
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ import { NodeMeta } from '../../useDiscover';

import type { ConnectionDiagnostic } from 'teleport/services/agents';
import type { AgentStepProps } from '../../types';
import type { MfaAuthnResponse } from 'teleport/services/mfa';

export function useTestConnection({ ctx, props }: Props) {
const { attempt, run } = useAttempt('');
const { attempt, setAttempt, handleError } = useAttempt('');
const [diagnosis, setDiagnosis] = useState<ConnectionDiagnostic>();
const [showMfaDialog, setShowMfaDialog] = useState(false);

const access = ctx.storeUser.getConnectionDiagnosticAccess();
const canTestConnection = access.create && access.edit && access.read;
Expand All @@ -44,18 +46,47 @@ export function useTestConnection({ ctx, props }: Props) {
openNewTab(url);
}

function runConnectionDiagnostic(login: string) {
async function runConnectionDiagnostic(
login: string,
mfaAuthnResponse?: MfaAuthnResponse
) {
const meta = props.agentMeta as NodeMeta;
setDiagnosis(null);
run(() =>
ctx.agentService
.createConnectionDiagnostic({
resourceKind: 'node',
resourceName: meta.node.hostname,
sshPrincipal: login,
})
.then(setDiagnosis)
);
setShowMfaDialog(false);
setAttempt({ status: 'processing' });

try {
if (!mfaAuthnResponse) {
const mfaReq = {
node: {
login,
node_name: meta.node.hostname,
},
};
const sessionMfa = await ctx.mfaService.isMfaRequired(mfaReq);
if (sessionMfa.required) {
setShowMfaDialog(true);
return;
}
}

const diag = await ctx.agentService.createConnectionDiagnostic({
resourceKind: 'node',
resourceName: meta.node.hostname,
sshPrincipal: login,
mfaAuthnResponse,
});

setAttempt({ status: 'success' });
setDiagnosis(diag);
} catch (err) {
handleError(err);
}
}

function cancelMfaDialog() {
setAttempt({ status: '' });
setShowMfaDialog(false);
}

return {
Expand All @@ -67,6 +98,8 @@ export function useTestConnection({ ctx, props }: Props) {
nextStep: props.nextStep,
prevStep: props.prevStep,
canTestConnection,
showMfaDialog,
cancelMfaDialog,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,72 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React from 'react';

import useAttempt from 'shared/hooks/useAttemptNext';

import cfg from 'teleport/config';
import auth from 'teleport/services/auth';

export default function useReAuthenticate({ onAuthenticated, onClose }: Props) {
import type { MfaAuthnResponse } from 'teleport/services/mfa';

// useReAuthenticate will have different "submit" behaviors depending on:
// - If prop field `onMfaResponse` is defined, after a user submits, the
// function `onMfaResponse` is called with the user's MFA response.
// - If prop field `onAuthenticated` is defined, after a user submits, the
// user's MFA response are submitted with the request to get a privilege
// token, and after successfully obtaining the token, the function
// `onAuthenticated` will be called with this token.
export default function useReAuthenticate(props: Props) {
const { onClose } = props;

// Note that attempt state "success" is not used or required.
// After the user submits, the control is passed back
// to the caller who is reponsible for rendering the `ReAuthenticate`
// component.
const { attempt, setAttempt, handleError } = useAttempt('');

function submitWithTotp(secondFactorToken: string) {
if ('onMfaResponse' in props) {
props.onMfaResponse({ totp_code: secondFactorToken });
return;
}

setAttempt({ status: 'processing' });
auth
.createPrivilegeTokenWithTotp(secondFactorToken)
.then(onAuthenticated)
.then(props.onAuthenticated)
.catch(handleError);
}

function submitWithWebauthn() {
setAttempt({ status: 'processing' });

if ('onMfaResponse' in props) {
auth
.getWebauthnResponse()
.then(webauthnResponse =>
props.onMfaResponse({ webauthn_response: webauthnResponse })
)
.catch(handleError);
return;
}

auth
.createPrivilegeTokenWithWebauthn()
.then(onAuthenticated)
.catch(handleError);
.then(props.onAuthenticated)
.catch((err: Error) => {
// This catches a webauthn frontend error that occurs on Firefox and replaces it with a more helpful error message.
if (
err.message.includes('attempt was made to use an object that is not')
) {
setAttempt({
status: 'failed',
statusText:
'The two-factor device you used is not registered on this account. You must verify using a device that has already been registered.',
});
} else {
setAttempt({ status: 'failed', statusText: err.message });
}
});
}

function clearAttempt() {
Expand All @@ -55,9 +97,27 @@ export default function useReAuthenticate({ onAuthenticated, onClose }: Props) {
};
}

export type Props = {
onAuthenticated: React.Dispatch<React.SetStateAction<string>>;
type BaseProps = {
onClose: () => void;
};

// MfaResponseProps defines a function
// that accepts a MFA response. No
// authentication has been done at this point.
type MfaResponseProps = BaseProps & {
onMfaResponse(res: MfaAuthnResponse): void;
onAuthenticated?: never;
};

// DefaultProps defines a function that
// accepts a privilegeTokenId that is only
// obtained after MFA response has been
// validated.
type DefaultProps = BaseProps & {
onAuthenticated(privilegeTokenId: string): void;
onMfaResponse?: never;
};

export type Props = MfaResponseProps | DefaultProps;

export type State = ReturnType<typeof useReAuthenticate>;
6 changes: 6 additions & 0 deletions web/packages/teleport/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ const cfg = {
nodeScriptPath: '/scripts/:token/install-node.sh',
appNodeScriptPath: '/scripts/:token/install-app.sh?name=:name&uri=:uri',

mfaRequired: '/v1/webapi/sites/:clusterId/mfa/required',
mfaLoginBegin: '/v1/webapi/mfa/login/begin', // creates authnenticate challenge with user and password
mfaLoginFinish: '/v1/webapi/mfa/login/finishsession', // creates a web session
mfaChangePasswordBegin: '/v1/webapi/mfa/authenticatechallenge/password',
Expand Down Expand Up @@ -363,6 +364,11 @@ const cfg = {
return generatePath(cfg.api.connectionDiagnostic, { clusterId });
},

getMfaRequiredUrl() {
const clusterId = cfg.proxyCluster;
return generatePath(cfg.api.mfaRequired, { clusterId });
},

getCheckAccessToRegisteredResourceUrl() {
const clusterId = cfg.proxyCluster;
return generatePath(cfg.api.checkAccessToRegisteredResource, {
Expand Down
1 change: 1 addition & 0 deletions web/packages/teleport/src/services/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const agentService = {
kubernetes_user: req.kubeImpersonation?.user,
kubernetes_groups: req.kubeImpersonation?.groups,
},
mfa_response: req.mfaAuthnResponse,
})
.then(makeConnectionDiagnostic);
},
Expand Down
Loading