[web] Accounts DB refactoring (complete) (#6458)

This commit is contained in:
Manav Rathi
2025-07-04 10:38:55 +05:30
committed by GitHub
30 changed files with 806 additions and 680 deletions

View File

@@ -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<AppProps> = ({ 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

View File

@@ -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<typeof setInterval> | 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,

View File

@@ -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 (
<TappableContainer onMaybeChangeHost={refreshHost}>

View File

@@ -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]),

View File

@@ -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,

View File

@@ -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<LoginContentsProps> = ({
onSignUp,
@@ -50,10 +55,10 @@ export const LoginContents: React.FC<LoginContentsProps> = ({
}
throw e;
}
savePartialLocalUser({ email });
replaceSavedLocalUser({ email });
void router.push("/verify");
} else {
savePartialLocalUser({ email });
replaceSavedLocalUser({ email });
saveSRPAttributes(srpAttributes);
void router.push("/credentials");
}

View File

@@ -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<SignUpContentsProps> = ({
router,
onLogin,
@@ -124,7 +130,7 @@ export const SignUpContents: React.FC<SignUpContentsProps> = ({
throw e;
}
savePartialLocalUser({ email });
replaceSavedLocalUser({ email });
let gkResult: GenerateKeysAndAttributesResult;
try {

View File

@@ -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<KeyAttributes | undefined>;
/**
* The user's SRP attributes.
*/
srpAttributes?: SRPAttributes;
getKeyAttributes?: (
srpAttributes: SRPAttributes,
kek: string,
) => Promise<KeyAttributes | "redirecting-second-factor" | undefined>;
/**
* 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;
}

View File

@@ -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

View File

@@ -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("/");
}

View File

@@ -38,7 +38,7 @@ const Page: React.FC = () => {
setUser(user);
} else {
stashRedirect("/change-password");
void router.push("/");
void router.replace("/");
}
}, [router]);

View File

@@ -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<PartialLocalUser | undefined>(undefined);
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
const [srpAttributes, setSrpAttributes] = useState<SRPAttributes>();
const [userEmail, setUserEmail] = useState<string>("");
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<void> | 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 <LoadingIndicator />;
@@ -326,7 +336,7 @@ const Page: React.FC = () => {
return (
<VerifyingPasskey
email={user?.email}
email={userEmail}
passkeySessionID={passkeyVerificationData?.passkeySessionID}
onRetry={() =>
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 (
<AccountsPageContents>
<PasswordHeader caption={userEmail} />

View File

@@ -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<PartialLocalUser | undefined>(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 ? (
<LoadingIndicator />
) : openRecoveryKey ? (
{openRecoveryKey ? (
<RecoveryKey
open={openRecoveryKey}
onClose={() => void router.push(appHomeRoute)}
showMiniDialog={showMiniDialog}
/>
) : (
) : userEmail ? (
<AccountsPageContents>
<AccountsPageTitle>{t("set_password")}</AccountsPageTitle>
<NewPasswordForm
userEmail={user.email!}
userEmail={userEmail}
submitButtonTitle={t("set_password")}
onSubmit={handleSubmit}
/>
@@ -122,6 +118,8 @@ const Page: React.FC = () => {
<LinkButton onClick={logout}>{t("go_back")}</LinkButton>
</AccountsPageFooter>
</AccountsPageContents>
) : (
<LoadingIndicator />
)}
</>
);

View File

@@ -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]);

View File

@@ -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";

View File

@@ -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 (
<AccountsPageContents>

View File

@@ -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]);

View File

@@ -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<RecoverPageProps> = ({ 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<RecoverPageProps> = ({ twoFactorType }) => {
} else {
onGenericError(e);
}
});
}
}
}
})();
}, [
twoFactorType,
logout,

View File

@@ -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 (
<AccountsPageContents>

View File

@@ -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 <LoadingIndicator />;
@@ -230,13 +224,13 @@ const Page: React.FC = () => {
/>
<AccountsPageFooter>
{resend == 0 && (
{resend == "enable" && (
<LinkButton onClick={resendEmail}>
{t("resend_code")}
</LinkButton>
)}
{resend == 1 && <span>{t("status_sending")}</span>}
{resend == 2 && <span>{t("status_sent")}</span>}
{resend == "sending" && <span>{t("status_sending")}</span>}
{resend == "sent" && <span>{t("status_sent")}</span>}
<LinkButton onClick={logout}>{t("change_email")}</LinkButton>
</AccountsPageFooter>
@@ -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";
}

View File

@@ -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<LocalUser>) =>
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<PartialLocalUser>) =>
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<LocalUser>) =>
* 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.

View File

@@ -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");
};

View File

@@ -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<SessionValidity> => {
* e.g. transient network issues.
*/
export const isSessionInvalid = async (): Promise<boolean> => {
const token = await getAuthToken();
const token = await savedAuthToken();
if (!token) {
return true; /* No saved token, session is invalid */
}

View File

@@ -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<TwoFactorRecoveryResponse> => {
@@ -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);
};

View File

@@ -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,
});
}
}

View File

@@ -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.

View File

@@ -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");

View File

@@ -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<GalleryState, GalleryAction> = (
});
}
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;

View File

@@ -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<ContentsProps> = ({ 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();
};

View File

@@ -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);
}
};