diff --git a/web/packages/teleport/src/Account/AccountNew.tsx b/web/packages/teleport/src/Account/AccountNew.tsx index 26eec9ec2c7c6..3e9b6560bd99e 100644 --- a/web/packages/teleport/src/Account/AccountNew.tsx +++ b/web/packages/teleport/src/Account/AccountNew.tsx @@ -28,7 +28,7 @@ import { FeatureBox } from 'teleport/components/Layout'; import ReAuthenticate from 'teleport/components/ReAuthenticate'; import { RemoveDialog } from 'teleport/components/MfaDeviceList'; -import { MFAChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; import { AuthDeviceList } from './ManageDevices/AuthDeviceList/AuthDeviceList'; import useManageDevices, { @@ -216,7 +216,7 @@ export function Account({ onAuthenticated={setToken} onClose={hideReAuthenticate} actionText="registering a new device" - challengeScope={MFAChallengeScope.USER_SESSION} + challengeScope={MfaChallengeScope.USER_SESSION} /> )} {isAddDeviceVisible && ( diff --git a/web/packages/teleport/src/Account/ManageDevices/ManageDevices.tsx b/web/packages/teleport/src/Account/ManageDevices/ManageDevices.tsx index b6ed2de99921c..e8ee2b85592d0 100644 --- a/web/packages/teleport/src/Account/ManageDevices/ManageDevices.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/ManageDevices.tsx @@ -30,7 +30,7 @@ import MfaDeviceList, { RemoveDialog } from 'teleport/components/MfaDeviceList'; import ReAuthenticate from 'teleport/components/ReAuthenticate'; -import { MFAChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; import AddDevice from './AddDevice'; import useManageDevices, { State } from './useManageDevices'; @@ -106,7 +106,7 @@ export function ManageDevices({ onAuthenticated={setToken} onClose={hideReAuthenticate} actionText="registering a new device" - challengeScope={MFAChallengeScope.USER_SESSION} + challengeScope={MfaChallengeScope.USER_SESSION} /> )} {isAddDeviceVisible && ( diff --git a/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts b/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts index 456e662def85f..220b925d3996f 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts +++ b/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts @@ -20,7 +20,7 @@ import { useCallback } from 'react'; import useAttempt from 'shared/hooks/useAttemptNext'; import cfg, { UrlScpParams } from 'teleport/config'; -import auth, { MFAChallengeScope } from 'teleport/services/auth/auth'; +import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; export default function useGetScpUrl(addMfaToScpUrls: boolean) { const { setAttempt, attempt, handleError } = useAttempt(''); @@ -36,7 +36,7 @@ export default function useGetScpUrl(addMfaToScpUrls: boolean) { } try { let webauthn = await auth.getWebauthnResponse( - MFAChallengeScope.USER_SESSION + MfaChallengeScope.USER_SESSION ); setAttempt({ status: 'success', diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx index 7aab6ab442223..c0bb48ea995ed 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx @@ -47,7 +47,7 @@ import { import { sortNodeLogins } from 'teleport/services/nodes'; import { ApiError } from 'teleport/services/api/parseError'; -import { MFAChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; import { NodeMeta } from '../../useDiscover'; @@ -174,7 +174,7 @@ export function TestConnection(props: AgentStepProps) { }) } onClose={cancelMfaDialog} - challengeScope={MFAChallengeScope.USER_SESSION} + challengeScope={MfaChallengeScope.USER_SESSION} /> )}
diff --git a/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx index 4ff8ee5219dd7..7255a5bb3693a 100644 --- a/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx @@ -25,7 +25,7 @@ import TextSelectCopy from 'teleport/components/TextSelectCopy'; import { generateTshLoginCommand } from 'teleport/lib/util'; import ReAuthenticate from 'teleport/components/ReAuthenticate'; -import { MFAChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; import { ActionButtons, @@ -91,7 +91,7 @@ export function TestConnectionView({ testConnection(makeTestConnRequest(), res)} onClose={cancelMfaDialog} - challengeScope={MFAChallengeScope.USER_SESSION} + challengeScope={MfaChallengeScope.USER_SESSION} /> )}
Test Connection
diff --git a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx index 23976922b1f6c..f674012789724 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx @@ -28,7 +28,7 @@ import TextSelectCopy from 'teleport/components/TextSelectCopy'; import { generateTshLoginCommand } from 'teleport/lib/util'; import ReAuthenticate from 'teleport/components/ReAuthenticate'; -import { MFAChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; import { ActionButtons, @@ -103,7 +103,7 @@ export function TestConnection({ testConnection(makeTestConnRequest(), res)} onClose={cancelMfaDialog} - challengeScope={MFAChallengeScope.USER_SESSION} + challengeScope={MfaChallengeScope.USER_SESSION} /> )}
Test Connection
diff --git a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx index 734b20bacdc9b..de96396e8c9b5 100644 --- a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx @@ -33,7 +33,7 @@ import { } from 'teleport/Discover/Shared'; import { sortNodeLogins } from 'teleport/services/nodes'; -import { MFAChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; import { NodeMeta } from '../../useDiscover'; @@ -89,7 +89,7 @@ export function TestConnection(props: AgentStepProps) { testConnection(selectedOpt.value, res)} onClose={cancelMfaDialog} - challengeScope={MFAChallengeScope.USER_SESSION} + challengeScope={MfaChallengeScope.USER_SESSION} /> )}
Test Connection
diff --git a/web/packages/teleport/src/Users/useUsers.ts b/web/packages/teleport/src/Users/useUsers.ts index 1f9632c7b4d04..94fdebaece54d 100644 --- a/web/packages/teleport/src/Users/useUsers.ts +++ b/web/packages/teleport/src/Users/useUsers.ts @@ -21,6 +21,7 @@ import { useAttempt } from 'shared/hooks'; import { User } from 'teleport/services/user'; import useTeleport from 'teleport/useTeleport'; +import auth from 'teleport/services/auth/auth'; export default function useUsers({ InviteCollaborators, @@ -82,11 +83,18 @@ export default function useUsers({ }); } - function onCreate(u: User) { + async function onCreate(u: User) { + const webauthnResponse = await auth.getWebauthnResponseForAdminAction(true); return ctx.userService - .createUser(u) + .createUser(u, webauthnResponse) .then(result => setUsers([result, ...users])) - .then(() => ctx.userService.createResetPasswordToken(u.name, 'invite')); + .then(() => + ctx.userService.createResetPasswordToken( + u.name, + 'invite', + webauthnResponse + ) + ); } function onInviteCollaboratorsClose(newUsers?: User[]) { diff --git a/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.story.tsx b/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.story.tsx index e55511d75404c..8ee1a0187bd14 100644 --- a/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.story.tsx +++ b/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.story.tsx @@ -18,7 +18,7 @@ import React from 'react'; -import { MFAChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; import { State } from './useReAuthenticate'; import { ReAuthenticate } from './ReAuthenticate'; @@ -49,5 +49,5 @@ const props: State = { onClose: () => null, auth2faType: 'on', actionText: 'performing this action', - challengeScope: MFAChallengeScope.UNSPECIFIED, + challengeScope: MfaChallengeScope.UNSPECIFIED, }; diff --git a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts index 28d7921768e44..5b91dfe11c443 100644 --- a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts +++ b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts @@ -20,7 +20,7 @@ import useAttempt from 'shared/hooks/useAttemptNext'; import cfg from 'teleport/config'; import auth from 'teleport/services/auth'; -import { MFAChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; import type { MfaAuthnResponse } from 'teleport/services/mfa'; @@ -53,7 +53,7 @@ export default function useReAuthenticate(props: Props) { .catch(handleError); } - function submitWithWebauthn(scope: MFAChallengeScope) { + function submitWithWebauthn(scope: MfaChallengeScope) { setAttempt({ status: 'processing' }); if ('onMfaResponse' in props) { @@ -119,7 +119,7 @@ type BaseProps = { /** * The MFA challenge scope of the action to perform, as defined in webauthn.proto. */ - challengeScope: MFAChallengeScope; + challengeScope: MfaChallengeScope; }; // MfaResponseProps defines a function diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index cebb28618a7b0..ac3521c95bb86 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -381,6 +381,10 @@ const cfg = { return cfg.auth.allowPasswordless; }, + isAdminActionMfaEnforced() { + return cfg.auth.second_factor === 'webauthn'; + }, + getPrimaryAuthType(): PrimaryAuthType { if (cfg.auth.localConnectorName === 'passwordless') { return 'passwordless'; diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts index c8d62fade3428..17729d6d5931e 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -17,7 +17,7 @@ */ import 'whatwg-fetch'; -import auth, { MFAChallengeScope } from 'teleport/services/auth/auth'; +import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; import { storageService } from '../storageService'; import { WebauthnAssertionResponse } from '../auth'; @@ -31,12 +31,16 @@ const api = { return api.fetchJsonWithMfaAuthnRetry(url, { signal: abortSignal }); }, - post(url, data?, abortSignal?) { - return api.fetchJsonWithMfaAuthnRetry(url, { - body: JSON.stringify(data), - method: 'POST', - signal: abortSignal, - }); + post(url, data?, abortSignal?, webauthnResponse?: WebauthnAssertionResponse) { + return api.fetchJsonWithMfaAuthnRetry( + url, + { + body: JSON.stringify(data), + method: 'POST', + signal: abortSignal, + }, + webauthnResponse + ); }, postFormData(url, formData) { @@ -58,18 +62,26 @@ const api = { throw new Error('data for body is not a type of FormData'); }, - delete(url, data?) { - return api.fetchJsonWithMfaAuthnRetry(url, { - body: JSON.stringify(data), - method: 'DELETE', - }); + delete(url, data?, webauthnResponse?: WebauthnAssertionResponse) { + return api.fetchJsonWithMfaAuthnRetry( + url, + { + body: JSON.stringify(data), + method: 'DELETE', + }, + webauthnResponse + ); }, - put(url, data) { - return api.fetchJsonWithMfaAuthnRetry(url, { - body: JSON.stringify(data), - method: 'PUT', - }); + put(url, data, webauthnResponse?: WebauthnAssertionResponse) { + return api.fetchJsonWithMfaAuthnRetry( + url, + { + body: JSON.stringify(data), + method: 'PUT', + }, + webauthnResponse + ); }, /** @@ -118,7 +130,7 @@ const api = { let webauthnResponseForRetry; try { webauthnResponseForRetry = await auth.getWebauthnResponse( - MFAChallengeScope.ADMIN_ACTION + MfaChallengeScope.ADMIN_ACTION ); } catch (err) { throw new Error( diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts index b1bde79e5d01e..7a5335791a857 100644 --- a/web/packages/teleport/src/services/auth/auth.ts +++ b/web/packages/teleport/src/services/auth/auth.ts @@ -240,7 +240,7 @@ const auth = { headlessSSOAccept(transactionId: string) { return auth - .fetchWebauthnChallenge(MFAChallengeScope.HEADLESS_LOGIN) + .fetchWebauthnChallenge(MfaChallengeScope.HEADLESS_LOGIN) .then(res => { const request = { action: 'accept', @@ -264,33 +264,16 @@ const auth = { }, async fetchWebauthnChallenge( - scope: MFAChallengeScope, + scope: MfaChallengeScope, allowReuse?: boolean, - isMFARequiredRequest?: IsMfaRequiredRequest + isMfaRequiredRequest?: IsMfaRequiredRequest ) { - // TODO(Joerger): DELETE IN 16.0.0 - // the create mfa challenge endpoint below supports - // MFARequired requests without the extra roundtrip. - if (isMFARequiredRequest) { - try { - const isMFARequired = await checkMfaRequired(isMFARequiredRequest); - if (!isMFARequired.required) { - return; - } - } catch { - // checking MFA requirement for admin actions is not supported by old - // auth servers, we expect an error instead. In this case, assume MFA is - // not required. Callers should fallback to retrying with MFA if needed. - return; - } - } - return auth .checkWebauthnSupport() .then(() => api .post(cfg.api.mfaAuthnChallengePath, { - is_mfa_required_req: isMFARequiredRequest, + is_mfa_required_req: isMfaRequiredRequest, challenge_scope: scope, challenge_allow_reuse: allowReuse, }) @@ -303,7 +286,7 @@ const auth = { ); }, - createPrivilegeTokenWithWebauthn(scope: MFAChallengeScope) { + createPrivilegeTokenWithWebauthn(scope: MfaChallengeScope) { return auth.fetchWebauthnChallenge(scope).then(res => api.post(cfg.api.createPrivilegeTokenPath, { webauthnAssertionResponse: makeWebauthnAssertionResponse(res), @@ -315,15 +298,55 @@ const auth = { return api.post(cfg.api.createPrivilegeTokenPath, {}); }, - getWebauthnResponse( - scope: MFAChallengeScope, + async getWebauthnResponse( + scope: MfaChallengeScope, allowReuse?: boolean, - isMFARequiredRequest?: IsMfaRequiredRequest + isMfaRequiredRequest?: IsMfaRequiredRequest ) { + // TODO(Joerger): DELETE IN 16.0.0 + // the create mfa challenge endpoint below supports + // MFARequired requests without the extra roundtrip. + if (isMfaRequiredRequest) { + try { + const isMFARequired = await checkMfaRequired(isMfaRequiredRequest); + if (!isMFARequired.required) { + return; + } + } catch (err) { + if ( + err?.response?.status === 400 && + err?.message.includes('missing target for MFA check') + ) { + // checking MFA requirement for admin actions is not supported by old + // auth servers, we expect an error instead. In this case, assume MFA is + // not required. Callers should fallback to retrying with MFA if needed. + return; + } + + throw err; + } + } + return auth - .fetchWebauthnChallenge(scope, allowReuse, isMFARequiredRequest) + .fetchWebauthnChallenge(scope, allowReuse, isMfaRequiredRequest) .then(res => makeWebauthnAssertionResponse(res)); }, + + getWebauthnResponseForAdminAction(allowReuse?: boolean) { + // If the client is checking if MFA is required for an admin action, + // but we know admin action MFA is not enforced, return early. + if (!cfg.isAdminActionMfaEnforced()) { + return; + } + + return auth.getWebauthnResponse( + MfaChallengeScope.ADMIN_ACTION, + allowReuse, + { + admin_action: {}, + } + ); + }, }; function checkMfaRequired( @@ -348,7 +371,7 @@ export type IsMfaRequiredRequest = | IsMfaRequiredNode | IsMfaRequiredKube | IsMfaRequiredWindowsDesktop - | IsMFARequiredAdminAction; + | IsMfaRequiredAdminAction; export type IsMfaRequiredResponse = { required: boolean; @@ -392,15 +415,13 @@ export type IsMfaRequiredKube = { }; }; -export type IsMFARequiredAdminAction = { - admin_action: { - // name is the name of the admin action RPC. - name: string; - }; +export type IsMfaRequiredAdminAction = { + // empty object. + admin_action: Record; }; -// MFAChallengeScope is an mfa challenge scope. Possible values are defined in mfa.proto -export enum MFAChallengeScope { +// MfaChallengeScope is an mfa challenge scope. Possible values are defined in mfa.proto +export enum MfaChallengeScope { UNSPECIFIED = 0, LOGIN = 1, PASSWORDLESS_LOGIN = 2, diff --git a/web/packages/teleport/src/services/user/user.ts b/web/packages/teleport/src/services/user/user.ts index a6b3c0f437e61..c0fa25c888073 100644 --- a/web/packages/teleport/src/services/user/user.ts +++ b/web/packages/teleport/src/services/user/user.ts @@ -20,6 +20,8 @@ import api from 'teleport/services/api'; import cfg from 'teleport/config'; import session from 'teleport/services/websession'; +import { WebauthnAssertionResponse } from '../auth'; + import makeUserContext from './makeUserContext'; import { makeResetToken } from './makeResetToken'; import makeUser, { makeUsers } from './makeUser'; @@ -60,13 +62,24 @@ const service = { return api.put(cfg.getUsersUrl(), user).then(makeUser); }, - createUser(user: User) { - return api.post(cfg.getUsersUrl(), user).then(makeUser); + createUser(user: User, webauthnResponse?: WebauthnAssertionResponse) { + return api + .post(cfg.getUsersUrl(), user, null, webauthnResponse) + .then(makeUser); }, - createResetPasswordToken(name: string, type: ResetPasswordType) { + createResetPasswordToken( + name: string, + type: ResetPasswordType, + webauthnResponse?: WebauthnAssertionResponse + ) { return api - .post(cfg.api.resetPasswordTokenPath, { name, type }) + .post( + cfg.api.resetPasswordTokenPath, + { name, type }, + null, + webauthnResponse + ) .then(makeResetToken); },