diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index baa3f357e9..5458061407 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -4,9 +4,9 @@ import { CssBaseline, Typography } from "@mui/material"; import { styled, ThemeProvider } from "@mui/material/styles"; import { useNotification } from "components/utils/hooks-app"; import { - getData, isLocalStorageAndIndexedDBMismatch, savedLocalUser, + savedPartialLocalUser, } from "ente-accounts/services/accounts-db"; import { isDesktop, staticAppTitle } from "ente-base/app"; import { CenteredRow } from "ente-base/components/containers"; @@ -127,11 +127,15 @@ const App: React.FC = ({ Component, pageProps }) => { useEffect(() => { const query = new URLSearchParams(window.location.search); const needsFamilyRedirect = query.get("redirect") == "families"; - if (needsFamilyRedirect && getData("user")?.token) + if (needsFamilyRedirect && savedPartialLocalUser()?.token) redirectToFamilyPortal(); - router.events.on("routeChangeStart", () => { - if (needsFamilyRedirect && getData("user")?.token) { + router.events.on("routeChangeStart", (url) => { + if (process.env.NEXT_PUBLIC_ENTE_TRACE_RT) { + log.debug(() => ["route", url]); + } + + if (needsFamilyRedirect && savedPartialLocalUser()?.token) { redirectToFamilyPortal(); // https://github.com/vercel/next.js/issues/2476#issuecomment-573460710 diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 46579debcf..f29869493a 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -17,10 +17,10 @@ import { sessionExpiredDialogAttributes } from "ente-accounts/components/utils/d import { getAndClearIsFirstLogin, getAndClearJustSignedUp, - getData, } from "ente-accounts/services/accounts-db"; import { stashRedirect } from "ente-accounts/services/redirect"; import { isSessionInvalid } from "ente-accounts/services/session"; +import { ensureLocalUser } from "ente-accounts/services/user"; import type { MiniDialogAttributes } from "ente-base/components/MiniDialog"; import { NavbarBase } from "ente-base/components/Navbar"; import { SingleInputDialog } from "ente-base/components/SingleInputDialog"; @@ -35,10 +35,10 @@ import { useBaseContext } from "ente-base/context"; import log from "ente-base/log"; import { clearSessionStorage, - haveCredentialsInSession, + haveMasterKeyInSession, masterKeyFromSession, } from "ente-base/session"; -import { getAuthToken } from "ente-base/token"; +import { savedAuthToken } from "ente-base/token"; import { FullScreenDropZone } from "ente-gallery/components/FullScreenDropZone"; import { type UploadTypeSelectorIntent } from "ente-gallery/components/Upload"; import { type Collection } from "ente-media/collection"; @@ -77,7 +77,10 @@ import { } from "ente-new/photos/components/gallery/reducer"; import { notifyOthersFilesDialogAttributes } from "ente-new/photos/components/utils/dialog-attributes"; import { useIsOffline } from "ente-new/photos/components/utils/use-is-offline"; -import { usePeopleStateSnapshot } from "ente-new/photos/components/utils/use-snapshot"; +import { + usePeopleStateSnapshot, + useUserDetailsSnapshot, +} from "ente-new/photos/components/utils/use-snapshot"; import { shouldShowWhatsNew } from "ente-new/photos/services/changelog"; import { addToFavoritesCollection, @@ -108,9 +111,8 @@ import { import type { SearchOption } from "ente-new/photos/services/search/types"; import { initSettings } from "ente-new/photos/services/settings"; import { - initUserDetailsOrTriggerPull, redirectToCustomerPortal, - userDetailsSnapshot, + savedUserDetailsOrTriggerPull, verifyStripeSubscription, } from "ente-new/photos/services/user-details"; import { usePhotosAppContext } from "ente-new/photos/types/context"; @@ -182,6 +184,7 @@ const Page: React.FC = () => { EnteFile[] >([]); + const userDetails = useUserDetailsSnapshot(); const peopleState = usePeopleStateSnapshot(); // The (non-sticky) header shown at the top of the gallery items. @@ -279,7 +282,7 @@ const Page: React.FC = () => { let syncIntervalID: ReturnType | undefined; void (async () => { - if (!haveCredentialsInSession() || !(await getAuthToken())) { + if (!haveMasterKeyInSession() || !(await savedAuthToken())) { // If we don't have master key or auth token, reauthenticate. stashRedirect("/gallery"); router.push("/"); @@ -301,7 +304,6 @@ const Page: React.FC = () => { // One time inits. preloadImage("/images/subscription-card-background"); initSettings(); - await initUserDetailsOrTriggerPull(); setupSelectAllKeyBoardShortcutHandler(); // Show the initial state while the rest of the sequence proceeds. @@ -318,13 +320,12 @@ const Page: React.FC = () => { } // Initialize the reducer. - const user = getData("user"); - // TODO: Pass entire snapshot to reducer? - const familyData = userDetailsSnapshot()?.familyData; + const user = ensureLocalUser(); + const userDetails = await savedUserDetailsOrTriggerPull(); dispatch({ type: "mount", user, - familyData, + familyData: userDetails?.familyData, collections: await savedCollections(), collectionFiles: await savedCollectionFiles(), trashItems: await savedTrashItems(), @@ -354,6 +355,13 @@ const Page: React.FC = () => { }; }, []); + useEffect(() => { + // Only act on updates after the initial mount has completed. + if (state.user && userDetails) { + dispatch({ type: "setUserDetails", userDetails }); + } + }, [state.user, userDetails]); + useEffect(() => { if (typeof activeCollectionID == "undefined" || !router.isReady) { return; @@ -368,7 +376,7 @@ const Page: React.FC = () => { }, [activeCollectionID, router.isReady]); useEffect(() => { - if (router.isReady && haveCredentialsInSession()) { + if (router.isReady && haveMasterKeyInSession()) { handleSubscriptionCompletionRedirectIfNeeded( showMiniDialog, showLoadingBar, diff --git a/web/apps/photos/src/pages/index.tsx b/web/apps/photos/src/pages/index.tsx index 291314daa5..d452129a18 100644 --- a/web/apps/photos/src/pages/index.tsx +++ b/web/apps/photos/src/pages/index.tsx @@ -1,7 +1,7 @@ import { Box, Stack, Typography, styled } from "@mui/material"; import { LoginContents } from "ente-accounts/components/LoginContents"; import { SignUpContents } from "ente-accounts/components/SignUpContents"; -import { getData } from "ente-accounts/services/accounts-db"; +import { savedPartialLocalUser } from "ente-accounts/services/accounts-db"; import { CenteredFill, CenteredRow } from "ente-base/components/containers"; import { EnteLogo } from "ente-base/components/EnteLogo"; import { ActivityIndicator } from "ente-base/components/mui/ActivityIndicator"; @@ -9,9 +9,10 @@ import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton" import { useBaseContext } from "ente-base/context"; import { albumsAppOrigin, customAPIHost } from "ente-base/origins"; import { - haveAuthenticatedSession, + masterKeyFromSession, updateSessionFromElectronSafeStorageIfNeeded, } from "ente-base/session"; +import { savedAuthToken } from "ente-base/token"; import { canAccessIndexedDB } from "ente-gallery/services/files-db"; import { DevSettings } from "ente-new/photos/components/DevSettings"; import { t } from "i18next"; @@ -34,53 +35,47 @@ const Page: React.FC = () => { ); useEffect(() => { - refreshHost(); - const currentURL = new URL(window.location.href); - const albumsURL = new URL(albumsAppOrigin()); - currentURL.pathname = router.pathname; - if ( - currentURL.host === albumsURL.host && - currentURL.pathname != "/shared-albums" - ) { - handleAlbumsRedirect(currentURL); - } else { - handleNormalRedirect(); - } - }, [refreshHost]); - - const handleAlbumsRedirect = async (currentURL: URL) => { - const end = currentURL.hash.lastIndexOf("&"); - const hash = currentURL.hash.slice(1, end !== -1 ? end : undefined); - await router.replace({ - pathname: "/shared-albums", - search: currentURL.search, - hash: hash, - }); - await ensureIndexedDBAccess(); - }; - - const handleNormalRedirect = async () => { - const user = getData("user"); - await updateSessionFromElectronSafeStorageIfNeeded(); - if (await haveAuthenticatedSession()) { - await router.push("/gallery"); - } else if (user?.email) { - await router.push("/verify"); - } - await ensureIndexedDBAccess(); - }; - - const ensureIndexedDBAccess = useCallback(async () => { - if (!(await canAccessIndexedDB())) { - showMiniDialog({ - title: t("error"), - message: t("local_storage_not_accessible"), - nonClosable: true, - cancel: false, - }); - } - setLoading(false); - }, [showMiniDialog]); + void (async () => { + refreshHost(); + const currentURL = new URL(window.location.href); + const albumsURL = new URL(albumsAppOrigin()); + currentURL.pathname = router.pathname; + if ( + currentURL.host == albumsURL.host && + currentURL.pathname != "/shared-albums" + ) { + const end = currentURL.hash.lastIndexOf("&"); + const hash = currentURL.hash.slice( + 1, + end !== -1 ? end : undefined, + ); + await router.replace({ + pathname: "/shared-albums", + search: currentURL.search, + hash: hash, + }); + } else { + await updateSessionFromElectronSafeStorageIfNeeded(); + if ( + (await masterKeyFromSession()) && + (await savedAuthToken()) + ) { + await router.push("/gallery"); + } else if (savedPartialLocalUser()?.email) { + await router.push("/verify"); + } + } + if (!(await canAccessIndexedDB())) { + showMiniDialog({ + title: t("error"), + message: t("local_storage_not_accessible"), + nonClosable: true, + cancel: false, + }); + } + setLoading(false); + })(); + }, [showMiniDialog, router, refreshHost]); return ( diff --git a/web/apps/photos/src/services/upload-manager.ts b/web/apps/photos/src/services/upload-manager.ts index 95087cdde5..9c351f8df7 100644 --- a/web/apps/photos/src/services/upload-manager.ts +++ b/web/apps/photos/src/services/upload-manager.ts @@ -1,3 +1,4 @@ +import { ensureLocalUser } from "ente-accounts/services/user"; import { isDesktop } from "ente-base/app"; import { createComlinkCryptoWorker } from "ente-base/crypto"; import { type CryptoWorker } from "ente-base/crypto/worker"; @@ -41,7 +42,6 @@ import { computeNormalCollectionFilesFromSaved } from "ente-new/photos/services/ import { indexNewUpload } from "ente-new/photos/services/ml"; import { wait } from "ente-utils/promise"; import watcher from "services/watch"; -import { getUserOwnedFiles } from "utils/file"; export type FileID = number; @@ -443,9 +443,9 @@ class UploadManager { this.publicAlbumsCredentials.accessToken, ); } else { - this.existingFiles = getUserOwnedFiles( - await computeNormalCollectionFilesFromSaved(), - ); + const files = await computeNormalCollectionFilesFromSaved(); + const userID = ensureLocalUser().id; + this.existingFiles = files.filter((file) => file.ownerID == userID); } this.collections = new Map( collections.map((collection) => [collection.id, collection]), diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 03ccbc90a0..5558efaff0 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -1,5 +1,4 @@ -import { getData } from "ente-accounts/services/accounts-db"; -import type { LocalUser, PartialLocalUser } from "ente-accounts/services/user"; +import type { LocalUser } from "ente-accounts/services/user"; import { joinPath } from "ente-base/file-name"; import log from "ente-base/log"; import { type Electron } from "ente-base/types/ipc"; @@ -8,11 +7,7 @@ import { downloadManager } from "ente-gallery/services/download"; import { detectFileTypeInfo } from "ente-gallery/utils/detect-type"; import { writeStream } from "ente-gallery/utils/native-stream"; import { EnteFile } from "ente-media/file"; -import { - ItemVisibility, - fileFileName, - isArchivedFile, -} from "ente-media/file-metadata"; +import { ItemVisibility, fileFileName } from "ente-media/file-metadata"; import { FileType } from "ente-media/file-type"; import { decodeLivePhoto } from "ente-media/live-photo"; import { type FileOp } from "ente-new/photos/components/SelectedFileOptions"; @@ -279,18 +274,6 @@ async function downloadFileDesktop( } } -export const getArchivedFiles = (files: EnteFile[]) => { - return files.filter(isArchivedFile).map((file) => file.id); -}; - -export const getUserOwnedFiles = (files: EnteFile[]) => { - const user: PartialLocalUser = getData("user"); - if (!user?.id) { - throw Error("user missing"); - } - return files.filter((file) => file.ownerID === user.id); -}; - export const shouldShowAvatar = ( file: EnteFile, user: LocalUser | undefined, diff --git a/web/packages/accounts/components/LoginContents.tsx b/web/packages/accounts/components/LoginContents.tsx index f0677daa52..8b4b471d08 100644 --- a/web/packages/accounts/components/LoginContents.tsx +++ b/web/packages/accounts/components/LoginContents.tsx @@ -1,7 +1,7 @@ import { Input, Stack, TextField, Typography } from "@mui/material"; import { AccountsPageFooter } from "ente-accounts/components/layouts/centered-paper"; import { - savePartialLocalUser, + replaceSavedLocalUser, saveSRPAttributes, } from "ente-accounts/services/accounts-db"; import { getSRPAttributes } from "ente-accounts/services/srp"; @@ -18,16 +18,21 @@ import { z } from "zod/v4"; import { AccountsPageTitleWithCaption } from "./LoginComponents"; interface LoginContentsProps { - /** Called when the user clicks the signup option instead. */ - onSignUp: () => void; - /** Reactive value of {@link customAPIHost}. */ + /** + * Reactive value of {@link customAPIHost}. + */ host: string | undefined; + /** + * Called when the user clicks the signup option instead. + */ + onSignUp: () => void; } /** - * Contents of the "login form", maintained as a separate component so that the - * same code can be used both in the standalone /login page, and also within the - * embedded login form shown on the photos index page. + * A contents of the "login" form. + * + * It is used both on the "/login" page, and as the embedded login form on the + * "/" page where the user can toggle between the signup and login forms inline. */ export const LoginContents: React.FC = ({ onSignUp, @@ -50,10 +55,10 @@ export const LoginContents: React.FC = ({ } throw e; } - savePartialLocalUser({ email }); + replaceSavedLocalUser({ email }); void router.push("/verify"); } else { - savePartialLocalUser({ email }); + replaceSavedLocalUser({ email }); saveSRPAttributes(srpAttributes); void router.push("/credentials"); } diff --git a/web/packages/accounts/components/SignUpContents.tsx b/web/packages/accounts/components/SignUpContents.tsx index 3a04d6e7e2..b4c3372f06 100644 --- a/web/packages/accounts/components/SignUpContents.tsx +++ b/web/packages/accounts/components/SignUpContents.tsx @@ -14,9 +14,9 @@ import { Typography, } from "@mui/material"; import { + replaceSavedLocalUser, saveJustSignedUp, saveOriginalKeyAttributes, - savePartialLocalUser, stashReferralSource, stashSRPSetupAttributes, } from "ente-accounts/services/accounts-db"; @@ -55,6 +55,12 @@ interface SignUpContentsProps { host: string | undefined; } +/** + * A contents of the "signup" form. + * + * It is used both on the "/signup" page itself, and as a subcomponent of the + * "/" page where the user can toggle between the signup and login forms inline. + */ export const SignUpContents: React.FC = ({ router, onLogin, @@ -124,7 +130,7 @@ export const SignUpContents: React.FC = ({ throw e; } - savePartialLocalUser({ email }); + replaceSavedLocalUser({ email }); let gkResult: GenerateKeysAndAttributesResult; try { diff --git a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx index c0eaf39b00..c974886508 100644 --- a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx +++ b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx @@ -11,15 +11,33 @@ import log from "ente-base/log"; import { useFormik } from "formik"; import { t } from "i18next"; import { useCallback, useState } from "react"; -import { twoFactorEnabledErrorMessage } from "./utils/second-factor-choice"; export interface VerifyMasterPasswordFormProps { /** * The email of the user whose password we're trying to verify. */ userEmail: string; + /** + * The user's SRP attributes. + * + * The SRP attributes are used to derive the KEK from the user's password. + * If they are not present, the {@link keyAttributes} will be used instead. + * + * At least one of {@link srpAttributes} and {@link keyAttributes} must be + * present, otherwise the verification will fail. + */ + srpAttributes?: SRPAttributes; /** * The user's key attributes. + * + * If they are present, they are used to derive the KEK from the user's + * password when {@link srpAttributes} are not present. This is the case + * when the user has already logged in (or signed up) on this client before, + * and is now doing a reauthentication. + * + * If they are not present, then {@link getKeyAttributes} must be present + * and will be used to obtain the user's key attributes. This is the case + * when the user is logging into a new client. */ keyAttributes: KeyAttributes | undefined; /** @@ -30,20 +48,19 @@ export interface VerifyMasterPasswordFormProps { * used for reauthenticating the user after they've already logged in, then * this function will not be provided. * - * @throws A Error with message {@link twoFactorEnabledErrorMessage} to - * signal to the form that some other form of second factor is enabled and - * the user has been redirected to a two factor verification page. + * @returns The user's key attributes obtained from remote, or + * "redirecting-second-factor" if the user has an additional second factor + * verification required and the app is redirecting there. * * @throws A Error with message * {@link srpVerificationUnauthorizedErrorMessage} to signal that either * that the password is incorrect, or no account with the provided email * exists. */ - getKeyAttributes?: (kek: string) => Promise; - /** - * The user's SRP attributes. - */ - srpAttributes?: SRPAttributes; + getKeyAttributes?: ( + srpAttributes: SRPAttributes, + kek: string, + ) => Promise; /** * The title of the submit button on the form. */ @@ -152,24 +169,24 @@ export const VerifyMasterPasswordForm: React.FC< } } else throw new Error("Both SRP and key attributes are missing"); - if (!keyAttributes && typeof getKeyAttributes == "function") { + if (!keyAttributes && getKeyAttributes && srpAttributes) { try { - keyAttributes = await getKeyAttributes(kek); + const result = await getKeyAttributes(srpAttributes, kek); + if (result == "redirecting-second-factor") { + // Two factor enabled, user has been redirected to the + // corresponding second factor verification page. + return; + } else { + keyAttributes = result; + } } catch (e) { - if (e instanceof Error) { - switch (e.message) { - case twoFactorEnabledErrorMessage: - // Two factor enabled, user has been redirected to - // the two-factor verification page. - return; - - case srpVerificationUnauthorizedErrorMessage: - log.error("Incorrect password or no account", e); - setFieldError( - t("incorrect_password_or_no_account"), - ); - return; - } + if ( + e instanceof Error && + e.message == srpVerificationUnauthorizedErrorMessage + ) { + log.error("Incorrect password or no account", e); + setFieldError(t("incorrect_password_or_no_account")); + return; } throw e; } diff --git a/web/packages/accounts/components/utils/second-factor-choice.ts b/web/packages/accounts/components/utils/second-factor-choice.ts index e2e2ca19a3..1557255ae3 100644 --- a/web/packages/accounts/components/utils/second-factor-choice.ts +++ b/web/packages/accounts/components/utils/second-factor-choice.ts @@ -8,15 +8,6 @@ import { useModalVisibility } from "ente-base/components/utils/modal"; import { useCallback, useMemo, useRef } from "react"; import type { SecondFactorType } from "../SecondFactorChoice"; -/** - * The message of the {@link Error} that is thrown when the user has enabled a - * second factor so further authentication is needed during the login sequence. - * - * TODO: This is not really an error but rather is a code flow flag; consider - * not using exceptions for flow control. - */ -export const twoFactorEnabledErrorMessage = "two factor enabled"; - /** * 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 diff --git a/web/packages/accounts/components/utils/use-redirect.ts b/web/packages/accounts/components/utils/use-redirect.ts index a96e79b750..de34afc717 100644 --- a/web/packages/accounts/components/utils/use-redirect.ts +++ b/web/packages/accounts/components/utils/use-redirect.ts @@ -1,4 +1,4 @@ -import { haveCredentialsInSession } from "ente-base/session"; +import { haveMasterKeyInSession } from "ente-base/session"; import { useRouter } from "next/router"; import { useEffect } from "react"; import { stashRedirect } from "../../services/redirect"; @@ -16,7 +16,7 @@ export const useRedirectIfNeedsCredentials = (currentPageSlug: string) => { const router = useRouter(); useEffect(() => { - if (!haveCredentialsInSession()) { + if (!haveMasterKeyInSession()) { stashRedirect(currentPageSlug); void router.push("/"); } diff --git a/web/packages/accounts/pages/change-password.tsx b/web/packages/accounts/pages/change-password.tsx index c05ed5f527..88b67b42a9 100644 --- a/web/packages/accounts/pages/change-password.tsx +++ b/web/packages/accounts/pages/change-password.tsx @@ -38,7 +38,7 @@ const Page: React.FC = () => { setUser(user); } else { stashRedirect("/change-password"); - void router.push("/"); + void router.replace("/"); } }, [router]); diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index f28bfceaaf..6f5b710261 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -6,17 +6,12 @@ import { } from "ente-accounts/components/LoginComponents"; import { SecondFactorChoice } from "ente-accounts/components/SecondFactorChoice"; import { sessionExpiredDialogAttributes } from "ente-accounts/components/utils/dialog"; -import { - twoFactorEnabledErrorMessage, - useSecondFactorChoiceIfNeeded, -} from "ente-accounts/components/utils/second-factor-choice"; +import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/second-factor-choice"; import { VerifyMasterPasswordForm, type VerifyMasterPasswordFormProps, } from "ente-accounts/components/VerifyMasterPasswordForm"; import { - getData, - getToken, savedIsFirstLogin, savedKeyAttributes, savedPartialLocalUser, @@ -24,7 +19,7 @@ import { saveIsFirstLogin, saveKeyAttributes, saveSRPAttributes, - setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { openPasskeyVerificationURL, @@ -44,24 +39,25 @@ import { verifySRP, } from "ente-accounts/services/srp"; import { + decryptAndStoreTokenIfNeeded, generateAndSaveInteractiveKeyAttributes, type KeyAttributes, - type PartialLocalUser, } from "ente-accounts/services/user"; -import { decryptAndStoreToken } from "ente-accounts/utils/helpers"; import { LinkButton } from "ente-base/components/LinkButton"; import { LoadingIndicator } from "ente-base/components/loaders"; import { useBaseContext } from "ente-base/context"; import { decryptBox } from "ente-base/crypto"; +import { isDevBuild } from "ente-base/env"; import { clearLocalStorage } from "ente-base/local-storage"; import log from "ente-base/log"; import { - haveAuthenticatedSession, + masterKeyFromSession, saveMasterKeyInSessionAndSafeStore, stashKeyEncryptionKeyInSessionStore, unstashKeyEncryptionKeyFromSession, updateSessionFromElectronSafeStorageIfNeeded, } from "ente-base/session"; +import { saveAuthToken, savedAuthToken } from "ente-base/token"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useCallback, useEffect, useState } from "react"; @@ -76,19 +72,25 @@ import { useCallback, useEffect, useState } from "react"; * - Subsequent reauthentication, when the user opens the web app in a new tab. * Such a tab won't have the user's master key in session storage, so we ask * the user to reauthenticate using their password. + * + * See: [Note: Login pages] */ const Page: React.FC = () => { const { logout, showMiniDialog } = useBaseContext(); - const [user, setUser] = useState(undefined); - const [keyAttributes, setKeyAttributes] = useState(); - const [srpAttributes, setSrpAttributes] = useState(); + const [userEmail, setUserEmail] = useState(""); + const [keyAttributes, setKeyAttributes] = useState< + KeyAttributes | undefined + >(undefined); + const [srpAttributes, setSRPAttributes] = useState< + SRPAttributes | undefined + >(undefined); const [passkeyVerificationData, setPasskeyVerificationData] = useState< { passkeySessionID: string; url: string } | undefined - >(); + >(undefined); const [sessionValidityCheck, setSessionValidityCheck] = useState< Promise | undefined - >(); + >(undefined); const { secondFactorChoiceProps, @@ -127,28 +129,56 @@ const Page: React.FC = () => { } }, [logout, showMiniDialog]); + const postVerification = useCallback( + async ( + userEmail: string, + masterKey: string, + kek: string, + keyAttributes: KeyAttributes, + ) => { + await saveMasterKeyInSessionAndSafeStore(masterKey); + await decryptAndStoreTokenIfNeeded(keyAttributes, masterKey); + try { + let srpAttributes = savedSRPAttributes(); + if (!srpAttributes) { + srpAttributes = await getSRPAttributes(userEmail); + if (srpAttributes) { + saveSRPAttributes(srpAttributes); + } else { + await setupSRP(await generateSRPSetupAttributes(kek)); + } + } + } catch (e) { + log.error("SRP migration failed", e); + } + void router.push(unstashRedirect() ?? appHomeRoute); + }, + [router], + ); + useEffect(() => { - const main = async () => { + void (async () => { const user = savedPartialLocalUser(); - if (!user?.email) { - void router.push("/"); + const userEmail = user?.email; + if (!userEmail) { + await router.replace("/"); return; } - setUser(user); await updateSessionFromElectronSafeStorageIfNeeded(); - if (await haveAuthenticatedSession()) { - void router.push(appHomeRoute); + if ((await masterKeyFromSession()) && (await savedAuthToken())) { + await router.replace(appHomeRoute); return; } + + setUserEmail(userEmail); + if (user.token) setSessionValidityCheck(validateSession()); + const kek = await unstashKeyEncryptionKeyFromSession(); const keyAttributes = savedKeyAttributes(); - const srpAttributes = savedSRPAttributes(); - - if (getToken()) { - setSessionValidityCheck(validateSession()); - } + // Refreshing an existing tab, or desktop app, or only the token + // needs to decrypted and set. if (kek && keyAttributes) { const masterKey = await decryptBox( { @@ -157,68 +187,65 @@ const Page: React.FC = () => { }, kek, ); - await postVerification(masterKey, kek, keyAttributes); + await postVerification( + userEmail, + masterKey, + kek, + keyAttributes, + ); return; } + // Reauthentication in a new tab on the web app. Use previously + // generated interactive key attributes to verify password. if (keyAttributes) { - if ( - (!user?.token && !user?.encryptedToken) || - (keyAttributes && !keyAttributes.memLimit) - ) { + if (!user?.token && !user?.encryptedToken) { + // TODO(RE): Why? For now, add a dev mode circuit breaker. + if (isDevBuild) throw new Error("Unexpected case reached"); clearLocalStorage(); - void router.push("/"); + void router.replace("/"); return; } setKeyAttributes(keyAttributes); return; } + // First login on a new client. `getKeyAttributes` from below will + // be used during password verification to generate interactive key + // attributes for subsequent reauthentications. + const srpAttributes = savedSRPAttributes(); if (srpAttributes) { - setSrpAttributes(srpAttributes); - } else { - void router.push("/"); + setSRPAttributes(srpAttributes); + return; } - }; - void main(); - // TODO: validateSession is a dependency, but add that only after we've - // wrapped items from the callback (like logout) in useCallback too. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + + void router.replace("/"); + })(); + }, [router, validateSession, postVerification]); const getKeyAttributes: VerifyMasterPasswordFormProps["getKeyAttributes"] = - async (kek: string) => { - try { - // Currently the page will get reloaded if any of the attributes - // have changed, so we don't need to worry about the KEK having - // been generated using stale credentials. This await on the - // promise is here to only ensure we're done with the check - // before we let the user in. - if (sessionValidityCheck) await sessionValidityCheck; - + useCallback( + async (srpAttributes: SRPAttributes, kek: string) => { const { - keyAttributes, - encryptedToken, - token, id, + keyAttributes, + token, + encryptedToken, twoFactorSessionID, passkeySessionID, accountsUrl, } = await userVerificationResultAfterResolvingSecondFactorChoice( - await verifySRP(srpAttributes!, kek), + await verifySRP(srpAttributes, kek), ); + + // If we had to ask remote for the key attributes, it is the + // initial login on this client. saveIsFirstLogin(); if (passkeySessionID) { await stashKeyEncryptionKeyInSessionStore(kek); - const user = getData("user"); - await setLSUser({ - ...user, - passkeySessionID, - isTwoFactorEnabled: true, - isTwoFactorPasskeysEnabled: true, - }); + updateSavedLocalUser({ passkeySessionID }); stashRedirect("/"); const url = passkeyVerificationRedirectURL( accountsUrl!, @@ -226,81 +253,64 @@ const Page: React.FC = () => { ); setPasskeyVerificationData({ passkeySessionID, url }); openPasskeyVerificationURL({ passkeySessionID, url }); - throw new Error(twoFactorEnabledErrorMessage); + return "redirecting-second-factor"; } 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); + return "redirecting-second-factor"; } else { - const user = getData("user"); - await setLSUser({ - ...user, + // In rare cases, if the user hasn't already setup their key + // attributes, we might get the plaintext token from remote. + if (token) await saveAuthToken(token); + updateSavedLocalUser({ + id, token, encryptedToken, - id, - isTwoFactorEnabled: false, + isTwoFactorEnabled: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, }); if (keyAttributes) saveKeyAttributes(keyAttributes); return keyAttributes; } - } catch (e) { - if ( - e instanceof Error && - e.message != twoFactorEnabledErrorMessage - ) { - log.error("getKeyAttributes failed", e); - } - throw e; - } - }; + }, + [userVerificationResultAfterResolvingSecondFactorChoice, router], + ); const handleVerifyMasterPassword: VerifyMasterPasswordFormProps["onVerify"] = - (key, kek, keyAttributes, password) => { - void (async () => { - const updatedKeyAttributes = savedIsFirstLogin() - ? await generateAndSaveInteractiveKeyAttributes( - password, - keyAttributes, - key, - ) - : keyAttributes; - await postVerification(key, kek, updatedKeyAttributes); - })(); - }; + useCallback( + (key, kek, keyAttributes, password) => { + void (async () => { + // Currently the page will get reloaded if any of the + // attributes have changed, so we don't need to worry about + // the KEK having been generated using stale credentials. + // + // This await on the promise is here to only ensure we're + // done with the check before we let the user in. + if (sessionValidityCheck) await sessionValidityCheck; - const postVerification = async ( - masterKey: string, - kek: string, - keyAttributes: KeyAttributes, - ) => { - await saveMasterKeyInSessionAndSafeStore(masterKey); - await decryptAndStoreToken(keyAttributes, masterKey); - try { - let srpAttributes = savedSRPAttributes(); - if (!srpAttributes && user?.email) { - srpAttributes = await getSRPAttributes(user.email); - if (srpAttributes) { - saveSRPAttributes(srpAttributes); - } - } - // TODO: todo? - log.debug(() => `userSRPSetupPending ${!srpAttributes}`); - if (!srpAttributes) { - await setupSRP(await generateSRPSetupAttributes(kek)); - } - } catch (e) { - log.error("migrate to srp failed", e); - } - void router.push(unstashRedirect() ?? appHomeRoute); - }; + const updatedKeyAttributes = savedIsFirstLogin() + ? await generateAndSaveInteractiveKeyAttributes( + password, + keyAttributes, + key, + ) + : keyAttributes; - const userEmail = user?.email; + await postVerification( + userEmail, + key, + kek, + updatedKeyAttributes, + ); + })(); + }, + [postVerification, userEmail, sessionValidityCheck], + ); if (!userEmail) { return ; @@ -326,7 +336,7 @@ const Page: React.FC = () => { return ( openPasskeyVerificationURL(passkeyVerificationData) @@ -336,8 +346,6 @@ const Page: React.FC = () => { ); } - // TODO: Handle the case when user is not present, or exclude that - // possibility using types. return ( diff --git a/web/packages/accounts/pages/generate.tsx b/web/packages/accounts/pages/generate.tsx index 92d3bb1900..d0b900dc45 100644 --- a/web/packages/accounts/pages/generate.tsx +++ b/web/packages/accounts/pages/generate.tsx @@ -16,7 +16,6 @@ import { generateSRPSetupAttributes, setupSRP, } from "ente-accounts/services/srp"; -import type { PartialLocalUser } from "ente-accounts/services/user"; import { generateAndSaveInteractiveKeyAttributes, generateKeysAndAttributes, @@ -28,12 +27,12 @@ import { useBaseContext } from "ente-base/context"; import { deriveKeyInsufficientMemoryErrorMessage } from "ente-base/crypto/types"; import log from "ente-base/log"; import { - haveCredentialsInSession, + haveMasterKeyInSession, saveMasterKeyInSessionAndSafeStore, } from "ente-base/session"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { NewPasswordForm, type NewPasswordFormProps, @@ -41,79 +40,76 @@ import { /** * A page that allows the user to generate key attributes if needed, and shows - * them their recovery key. + * them their recovery key if they just signed up. * * See: [Note: Login pages] */ const Page: React.FC = () => { const { logout, showMiniDialog } = useBaseContext(); - const [user, setUser] = useState(undefined); + const [userEmail, setUserEmail] = useState(""); const [openRecoveryKey, setOpenRecoveryKey] = useState(false); const router = useRouter(); useEffect(() => { const user = savedPartialLocalUser(); - if (!user?.token) { - void router.push("/"); - } else if (haveCredentialsInSession()) { + if (!user?.email || !user?.token) { + void router.replace("/"); + } else if (haveMasterKeyInSession()) { if (savedJustSignedUp()) { setOpenRecoveryKey(true); - setUser(user); } else { - void router.push(appHomeRoute); + void router.replace(appHomeRoute); } - } else if (savedOriginalKeyAttributes()?.encryptedKey) { - void router.push("/credentials"); + } else if (savedOriginalKeyAttributes()) { + void router.replace("/credentials"); } else { - setUser(user); + setUserEmail(user.email); } }, [router]); - const handleSubmit: NewPasswordFormProps["onSubmit"] = async ( - password, - setPasswordsFieldError, - ) => { - try { - const { masterKey, kek, keyAttributes } = - await generateKeysAndAttributes(password); - await putUserKeyAttributes(keyAttributes); - await setupSRP(await generateSRPSetupAttributes(kek)); - await generateAndSaveInteractiveKeyAttributes( - password, - keyAttributes, - masterKey, - ); - await saveMasterKeyInSessionAndSafeStore(masterKey); - saveJustSignedUp(); - setOpenRecoveryKey(true); - } catch (e) { - log.error("failed to generate password", e); - setPasswordsFieldError( - e instanceof Error && - e.message == deriveKeyInsufficientMemoryErrorMessage - ? t("password_generation_failed") - : t("generic_error"), - ); - } - }; + const handleSubmit: NewPasswordFormProps["onSubmit"] = useCallback( + async (password, setPasswordsFieldError) => { + try { + const { masterKey, kek, keyAttributes } = + await generateKeysAndAttributes(password); + await putUserKeyAttributes(keyAttributes); + await setupSRP(await generateSRPSetupAttributes(kek)); + await generateAndSaveInteractiveKeyAttributes( + password, + keyAttributes, + masterKey, + ); + await saveMasterKeyInSessionAndSafeStore(masterKey); + saveJustSignedUp(); + setOpenRecoveryKey(true); + } catch (e) { + log.error("Could not generate key attributes from password", e); + setPasswordsFieldError( + e instanceof Error && + e.message == deriveKeyInsufficientMemoryErrorMessage + ? t("password_generation_failed") + : t("generic_error"), + ); + } + }, + [], + ); return ( <> - {!user ? ( - - ) : openRecoveryKey ? ( + {openRecoveryKey ? ( void router.push(appHomeRoute)} showMiniDialog={showMiniDialog} /> - ) : ( + ) : userEmail ? ( {t("set_password")} @@ -122,6 +118,8 @@ const Page: React.FC = () => { {t("go_back")} + ) : ( + )} ); diff --git a/web/packages/accounts/pages/login.tsx b/web/packages/accounts/pages/login.tsx index bf754056ff..bfa311a03a 100644 --- a/web/packages/accounts/pages/login.tsx +++ b/web/packages/accounts/pages/login.tsx @@ -40,15 +40,20 @@ import React, { useCallback, useEffect, useState } from "react"; * - Redirects to "/" if there is no `email` present in the saved partial * local user. * - * - Redirects to "/credentials" if email verification is not needed, and also - * when email verification completes. + * - Redirects to "/credentials" if both saved key attributes and a `token` + * (or `encryptedToken`) in present in the saved partial local user, or if + * email verification is not needed, or when email verification completes + * and remote sent us key attributes (which will happen on login). * - * - Redirects to "/two-factor/verify" once email verification is complete if - * the user has setup an additional TOTP second factor that also needs to be - * verified. + * - Redirects to "/generate" once email verification is complete and the user + * does not have key attributes (which will happen for new signups). * - * - Redirects to the passkey app once email verification is complete if the - * user has setup an additional passkey that also needs to be verified. + * - Redirects to "/two-factor/verify" when email verification completes and + * the user has setup a TOTP second factor that also needs to be verified. + * + * - Redirects to the passkey app when email verification completes and the + * user has setup a passkey that also needs to be verified. Before + * redirecting, `inflightPasskeySessionID` in saved in session storage. * * - "/credentials" - A page that allows the user to enter their password to * authenticate (initial login) or reauthenticate (new web app tab) @@ -56,15 +61,30 @@ import React, { useCallback, useEffect, useState } from "react"; * - Redirects to "/" if there is no `email` present in the saved partial * local user. * + * - Redirects to "/two-factor/verify" if saved key attributes are not present + * once password is verified and the user has setup a TOTP second factor + * that also needs to be verified. + * + * - Redirects to the passkey app once password is verified if saved key + * attributes are not present if the user has setup a passkey that also + * needs to be verified. Before redirecting, `inflightPasskeySessionID` is + * saved in session storage. + * + * - Redirects to the `appHomeRoute` otherwise (e.g. /gallery). **The flow is + * complete**. + * * - "/generate" - A page that allows the user to generate key attributes if - * needed, and shows them their recovery key. + * needed, and shows them their recovery key if they just signed up. * - * - Redirects to "/" if there is no `email` present in the saved partial - * local user, or after viewing the recovery key, or after the user sets - * their password (if they did no have key attributes). + * - Redirects to "/" if there is no `email` or `token` present in the saved + * partial user. * - * - Redirects to "/credentials" if they already have the original key - * attributes. + * - Redirects to `appHomeRoute` after viewing the recovery key if they have a + * master key in session, or after setting the password and then viewing the + * recovery key (if they did not have a master key in session store). + * + * - Redirects to "/credentials" if if they don't have a master key in session + * store but have saved original key attributes. * * - "/recover" - A page that allows the user to recover their master key using * their recovery key. @@ -79,11 +99,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 `isTwoFactorEnabled` is not `true` and + * either of `encryptedToken` or `token` is present in the saved partial + * local user. + * + * - "/passkeys/finish" - A page where the accounts app hands off control back + * to us (the calling app) once the passkey has been verified. + * + * - 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 for the second factor they act on. + * + * - Redirect to "/" if there is no `email` or `twoFactorSessionID` / + * `passkeySessionID` in the saved partial local user. + * + * - Redirect to "/generate" if there is an `encryptedToken` or `token` in the + * saved partial local user. + * + * - Redirect to "/credentials" after recovery. + * */ const Page: React.FC = () => { const [loading, setLoading] = useState(true); @@ -93,7 +143,7 @@ const Page: React.FC = () => { useEffect(() => { void customAPIHost().then(setHost); - if (savedPartialLocalUser()?.email) void router.push("/verify"); + if (savedPartialLocalUser()?.email) void router.replace("/verify"); setLoading(false); }, [router]); diff --git a/web/packages/accounts/pages/passkeys/finish.tsx b/web/packages/accounts/pages/passkeys/finish.tsx index 1518a61b94..7a1f6ddcf8 100644 --- a/web/packages/accounts/pages/passkeys/finish.tsx +++ b/web/packages/accounts/pages/passkeys/finish.tsx @@ -1,10 +1,13 @@ import { - getData, saveKeyAttributes, - setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; +import { clearInflightPasskeySessionID } from "ente-accounts/services/passkey"; import { unstashRedirect } from "ente-accounts/services/redirect"; -import { TwoFactorAuthorizationResponse } from "ente-accounts/services/user"; +import { + resetSavedLocalUserTokens, + TwoFactorAuthorizationResponse, +} from "ente-accounts/services/user"; import { LoadingIndicator } from "ente-base/components/loaders"; import { fromB64URLSafeNoPadding } from "ente-base/crypto"; import log from "ente-base/log"; @@ -13,6 +16,11 @@ import { useRouter } from "next/router"; import React, { useEffect } from "react"; /** + * The page where the accounts app hands back control to us once the passkey has + * been verified. + * + * See: [Note: Login pages] + * * [Note: Finish passkey flow in the requesting app] * * The passkey finish step needs to happen in the context of the client which @@ -23,14 +31,14 @@ const Page: React.FC = () => { const router = useRouter(); useEffect(() => { - // Extract response from query params + // Extract response from query params. const searchParams = new URLSearchParams(window.location.search); const passkeySessionID = searchParams.get("passkeySessionID"); const response = searchParams.get("response"); if (!passkeySessionID || !response) return; void saveQueryCredentialsAndNavigateTo(passkeySessionID, response).then( - (slug) => router.push(slug), + (slug) => router.replace(slug), ); }, [router]); @@ -78,7 +86,7 @@ const saveQueryCredentialsAndNavigateTo = async ( return "/"; } - sessionStorage.removeItem("inflightPasskeySessionID"); + clearInflightPasskeySessionID(); // Decode response string (inverse of the steps we perform in // `passkeyAuthenticationSuccessRedirectURL`). @@ -90,10 +98,8 @@ const saveQueryCredentialsAndNavigateTo = async ( const { id, keyAttributes, encryptedToken } = decodedResponse; - // TODO: See: [Note: empty token?] - const token = undefined; - - await setLSUser({ ...getData("user"), token, encryptedToken, id }); + await resetSavedLocalUserTokens(id, encryptedToken); + updateSavedLocalUser({ passkeySessionID: undefined }); saveKeyAttributes(keyAttributes); return unstashRedirect() ?? "/credentials"; diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx index ac91d3c9fc..ac4b791e7c 100644 --- a/web/packages/accounts/pages/recover.tsx +++ b/web/packages/accounts/pages/recover.tsx @@ -10,8 +10,10 @@ import { import { recoveryKeyFromMnemonic } from "ente-accounts/services/recovery-key"; import { appHomeRoute, stashRedirect } from "ente-accounts/services/redirect"; import type { KeyAttributes } from "ente-accounts/services/user"; -import { sendOTT } from "ente-accounts/services/user"; -import { decryptAndStoreToken } from "ente-accounts/utils/helpers"; +import { + decryptAndStoreTokenIfNeeded, + sendOTT, +} from "ente-accounts/services/user"; import { LinkButton } from "ente-base/components/LinkButton"; import { SingleInputForm, @@ -21,12 +23,12 @@ import { useBaseContext } from "ente-base/context"; import { decryptBox } from "ente-base/crypto"; import log from "ente-base/log"; import { - haveCredentialsInSession, + haveMasterKeyInSession, saveMasterKeyInSessionAndSafeStore, } from "ente-base/session"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; /** * A page that allows the user to enter their recovery key to recover their @@ -44,58 +46,63 @@ const Page: React.FC = () => { const router = useRouter(); useEffect(() => { - const user = savedPartialLocalUser(); - if (!user?.email) { - void router.push("/"); - return; - } - if (!user?.encryptedToken && !user?.token) { - void sendOTT(user.email, undefined); - stashRedirect("/recover"); - void router.push("/verify"); - return; - } + void (async () => { + const user = savedPartialLocalUser(); + if (!user?.email) { + await router.replace("/"); + return; + } - const keyAttributes = savedKeyAttributes(); - if (!keyAttributes) { - void router.push("/generate"); - } else if (haveCredentialsInSession()) { - void router.push(appHomeRoute); - } else { - setKeyAttributes(keyAttributes); - } + if (!user.encryptedToken && !user.token) { + await sendOTT(user.email, undefined); + stashRedirect("/recover"); + await router.replace("/verify"); + return; + } + + const keyAttributes = savedKeyAttributes(); + if (!keyAttributes) { + await router.replace("/generate"); + } else if (haveMasterKeyInSession()) { + await router.replace(appHomeRoute); + } else { + setKeyAttributes(keyAttributes); + } + })(); }, [router]); - const handleSubmit: SingleInputFormProps["onSubmit"] = async ( - recoveryKeyMnemonic: string, - setFieldError, - ) => { - try { - const keyAttr = keyAttributes!; - const masterKey = await decryptBox( - { - encryptedData: keyAttr.masterKeyEncryptedWithRecoveryKey!, - nonce: keyAttr.masterKeyDecryptionNonce!, - }, - await recoveryKeyFromMnemonic(recoveryKeyMnemonic), - ); - await saveMasterKeyInSessionAndSafeStore(masterKey); - await decryptAndStoreToken(keyAttr, masterKey); + const handleSubmit: SingleInputFormProps["onSubmit"] = useCallback( + async (recoveryKeyMnemonic: string, setFieldError) => { + try { + const keyAttr = keyAttributes!; + const masterKey = await decryptBox( + { + encryptedData: + keyAttr.masterKeyEncryptedWithRecoveryKey!, + nonce: keyAttr.masterKeyDecryptionNonce!, + }, + await recoveryKeyFromMnemonic(recoveryKeyMnemonic), + ); + await saveMasterKeyInSessionAndSafeStore(masterKey); + await decryptAndStoreTokenIfNeeded(keyAttr, masterKey); - void router.push("/change-password?op=reset"); - } catch (e) { - log.error("password recovery failed", e); - setFieldError(t("incorrect_recovery_key")); - } - }; + void router.push("/change-password?op=reset"); + } catch (e) { + log.error("Master key recovery failed", e); + setFieldError(t("incorrect_recovery_key")); + } + }, + [router, keyAttributes], + ); - const showNoRecoveryKeyMessage = () => + const showNoRecoveryKeyMessage = useCallback(() => { showMiniDialog({ title: t("sorry"), message: t("no_recovery_key_message"), continue: { color: "secondary" }, cancel: false, }); + }, [showMiniDialog]); return ( diff --git a/web/packages/accounts/pages/signup.tsx b/web/packages/accounts/pages/signup.tsx index cff9575c76..5d06da4dad 100644 --- a/web/packages/accounts/pages/signup.tsx +++ b/web/packages/accounts/pages/signup.tsx @@ -19,7 +19,7 @@ const Page: React.FC = () => { useEffect(() => { void customAPIHost().then(setHost); - if (savedPartialLocalUser()?.email) void router.push("/verify"); + if (savedPartialLocalUser()?.email) void router.replace("/verify"); setLoading(false); }, [router]); diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index e8fa006da2..42e9b9f9df 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -4,9 +4,9 @@ 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, + getRecoverTwoFactor, recoverTwoFactorFinish, type TwoFactorRecoveryResponse, type TwoFactorType, @@ -64,20 +64,23 @@ const Page: React.FC = ({ twoFactorType }) => { ); useEffect(() => { - const user = getData("user"); - const sessionID = user.passkeySessionID || user.twoFactorSessionID; - if (!user?.email || !sessionID) { - void router.push("/"); - } else if ( - !(user.isTwoFactorEnabled || user.isTwoFactorEnabledPasskey) && - (user.encryptedToken || user.token) - ) { - void router.push("/generate"); - } else { - setSessionID(sessionID); - void recoverTwoFactor(twoFactorType, sessionID) - .then(setRecoveryResponse) - .catch((e: unknown) => { + void (async () => { + const user = savedPartialLocalUser(); + const sessionID = + twoFactorType == "passkey" + ? user?.passkeySessionID + : user?.twoFactorSessionID; + if (!user?.email || !sessionID) { + await router.replace("/"); + } else if (user.encryptedToken || user.token) { + await router.replace("/generate"); + } else { + setSessionID(sessionID); + try { + setRecoveryResponse( + await getRecoverTwoFactor(twoFactorType, sessionID), + ); + } catch (e) { log.error("Second factor recovery page setup failed", e); if (isHTTPErrorWithStatus(e, 404)) { logout(); @@ -86,8 +89,9 @@ const Page: React.FC = ({ twoFactorType }) => { } else { onGenericError(e); } - }); - } + } + } + })(); }, [ twoFactorType, logout, diff --git a/web/packages/accounts/pages/two-factor/verify.tsx b/web/packages/accounts/pages/two-factor/verify.tsx index 8c32a92b3e..e0038ef9ae 100644 --- a/web/packages/accounts/pages/two-factor/verify.tsx +++ b/web/packages/accounts/pages/two-factor/verify.tsx @@ -1,17 +1,19 @@ import { Verify2FACodeForm } from "ente-accounts/components/Verify2FACodeForm"; import { - getData, + savedPartialLocalUser, saveKeyAttributes, - setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; -import type { PartialLocalUser } from "ente-accounts/services/user"; -import { verifyTwoFactor } from "ente-accounts/services/user"; +import { + resetSavedLocalUserTokens, + 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,54 +21,51 @@ import { } from "../../components/layouts/centered-paper"; import { unstashRedirect } from "../../services/redirect"; +/** + * A page that allows the user to verify their TOTP based second factor. + * + * See: [Note: Login pages] + */ 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("/"); + void router.replace("/"); } else if ( !user.isTwoFactorEnabled && (user.encryptedToken || user.token) ) { - void router.push("/credentials"); + void router.replace("/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 resetSavedLocalUserTokens(id, encryptedToken); + updateSavedLocalUser({ twoFactorSessionID: undefined }); + 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 7111b57435..6a1806c41e 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -8,7 +8,7 @@ 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, + replaceSavedLocalUser, savedKeyAttributes, savedOriginalKeyAttributes, savedPartialLocalUser, @@ -16,9 +16,9 @@ import { saveIsFirstLogin, saveKeyAttributes, saveOriginalKeyAttributes, - setLSUser, unstashAfterUseSRPSetupAttributes, unstashReferralSource, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { openPasskeyVerificationURL, @@ -44,9 +44,10 @@ import { useBaseContext } from "ente-base/context"; import { isHTTPErrorWithStatus } from "ente-base/http"; import log from "ente-base/log"; import { clearSessionStorage } from "ente-base/session"; +import { saveAuthToken } from "ente-base/token"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Trans } from "react-i18next"; /** @@ -58,7 +59,9 @@ const Page: React.FC = () => { const { logout, showMiniDialog } = useBaseContext(); const [email, setEmail] = useState(""); - const [resend, setResend] = useState(0); + const [resend, setResend] = useState<"enable" | "sending" | "sent">( + "enable", + ); const [passkeyVerificationData, setPasskeyVerificationData] = useState< { passkeySessionID: string; url: string } | undefined >(); @@ -73,7 +76,7 @@ const Page: React.FC = () => { useEffect(() => { void redirectionIfNeededOrEmail().then((redirectOrEmail) => { if (typeof redirectOrEmail == "string") { - void router.push(redirectOrEmail); + void router.replace(redirectOrEmail); } else { setEmail(redirectOrEmail.email); } @@ -100,14 +103,12 @@ const Page: React.FC = () => { } = await userVerificationResultAfterResolvingSecondFactorChoice( await verifyEmail(email, ott, cleanedReferral), ); + + // The following flow is similar to (but not the same) as what + // happens after `verifySRP` in the `/credentials` page. + if (passkeySessionID) { - const user = getData("user"); - await setLSUser({ - ...user, - passkeySessionID, - isTwoFactorEnabled: true, - isTwoFactorPasskeysEnabled: true, - }); + updateSavedLocalUser({ passkeySessionID }); saveIsFirstLogin(); const url = passkeyVerificationRedirectURL( accountsUrl!, @@ -116,21 +117,15 @@ 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"); } else { - await setLSUser({ - email, - token, - encryptedToken, - id, - isTwoFactorEnabled: false, - }); + if (token) await saveAuthToken(token); + replaceSavedLocalUser({ id, email, token, encryptedToken }); if (keyAttributes) { saveKeyAttributes(keyAttributes); saveOriginalKeyAttributes(keyAttributes); @@ -142,12 +137,11 @@ const Page: React.FC = () => { await unstashAfterUseSRPSetupAttributes(setupSRP); } saveIsFirstLogin(); - const redirectURL = unstashRedirect(); - if (keyAttributes?.encryptedKey) { + if (keyAttributes) { clearSessionStorage(); - void router.push(redirectURL ?? "/credentials"); + void router.push(unstashRedirect() ?? "/credentials"); } else { - void router.push(redirectURL ?? "/generate"); + void router.push(unstashRedirect() ?? "/generate"); } } } catch (e) { @@ -162,12 +156,12 @@ const Page: React.FC = () => { } }; - const resendEmail = async () => { - setResend(1); + const resendEmail = useCallback(async () => { + setResend("sending"); await sendOTT(email, undefined); - setResend(2); - setTimeout(() => setResend(0), 3000); - }; + setResend("sent"); + setTimeout(() => setResend("enable"), 3000); + }, [email]); if (!email) { return ; @@ -230,13 +224,13 @@ const Page: React.FC = () => { /> - {resend == 0 && ( + {resend == "enable" && ( {t("resend_code")} )} - {resend == 1 && {t("status_sending")}} - {resend == 2 && {t("status_sent")}} + {resend == "sending" && {t("status_sending")}} + {resend == "sent" && {t("status_sent")}} {t("change_email")} @@ -261,9 +255,7 @@ const redirectionIfNeededOrEmail = async () => { return "/"; } - const keyAttributes = savedKeyAttributes(); - - if (keyAttributes?.encryptedKey && (user.token || user.encryptedToken)) { + if (savedKeyAttributes() && (user.token || user.encryptedToken)) { return "/credentials"; } diff --git a/web/packages/accounts/services/accounts-db.ts b/web/packages/accounts/services/accounts-db.ts index 2aff817739..d2af23aee8 100644 --- a/web/packages/accounts/services/accounts-db.ts +++ b/web/packages/accounts/services/accounts-db.ts @@ -17,9 +17,7 @@ * - "srpAttributes" */ -import { getKVS, removeKV, setKV } from "ente-base/kv"; -import log from "ente-base/log"; -import { getAuthToken } from "ente-base/token"; +import { savedAuthToken } from "ente-base/token"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; import { @@ -58,6 +56,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. @@ -83,6 +84,7 @@ export interface PartialLocalUser { encryptedToken?: string; isTwoFactorEnabled?: boolean; twoFactorSessionID?: string; + passkeySessionID?: string; } const PartialLocalUser = z.object({ @@ -92,6 +94,7 @@ const PartialLocalUser = z.object({ encryptedToken: z.string().nullish().transform(nullToUndefined), isTwoFactorEnabled: z.boolean().nullish().transform(nullToUndefined), twoFactorSessionID: z.string().nullish().transform(nullToUndefined), + passkeySessionID: z.string().nullish().transform(nullToUndefined), }); /** @@ -103,6 +106,7 @@ const LocalUser = z.object({ id: z.number(), email: z.string(), token: z.string(), + isTwoFactorEnabled: z.boolean().nullish().transform(nullToUndefined), }); /** @@ -112,12 +116,14 @@ const LocalUser = z.object({ * After the user is logged in, use {@link savedLocalUser} or * {@link ensureLocalUser} instead. * - * Use {@link savePartialLocalUser} to updated the saved value. + * Use {@link replaceSavedLocalUser} to updated the saved value. */ export const savedPartialLocalUser = (): PartialLocalUser | undefined => { const jsonString = localStorage.getItem("user"); if (!jsonString) return undefined; - return PartialLocalUser.parse(JSON.parse(jsonString)); + const result = PartialLocalUser.parse(JSON.parse(jsonString)); + void ensureTokensMatch(result); + return result; }; /** @@ -125,13 +131,25 @@ export const savedPartialLocalUser = (): PartialLocalUser | undefined => { * * See: [Note: Partial local user]. * - * 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. + * This method replaces the existing data. Use {@link updateSavedLocalUser} to + * update selected fields while keeping the other fields as it is. */ -export const savePartialLocalUser = (partialLocalUser: Partial) => +export const replaceSavedLocalUser = (partialLocalUser: PartialLocalUser) => localStorage.setItem("user", JSON.stringify(partialLocalUser)); +/** + * Partially update the saved user data. + * + * This is a delta variant of {@link replaceSavedLocalUser}, 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 present in local storage, remain unchanged. + */ +export const updateSavedLocalUser = (updates: Partial) => + replaceSavedLocalUser({ ...savedPartialLocalUser(), ...updates }); + /** * Return data about the logged-in user, if someone is indeed logged in. * Otherwise return `undefined`. @@ -141,7 +159,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 replaceSavedLocalUser} or + * {@link updateSavedLocalUser}. * * See: [Note: Partial local user] for more about the whole shebang. */ @@ -150,84 +169,20 @@ export const savedLocalUser = (): LocalUser | undefined => { if (!jsonString) return undefined; // We might have some data, but not all of it. So do a non-throwing parse. const { success, data } = LocalUser.safeParse(JSON.parse(jsonString)); + if (success) void ensureTokensMatch(data); return success ? data : undefined; }; -export type LocalStorageKey = "user"; - -export const getData = (key: LocalStorageKey) => { - try { - if ( - typeof localStorage == "undefined" || - typeof key == "undefined" || - typeof localStorage.getItem(key) == "undefined" || - localStorage.getItem(key) == "undefined" - ) { - return null; - } - const data = localStorage.getItem(key); - return data && JSON.parse(data); - } catch (e) { - log.error(`Failed to Parse JSON for key ${key}`, e); - } -}; - -export const setData = (key: LocalStorageKey, value: object) => - localStorage.setItem(key, JSON.stringify(value)); - -// TODO: Migrate this to `local-user.ts`, with (a) more precise optionality -// indication of the constituent fields, (b) moving any fields that need to be -// accessed from web workers to KV DB. -// -// Creating a new function here to act as a funnel point. -export const setLSUser = async (user: object) => { - await migrateKVToken(user); - setData("user", user); -}; - /** - * Update the "token" KV with the token (if any) for the given {@link user}. + * Sanity check to ensure that KV token and local storage token are the same. * - * This is an internal implementation details of {@link setLSUser} and doesn't - * need to exposed conceptually. For now though, we need to call this externally - * at an early point in the app startup to also copy over the token into KV DB - * for existing users. - * - * This was added 1 July 2024, can be removed after a while and this code - * inlined into `setLSUser` (tag: Migration). + * TODO: Added July 2025, can just be removed soon, there is already a sanity + * check `isLocalStorageAndIndexedDBMismatch` on app start (tag: Migration). */ -export const migrateKVToken = async (user: unknown) => { - // Throw an error if the data is in local storage but not in IndexedDB. This - // is a pre-cursor to inlining this code. - // TODO: Remove this sanity check eventually when this code is revisited. - const oldLSUser = getData("user"); - const wasMissing = - oldLSUser && - typeof oldLSUser == "object" && - "token" in oldLSUser && - typeof oldLSUser.token == "string" && - !(await getKVS("token")); - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - user && - typeof user == "object" && - "id" in user && - typeof user.id == "number" - ? await setKV("userID", user.id) - : await removeKV("userID"); - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - user && - typeof user == "object" && - "token" in user && - typeof user.token == "string" - ? await setKV("token", user.token) - : await removeKV("token"); - - if (wasMissing) - throw new Error( - "The user's token was present in local storage but not in IndexedDB", - ); +export const ensureTokensMatch = async (user: PartialLocalUser | undefined) => { + if (user?.token !== (await savedAuthToken())) { + throw new Error("Token mismatch"); + } }; /** @@ -237,7 +192,7 @@ export const migrateKVToken = async (user: unknown) => { * token in local storage, then it should also be present in IndexedDB. */ export const isLocalStorageAndIndexedDBMismatch = async () => - savedPartialLocalUser()?.token && !(await getAuthToken()); + savedPartialLocalUser()?.token && !(await savedAuthToken()); /** * Return the user's {@link KeyAttributes} if they are present in local storage. @@ -354,11 +309,6 @@ export const unstashAfterUseSRPSetupAttributes = async ( localStorage.removeItem("srpSetupAttributes"); }; -export const getToken = (): string => { - const token = getData("user")?.token; - return token; -}; - /** * Zod schema for the legacy format in which the {@link savedIsFirstLogin} and * {@link savedJustSignedUp} flags were saved in local storage. diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index 52f39ab7ec..0dce3b22bf 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -1,9 +1,11 @@ import { - getData, saveKeyAttributes, - setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; -import { TwoFactorAuthorizationResponse } from "ente-accounts/services/user"; +import { + resetSavedLocalUserTokens, + TwoFactorAuthorizationResponse, +} from "ente-accounts/services/user"; import { clientPackageName, isDesktop } from "ente-base/app"; import { encryptBox, generateKey } from "ente-base/crypto"; import { @@ -256,12 +258,23 @@ export const saveCredentialsAndNavigateTo = async ( // goes through the passkey flow in the browser itself (when they are using // the web app). - sessionStorage.removeItem("inflightPasskeySessionID"); + clearInflightPasskeySessionID(); const { id, encryptedToken, keyAttributes } = response; - await setLSUser({ ...getData("user"), encryptedToken, id }); + await resetSavedLocalUserTokens(id, encryptedToken); + updateSavedLocalUser({ passkeySessionID: undefined }); saveKeyAttributes(keyAttributes); return unstashRedirect() ?? "/credentials"; }; + +/** + * Remove the inflight passkey session ID, if any, present in session storage. + * + * This should be called whenever we get back control from the passkey app to + * clean up after ourselves. + */ +export const clearInflightPasskeySessionID = () => { + sessionStorage.removeItem("inflightPasskeySessionID"); +}; diff --git a/web/packages/accounts/services/session.ts b/web/packages/accounts/services/session.ts index 414fba3378..8bfbc856eb 100644 --- a/web/packages/accounts/services/session.ts +++ b/web/packages/accounts/services/session.ts @@ -6,7 +6,7 @@ import type { KeyAttributes } from "ente-accounts/services/user"; import { authenticatedRequestHeaders, HTTPError } from "ente-base/http"; import log from "ente-base/log"; import { apiURL } from "ente-base/origins"; -import { getAuthToken } from "ente-base/token"; +import { savedAuthToken } from "ente-base/token"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; import { getSRPAttributes, type SRPAttributes } from "./srp"; @@ -150,7 +150,7 @@ export const checkSessionValidity = async (): Promise => { * e.g. transient network issues. */ export const isSessionInvalid = async (): Promise => { - const token = await getAuthToken(); + const token = await savedAuthToken(); if (!token) { return true; /* No saved token, session is invalid */ } diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index abfd6ea5ed..91b1f1ea5f 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -1,11 +1,11 @@ import { - getData, + replaceSavedLocalUser, savedKeyAttributes, savedLocalUser, + savedPartialLocalUser, saveKeyAttributes, saveSRPAttributes, - setLSUser, - type PartialLocalUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { generateSRPSetupAttributes, @@ -14,33 +14,33 @@ import { type UpdatedKeyAttr, } from "ente-accounts/services/srp"; import { + boxSealOpenBytes, decryptBox, deriveInteractiveKey, deriveSensitiveKey, encryptBox, generateKey, generateKeyPair, + toB64URLSafe, } from "ente-base/crypto"; -import { isDevBuild } from "ente-base/env"; import { authenticatedRequestHeaders, ensureOk, publicRequestHeaders, } from "ente-base/http"; import { apiURL } from "ente-base/origins"; +import { ensureMasterKeyFromSession } from "ente-base/session"; import { - ensureMasterKeyFromSession, - saveMasterKeyInSessionAndSafeStore, -} from "ente-base/session"; -import { getAuthToken } from "ente-base/token"; + removeAuthToken, + saveAuthToken, + savedAuthToken, +} from "ente-base/token"; import { ensure } from "ente-utils/ensure"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; +import { clearInflightPasskeySessionID } from "./passkey"; import { getUserRecoveryKey, recoveryKeyFromMnemonic } from "./recovery-key"; -// TODO(RE): Temporary re-export -export type { PartialLocalUser }; - /** * The locally persisted data we have about the user after they've logged in. * @@ -66,10 +66,14 @@ export interface LocalUser { * the value of the X-Auth-Token header in the HTTP request. * * Usually you shouldn't be needing to access this property; instead use - * {@link getAuthToken()} which is kept in sync with this value, and lives + * {@link savedAuthToken()} which is kept in sync with this value, and lives * 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; } /** @@ -577,9 +581,9 @@ export const verifyEmail = async ( * Log the user out on remote, if possible and needed. */ export const remoteLogoutIfNeeded = async () => { - if (!(await getAuthToken())) { - // If the logout is attempted during the signup flow itself, then we - // won't have an auth token. + if (!(await savedAuthToken())) { + // If the logout is attempted during the login / signup flow itself, + // then we won't have an auth token. Handle that gracefully. return; } @@ -664,7 +668,7 @@ export const generateAndSaveInteractiveKeyAttributes = async ( */ export const changeEmail = async (email: string, ott: string) => { await postChangeEmail(email, ott); - await setLSUser({ ...getData("user"), email }); + updateSavedLocalUser({ email }); }; /** @@ -725,12 +729,72 @@ export const changePassword = async (password: string) => { { ...keyAttributes, ...updatedKeyAttr }, masterKey, ); +}; - // TODO(RE): This shouldn't be needed, remove me. As a soft remove, - // disabling it for dev builds. (tag: Migration) - if (!isDevBuild) { - await saveMasterKeyInSessionAndSafeStore(masterKey); +/** + * Update the {@link id} and {@link encryptedToken} present in the saved partial + * local user. + * + * This function removes the {@link token}, if any, present in the saved partial + * local user and sets the provided {@link encryptedToken}. + * + * It is expected that the code will subsequently redirect to "/credentials", + * which should call {@link decryptAndStoreTokenIfNeeded} which will decrypt the + * newly set {@link encryptedToken} and write out the decrypted value as the + * {@link token} in the saved local user. + * + * @param userID The ID of the user whose token this is. This is also saved to + * the partial local user (after doing a sanity check that we're not replacing + * partial data with a different userID). + * + * @param encryptedToken The newly obtained base64 encoded encrypted token from + * remote (e.g. as a result of the user verifying their email). + */ +export const resetSavedLocalUserTokens = async ( + userID: number, + encryptedToken: string, +) => { + const user = savedPartialLocalUser(); + if (user?.id && user.id != userID) { + throw new Error(`User ID mismatch (${user.id}, ${userID})`); } + replaceSavedLocalUser({ + ...user, + id: userID, + token: undefined, + encryptedToken, + }); + return removeAuthToken(); +}; + +/** + * Decrypt the user's {@link encryptedToken}, if present, and use it to update + * both the locally saved user and the KV DB. + * + * @param keyAttributes The user's key attributes. + * + * @param masterKey The user's master key (base64 encoded). + */ +export const decryptAndStoreTokenIfNeeded = async ( + keyAttributes: KeyAttributes, + masterKey: string, +) => { + const { encryptedToken } = savedPartialLocalUser() ?? {}; + if (!encryptedToken) return; + + const { encryptedSecretKey, secretKeyDecryptionNonce, publicKey } = + keyAttributes; + const privateKey = await decryptBox( + { encryptedData: encryptedSecretKey, nonce: secretKeyDecryptionNonce }, + masterKey, + ); + + const token = await toB64URLSafe( + await boxSealOpenBytes(encryptedToken, { publicKey, privateKey }), + ); + + updateSavedLocalUser({ token, encryptedToken: undefined }); + return saveAuthToken(token); }; const TwoFactorSecret = z.object({ @@ -784,7 +848,7 @@ export const setupTwoFactorFinish = async ( encryptedTwoFactorSecret: box.encryptedData, twoFactorSecretDecryptionNonce: box.nonce, }); - await setLSUser({ ...getData("user"), isTwoFactorEnabled: true }); + updateSavedLocalUser({ isTwoFactorEnabled: true }); }; interface EnableTwoFactorRequest { @@ -893,7 +957,7 @@ export type TwoFactorRecoveryResponse = z.infer< * sends a encrypted recovery secret (see {@link configurePasskeyRecovery}). * * 3. When the user wishes to reset or bypass their second factor, the client - * asks remote for these encrypted secrets (using {@link recoverTwoFactor}). + * asks remote for these encrypted secrets (using {@link getRecoverTwoFactor}). * * 4. User then enters their recovery key, which the client uses to decrypt the * recovery secret and provide it back to remote for verification (using @@ -902,7 +966,7 @@ export type TwoFactorRecoveryResponse = z.infer< * 5. If the recovery secret matches, then remote resets (TOTP based) or bypass * (passkey based) the user's second factor. */ -export const recoverTwoFactor = async ( +export const getRecoverTwoFactor = async ( twoFactorType: TwoFactorType, sessionID: string, ): Promise => { @@ -916,22 +980,23 @@ export const recoverTwoFactor = async ( /** * Finish the second factor recovery / bypass initiated by - * {@link recoverTwoFactor} using the provided recovery key mnemonic entered by - * the user. + * {@link getRecoverTwoFactor} using the provided recovery key mnemonic entered + * by the user. * * See: [Note: Second factor recovery]. * * This completes the recovery process both locally, and on remote. * * @param twoFactorType The second factor type (same value as what would've been - * passed to {@link recoverTwoFactor} for obtaining {@link recoveryResponse}). + * passed to {@link getRecoverTwoFactor} for obtaining + * {@link recoveryResponse}). * * @param sessionID The second factor session ID (same value as what would've - * been passed to {@link recoverTwoFactor} for obtaining + * been passed to {@link getRecoverTwoFactor} for obtaining * {@link recoveryResponse}). * * @param recoveryResponse The response to a previous call to - * {@link recoverTwoFactor}. + * {@link getRecoverTwoFactor}. * * @param recoveryKeyMnemonic The 24-word BIP-39 recovery key mnemonic provided * by the user to complete recovery. @@ -953,13 +1018,13 @@ export const recoverTwoFactorFinish = async ( sessionID, twoFactorSecret, ); - await setLSUser({ - ...getData("user"), - id, - isTwoFactorEnabled: false, - encryptedToken, - token: undefined, + await resetSavedLocalUserTokens(id, encryptedToken); + updateSavedLocalUser({ + isTwoFactorEnabled: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, }); + if (twoFactorType == "passkey") clearInflightPasskeySessionID(); saveKeyAttributes(keyAttributes); }; diff --git a/web/packages/accounts/utils/helpers.ts b/web/packages/accounts/utils/helpers.ts deleted file mode 100644 index 9a980587f4..0000000000 --- a/web/packages/accounts/utils/helpers.ts +++ /dev/null @@ -1,35 +0,0 @@ -// TODO: Audit this file, this can be better. e.g. do we need the Object.assign? - -import { getData, setLSUser } from "ente-accounts/services/accounts-db"; -import { type KeyAttributes } from "ente-accounts/services/user"; -import { boxSealOpenBytes, decryptBox, toB64URLSafe } from "ente-base/crypto"; - -export async function decryptAndStoreToken( - keyAttributes: KeyAttributes, - masterKey: string, -) { - const user = getData("user"); - const { encryptedToken } = user; - - if (encryptedToken && encryptedToken.length > 0) { - const { encryptedSecretKey, secretKeyDecryptionNonce, publicKey } = - keyAttributes; - const privateKey = await decryptBox( - { - encryptedData: encryptedSecretKey, - nonce: secretKeyDecryptionNonce, - }, - masterKey, - ); - - const decryptedToken = await toB64URLSafe( - await boxSealOpenBytes(encryptedToken, { publicKey, privateKey }), - ); - - await setLSUser({ - ...user, - token: decryptedToken, - encryptedToken: null, - }); - } -} diff --git a/web/packages/base/session.ts b/web/packages/base/session.ts index 0adc3003ed..077e6d3271 100644 --- a/web/packages/base/session.ts +++ b/web/packages/base/session.ts @@ -1,7 +1,6 @@ import { z } from "zod/v4"; import { decryptBox, encryptBox, generateKey } from "./crypto"; import log from "./log"; -import { getAuthToken } from "./token"; /** * Remove all data stored in session storage (data tied to the browser tab). @@ -51,7 +50,7 @@ export const ensureMasterKeyFromSession = async () => { * have credentials at hand or not, however it doesn't attempt to verify that * the key present in the session can actually be decrypted. */ -export const haveCredentialsInSession = () => +export const haveMasterKeyInSession = () => !!sessionStorage.getItem("encryptionKey"); /** @@ -140,7 +139,7 @@ export const updateSessionFromElectronSafeStorageIfNeeded = async () => { const electron = globalThis.electron; if (!electron) return; - if (haveCredentialsInSession()) return; + if (haveMasterKeyInSession()) return; let masterKey: string | undefined; try { @@ -154,13 +153,6 @@ export const updateSessionFromElectronSafeStorageIfNeeded = async () => { } }; -/** - * Return true if we both have a usable user's master key in session storage, - * and their auth token in KV DB. - */ -export const haveAuthenticatedSession = async () => - (await masterKeyFromSession()) && !!(await getAuthToken()); - /** * Save the user's encypted key encryption key ("key") in session store * temporarily, until we get back here after completing the second factor. diff --git a/web/packages/base/token.ts b/web/packages/base/token.ts index f23d7866c7..109322b339 100644 --- a/web/packages/base/token.ts +++ b/web/packages/base/token.ts @@ -1,25 +1,42 @@ -import { getKVS } from "./kv"; - -/** - * Return the user's auth token, if present. - * - * The user's auth token is stored in KV DB after they have successfully logged - * in. This function returns that saved auth token. - * - * The underlying data is stored in IndexedDB, and can be accessed from web - * workers. - */ -export const getAuthToken = () => getKVS("token"); +import { getKVS, removeKV, setKV } from "./kv"; /** * Return the user's auth token, or throw an error. * - * The user's auth token can be retrieved using {@link getAuthToken}. This + * The user's auth token can be retrieved using {@link savedAuthToken}. This * function is a wrapper which throws an error if the token is not found (which * should only happen if the user is not logged in). */ export const ensureAuthToken = async () => { - const token = await getAuthToken(); + const token = await savedAuthToken(); if (!token) throw new Error("Not logged in"); return token; }; + +/** + * Return the user's auth token, if available. + * + * The user's auth token is stored in KV DB using {@link saveAuthToken} during + * the login / signup flow. This function returns that saved auth token. + * + * The underlying data is stored in IndexedDB, and can be accessed from web + * workers. + * + * If your code is running in a context where the user is already expected to be + * logged in, use {@link ensureAuthToken} instead. + */ +export const savedAuthToken = () => getKVS("token"); + +/** + * Save the user's auth token in KV DB. + * + * This is the setter corresponding to {@link savedAuthToken}. + */ +export const saveAuthToken = (token: string) => setKV("token", token); + +/** + * Remove the user's auth token from KV DB. + * + * See {@link saveAuthToken}. + */ +export const removeAuthToken = () => removeKV("token"); diff --git a/web/packages/new/photos/components/gallery/reducer.ts b/web/packages/new/photos/components/gallery/reducer.ts index 56a2590ff0..d7aedfc0cb 100644 --- a/web/packages/new/photos/components/gallery/reducer.ts +++ b/web/packages/new/photos/components/gallery/reducer.ts @@ -38,7 +38,7 @@ import { } from "../../services/collection-summary"; import type { PeopleState, Person } from "../../services/ml/people"; import type { SearchSuggestion } from "../../services/search/types"; -import type { FamilyData } from "../../services/user-details"; +import type { FamilyData, UserDetails } from "../../services/user-details"; /** * Specifies what the bar at the top of the gallery is displaying currently. @@ -455,6 +455,7 @@ export type GalleryAction = collectionFiles: EnteFile[]; trashItems: TrashItem[]; } + | { type: "setUserDetails"; userDetails: UserDetails } | { type: "setCollections"; collections: Collection[] } | { type: "setCollectionFiles"; collectionFiles: EnteFile[] } | { type: "uploadFile"; file: EnteFile } @@ -623,6 +624,32 @@ const galleryReducer: React.Reducer = ( }); } + case "setUserDetails": { + // While user details have more state that can change, the only + // changes that affect the reducer's state (so far) are if the + // user's own email changes, or the list of their family members + // changes. + // + // Both of these affect only the list of share suggestion emails. + + let user = state.user!; + const { email, familyData } = action.userDetails; + if (email != user.email) { + user = { ...user, email }; + } + + return { + ...state, + user, + familyData, + shareSuggestionEmails: createShareSuggestionEmails( + user, + familyData, + state.collections, + ), + }; + } + case "setCollections": { const collections = action.collections; 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 4d48d7449e..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"; @@ -177,18 +177,22 @@ export const logoutUserDetails = () => { }; /** - * Read in the locally persisted settings into memory, otherwise initiate a - * network requests to fetch the latest values (but don't wait for it to - * complete). + * Read in the locally persisted user details into memory and return them. + * + * If there are no locally persisted values, initiate a network requests to + * fetch the latest values (but don't wait for it to complete). * * This assumes that the user is already logged in. */ -export const initUserDetailsOrTriggerPull = async () => { +export const savedUserDetailsOrTriggerPull = async () => { const saved = await getKV("userDetails"); if (saved) { - setUserDetailsSnapshot(UserDetails.parse(saved)); + const userDetails = UserDetails.parse(saved); + setUserDetailsSnapshot(userDetails); + return userDetails; } else { void pullUserDetails(); + return undefined; } }; @@ -234,14 +238,39 @@ const setUserDetailsSnapshot = (snapshot: UserDetails) => { export const pullUserDetails = async () => { const userDetails = await getUserDetails(); await setKV("userDetails", userDetails); - setUserDetailsSnapshot(userDetails); // 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 + // deep equality check so as to not rerender it on redundant updates. + // + // [Note: Deep equal check] + // + // React uses `Object.is` to detect changes, which changes for arrays, + // objects and combinations thereof even if the underlying data is the same. + // + // In many cases, the code can be restructured to avoid this being a + // problem, or the rerender might be infrequent enough that it is not a + // problem. + // + // However, when used with useSyncExternalStore, there is an easy way to + // prevent this, by doing a preflight deep equality comparison. + // + // There are arguably faster libraries out there that'll do the deep + // equality check for us, but since it is an infrequent pattern in our code + // base currently, we just use the JSON serialization. + // + // Mark all cases that do this using this note's title so we can audit them + // if we move to a deep equality comparison library in the future. + + if (JSON.stringify(userDetails) != JSON.stringify(userDetailsSnapshot())) { + setUserDetailsSnapshot(userDetails); } };