This commit is contained in:
Manav Rathi
2025-07-03 18:52:33 +05:30
parent 9e4a67312f
commit 69cf09e13d
10 changed files with 235 additions and 201 deletions

View File

@@ -38,7 +38,7 @@ import {
haveCredentialsInSession,
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";
@@ -282,7 +282,7 @@ const Page: React.FC = () => {
let syncIntervalID: ReturnType<typeof setInterval> | undefined;
void (async () => {
if (!haveCredentialsInSession() || !(await getAuthToken())) {
if (!haveCredentialsInSession() || !(await savedAuthToken())) {
// If we don't have master key or auth token, reauthenticate.
stashRedirect("/gallery");
router.push("/");

View File

@@ -11,15 +11,33 @@ import log from "ente-base/log";
import { useFormik } from "formik";
import { t } from "i18next";
import { useCallback, useState } from "react";
import { twoFactorEnabledErrorMessage } from "./utils/second-factor-choice";
export interface VerifyMasterPasswordFormProps {
/**
* The email of the user whose password we're trying to verify.
*/
userEmail: string;
/**
* The user's SRP attributes.
*
* The SRP attributes are used to derive the KEK from the user's password.
* If they are not present, the {@link keyAttributes} will be used instead.
*
* At least one of {@link srpAttributes} and {@link keyAttributes} must be
* present, otherwise the verification will fail.
*/
srpAttributes?: SRPAttributes;
/**
* The user's key attributes.
*
* If they are present, they are used to derive the KEK from the user's
* password when {@link srpAttributes} are not present. This is the case
* when the user has already logged in (or signed up) on this client before,
* and is now doing a reauthentication.
*
* If they are not present, then {@link getKeyAttributes} must be present
* and will be used to obtain the user's key attributes. This is the case
* when the user is logging into a new client.
*/
keyAttributes: KeyAttributes | undefined;
/**
@@ -30,20 +48,18 @@ 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?: (
kek: string,
) => Promise<KeyAttributes | "redirecting-second-factor" | undefined>;
/**
* The title of the submit button on the form.
*/
@@ -152,24 +168,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) {
try {
keyAttributes = await getKeyAttributes(kek);
const result = await getKeyAttributes(kek);
if (result == "redirecting-second-factor") {
// Two factor enabled, user has been redirected to the
// corresponding second factor verification page.
return;
} else {
keyAttributes = result;
}
} catch (e) {
if (e instanceof Error) {
switch (e.message) {
case twoFactorEnabledErrorMessage:
// Two factor enabled, user has been redirected to
// the two-factor verification page.
return;
case srpVerificationUnauthorizedErrorMessage:
log.error("Incorrect password or no account", e);
setFieldError(
t("incorrect_password_or_no_account"),
);
return;
}
if (
e instanceof Error &&
e.message == srpVerificationUnauthorizedErrorMessage
) {
log.error("Incorrect password or no account", e);
setFieldError(t("incorrect_password_or_no_account"));
return;
}
throw e;
}

View File

@@ -8,15 +8,6 @@ import { useModalVisibility } from "ente-base/components/utils/modal";
import { useCallback, useMemo, useRef } from "react";
import type { SecondFactorType } from "../SecondFactorChoice";
/**
* The message of the {@link Error} that is thrown when the user has enabled a
* second factor so further authentication is needed during the login sequence.
*
* TODO: This is not really an error but rather is a code flow flag; consider
* not using exceptions for flow control.
*/
export const twoFactorEnabledErrorMessage = "two factor enabled";
/**
* A convenience hook for keeping track of the state and logic that is needed
* after password verification to determine which second factor (if any) we

View File

@@ -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,6 @@ import {
saveIsFirstLogin,
saveKeyAttributes,
saveSRPAttributes,
setLSUser,
updateSavedLocalUser,
} from "ente-accounts/services/accounts-db";
import {
@@ -47,13 +41,13 @@ import {
import {
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 {
@@ -63,6 +57,7 @@ import {
unstashKeyEncryptionKeyFromSession,
updateSessionFromElectronSafeStorageIfNeeded,
} from "ente-base/session";
import { saveAuthToken } from "ente-base/token";
import { t } from "i18next";
import { useRouter } from "next/router";
import { useCallback, useEffect, useState } from "react";
@@ -77,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,
@@ -128,28 +129,55 @@ const Page: React.FC = () => {
}
}, [logout, showMiniDialog]);
const postVerification = useCallback(
async (
userEmail: string,
masterKey: string,
kek: string,
keyAttributes: KeyAttributes,
) => {
await saveMasterKeyInSessionAndSafeStore(masterKey);
await decryptAndStoreToken(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) {
const userEmail = user?.email;
if (!userEmail) {
void router.push("/");
return;
}
setUser(user);
await updateSessionFromElectronSafeStorageIfNeeded();
if (await haveAuthenticatedSession()) {
void router.push(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.
if (kek && keyAttributes) {
const masterKey = await decryptBox(
{
@@ -158,15 +186,21 @@ 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("/");
return;
@@ -175,127 +209,103 @@ const Page: React.FC = () => {
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("/");
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.push("/");
})();
}, [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;
const {
id,
keyAttributes,
token,
encryptedToken,
twoFactorSessionID,
passkeySessionID,
accountsUrl,
} = await userVerificationResultAfterResolvingSecondFactorChoice(
await verifySRP(srpAttributes!, kek),
);
const {
keyAttributes,
encryptedToken,
token,
id,
twoFactorSessionID,
// If we had to ask remote for the key attributes, it is the initial
// login on this client.
saveIsFirstLogin();
if (passkeySessionID) {
await stashKeyEncryptionKeyInSessionStore(kek);
updateSavedLocalUser({ passkeySessionID });
stashRedirect("/");
const url = passkeyVerificationRedirectURL(
accountsUrl!,
passkeySessionID,
accountsUrl,
} =
await userVerificationResultAfterResolvingSecondFactorChoice(
await verifySRP(srpAttributes!, kek),
);
saveIsFirstLogin();
if (passkeySessionID) {
await stashKeyEncryptionKeyInSessionStore(kek);
updateSavedLocalUser({ passkeySessionID });
stashRedirect("/");
const url = passkeyVerificationRedirectURL(
accountsUrl!,
passkeySessionID,
);
setPasskeyVerificationData({ passkeySessionID, url });
openPasskeyVerificationURL({ passkeySessionID, url });
throw new Error(twoFactorEnabledErrorMessage);
} else if (twoFactorSessionID) {
await stashKeyEncryptionKeyInSessionStore(kek);
updateSavedLocalUser({
isTwoFactorEnabled: true,
twoFactorSessionID,
});
void router.push("/two-factor/verify");
throw new Error(twoFactorEnabledErrorMessage);
} else {
const user = getData("user");
await setLSUser({
...user,
token,
encryptedToken,
id,
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;
);
setPasskeyVerificationData({ passkeySessionID, url });
openPasskeyVerificationURL({ passkeySessionID, url });
return "redirecting-second-factor";
} else if (twoFactorSessionID) {
await stashKeyEncryptionKeyInSessionStore(kek);
updateSavedLocalUser({
isTwoFactorEnabled: true,
twoFactorSessionID,
});
void router.push("/two-factor/verify");
return "redirecting-second-factor";
} else {
// 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,
isTwoFactorEnabled: undefined,
twoFactorSessionID: undefined,
passkeySessionID: undefined,
});
if (keyAttributes) saveKeyAttributes(keyAttributes);
return keyAttributes;
}
};
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 />;
@@ -321,7 +331,7 @@ const Page: React.FC = () => {
return (
<VerifyingPasskey
email={user?.email}
email={userEmail}
passkeySessionID={passkeyVerificationData?.passkeySessionID}
onRetry={() =>
openPasskeyVerificationURL(passkeyVerificationData)

View File

@@ -58,6 +58,18 @@ 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 an additional 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 an additional passkey
* that also needs to be verified. Before redirecting, it sets the
* `inflightPasskeySessionID` 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.
*
@@ -112,7 +124,7 @@ import React, { useCallback, useEffect, useState } from "react";
* or either of `twoFactorSessionID` and `twoFactorSessionID` is set.
*
* - Redirects to "/generate" if there is an `encryptedToken` or `token` in
* the saved partial local user (TODO: Why?).
* the saved partial local user.
*
* - Redirects to "/credentials" after recovery.
*

View File

@@ -19,7 +19,7 @@
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 {
@@ -264,7 +264,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.
@@ -381,11 +381,6 @@ export const unstashAfterUseSRPSetupAttributes = async (
localStorage.removeItem("srpSetupAttributes");
};
export const getToken = (): string => {
const token = getData("user")?.token;
return token;
};
/**
* Zod schema for the legacy format in which the {@link savedIsFirstLogin} and
* {@link savedJustSignedUp} flags were saved in local storage.

View File

@@ -6,7 +6,7 @@ import type { KeyAttributes } from "ente-accounts/services/user";
import { authenticatedRequestHeaders, HTTPError } from "ente-base/http";
import log from "ente-base/log";
import { apiURL } from "ente-base/origins";
import { getAuthToken } from "ente-base/token";
import { savedAuthToken } from "ente-base/token";
import { nullToUndefined } from "ente-utils/transform";
import { z } from "zod/v4";
import { getSRPAttributes, type SRPAttributes } from "./srp";
@@ -150,7 +150,7 @@ export const checkSessionValidity = async (): Promise<SessionValidity> => {
* e.g. transient network issues.
*/
export const isSessionInvalid = async (): Promise<boolean> => {
const token = await getAuthToken();
const token = await savedAuthToken();
if (!token) {
return true; /* No saved token, session is invalid */
}

View File

@@ -33,7 +33,7 @@ import {
ensureMasterKeyFromSession,
saveMasterKeyInSessionAndSafeStore,
} from "ente-base/session";
import { getAuthToken } from "ente-base/token";
import { savedAuthToken } from "ente-base/token";
import { ensure } from "ente-utils/ensure";
import { nullToUndefined } from "ente-utils/transform";
import { z } from "zod/v4";
@@ -67,7 +67,7 @@ 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;
@@ -582,9 +582,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;
}

View File

@@ -1,7 +1,7 @@
import { z } from "zod/v4";
import { decryptBox, encryptBox, generateKey } from "./crypto";
import log from "./log";
import { getAuthToken } from "./token";
import { savedAuthToken } from "./token";
/**
* Remove all data stored in session storage (data tied to the browser tab).
@@ -159,7 +159,7 @@ export const updateSessionFromElectronSafeStorageIfNeeded = async () => {
* and their auth token in KV DB.
*/
export const haveAuthenticatedSession = async () =>
(await masterKeyFromSession()) && !!(await getAuthToken());
(await masterKeyFromSession()) && !!(await savedAuthToken());
/**
* Save the user's encypted key encryption key ("key") in session store

View File

@@ -1,25 +1,35 @@
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, 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);