Skip to content

Commit

Permalink
[web] Ask the user their preferred 2FA choice if both are enabled (en…
Browse files Browse the repository at this point in the history
…te-io#4224)

Sibling of mobile ente-io#4210. Unlike
mobile, we automatically redirect so we need to ask the user their pref
beforehand.
  • Loading branch information
mnvr authored Nov 28, 2024
2 parents 0782b00 + 4bca5f8 commit b465817
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 2 deletions.
60 changes: 60 additions & 0 deletions web/packages/accounts/components/SecondFactorChoice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
import type { ModalVisibilityProps } from "@/base/components/utils/modal";
import { Dialog, DialogContent, DialogTitle, Stack } from "@mui/material";
import { t } from "i18next";
import React from "react";

export type SecondFactorType = "totp" | "passkey";

type SecondFactorChoiceProps = ModalVisibilityProps & {
/**
* Callback invoked with the selected choice.
*
* The dialog will automatically be closed before this callback is invoked.
*/
onSelect: (factor: SecondFactorType) => void;
};

/**
* A {@link Dialog} that allow the user to choose which second factor they'd
* like to verify during login.
*/
export const SecondFactorChoice: React.FC<SecondFactorChoiceProps> = ({
open,
onClose,
onSelect,
}) => (
<Dialog
open={open}
onClose={(_, reason) => {
if (reason != "backdropClick") onClose();
}}
fullWidth
PaperProps={{ sx: { maxWidth: "360px", padding: "12px" } }}
>
<DialogTitle>{t("two_factor")}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: "12px" }}>
<FocusVisibleButton
color="accent"
onClick={() => {
onClose();
onSelect("totp");
}}
>
{t("totp_login")}
</FocusVisibleButton>

<FocusVisibleButton
color="accent"
onClick={() => {
onClose();
onSelect("passkey");
}}
>
{t("passkey_login")}
</FocusVisibleButton>
</Stack>
</DialogContent>
</Dialog>
);
92 changes: 92 additions & 0 deletions web/packages/accounts/components/utils/second-factor-choice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* @file This code is conceputally related to `SecondFactorChoice.tsx`, but
* needs to be in a separate file to allow fast refresh.
*/

import { useModalVisibility } from "@/base/components/utils/modal";
import { useCallback, useMemo, useRef } from "react";
import type { UserVerificationResponse } from "../../services/user";
import type { SecondFactorType } from "../SecondFactorChoice";

/**
* A convenience hook for keeping track of the state and logic that is needed
* after password verification to determine which second factor (if any) we
* should be asking the user for.
*
* This is a rather ad-hoc abstraction meant to be used in a very specific way;
* the only intent is to reduce code duplication between the two pages that need
* this choice.
*/
export const useSecondFactorChoiceIfNeeded = () => {
const resolveSecondFactorChoice = useRef<
| ((value: SecondFactorType | PromiseLike<SecondFactorType>) => void)
| undefined
>();
const {
show: showSecondFactorChoice,
props: secondFactorChoiceVisibilityProps,
} = useModalVisibility();

const onSelect = useCallback((factor: SecondFactorType) => {
const resolve = resolveSecondFactorChoice.current!;
resolveSecondFactorChoice.current = undefined;
resolve(factor);
}, []);

const secondFactorChoiceProps = useMemo(
() => ({ ...secondFactorChoiceVisibilityProps, onSelect }),
[secondFactorChoiceVisibilityProps, onSelect],
);

const userVerificationResultAfterResolvingSecondFactorChoice = useCallback(
async (response: UserVerificationResponse) => {
const {
twoFactorSessionID: _twoFactorSessionIDV1,
twoFactorSessionIDV2: _twoFactorSessionIDV2,
passkeySessionID: _passkeySessionID,
} = response;

// When the user has both TOTP and pk set as the second factor,
// we'll get two session IDs. For backward compat, the TOTP session
// ID will be in a V2 attribute during a transient migration period.
//
// Note the use of || instead of ?? since _twoFactorSessionIDV1 will
// be an empty string, not undefined, if it is unset. We might need
// to add a `xxx-eslint-disable
// @typescript-eslint/prefer-nullish-coalescing` here too later.
const _twoFactorSessionID =
_twoFactorSessionIDV1 || _twoFactorSessionIDV2;

let passkeySessionID: string | undefined;
let twoFactorSessionID: string | undefined;
// If both factors are set, ask the user which one they want to use.
if (_twoFactorSessionID && _passkeySessionID) {
const choice = await new Promise<SecondFactorType>(
(resolve) => {
resolveSecondFactorChoice.current = resolve;
showSecondFactorChoice();
},
);
switch (choice) {
case "passkey":
passkeySessionID = _passkeySessionID;
break;
case "totp":
twoFactorSessionID = _twoFactorSessionID;
break;
}
} else {
passkeySessionID = _passkeySessionID;
twoFactorSessionID = _twoFactorSessionID;
}

return { ...response, passkeySessionID, twoFactorSessionID };
},
[showSecondFactorChoice],
);

return {
secondFactorChoiceProps,
userVerificationResultAfterResolvingSecondFactorChoice,
};
};
14 changes: 13 additions & 1 deletion web/packages/accounts/pages/credentials.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
PasswordHeader,
VerifyingPasskey,
} from "../components/LoginComponents";
import { SecondFactorChoice } from "../components/SecondFactorChoice";
import { useSecondFactorChoiceIfNeeded } from "../components/utils/second-factor-choice";
import { PAGES } from "../constants/pages";
import {
openPasskeyVerificationURL,
Expand Down Expand Up @@ -76,6 +78,10 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
const [sessionValidityCheck, setSessionValidityCheck] = useState<
Promise<void> | undefined
>();
const {
secondFactorChoiceProps,
userVerificationResultAfterResolvingSecondFactorChoice,
} = useSecondFactorChoiceIfNeeded();

const router = useRouter();

Expand Down Expand Up @@ -218,8 +224,12 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
id,
twoFactorSessionID,
passkeySessionID,
} = await loginViaSRP(srpAttributes!, kek);
} =
await userVerificationResultAfterResolvingSecondFactorChoice(
await loginViaSRP(srpAttributes!, kek),
);
setIsFirstLogin(true);

if (passkeySessionID) {
const sessionKeyAttributes =
await cryptoWorker.generateKeyAndEncryptToB64(kek);
Expand Down Expand Up @@ -387,6 +397,8 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
</Stack>
</LoginFlowFormFooter>
</FormPaper>

<SecondFactorChoice {...secondFactorChoiceProps} />
</VerticallyCentered>
);
};
Expand Down
12 changes: 11 additions & 1 deletion web/packages/accounts/pages/verify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
LoginFlowFormFooter,
VerifyingPasskey,
} from "../components/LoginComponents";
import { SecondFactorChoice } from "../components/SecondFactorChoice";
import { useSecondFactorChoiceIfNeeded } from "../components/utils/second-factor-choice";
import { PAGES } from "../constants/pages";
import {
openPasskeyVerificationURL,
Expand All @@ -51,6 +53,10 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
const [passkeyVerificationData, setPasskeyVerificationData] = useState<
{ passkeySessionID: string; url: string } | undefined
>();
const {
secondFactorChoiceProps,
userVerificationResultAfterResolvingSecondFactorChoice,
} = useSecondFactorChoiceIfNeeded();

const router = useRouter();

Expand Down Expand Up @@ -83,7 +89,9 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
id,
twoFactorSessionID,
passkeySessionID,
} = resp.data as UserVerificationResponse;
} = await userVerificationResultAfterResolvingSecondFactorChoice(
resp.data as UserVerificationResponse,
);
if (passkeySessionID) {
const user = getData(LS_KEYS.USER);
await setLSUser({
Expand Down Expand Up @@ -243,6 +251,8 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
</Stack>
</LoginFlowFormFooter>
</FormPaper>

<SecondFactorChoice {...secondFactorChoiceProps} />
</VerticallyCentered>
);
};
Expand Down
6 changes: 6 additions & 0 deletions web/packages/accounts/services/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export interface UserVerificationResponse {
token?: string;
twoFactorSessionID: string;
passkeySessionID: string;
/**
* If both passkeys and TOTP based two factors are enabled, then {@link
* twoFactorSessionIDV2} will be set to the TOTP session ID instead of
* {@link twoFactorSessionID}.
*/
twoFactorSessionIDV2?: string | undefined;
srpM2?: string;
}

Expand Down
1 change: 1 addition & 0 deletions web/packages/base/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@
"check_status": "Check status",
"passkey_login_instructions": "Follow the steps from your browser to continue logging in.",
"passkey_login": "Login with passkey",
"totp_login": "Login with TOTP",
"passkey": "Passkey",
"passkey_verify_description": "Verify your passkey to login into your account.",
"waiting_for_verification": "Waiting for verification...",
Expand Down

0 comments on commit b465817

Please sign in to comment.