diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 8b0b1757d0..046770bc43 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -25,6 +25,7 @@ import { saveKeyAttributes, saveSRPAttributes, setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { openPasskeyVerificationURL, @@ -212,12 +213,7 @@ const Page: React.FC = () => { if (passkeySessionID) { await stashKeyEncryptionKeyInSessionStore(kek); - const user = getData("user"); - await setLSUser({ - ...user, - passkeySessionID, - isTwoFactorEnabled: true, - }); + updateSavedLocalUser({ passkeySessionID }); stashRedirect("/"); const url = passkeyVerificationRedirectURL( accountsUrl!, @@ -228,11 +224,9 @@ const Page: React.FC = () => { throw new Error(twoFactorEnabledErrorMessage); } else if (twoFactorSessionID) { await stashKeyEncryptionKeyInSessionStore(kek); - const user = getData("user"); - await setLSUser({ - ...user, - twoFactorSessionID, + updateSavedLocalUser({ isTwoFactorEnabled: true, + twoFactorSessionID, }); void router.push("/two-factor/verify"); throw new Error(twoFactorEnabledErrorMessage); @@ -243,7 +237,9 @@ const Page: React.FC = () => { token, encryptedToken, id, - isTwoFactorEnabled: false, + isTwoFactorEnabled: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, }); if (keyAttributes) saveKeyAttributes(keyAttributes); return keyAttributes; diff --git a/web/packages/accounts/pages/login.tsx b/web/packages/accounts/pages/login.tsx index bf754056ff..b98eebaefb 100644 --- a/web/packages/accounts/pages/login.tsx +++ b/web/packages/accounts/pages/login.tsx @@ -49,6 +49,8 @@ import React, { useCallback, useEffect, useState } from "react"; * * - Redirects to the passkey app once email verification is complete if the * user has setup an additional passkey that also needs to be verified. + * Before redirecting, it sets the `inflightPasskeySessionID` in session + * storage. * * - "/credentials" - A page that allows the user to enter their password to * authenticate (initial login) or reauthenticate (new web app tab) @@ -79,11 +81,41 @@ import React, { useCallback, useEffect, useState } from "react"; * * - Redirects to "/change-password" once the recovery key is verified. * - * - "/change-password" - A page that allows the user to reset their password. + * - "/change-password" - A page that allows the user to reset their password. * * - Redirects to "/" if there is no `email` present in the saved partial * user, and after successfully changing the password. * + * - "/two-factor/verify" - A page that allows the user to verify their TOTP + * based second factor. + * + * - Redirects to "/" if there is no `email` or `twoFactorSessionID` in the + * saved partial local user. + * + * - Redirects to "/credentials" if there `isTwoFactorEnabled` is not `true` + * and either of `encryptedToken` or `token` is present in the saved partial + * local user. + * + * - "/passkeys/finish" - A page that the accounts app hands off control back to + * us (the calling app) to continue the rest of the authentication. + * + * - Redirects to "/" if there is no matching `inflightPasskeySessionID` in + * session storage. + * + * - Redirects to "/credentials" otherwise. + * + * - "/two-factor/recover" and "/passkeys/recover" - Pages that allow the user + * to reset or bypass their second factor if they possess their recovery key. + * Both pages work similarly, except the second factor they act on. + * + * - Redirects to "/" if there is no `email` in the saved partial local user, + * or either of `twoFactorSessionID` and `twoFactorSessionID` is set. + * + * - Redirects to "/generate" if there is an `encryptedToken` or `token` in + * the saved partial local user (TODO: Why?). + * + * - Redirects to "/credentials" after recovery. + * */ const Page: React.FC = () => { const [loading, setLoading] = useState(true); diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index d6fcb0e2fb..caa3480661 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -4,7 +4,7 @@ import { AccountsPageFooter, AccountsPageTitle, } from "ente-accounts/components/layouts/centered-paper"; -import { getData } from "ente-accounts/services/accounts-db"; +import { savedPartialLocalUser } from "ente-accounts/services/accounts-db"; import { recoverTwoFactor, recoverTwoFactorFinish, @@ -64,14 +64,14 @@ const Page: React.FC = ({ twoFactorType }) => { ); useEffect(() => { - const user = getData("user"); - const sessionID = user.passkeySessionID || user.twoFactorSessionID; + const user = savedPartialLocalUser(); + const sessionID = + twoFactorType == "passkey" + ? user?.passkeySessionID + : user?.twoFactorSessionID; if (!user?.email || !sessionID) { void router.push("/"); - } else if ( - !user.isTwoFactorEnabled && - (user.encryptedToken || user.token) - ) { + } else if (user.encryptedToken || user.token) { void router.push("/generate"); } else { setSessionID(sessionID); diff --git a/web/packages/accounts/pages/two-factor/verify.tsx b/web/packages/accounts/pages/two-factor/verify.tsx index 8c32a92b3e..918fba2486 100644 --- a/web/packages/accounts/pages/two-factor/verify.tsx +++ b/web/packages/accounts/pages/two-factor/verify.tsx @@ -1,17 +1,17 @@ import { Verify2FACodeForm } from "ente-accounts/components/Verify2FACodeForm"; import { getData, + savedPartialLocalUser, saveKeyAttributes, setLSUser, } from "ente-accounts/services/accounts-db"; -import type { PartialLocalUser } from "ente-accounts/services/user"; import { verifyTwoFactor } from "ente-accounts/services/user"; import { LinkButton } from "ente-base/components/LinkButton"; import { useBaseContext } from "ente-base/context"; import { isHTTPErrorWithStatus } from "ente-base/http"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { AccountsPageContents, AccountsPageFooter, @@ -19,15 +19,18 @@ import { } from "../../components/layouts/centered-paper"; import { unstashRedirect } from "../../services/redirect"; +/** + * A page that allows the user to verify their TOTP based second factor. + */ const Page: React.FC = () => { const { logout } = useBaseContext(); - const [sessionID, setSessionID] = useState(""); + const [twoFactorSessionID, setTwoFactorSessionID] = useState(""); const router = useRouter(); useEffect(() => { - const user: PartialLocalUser = getData("user"); + const user = savedPartialLocalUser(); if (!user?.email || !user.twoFactorSessionID) { void router.push("/"); } else if ( @@ -36,37 +39,38 @@ const Page: React.FC = () => { ) { void router.push("/credentials"); } else { - setSessionID(user.twoFactorSessionID); + setTwoFactorSessionID(user.twoFactorSessionID); } }, [router]); - const handleSubmit = async (otp: string) => { - try { - const { keyAttributes, encryptedToken, id } = await verifyTwoFactor( - otp, - sessionID, - ); - await setLSUser({ - ...getData("user"), - id, - // TODO: [Note: empty token?] - // - // The original code was parsing an token which is never going - // to be present in the response, so effectively was always - // setting token to undefined. So this works, but is it needed? - token: undefined, - encryptedToken, - }); - saveKeyAttributes(keyAttributes); - await router.push(unstashRedirect() ?? "/credentials"); - } catch (e) { - if (isHTTPErrorWithStatus(e, 404)) { - logout(); - } else { - throw e; + const handleSubmit = useCallback( + async (otp: string) => { + try { + const { keyAttributes, encryptedToken, id } = + await verifyTwoFactor(otp, twoFactorSessionID); + await setLSUser({ + ...getData("user"), + id, + // TODO: [Note: empty token?] + // + // The original code was parsing an token which is never going + // to be present in the response, so effectively was always + // setting token to undefined. So this works, but is it needed? + token: undefined, + encryptedToken, + }); + saveKeyAttributes(keyAttributes); + await router.push(unstashRedirect() ?? "/credentials"); + } catch (e) { + if (isHTTPErrorWithStatus(e, 404)) { + logout(); + } else { + throw e; + } } - } - }; + }, + [logout, router, twoFactorSessionID], + ); return ( diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index a711248d4e..9eac4d569d 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -8,7 +8,6 @@ import { VerifyingPasskey } from "ente-accounts/components/LoginComponents"; import { SecondFactorChoice } from "ente-accounts/components/SecondFactorChoice"; import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/second-factor-choice"; import { - getData, savedKeyAttributes, savedOriginalKeyAttributes, savedPartialLocalUser, @@ -19,6 +18,7 @@ import { setLSUser, unstashAfterUseSRPSetupAttributes, unstashReferralSource, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { openPasskeyVerificationURL, @@ -101,12 +101,7 @@ const Page: React.FC = () => { await verifyEmail(email, ott, cleanedReferral), ); if (passkeySessionID) { - const user = getData("user"); - await setLSUser({ - ...user, - passkeySessionID, - isTwoFactorEnabled: true, - }); + updateSavedLocalUser({ passkeySessionID }); saveIsFirstLogin(); const url = passkeyVerificationRedirectURL( accountsUrl!, @@ -115,10 +110,9 @@ const Page: React.FC = () => { setPasskeyVerificationData({ passkeySessionID, url }); openPasskeyVerificationURL({ passkeySessionID, url }); } else if (twoFactorSessionID) { - await setLSUser({ - email, - twoFactorSessionID, + updateSavedLocalUser({ isTwoFactorEnabled: true, + twoFactorSessionID, }); saveIsFirstLogin(); void router.push("/two-factor/verify"); @@ -128,7 +122,9 @@ const Page: React.FC = () => { token, encryptedToken, id, - isTwoFactorEnabled: false, + isTwoFactorEnabled: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, }); if (keyAttributes) { saveKeyAttributes(keyAttributes); diff --git a/web/packages/accounts/services/accounts-db.ts b/web/packages/accounts/services/accounts-db.ts index e3411d9767..98c4577058 100644 --- a/web/packages/accounts/services/accounts-db.ts +++ b/web/packages/accounts/services/accounts-db.ts @@ -58,6 +58,9 @@ import { * also get filled in. Once they verify their TOTP based second factor, their * {@link id} and {@link encryptedToken} will also get filled in. * + * - If they have a passkey set as a second factor set, then after verifying + * their password the {@link passkeySessionID} will be set. + * * - As the login or signup sequence completes, a {@link token} obtained from * the {@link encryptedToken} will be written out, and the * {@link encryptedToken} cleared since it is not needed anymore. @@ -105,6 +108,7 @@ const LocalUser = z.object({ id: z.number(), email: z.string(), token: z.string(), + isTwoFactorEnabled: z.boolean().nullish().transform(nullToUndefined), }); /** @@ -127,13 +131,33 @@ export const savedPartialLocalUser = (): PartialLocalUser | undefined => { * * See: [Note: Partial local user]. * + * This method replaces the existing data. Use {@link updatePartialLocalUser} to + * update selected fields while keeping the other fields as it is. + * * TODO: WARNING: This does not update the KV token. The idea is to gradually * move over uses of setLSUser to this while explicitly setting the KV token * where needed. */ -export const savePartialLocalUser = (partialLocalUser: Partial) => +export const savePartialLocalUser = (partialLocalUser: PartialLocalUser) => localStorage.setItem("user", JSON.stringify(partialLocalUser)); +/** + * Partially update the saved user data. + * + * This is a delta variant of {@link savePartialLocalUser}, which replaces the + * entire saved object, while this function spreads the provided {@link updates} + * onto the currently saved value. + * + * @param updates A subset of {@link PartialLocalUser} fields that we'd like to + * update. The other fields, if any, remain unchanged. + * + * TODO: WARNING: This does not update the KV token. The idea is to gradually + * move over uses of setLSUser to this while explicitly setting the KV token + * where needed. + */ +export const updateSavedLocalUser = (updates: Partial) => + savePartialLocalUser({ ...savedPartialLocalUser(), ...updates }); + /** * Return data about the logged-in user, if someone is indeed logged in. * Otherwise return `undefined`. @@ -143,7 +167,8 @@ export const savePartialLocalUser = (partialLocalUser: Partial) => * not accessible to web workers. * * There is no setter corresponding to this function since this is only a view - * on data saved using {@link savePartialLocalUser}. + * on data saved using {@link savePartialLocalUser} or + * {@link updateSavedLocalUser}. * * See: [Note: Partial local user] for more about the whole shebang. */ diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index abfd6ea5ed..7fb7390c91 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -5,6 +5,7 @@ import { saveKeyAttributes, saveSRPAttributes, setLSUser, + updateSavedLocalUser, type PartialLocalUser, } from "ente-accounts/services/accounts-db"; import { @@ -70,6 +71,10 @@ export interface LocalUser { * in IndexedDB and thus can also be used in web workers. */ token: string; + /** + * `true` if the TOTP based second factor is enabled for the user. + */ + isTwoFactorEnabled?: boolean; } /** @@ -664,7 +669,7 @@ export const generateAndSaveInteractiveKeyAttributes = async ( */ export const changeEmail = async (email: string, ott: string) => { await postChangeEmail(email, ott); - await setLSUser({ ...getData("user"), email }); + updateSavedLocalUser({ email }); }; /** @@ -784,7 +789,7 @@ export const setupTwoFactorFinish = async ( encryptedTwoFactorSecret: box.encryptedData, twoFactorSecretDecryptionNonce: box.nonce, }); - await setLSUser({ ...getData("user"), isTwoFactorEnabled: true }); + updateSavedLocalUser({ isTwoFactorEnabled: true }); }; interface EnableTwoFactorRequest { @@ -956,7 +961,7 @@ export const recoverTwoFactorFinish = async ( await setLSUser({ ...getData("user"), id, - isTwoFactorEnabled: false, + isTwoFactorEnabled: undefined, encryptedToken, token: undefined, }); diff --git a/web/packages/new/photos/components/sidebar/TwoFactorSettings.tsx b/web/packages/new/photos/components/sidebar/TwoFactorSettings.tsx index badbe850b6..c20f378f7b 100644 --- a/web/packages/new/photos/components/sidebar/TwoFactorSettings.tsx +++ b/web/packages/new/photos/components/sidebar/TwoFactorSettings.tsx @@ -1,6 +1,9 @@ import LockIcon from "@mui/icons-material/Lock"; import { Stack, Typography } from "@mui/material"; -import { getData, setLSUser } from "ente-accounts/services/accounts-db"; +import { + savedPartialLocalUser, + updateSavedLocalUser, +} from "ente-accounts/services/accounts-db"; import { RowButton, RowButtonGroup, @@ -24,12 +27,9 @@ export const TwoFactorSettings: React.FC< const [isTwoFactorEnabled, setIsTwoFactorEnabled] = useState(false); useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const isTwoFactorEnabled = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - getData("user").isTwoFactorEnabled ?? false; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - setIsTwoFactorEnabled(isTwoFactorEnabled); + if (savedPartialLocalUser()?.isTwoFactorEnabled) { + setIsTwoFactorEnabled(true); + } }, []); useEffect(() => { @@ -37,11 +37,7 @@ export const TwoFactorSettings: React.FC< void (async () => { const isEnabled = await get2FAStatus(); setIsTwoFactorEnabled(isEnabled); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await setLSUser({ - ...getData("user"), - isTwoFactorEnabled: isEnabled, - }); + updateSavedLocalUser({ isTwoFactorEnabled: isEnabled }); })(); }, [open]); @@ -112,8 +108,7 @@ const ManageDrawerContents: React.FC = ({ onRootClose }) => { const disable = async () => { await disable2FA(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await setLSUser({ ...getData("user"), isTwoFactorEnabled: false }); + updateSavedLocalUser({ isTwoFactorEnabled: undefined }); onRootClose(); }; diff --git a/web/packages/new/photos/services/user-details.ts b/web/packages/new/photos/services/user-details.ts index b7805e1633..596112b8a1 100644 --- a/web/packages/new/photos/services/user-details.ts +++ b/web/packages/new/photos/services/user-details.ts @@ -1,4 +1,4 @@ -import { getData, setLSUser } from "ente-accounts/services/accounts-db"; +import { updateSavedLocalUser } from "ente-accounts/services/accounts-db"; import { ensureLocalUser } from "ente-accounts/services/user"; import { isDesktop } from "ente-base/app"; import { authenticatedRequestHeaders, ensureOk } from "ente-base/http"; @@ -241,10 +241,10 @@ export const pullUserDetails = async () => { // Update the email for the local storage user if needed (the user might've // changed their email on a different client). - if (ensureLocalUser().email != userDetails.email) { + const { email } = userDetails; + if (ensureLocalUser().email != email) { log.info("Updating user email to match fetched user details"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await setLSUser({ ...getData("user"), email: userDetails.email }); + updateSavedLocalUser({ email }); } // The gallery listens for updates to userDetails, so a special case, do a