[web] Accounts DB refactoring (complete) (#6458)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ const Page: React.FC = () => {
|
||||
setUser(user);
|
||||
} else {
|
||||
stashRedirect("/change-password");
|
||||
void router.push("/");
|
||||
void router.replace("/");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user