Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Web MFA dialog for admin actions #50373

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions lib/web/mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ func (h *Handler) createAuthenticateChallengeHandle(w http.ResponseWriter, r *ht
return nil, trace.Wrap(err)
}

// TODO(Joerger): return whether MFA is required along with the challenge. Useful for determining
// whether the challenge is empty because the user has no devices, or because the user does not
// require MFA for the action.
return makeAuthenticateChallenge(chal, channelID), nil
}

Expand Down
3 changes: 2 additions & 1 deletion web/packages/teleport/src/JoinTokens/JoinTokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,12 @@ function makeTokenResource(token: JoinToken): Resource<KindJoinToken> {

export const JoinTokens = () => {
const ctx = useTeleport();

const [creatingToken, setCreatingToken] = useState(false);
const [editingToken, setEditingToken] = useState<JoinToken | null>(null);
const [tokenToDelete, setTokenToDelete] = useState<JoinToken | null>(null);
const [joinTokensAttempt, runJoinTokensAttempt, setJoinTokensAttempt] =
useAsync(async () => await ctx.joinTokenService.fetchJoinTokens());
useAsync(() => ctx.joinTokenService.fetchJoinTokens(null));

const resources = useResources(
joinTokensAttempt.data?.items.map(makeTokenResource) || [],
Expand Down
56 changes: 56 additions & 0 deletions web/packages/teleport/src/MFAContext/MFAContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createContext, PropsWithChildren, useCallback, useState } from 'react';

import AuthnDialog from 'teleport/components/AuthnDialog';
import { useMfa } from 'teleport/lib/useMfa';
import api from 'teleport/services/api/api';
import { CreateAuthenticateChallengeRequest } from 'teleport/services/auth';
import auth from 'teleport/services/auth/auth';
import { MfaChallengeResponse } from 'teleport/services/mfa';

export interface MfaContextValue {
getMfaChallengeResponse(
req: CreateAuthenticateChallengeRequest
): Promise<MfaChallengeResponse>;
}

export const MfaContext = createContext<MfaContextValue>(null);

/**
* Provides a global MFA context to handle MFA prompts for methods outside
* of the React scope, such as admin action API calls in auth.ts or api.ts.
* This is intended as a workaround for such cases, and should not be used
* for methods with access to the React scope. Use useMfa directly instead.
*/
export const MfaContextProvider = ({ children }: PropsWithChildren) => {
const adminMfa = useMfa({});

const getMfaChallengeResponse = useCallback(
async (req: CreateAuthenticateChallengeRequest) => {
const chal = await auth.getMfaChallenge(req);

const res = await adminMfa.getChallengeResponse(chal);
if (!res) {
return {}; // return an empty challenge to prevent mfa retry.
}

return res;
},
[adminMfa]
);

const [mfaCtx, setMfaCtx] = useState<MfaContextValue>();

if (!mfaCtx) {
const mfaCtx = { getMfaChallengeResponse };
setMfaCtx(mfaCtx);
auth.setMfaContext(mfaCtx);
api.setMfaContext(mfaCtx);
}

return (
<MfaContext.Provider value={mfaCtx}>
<AuthnDialog mfaState={adminMfa}></AuthnDialog>
{children}
</MfaContext.Provider>
);
};
26 changes: 16 additions & 10 deletions web/packages/teleport/src/Teleport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ import { LoginClose } from './Login/LoginClose';
import { LoginFailedComponent as LoginFailed } from './Login/LoginFailed';
import { LoginSuccess } from './Login/LoginSuccess';
import { LoginTerminalRedirect } from './Login/LoginTerminalRedirect';
import { Main } from './Main';
import { Player } from './Player';
import { SingleLogoutFailed } from './SingleLogoutFailed';
import { Welcome } from './Welcome';

import { Player } from './Player';

import { MfaContextProvider } from './MFAContext/MFAContext';

import { Main } from './Main';
import TeleportContext from './teleportContext';
import TeleportContextProvider from './TeleportContextProvider';
import { Welcome } from './Welcome';

const Teleport: React.FC<Props> = props => {
const { ctx, history } = props;
Expand Down Expand Up @@ -86,13 +90,15 @@ const Teleport: React.FC<Props> = props => {
<Authenticated>
<UserContextProvider>
<TeleportContextProvider ctx={ctx}>
<Switch>
<Route
path={cfg.routes.appLauncher}
component={AppLauncher}
/>
<Route>{createPrivateRoutes()}</Route>
</Switch>
<MfaContextProvider>
<Switch>
<Route
path={cfg.routes.appLauncher}
component={AppLauncher}
/>
<Route>{createPrivateRoutes()}</Route>
</Switch>
</MfaContextProvider>
</TeleportContextProvider>
</UserContextProvider>
</Authenticated>
Expand Down
2 changes: 1 addition & 1 deletion web/packages/teleport/src/Users/useUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default function useUsers({
}

async function onCreate(u: User) {
const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true);
const mfaResponse = await auth.getAdminActionMfaResponse(true);
return ctx.userService
.createUser(u, ExcludeUserField.Traits, mfaResponse)
.then(result => setUsers([result, ...users]))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ export type Props = {
};

export default function AuthnDialog({
mfaState: { options, challenge, submit, attempt, resetAttempt },
mfaState: { options, challenge, submit, attempt, cancelAttempt },
replaceErrorText,
onClose,
onClose = () => {},
}: Props) {
if (!challenge && attempt.status !== 'error') return;

Expand All @@ -50,7 +50,7 @@ export default function AuthnDialog({
<ButtonIcon
data-testid="close-dialog"
onClick={() => {
resetAttempt();
cancelAttempt();
onClose();
}}
>
Expand Down
2 changes: 1 addition & 1 deletion web/packages/teleport/src/lib/useMfa.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe('useMfa', () => {
expect(auth.getMfaChallenge).toHaveBeenCalled();
});

mfa.current.resetAttempt();
mfa.current.cancelAttempt();

await expect(respPromise).rejects.toThrow(
new Error('MFA attempt cancelled by user')
Expand Down
88 changes: 46 additions & 42 deletions web/packages/teleport/src/lib/useMfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,8 @@ export type MfaProps = {
};

type mfaResponsePromiseWithResolvers = {
promise: Promise<MfaChallengeResponse>;
resolve: (v: MfaChallengeResponse) => void;
reject: (v?: any) => void;
promise: Promise<MfaChallengeResponse | Error>;
resolve: (v: MfaChallengeResponse | Error) => void;
};

/**
Expand All @@ -57,9 +56,6 @@ export function useMfa({ req, isMfaRequired }: MfaProps): MfaState {
const [options, setMfaOptions] = useState<MfaOption[]>();
const [challenge, setMfaChallenge] = useState<MfaAuthenticateChallenge>();

const mfaResponsePromiseWithResolvers =
useRef<mfaResponsePromiseWithResolvers>();

useEffect(() => {
setMfaRequired(isMfaRequired);
}, [isMfaRequired]);
Expand All @@ -80,7 +76,10 @@ export function useMfa({ req, isMfaRequired }: MfaProps): MfaState {

const [attempt, getResponse, setMfaAttempt] = useAsync(
useCallback(
async (challenge?: MfaAuthenticateChallenge) => {
async (
responsePromise: Promise<MfaChallengeResponse | Error>,
challenge?: MfaAuthenticateChallenge
) => {
// If a previous call determined that MFA is not required, this is a noop.
if (mfaRequired === false) return;

Expand All @@ -91,62 +90,67 @@ export function useMfa({ req, isMfaRequired }: MfaProps): MfaState {
}

// Set mfa requirement and options after we get a challenge for the first time.
if (!mfaRequired) setMfaRequired(true);
if (!options) setMfaOptions(getMfaChallengeOptions(challenge));

// Prepare a new promise to collect the mfa response retrieved
// through the submit function.
let resolve, reject;
const promise = new Promise<MfaChallengeResponse>((res, rej) => {
resolve = res;
reject = rej;
});

mfaResponsePromiseWithResolvers.current = {
promise,
resolve,
reject,
};
setMfaRequired(true);
setMfaOptions(getMfaChallengeOptions(challenge));

setMfaChallenge(challenge);
try {
return await promise;
return await responsePromise;
} finally {
mfaResponsePromiseWithResolvers.current = null;
setMfaChallenge(null);
}
},
[req, mfaResponsePromiseWithResolvers, options, mfaRequired]
[req, mfaRequired]
)
);

const resetAttempt = () => {
if (mfaResponsePromiseWithResolvers.current)
mfaResponsePromiseWithResolvers.current.reject(
new Error('MFA attempt cancelled by user')
const mfaResponseRef = useRef<mfaResponsePromiseWithResolvers>();

const cancelAttempt = () => {
if (mfaResponseRef.current) {
mfaResponseRef.current.resolve(
new Error(
'User cancelled MFA attempt. This is an admin-level API request and requires MFA verification. Please try again with a registered MFA device. If you do not have an MFA device registered, you can add one in the account settings page.'
)
);
mfaResponsePromiseWithResolvers.current = null;
setMfaChallenge(null);
setMfaAttempt(makeEmptyAttempt());
}
};

const getChallengeResponse = useCallback(
async (challenge?: MfaAuthenticateChallenge) => {
const [resp, err] = await getResponse(challenge);
// Prepare a new promise to collect the mfa response retrieved
// through the submit function.
let resolve;
const promise = new Promise<MfaChallengeResponse | Error>(res => {
resolve = res;
});

mfaResponseRef.current = {
promise,
resolve,
};

const [resp, err] = await getResponse(promise, challenge);

if (err) throw err;
return resp;

if (resp instanceof Error) {
throw resp;
}

return resp as MfaChallengeResponse;
},
[getResponse]
[getResponse, mfaResponseRef]
);

const submit = useCallback(
async (mfaType?: DeviceType, totpCode?: string) => {
if (!mfaResponsePromiseWithResolvers.current) {
if (!mfaResponseRef.current) {
throw new Error('submit called without an in flight MFA attempt');
}

try {
await mfaResponsePromiseWithResolvers.current.resolve(
await mfaResponseRef.current.resolve(
await auth.getMfaChallengeResponse(challenge, mfaType, totpCode)
);
} catch (err) {
Expand All @@ -158,7 +162,7 @@ export function useMfa({ req, isMfaRequired }: MfaProps): MfaState {
});
}
},
[challenge, mfaResponsePromiseWithResolvers, setMfaAttempt]
[challenge, mfaResponseRef, setMfaAttempt]
);

return {
Expand All @@ -168,7 +172,7 @@ export function useMfa({ req, isMfaRequired }: MfaProps): MfaState {
getChallengeResponse,
submit,
attempt,
resetAttempt,
cancelAttempt,
};
}

Expand Down Expand Up @@ -207,7 +211,7 @@ export type MfaState = {
) => Promise<MfaChallengeResponse>;
submit: (mfaType?: DeviceType, totpCode?: string) => Promise<void>;
attempt: Attempt<any>;
resetAttempt: () => void;
cancelAttempt: () => void;
};

// used for testing
Expand All @@ -219,6 +223,6 @@ export function makeDefaultMfaState(): MfaState {
getChallengeResponse: async () => null,
submit: () => null,
attempt: makeEmptyAttempt(),
resetAttempt: () => null,
cancelAttempt: () => null,
};
}
Loading
Loading