This commit is contained in:
Manav Rathi
2025-07-03 16:48:32 +05:30
parent 4e8a4250dc
commit 9e4a67312f
9 changed files with 137 additions and 84 deletions

View File

@@ -25,6 +25,7 @@ import {
saveKeyAttributes,
saveSRPAttributes,
setLSUser,
updateSavedLocalUser,
} from "ente-accounts/services/accounts-db";
import {
openPasskeyVerificationURL,
@@ -212,12 +213,7 @@ const Page: React.FC = () => {
if (passkeySessionID) {
await stashKeyEncryptionKeyInSessionStore(kek);
const user = getData("user");
await setLSUser({
...user,
passkeySessionID,
isTwoFactorEnabled: true,
});
updateSavedLocalUser({ passkeySessionID });
stashRedirect("/");
const url = passkeyVerificationRedirectURL(
accountsUrl!,
@@ -228,11 +224,9 @@ const Page: React.FC = () => {
throw new Error(twoFactorEnabledErrorMessage);
} else if (twoFactorSessionID) {
await stashKeyEncryptionKeyInSessionStore(kek);
const user = getData("user");
await setLSUser({
...user,
twoFactorSessionID,
updateSavedLocalUser({
isTwoFactorEnabled: true,
twoFactorSessionID,
});
void router.push("/two-factor/verify");
throw new Error(twoFactorEnabledErrorMessage);
@@ -243,7 +237,9 @@ const Page: React.FC = () => {
token,
encryptedToken,
id,
isTwoFactorEnabled: false,
isTwoFactorEnabled: undefined,
twoFactorSessionID: undefined,
passkeySessionID: undefined,
});
if (keyAttributes) saveKeyAttributes(keyAttributes);
return keyAttributes;

View File

@@ -49,6 +49,8 @@ import React, { useCallback, useEffect, useState } from "react";
*
* - Redirects to the passkey app once email verification is complete if the
* user has setup an additional passkey that also needs to be verified.
* Before redirecting, it sets the `inflightPasskeySessionID` in session
* storage.
*
* - "/credentials" - A page that allows the user to enter their password to
* authenticate (initial login) or reauthenticate (new web app tab)
@@ -79,11 +81,41 @@ import React, { useCallback, useEffect, useState } from "react";
*
* - Redirects to "/change-password" once the recovery key is verified.
*
* - "/change-password" - A page that allows the user to reset their password.
* - "/change-password" - A page that allows the user to reset their password.
*
* - Redirects to "/" if there is no `email` present in the saved partial
* user, and after successfully changing the password.
*
* - "/two-factor/verify" - A page that allows the user to verify their TOTP
* based second factor.
*
* - Redirects to "/" if there is no `email` or `twoFactorSessionID` in the
* saved partial local user.
*
* - Redirects to "/credentials" if there `isTwoFactorEnabled` is not `true`
* and either of `encryptedToken` or `token` is present in the saved partial
* local user.
*
* - "/passkeys/finish" - A page that the accounts app hands off control back to
* us (the calling app) to continue the rest of the authentication.
*
* - Redirects to "/" if there is no matching `inflightPasskeySessionID` in
* session storage.
*
* - Redirects to "/credentials" otherwise.
*
* - "/two-factor/recover" and "/passkeys/recover" - Pages that allow the user
* to reset or bypass their second factor if they possess their recovery key.
* Both pages work similarly, except the second factor they act on.
*
* - Redirects to "/" if there is no `email` in the saved partial local user,
* or either of `twoFactorSessionID` and `twoFactorSessionID` is set.
*
* - Redirects to "/generate" if there is an `encryptedToken` or `token` in
* the saved partial local user (TODO: Why?).
*
* - Redirects to "/credentials" after recovery.
*
*/
const Page: React.FC = () => {
const [loading, setLoading] = useState(true);

View File

@@ -4,7 +4,7 @@ import {
AccountsPageFooter,
AccountsPageTitle,
} from "ente-accounts/components/layouts/centered-paper";
import { getData } from "ente-accounts/services/accounts-db";
import { savedPartialLocalUser } from "ente-accounts/services/accounts-db";
import {
recoverTwoFactor,
recoverTwoFactorFinish,
@@ -64,14 +64,14 @@ const Page: React.FC<RecoverPageProps> = ({ twoFactorType }) => {
);
useEffect(() => {
const user = getData("user");
const sessionID = user.passkeySessionID || user.twoFactorSessionID;
const user = savedPartialLocalUser();
const sessionID =
twoFactorType == "passkey"
? user?.passkeySessionID
: user?.twoFactorSessionID;
if (!user?.email || !sessionID) {
void router.push("/");
} else if (
!user.isTwoFactorEnabled &&
(user.encryptedToken || user.token)
) {
} else if (user.encryptedToken || user.token) {
void router.push("/generate");
} else {
setSessionID(sessionID);

View File

@@ -1,17 +1,17 @@
import { Verify2FACodeForm } from "ente-accounts/components/Verify2FACodeForm";
import {
getData,
savedPartialLocalUser,
saveKeyAttributes,
setLSUser,
} from "ente-accounts/services/accounts-db";
import type { PartialLocalUser } from "ente-accounts/services/user";
import { verifyTwoFactor } from "ente-accounts/services/user";
import { LinkButton } from "ente-base/components/LinkButton";
import { useBaseContext } from "ente-base/context";
import { isHTTPErrorWithStatus } from "ente-base/http";
import { t } from "i18next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
AccountsPageContents,
AccountsPageFooter,
@@ -19,15 +19,18 @@ import {
} from "../../components/layouts/centered-paper";
import { unstashRedirect } from "../../services/redirect";
/**
* A page that allows the user to verify their TOTP based second factor.
*/
const Page: React.FC = () => {
const { logout } = useBaseContext();
const [sessionID, setSessionID] = useState("");
const [twoFactorSessionID, setTwoFactorSessionID] = useState("");
const router = useRouter();
useEffect(() => {
const user: PartialLocalUser = getData("user");
const user = savedPartialLocalUser();
if (!user?.email || !user.twoFactorSessionID) {
void router.push("/");
} else if (
@@ -36,37 +39,38 @@ const Page: React.FC = () => {
) {
void router.push("/credentials");
} else {
setSessionID(user.twoFactorSessionID);
setTwoFactorSessionID(user.twoFactorSessionID);
}
}, [router]);
const handleSubmit = async (otp: string) => {
try {
const { keyAttributes, encryptedToken, id } = await verifyTwoFactor(
otp,
sessionID,
);
await setLSUser({
...getData("user"),
id,
// TODO: [Note: empty token?]
//
// The original code was parsing an token which is never going
// to be present in the response, so effectively was always
// setting token to undefined. So this works, but is it needed?
token: undefined,
encryptedToken,
});
saveKeyAttributes(keyAttributes);
await router.push(unstashRedirect() ?? "/credentials");
} catch (e) {
if (isHTTPErrorWithStatus(e, 404)) {
logout();
} else {
throw e;
const handleSubmit = useCallback(
async (otp: string) => {
try {
const { keyAttributes, encryptedToken, id } =
await verifyTwoFactor(otp, twoFactorSessionID);
await setLSUser({
...getData("user"),
id,
// TODO: [Note: empty token?]
//
// The original code was parsing an token which is never going
// to be present in the response, so effectively was always
// setting token to undefined. So this works, but is it needed?
token: undefined,
encryptedToken,
});
saveKeyAttributes(keyAttributes);
await router.push(unstashRedirect() ?? "/credentials");
} catch (e) {
if (isHTTPErrorWithStatus(e, 404)) {
logout();
} else {
throw e;
}
}
}
};
},
[logout, router, twoFactorSessionID],
);
return (
<AccountsPageContents>

View File

@@ -8,7 +8,6 @@ import { VerifyingPasskey } from "ente-accounts/components/LoginComponents";
import { SecondFactorChoice } from "ente-accounts/components/SecondFactorChoice";
import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/second-factor-choice";
import {
getData,
savedKeyAttributes,
savedOriginalKeyAttributes,
savedPartialLocalUser,
@@ -19,6 +18,7 @@ import {
setLSUser,
unstashAfterUseSRPSetupAttributes,
unstashReferralSource,
updateSavedLocalUser,
} from "ente-accounts/services/accounts-db";
import {
openPasskeyVerificationURL,
@@ -101,12 +101,7 @@ const Page: React.FC = () => {
await verifyEmail(email, ott, cleanedReferral),
);
if (passkeySessionID) {
const user = getData("user");
await setLSUser({
...user,
passkeySessionID,
isTwoFactorEnabled: true,
});
updateSavedLocalUser({ passkeySessionID });
saveIsFirstLogin();
const url = passkeyVerificationRedirectURL(
accountsUrl!,
@@ -115,10 +110,9 @@ const Page: React.FC = () => {
setPasskeyVerificationData({ passkeySessionID, url });
openPasskeyVerificationURL({ passkeySessionID, url });
} else if (twoFactorSessionID) {
await setLSUser({
email,
twoFactorSessionID,
updateSavedLocalUser({
isTwoFactorEnabled: true,
twoFactorSessionID,
});
saveIsFirstLogin();
void router.push("/two-factor/verify");
@@ -128,7 +122,9 @@ const Page: React.FC = () => {
token,
encryptedToken,
id,
isTwoFactorEnabled: false,
isTwoFactorEnabled: undefined,
twoFactorSessionID: undefined,
passkeySessionID: undefined,
});
if (keyAttributes) {
saveKeyAttributes(keyAttributes);

View File

@@ -58,6 +58,9 @@ import {
* also get filled in. Once they verify their TOTP based second factor, their
* {@link id} and {@link encryptedToken} will also get filled in.
*
* - If they have a passkey set as a second factor set, then after verifying
* their password the {@link passkeySessionID} will be set.
*
* - As the login or signup sequence completes, a {@link token} obtained from
* the {@link encryptedToken} will be written out, and the
* {@link encryptedToken} cleared since it is not needed anymore.
@@ -105,6 +108,7 @@ const LocalUser = z.object({
id: z.number(),
email: z.string(),
token: z.string(),
isTwoFactorEnabled: z.boolean().nullish().transform(nullToUndefined),
});
/**
@@ -127,13 +131,33 @@ export const savedPartialLocalUser = (): PartialLocalUser | undefined => {
*
* See: [Note: Partial local user].
*
* This method replaces the existing data. Use {@link updatePartialLocalUser} to
* update selected fields while keeping the other fields as it is.
*
* TODO: WARNING: This does not update the KV token. The idea is to gradually
* move over uses of setLSUser to this while explicitly setting the KV token
* where needed.
*/
export const savePartialLocalUser = (partialLocalUser: Partial<LocalUser>) =>
export const savePartialLocalUser = (partialLocalUser: PartialLocalUser) =>
localStorage.setItem("user", JSON.stringify(partialLocalUser));
/**
* Partially update the saved user data.
*
* This is a delta variant of {@link savePartialLocalUser}, which replaces the
* entire saved object, while this function spreads the provided {@link updates}
* onto the currently saved value.
*
* @param updates A subset of {@link PartialLocalUser} fields that we'd like to
* update. The other fields, if any, remain unchanged.
*
* TODO: WARNING: This does not update the KV token. The idea is to gradually
* move over uses of setLSUser to this while explicitly setting the KV token
* where needed.
*/
export const updateSavedLocalUser = (updates: Partial<PartialLocalUser>) =>
savePartialLocalUser({ ...savedPartialLocalUser(), ...updates });
/**
* Return data about the logged-in user, if someone is indeed logged in.
* Otherwise return `undefined`.
@@ -143,7 +167,8 @@ export const savePartialLocalUser = (partialLocalUser: Partial<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 savePartialLocalUser} or
* {@link updateSavedLocalUser}.
*
* See: [Note: Partial local user] for more about the whole shebang.
*/

View File

@@ -5,6 +5,7 @@ import {
saveKeyAttributes,
saveSRPAttributes,
setLSUser,
updateSavedLocalUser,
type PartialLocalUser,
} from "ente-accounts/services/accounts-db";
import {
@@ -70,6 +71,10 @@ export interface LocalUser {
* in IndexedDB and thus can also be used in web workers.
*/
token: string;
/**
* `true` if the TOTP based second factor is enabled for the user.
*/
isTwoFactorEnabled?: boolean;
}
/**
@@ -664,7 +669,7 @@ export const generateAndSaveInteractiveKeyAttributes = async (
*/
export const changeEmail = async (email: string, ott: string) => {
await postChangeEmail(email, ott);
await setLSUser({ ...getData("user"), email });
updateSavedLocalUser({ email });
};
/**
@@ -784,7 +789,7 @@ export const setupTwoFactorFinish = async (
encryptedTwoFactorSecret: box.encryptedData,
twoFactorSecretDecryptionNonce: box.nonce,
});
await setLSUser({ ...getData("user"), isTwoFactorEnabled: true });
updateSavedLocalUser({ isTwoFactorEnabled: true });
};
interface EnableTwoFactorRequest {
@@ -956,7 +961,7 @@ export const recoverTwoFactorFinish = async (
await setLSUser({
...getData("user"),
id,
isTwoFactorEnabled: false,
isTwoFactorEnabled: undefined,
encryptedToken,
token: undefined,
});

View File

@@ -1,6 +1,9 @@
import LockIcon from "@mui/icons-material/Lock";
import { Stack, Typography } from "@mui/material";
import { getData, setLSUser } from "ente-accounts/services/accounts-db";
import {
savedPartialLocalUser,
updateSavedLocalUser,
} from "ente-accounts/services/accounts-db";
import {
RowButton,
RowButtonGroup,
@@ -24,12 +27,9 @@ export const TwoFactorSettings: React.FC<
const [isTwoFactorEnabled, setIsTwoFactorEnabled] = useState(false);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const isTwoFactorEnabled =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
getData("user").isTwoFactorEnabled ?? false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
setIsTwoFactorEnabled(isTwoFactorEnabled);
if (savedPartialLocalUser()?.isTwoFactorEnabled) {
setIsTwoFactorEnabled(true);
}
}, []);
useEffect(() => {
@@ -37,11 +37,7 @@ export const TwoFactorSettings: React.FC<
void (async () => {
const isEnabled = await get2FAStatus();
setIsTwoFactorEnabled(isEnabled);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await setLSUser({
...getData("user"),
isTwoFactorEnabled: isEnabled,
});
updateSavedLocalUser({ isTwoFactorEnabled: isEnabled });
})();
}, [open]);
@@ -112,8 +108,7 @@ const ManageDrawerContents: React.FC<ContentsProps> = ({ onRootClose }) => {
const disable = async () => {
await disable2FA();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await setLSUser({ ...getData("user"), isTwoFactorEnabled: false });
updateSavedLocalUser({ isTwoFactorEnabled: undefined });
onRootClose();
};

View File

@@ -1,4 +1,4 @@
import { getData, setLSUser } from "ente-accounts/services/accounts-db";
import { updateSavedLocalUser } from "ente-accounts/services/accounts-db";
import { ensureLocalUser } from "ente-accounts/services/user";
import { isDesktop } from "ente-base/app";
import { authenticatedRequestHeaders, ensureOk } from "ente-base/http";
@@ -241,10 +241,10 @@ export const pullUserDetails = async () => {
// Update the email for the local storage user if needed (the user might've
// changed their email on a different client).
if (ensureLocalUser().email != userDetails.email) {
const { email } = userDetails;
if (ensureLocalUser().email != email) {
log.info("Updating user email to match fetched user details");
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await setLSUser({ ...getData("user"), email: userDetails.email });
updateSavedLocalUser({ email });
}
// The gallery listens for updates to userDetails, so a special case, do a