mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(auth): a new email address on an account with a second factor needs that factor
The address is what single sign-on links an existing account by, and changing it took the password or a session younger than five minutes on every account. A remembered browser skips the second factor at sign-in and still makes a fresh session, so the password alone could move the address of an account the second factor was meant to protect. On an account with a second factor only a second factor or passkey completed in the last five minutes counts now; the password is not checked. The refusal names it (second_factor_required in rejectedFields, or 401 profile.update.emailSecondFactorRequired when the address was all that changed), and the profile form opens the re-proof dialog with the factors the account holds, then saves again, no longer offering the password field. Accounts without a second factor keep the password or a recent sign-in. A token cannot change the address of such an account; the app does not change it.
This commit is contained in:
1 parent
bd270d635a
commit
9642c749c9
16 files changed
+280
-61
No files matched your search
@@ -34,7 +34,11 @@ import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
|
||||
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import {
|
||||
describeRejectedProfileField,
|
||||
describeRejectedProfileFields,
|
||||
@@ -75,6 +79,12 @@ export function AccountSection() {
|
||||
// in within the last few minutes; the server decides and names the field if
|
||||
// it wanted one. Never stored beyond this form.
|
||||
const [emailPassword, setEmailPassword] = useState("");
|
||||
// On an account with a second factor the password does not move the
|
||||
// address: the server asks for the factor instead (`second_factor_required`)
|
||||
// and the save runs once more after the re-proof dialog. Once that is
|
||||
// known the password field is not offered, since it could only be refused.
|
||||
const [emailNeedsFactor, setEmailNeedsFactor] = useState(false);
|
||||
const recentProof = useRecentProof();
|
||||
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState<string>("");
|
||||
@@ -193,8 +203,46 @@ export function AccountSection() {
|
||||
email.trim() !== "" &&
|
||||
email.trim().toLowerCase() !== profileSeed.email.trim().toLowerCase();
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
/**
|
||||
* Did the server keep the new address back until a second factor is
|
||||
* proved? Either as the one rejected field of a partial save, or as the
|
||||
* whole answer when the address was all that changed.
|
||||
*/
|
||||
function needsSecondFactor(
|
||||
rejected: RejectedProfileField[] | undefined,
|
||||
errorCode: string | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
errorCode === "profile.update.emailSecondFactorRequired" ||
|
||||
(rejected ?? []).some(
|
||||
(r) => r.path === "email" && r.code === "second_factor_required",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the second factor (or a passkey) through the shared re-proof
|
||||
* dialog, then save once more. Returns false when the person closed the
|
||||
* dialog or could not confirm, leaving the message to show.
|
||||
*/
|
||||
async function proveFactorThenSave(): Promise<boolean> {
|
||||
setEmailNeedsFactor(true);
|
||||
try {
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
} catch (err) {
|
||||
const message = recentProofErrorMessage(err, "");
|
||||
if (message) {
|
||||
setSaveMsg({ text: message });
|
||||
setSaveMsgType("error");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
await handleSaveProfile(undefined, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSaveProfile(e?: React.FormEvent, afterFactor = false) {
|
||||
e?.preventDefault();
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
setSaveMsgType(null);
|
||||
@@ -255,6 +303,10 @@ export function AccountSection() {
|
||||
} | null;
|
||||
const rejected = profileJson?.data?.rejectedFields;
|
||||
|
||||
if (!afterFactor && needsSecondFactor(rejected, undefined)) {
|
||||
if (await proveFactorThenSave()) return;
|
||||
}
|
||||
|
||||
if (rejected && rejected.length > 0) {
|
||||
// The write is field-independent: everything but the rejected
|
||||
// field(s) landed. Name the field instead of implying full
|
||||
@@ -304,6 +356,9 @@ export function AccountSection() {
|
||||
details?: { issues?: RejectedProfileField[] };
|
||||
} | null;
|
||||
const errorCode = json?.meta?.errorCode;
|
||||
if (!afterFactor && needsSecondFactor(undefined, errorCode)) {
|
||||
if (await proveFactorThenSave()) return;
|
||||
}
|
||||
const field = describeRejectedProfileField(json?.details?.issues, t);
|
||||
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
|
||||
if (errorCode === "profile.update.nothingSaved" && field) {
|
||||
@@ -350,7 +405,10 @@ export function AccountSection() {
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader icon={User} title={t("settings.profile")} />
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
||||
<form
|
||||
onSubmit={(event) => void handleSaveProfile(event)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t("settings.username")}</Label>
|
||||
@@ -386,7 +444,7 @@ export function AccountSection() {
|
||||
aria-describedby={fieldErrors.email ? "email-error" : undefined}
|
||||
/>
|
||||
<FieldError id="email-error" message={fieldErrors.email} />
|
||||
{emailChanged && (
|
||||
{emailChanged && !emailNeedsFactor && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label htmlFor="email-current-password">
|
||||
{t("settings.emailChangePassword")}
|
||||
@@ -636,6 +694,7 @@ export function AccountSection() {
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
{recentProof.dialog}
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated
|
||||
|
||||
Reference in new issue
Block a user