diff --git a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.story.tsx b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.story.tsx index 5b184bc939295..3f8994167f1c4 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.story.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.story.tsx @@ -196,4 +196,6 @@ const props: State = { username: 'teleport-username', authType: 'local', clusterId: 'some-cluster-id', + showMfaDialog: false, + cancelMfaDialog: () => null, }; diff --git a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx index afed42820ebd9..ab506e4f9f0c8 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx @@ -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, @@ -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(); @@ -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 })); @@ -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 ( {({ validator }) => ( + {showMfaDialog && ( + + runConnectionDiagnostic(makeTestConnRequest(), res) + } + onClose={cancelMfaDialog} + /> + )} Test Connection diff --git a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/useTestConnection.ts b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/useTestConnection.ts index d5080ea41b404..7bce481b71b8b 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/useTestConnection.ts +++ b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/useTestConnection.ts @@ -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(); + 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; @@ -61,6 +91,8 @@ export function useTestConnection({ ctx, props }: Props) { username, authType, clusterId: cluster.clusterId, + showMfaDialog, + cancelMfaDialog, }; } diff --git a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.story.tsx b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.story.tsx index 58427100cb273..8bfd24e50e703 100644 --- a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.story.tsx +++ b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.story.tsx @@ -140,4 +140,6 @@ const props: State = { prevStep: () => null, diagnosis: null, canTestConnection: true, + showMfaDialog: false, + cancelMfaDialog: () => null, }; diff --git a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx index 16ba2632a1785..c5f637d128000 100644 --- a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx @@ -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, @@ -52,6 +53,8 @@ export function TestConnection({ nextStep, prevStep, canTestConnection, + showMfaDialog, + cancelMfaDialog, }: State) { const [usernameOpts] = useState(() => logins.map(l => ({ value: l, label: l })) @@ -88,6 +91,12 @@ export function TestConnection({ return ( + {showMfaDialog && ( + runConnectionDiagnostic(selectedOpt.value, res)} + onClose={cancelMfaDialog} + /> + )} Test Connection Optionally verify that you can successfully connect to the server you diff --git a/web/packages/teleport/src/Discover/Server/TestConnection/useTestConnection.ts b/web/packages/teleport/src/Discover/Server/TestConnection/useTestConnection.ts index 161c9b0d0f3b0..79a9b7946eb83 100644 --- a/web/packages/teleport/src/Discover/Server/TestConnection/useTestConnection.ts +++ b/web/packages/teleport/src/Discover/Server/TestConnection/useTestConnection.ts @@ -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(); + const [showMfaDialog, setShowMfaDialog] = useState(false); const access = ctx.storeUser.getConnectionDiagnosticAccess(); const canTestConnection = access.create && access.edit && access.read; @@ -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 { @@ -67,6 +98,8 @@ export function useTestConnection({ ctx, props }: Props) { nextStep: props.nextStep, prevStep: props.prevStep, canTestConnection, + showMfaDialog, + cancelMfaDialog, }; } diff --git a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts index a876d1fed35ff..172f4bbd407b9 100644 --- a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts +++ b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts @@ -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() { @@ -55,9 +97,27 @@ export default function useReAuthenticate({ onAuthenticated, onClose }: Props) { }; } -export type Props = { - onAuthenticated: React.Dispatch>; +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; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 51d80b4d7eeb4..f234b60f7ec2f 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -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', @@ -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, { diff --git a/web/packages/teleport/src/services/agents/agents.ts b/web/packages/teleport/src/services/agents/agents.ts index f16a634a2a727..1c9a9f52bc462 100644 --- a/web/packages/teleport/src/services/agents/agents.ts +++ b/web/packages/teleport/src/services/agents/agents.ts @@ -38,6 +38,7 @@ export const agentService = { kubernetes_user: req.kubeImpersonation?.user, kubernetes_groups: req.kubeImpersonation?.groups, }, + mfa_response: req.mfaAuthnResponse, }) .then(makeConnectionDiagnostic); }, diff --git a/web/packages/teleport/src/services/agents/types.ts b/web/packages/teleport/src/services/agents/types.ts index 2fb812c658577..fe90d7a9fc910 100644 --- a/web/packages/teleport/src/services/agents/types.ts +++ b/web/packages/teleport/src/services/agents/types.ts @@ -21,6 +21,8 @@ import { Kube } from 'teleport/services/kube'; import { Desktop, WindowsDesktopService } from 'teleport/services/desktops'; import { AgentQueryMeta } from 'teleport/services/resources'; +import type { MfaAuthnResponse } from '../mfa'; + export type AgentKind = | App | Database @@ -97,6 +99,7 @@ export type ConnectionDiagnosticRequest = { resourceName: string; //`json:"resource_name"` sshPrincipal?: string; //`json:"ssh_principal"` kubeImpersonation?: KubeImpersonation; // `json:"kubernetes_impersonation` + mfaAuthnResponse?: MfaAuthnResponse; }; export type KubeImpersonation = { diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts index f7f839f4ff272..cb9d5f4c70f78 100644 --- a/web/packages/teleport/src/services/auth/auth.ts +++ b/web/packages/teleport/src/services/auth/auth.ts @@ -193,7 +193,7 @@ const auth = { return api.post(cfg.api.createPrivilegeTokenPath, { secondFactorToken }); }, - createPrivilegeTokenWithWebauthn() { + fetchWebauthnChallenge() { return auth .checkWebauthnSupport() .then(() => @@ -205,17 +205,26 @@ const auth = { navigator.credentials.get({ publicKey: res.webauthnPublicKey, }) - ) - .then(res => - api.post(cfg.api.createPrivilegeTokenPath, { - webauthnAssertionResponse: makeWebauthnAssertionResponse(res), - }) ); }, + createPrivilegeTokenWithWebauthn() { + return auth.fetchWebauthnChallenge().then(res => + api.post(cfg.api.createPrivilegeTokenPath, { + webauthnAssertionResponse: makeWebauthnAssertionResponse(res), + }) + ); + }, + createRestrictedPrivilegeToken() { return api.post(cfg.api.createPrivilegeTokenPath, {}); }, + + getWebauthnResponse() { + return auth + .fetchWebauthnChallenge() + .then(res => makeWebauthnAssertionResponse(res)); + }, }; function base64EncodeUnicode(str: string) { diff --git a/web/packages/teleport/src/services/mfa/mfa.ts b/web/packages/teleport/src/services/mfa/mfa.ts index a09939700db4c..2d9c61643e0c1 100644 --- a/web/packages/teleport/src/services/mfa/mfa.ts +++ b/web/packages/teleport/src/services/mfa/mfa.ts @@ -22,6 +22,7 @@ import { MfaDevice, AddNewTotpDeviceRequest, AddNewHardwareDeviceRequest, + IsMfaRequiredRequest, } from './types'; import makeMfaDevice from './makeMfaDevice'; @@ -42,6 +43,10 @@ class MfaService { .then(devices => devices.map(makeMfaDevice)); } + isMfaRequired(req: IsMfaRequiredRequest): Promise<{ required: boolean }> { + return api.post(cfg.getMfaRequiredUrl(), req); + } + addNewTotpDevice(req: AddNewTotpDeviceRequest) { return api.post(cfg.api.mfaDevicesPath, req); } diff --git a/web/packages/teleport/src/services/mfa/types.ts b/web/packages/teleport/src/services/mfa/types.ts index dbbd4d08ee019..be960ec52c7bc 100644 --- a/web/packages/teleport/src/services/mfa/types.ts +++ b/web/packages/teleport/src/services/mfa/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { WebauthnAssertionResponse } from '../auth'; + export type MfaDevice = { id: string; name: string; @@ -38,3 +40,52 @@ export type DeviceType = 'totp' | 'webauthn'; // DeviceUsage is the intended usage of the device (MFA, Passwordless, etc). export type DeviceUsage = 'passwordless' | 'mfa'; + +// MfaAuthnResponse is a response to a MFA device challenge. +export type MfaAuthnResponse = + | { totp_code: string } + | { webauthn_response: WebauthnAssertionResponse }; + +export type IsMfaRequiredDatabase = { + database: { + // service_name is the database service name. + service_name: string; + // protocol is the type of the database protocol. + protocol: string; + // username is an optional database username. + username?: string; + // database_name is an optional database name. + database_name?: string; + }; +}; + +export type IsMfaRequiredNode = { + node: { + // node_name can be node's hostname or UUID. + node_name: string; + // login is the OS login name. + login: string; + }; +}; + +export type IsMfaRequiredWindowsDesktop = { + windows_desktop: { + // desktop_name is the Windows Desktop server name. + desktop_name: string; + // login is the Windows desktop user login. + login: string; + }; +}; + +export type IsMfaRequiredKube = { + kube: { + // cluster_name is the name of the kube cluster. + cluster_name: string; + }; +}; + +export type IsMfaRequiredRequest = + | IsMfaRequiredDatabase + | IsMfaRequiredNode + | IsMfaRequiredKube + | IsMfaRequiredWindowsDesktop;