mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
+29
-25
@@ -23019,8 +23019,12 @@ components:
|
||||
a second factor in the last five minutes needs nothing more, otherwise this field must carry the current
|
||||
password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as
|
||||
`reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401
|
||||
`auth.reproof.required` / `auth.reproof.failed`). Attempts draw on the account's shared re-proof budget of
|
||||
five per fifteen minutes. Never stored."
|
||||
`auth.reproof.required` / `auth.reproof.failed`). On an account with a second factor the password is not
|
||||
accepted and not checked: only a second factor or passkey completed in the last five minutes counts (at
|
||||
sign-in or at POST /api/auth/reproof), and without one the address comes back as `second_factor_required`
|
||||
(or 401 `profile.update.emailSecondFactorRequired` when it was the only field). A token cannot change the
|
||||
address of such an account. Attempts draw on the account's shared re-proof budget of five per fifteen
|
||||
minutes. Never stored."
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null
|
||||
@@ -31522,29 +31526,29 @@ components:
|
||||
onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
|
||||
onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner —
|
||||
practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
|
||||
profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited;
|
||||
report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape,
|
||||
report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed,
|
||||
share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid,
|
||||
share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf,
|
||||
share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found,
|
||||
sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate,
|
||||
sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only,
|
||||
sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
|
||||
sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
|
||||
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
|
||||
source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens —
|
||||
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached; vaccination —
|
||||
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
|
||||
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
|
||||
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
|
||||
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) —
|
||||
about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
|
||||
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
|
||||
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
|
||||
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
|
||||
private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows,
|
||||
unreadable_json, upstream_invalid_json, vapid_not_configured"
|
||||
profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
|
||||
record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json,
|
||||
report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link
|
||||
— share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
|
||||
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
|
||||
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
|
||||
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
|
||||
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
|
||||
sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted,
|
||||
sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended,
|
||||
sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
|
||||
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
|
||||
source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached,
|
||||
tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid,
|
||||
vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found,
|
||||
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
|
||||
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
|
||||
workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
|
||||
dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
|
||||
invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict,
|
||||
missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured,
|
||||
notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable,
|
||||
rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
|
||||
type: string
|
||||
capability:
|
||||
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
|
||||
|
||||
+4
-2
@@ -126,7 +126,8 @@
|
||||
"nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.",
|
||||
"invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.",
|
||||
"emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.",
|
||||
"emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut."
|
||||
"emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut.",
|
||||
"emailSecondFactorRequired": "Bestätige mit deinem zweiten Faktor oder einem Passkey, um die E-Mail-Adresse zu ändern."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -5568,7 +5569,8 @@
|
||||
"rateLimited": "Zu oft geändert. Warte kurz und versuche es dann erneut.",
|
||||
"other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen.",
|
||||
"reproofRequired": "Gib dein aktuelles Passwort ein, um die E-Mail-Adresse zu ändern.",
|
||||
"reproofFailed": "Das Passwort stimmt nicht, die E-Mail-Adresse wurde nicht geändert."
|
||||
"reproofFailed": "Das Passwort stimmt nicht, die E-Mail-Adresse wurde nicht geändert.",
|
||||
"secondFactorRequired": "Bestätige mit deinem zweiten Faktor oder einem Passkey, um die E-Mail-Adresse zu ändern."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Profilfoto",
|
||||
|
||||
+4
-2
@@ -126,7 +126,8 @@
|
||||
"nothingSaved": "Nothing was saved. Check the highlighted field and try again.",
|
||||
"invalidBody": "The profile update could not be read.",
|
||||
"emailInUse": "This email address is already in use.",
|
||||
"emailRateLimited": "Too many email-address changes. Try again later."
|
||||
"emailRateLimited": "Too many email-address changes. Try again later.",
|
||||
"emailSecondFactorRequired": "Confirm with your second factor or a passkey to change the email address."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -5568,7 +5569,8 @@
|
||||
"rateLimited": "Changed too often. Wait a little and try again.",
|
||||
"other": "This value was not accepted. Check it and try again.",
|
||||
"reproofRequired": "Enter your current password to change the email address.",
|
||||
"reproofFailed": "That password is not correct, so the email address was not changed."
|
||||
"reproofFailed": "That password is not correct, so the email address was not changed.",
|
||||
"secondFactorRequired": "Confirm with your second factor or a passkey to change the email address."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Profile photo",
|
||||
|
||||
+4
-2
@@ -126,7 +126,8 @@
|
||||
"nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.",
|
||||
"invalidBody": "No se pudo leer la actualización del perfil.",
|
||||
"emailInUse": "Esta dirección de correo ya está en uso.",
|
||||
"emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde."
|
||||
"emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde.",
|
||||
"emailSecondFactorRequired": "Confirma con tu segundo factor o una clave de acceso para cambiar el correo electrónico."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -5568,7 +5569,8 @@
|
||||
"rateLimited": "Se ha cambiado demasiadas veces. Espera un poco e inténtalo de nuevo.",
|
||||
"other": "No se aceptó este valor. Revísalo e inténtalo de nuevo.",
|
||||
"reproofRequired": "Introduce tu contraseña actual para cambiar la dirección de correo.",
|
||||
"reproofFailed": "La contraseña no es correcta, así que la dirección de correo no se ha cambiado."
|
||||
"reproofFailed": "La contraseña no es correcta, así que la dirección de correo no se ha cambiado.",
|
||||
"secondFactorRequired": "Confirma con tu segundo factor o una clave de acceso para cambiar el correo electrónico."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Foto de perfil",
|
||||
|
||||
+4
-2
@@ -126,7 +126,8 @@
|
||||
"nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.",
|
||||
"invalidBody": "La mise à jour du profil n'a pas pu être lue.",
|
||||
"emailInUse": "Cette adresse e-mail est déjà utilisée.",
|
||||
"emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard."
|
||||
"emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard.",
|
||||
"emailSecondFactorRequired": "Confirmez avec votre second facteur ou une clé d'accès pour changer l'adresse e-mail."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -5568,7 +5569,8 @@
|
||||
"rateLimited": "Modifié trop souvent. Patientez un peu et réessayez.",
|
||||
"other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez.",
|
||||
"reproofRequired": "Saisissez votre mot de passe actuel pour changer l'adresse e-mail.",
|
||||
"reproofFailed": "Ce mot de passe est incorrect, l'adresse e-mail n'a donc pas été modifiée."
|
||||
"reproofFailed": "Ce mot de passe est incorrect, l'adresse e-mail n'a donc pas été modifiée.",
|
||||
"secondFactorRequired": "Confirmez avec votre second facteur ou une clé d'accès pour changer l'adresse e-mail."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Photo de profil",
|
||||
|
||||
+4
-2
@@ -126,7 +126,8 @@
|
||||
"nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.",
|
||||
"invalidBody": "Impossibile leggere l'aggiornamento del profilo.",
|
||||
"emailInUse": "Questo indirizzo email è già in uso.",
|
||||
"emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi."
|
||||
"emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi.",
|
||||
"emailSecondFactorRequired": "Conferma con il tuo secondo fattore o una passkey per cambiare l'indirizzo email."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -5568,7 +5569,8 @@
|
||||
"rateLimited": "Modificato troppe volte. Aspetta un momento e riprova.",
|
||||
"other": "Questo valore non è stato accettato. Controllalo e riprova.",
|
||||
"reproofRequired": "Inserisci la password attuale per cambiare l'indirizzo email.",
|
||||
"reproofFailed": "La password non è corretta, quindi l'indirizzo email non è stato cambiato."
|
||||
"reproofFailed": "La password non è corretta, quindi l'indirizzo email non è stato cambiato.",
|
||||
"secondFactorRequired": "Conferma con il tuo secondo fattore o una passkey per cambiare l'indirizzo email."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Foto del profilo",
|
||||
|
||||
+4
-2
@@ -126,7 +126,8 @@
|
||||
"nothingSaved": "저장된 내용이 없어요. 표시된 항목을 확인하고 다시 시도해 주세요.",
|
||||
"invalidBody": "프로필 변경 내용을 읽지 못했어요.",
|
||||
"emailInUse": "이미 사용 중인 이메일 주소예요.",
|
||||
"emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요."
|
||||
"emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요.",
|
||||
"emailSecondFactorRequired": "이메일 주소를 바꾸려면 2단계 인증 수단이나 패스키로 확인해 주세요."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -5568,7 +5569,8 @@
|
||||
"rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.",
|
||||
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요.",
|
||||
"reproofRequired": "이메일 주소를 변경하려면 현재 비밀번호를 입력하세요.",
|
||||
"reproofFailed": "비밀번호가 올바르지 않아 이메일 주소가 변경되지 않았습니다."
|
||||
"reproofFailed": "비밀번호가 올바르지 않아 이메일 주소가 변경되지 않았습니다.",
|
||||
"secondFactorRequired": "이메일 주소를 바꾸려면 2단계 인증 수단이나 패스키로 확인해 주세요."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "프로필 사진",
|
||||
|
||||
+4
-2
@@ -126,7 +126,8 @@
|
||||
"nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.",
|
||||
"invalidBody": "Nie udało się odczytać aktualizacji profilu.",
|
||||
"emailInUse": "Ten adres e-mail jest już używany.",
|
||||
"emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później."
|
||||
"emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później.",
|
||||
"emailSecondFactorRequired": "Potwierdź drugim składnikiem lub kluczem dostępu, aby zmienić adres e-mail."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -5568,7 +5569,8 @@
|
||||
"rateLimited": "Zmieniane zbyt często. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie.",
|
||||
"reproofRequired": "Wpisz obecne hasło, aby zmienić adres e-mail.",
|
||||
"reproofFailed": "Hasło jest nieprawidłowe, więc adres e-mail nie został zmieniony."
|
||||
"reproofFailed": "Hasło jest nieprawidłowe, więc adres e-mail nie został zmieniony.",
|
||||
"secondFactorRequired": "Potwierdź drugim składnikiem lub kluczem dostępu, aby zmienić adres e-mail."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Zdjęcie profilowe",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +50,10 @@ import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { createRegistrationOptions } from "@/lib/auth/passkey";
|
||||
import { requireCookieAuth } from "@/lib/api-handler";
|
||||
import { checkCookieEnrollmentProof } from "../existing-factor-proof";
|
||||
import {
|
||||
authorizeSensitiveChange,
|
||||
checkCookieEnrollmentProof,
|
||||
} from "../existing-factor-proof";
|
||||
import { POST as PASSKEY_REGISTER_OPTIONS } from "@/app/api/auth/passkey/register-options/route";
|
||||
|
||||
const NOW = Date.now();
|
||||
@@ -226,3 +229,69 @@ describe("POST /api/auth/passkey/register-options", () => {
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changing the email address", () => {
|
||||
function change(
|
||||
user: typeof ENROLLED | typeof PLAIN,
|
||||
opts: { cookie?: boolean; password?: string | null } = {},
|
||||
) {
|
||||
return authorizeSensitiveChange({
|
||||
user: user as never,
|
||||
cookieSessionId: opts.cookie === false ? null : "sess-1",
|
||||
currentPassword: opts.password ?? null,
|
||||
ipAddress: "203.0.113.9",
|
||||
stage: "email_change",
|
||||
});
|
||||
}
|
||||
|
||||
it("with a second factor, a session signed in a minute ago is not enough", async () => {
|
||||
session({ createdAt: fresh });
|
||||
expect(await change(ENROLLED)).toBe("second_factor_required");
|
||||
});
|
||||
|
||||
it("with a second factor, a password re-proof stamp is not enough", async () => {
|
||||
session({ reproofAt: fresh });
|
||||
expect(await change(ENROLLED)).toBe("second_factor_required");
|
||||
});
|
||||
|
||||
it("with a second factor, the right password is not even checked", async () => {
|
||||
session({ createdAt: fresh });
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
expect(await change(ENROLLED, { password: "correct horse" })).toBe(
|
||||
"second_factor_required",
|
||||
);
|
||||
expect(verifyPassword).not.toHaveBeenCalled();
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("with a second factor, a token and the password cannot change it", async () => {
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
expect(
|
||||
await change(ENROLLED, { cookie: false, password: "correct horse" }),
|
||||
).toBe("second_factor_required");
|
||||
});
|
||||
|
||||
it("with a second factor, a factor proved within five minutes is", async () => {
|
||||
session({ mfaVerifiedAt: fresh });
|
||||
expect(await change(ENROLLED)).toBe("ok");
|
||||
});
|
||||
|
||||
it("without one, a young session is enough, as before", async () => {
|
||||
session({ createdAt: fresh });
|
||||
expect(await change(PLAIN)).toBe("ok");
|
||||
});
|
||||
|
||||
it("without one, the right password is enough and gives its attempt back", async () => {
|
||||
session({});
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
expect(await change(PLAIN, { password: "pw" })).toBe("ok");
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
|
||||
});
|
||||
|
||||
it("without one, a wrong password fails and nothing at all is required", async () => {
|
||||
session({});
|
||||
vi.mocked(verifyPassword).mockResolvedValue(false);
|
||||
expect(await change(PLAIN, { password: "nope" })).toBe("failed");
|
||||
expect(await change(PLAIN)).toBe("required");
|
||||
});
|
||||
});
|
||||
@@ -270,6 +270,36 @@ describe("applyProfileUpdate — a new address needs a fresh proof", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the second factor when the account has one", async () => {
|
||||
const alone = await applyWithProof(
|
||||
USER_ID,
|
||||
{ email: "someone@example.test" },
|
||||
null,
|
||||
{ authorizeEmailChange: async () => "second_factor_required" },
|
||||
);
|
||||
expect(alone.ok).toBe(false);
|
||||
if (!alone.ok) {
|
||||
expect(alone.status).toBe(401);
|
||||
expect(alone.errorCode).toBe("profile.update.emailSecondFactorRequired");
|
||||
}
|
||||
|
||||
const beside = await applyWithProof(
|
||||
USER_ID,
|
||||
{ email: "someone@example.test", heightCm: 180 },
|
||||
null,
|
||||
{ authorizeEmailChange: async () => "second_factor_required" },
|
||||
);
|
||||
expect(beside.ok).toBe(true);
|
||||
if (beside.ok) {
|
||||
expect(beside.rejectedFields).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "email",
|
||||
code: "second_factor_required",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops only the address when the save carries other fields", async () => {
|
||||
const result = await applyWithProof(
|
||||
USER_ID,
|
||||
|
||||
@@ -348,17 +348,29 @@ async function readProof(
|
||||
}
|
||||
|
||||
export type SensitiveChangeProof =
|
||||
"ok" | "required" | "failed" | "rate_limited";
|
||||
"ok" | "required" | "second_factor_required" | "failed" | "rate_limited";
|
||||
|
||||
/**
|
||||
* The proof in front of changing the account's email address.
|
||||
*
|
||||
* The address is what single sign-on matches an existing account by, so it is
|
||||
* part of how the account is reached and a stolen session must not rewrite it.
|
||||
* Accepted: on a cookie session, a sign-in or second factor inside the last
|
||||
* five minutes; on either transport, the current password alongside the
|
||||
* change. The password draws on the shared re-proof budget and a wrong one is
|
||||
* audited like every other refused proof.
|
||||
*
|
||||
* On an account WITHOUT a second factor: on a cookie session, a sign-in or
|
||||
* re-proof inside the last five minutes; on either transport, the current
|
||||
* password alongside the change. The password draws on the shared re-proof
|
||||
* budget and a wrong one is audited like every other refused proof.
|
||||
*
|
||||
* On an account WITH a second factor, the rule `checkCookieEnrollmentProof`
|
||||
* holds for adding a credential: only possession of a factor counts, which on
|
||||
* a cookie session is `Session.mfaVerifiedAt` inside five minutes (a completed
|
||||
* second factor or passkey, at sign-in or at `POST /api/auth/reproof`). The
|
||||
* password is not verified at all, and neither a young session nor a password
|
||||
* re-proof stands in: a remembered browser skips the factor at sign-in and
|
||||
* still creates a fresh session. The answer is `second_factor_required`, so
|
||||
* the client opens the re-proof dialog with the factors the account holds. A
|
||||
* token carries no such stamp, so a Bearer caller cannot change the address
|
||||
* of such an account; the shipped app does not change it.
|
||||
*/
|
||||
export async function authorizeSensitiveChange(args: {
|
||||
user: Pick<
|
||||
@@ -377,6 +389,8 @@ export async function authorizeSensitiveChange(args: {
|
||||
}): Promise<SensitiveChangeProof> {
|
||||
const { user, cookieSessionId, currentPassword, ipAddress, stage } = args;
|
||||
|
||||
const enrolled = await hasSecondFactorEnrolled(user);
|
||||
|
||||
if (cookieSessionId) {
|
||||
const row = await prisma.session.findUnique({
|
||||
where: { id: cookieSessionId },
|
||||
@@ -385,12 +399,22 @@ export async function authorizeSensitiveChange(args: {
|
||||
const now = Date.now();
|
||||
const recent = (d: Date | null | undefined) =>
|
||||
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
|
||||
if (
|
||||
recent(row?.mfaVerifiedAt) ||
|
||||
recent(row?.createdAt) ||
|
||||
recent(row?.reproofAt)
|
||||
)
|
||||
return "ok";
|
||||
const satisfied = enrolled
|
||||
? recent(row?.mfaVerifiedAt)
|
||||
: recent(row?.mfaVerifiedAt) ||
|
||||
recent(row?.createdAt) ||
|
||||
recent(row?.reproofAt);
|
||||
if (satisfied) return "ok";
|
||||
}
|
||||
|
||||
// Before any password is looked at: on this account a right password would
|
||||
// not be enough, so checking it would only spend a guess.
|
||||
if (enrolled) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.method_refused" },
|
||||
meta: { stage, method: currentPassword ? "password" : "none" },
|
||||
});
|
||||
return "second_factor_required";
|
||||
}
|
||||
|
||||
if (!currentPassword) return "required";
|
||||
@@ -423,5 +447,6 @@ export async function authorizeSensitiveChange(args: {
|
||||
);
|
||||
return "failed";
|
||||
}
|
||||
await refundReproof(user.id);
|
||||
return "ok";
|
||||
}
|
||||
@@ -55,6 +55,14 @@ const extendedProfileSchema = profileSchema.extend({
|
||||
|
||||
type ExtendedProfileInput = z.infer<typeof extendedProfileSchema>;
|
||||
|
||||
/**
|
||||
* A new email address on an account with a second factor, without that
|
||||
* factor proved in the last five minutes. The client answers it with the
|
||||
* re-proof dialog (second factor or passkey) and saves again.
|
||||
*/
|
||||
export const EMAIL_SECOND_FACTOR_REQUIRED_CODE =
|
||||
"profile.update.emailSecondFactorRequired";
|
||||
|
||||
/**
|
||||
* The only profile fields a demo instance may write: the three the setup
|
||||
* flow's baseline step collects.
|
||||
@@ -299,7 +307,9 @@ export async function applyProfileUpdate(
|
||||
? "rate_limited"
|
||||
: proof === "failed"
|
||||
? "reproof_failed"
|
||||
: "reproof_required";
|
||||
: proof === "second_factor_required"
|
||||
? "second_factor_required"
|
||||
: "reproof_required";
|
||||
// Same narrowing as the email budget below: the address is dropped,
|
||||
// the rest of the save lands, and `rejectedFields` says why.
|
||||
const { email: _unproven, ...keepable } = data;
|
||||
@@ -312,13 +322,17 @@ export async function applyProfileUpdate(
|
||||
? "Verification failed"
|
||||
: proof === "rate_limited"
|
||||
? "Too many attempts. Please wait 15 minutes."
|
||||
: "Confirm your current password to change the email address.",
|
||||
: proof === "second_factor_required"
|
||||
? "Confirm with your second factor or a passkey to change the email address."
|
||||
: "Confirm your current password to change the email address.",
|
||||
errorCode:
|
||||
proof === "failed"
|
||||
? REPROOF_FAILED_CODE
|
||||
: proof === "rate_limited"
|
||||
? "profile.update.emailRateLimited"
|
||||
: REPROOF_REQUIRED_CODE,
|
||||
: proof === "second_factor_required"
|
||||
? EMAIL_SECOND_FACTOR_REQUIRED_CODE
|
||||
: REPROOF_REQUIRED_CODE,
|
||||
};
|
||||
}
|
||||
data = keepable;
|
||||
@@ -328,7 +342,9 @@ export async function applyProfileUpdate(
|
||||
path: "email",
|
||||
code,
|
||||
message:
|
||||
"Confirm your current password to change the email address.",
|
||||
proof === "second_factor_required"
|
||||
? "Confirm with your second factor or a passkey to change the email address."
|
||||
: "Confirm your current password to change the email address.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
profile: [
|
||||
"profile.update.emailInUse",
|
||||
"profile.update.emailRateLimited",
|
||||
"profile.update.emailSecondFactorRequired",
|
||||
"profile.update.invalidBody",
|
||||
"profile.update.nothingSaved",
|
||||
],
|
||||
|
||||
@@ -487,7 +487,7 @@ export const profileUpdateRequest = z
|
||||
.max(512)
|
||||
.optional()
|
||||
.describe(
|
||||
"The account password, sent only alongside a NEW `email`. Moving the account to a new address needs a fresh proof because single sign-on links an existing account by it: a browser session that signed in or completed a second factor in the last five minutes needs nothing more, otherwise this field must carry the current password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as `reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401 `auth.reproof.required` / `auth.reproof.failed`). Attempts draw on the account's shared re-proof budget of five per fifteen minutes. Never stored.",
|
||||
"The account password, sent only alongside a NEW `email`. Moving the account to a new address needs a fresh proof because single sign-on links an existing account by it: a browser session that signed in or completed a second factor in the last five minutes needs nothing more, otherwise this field must carry the current password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as `reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401 `auth.reproof.required` / `auth.reproof.failed`). On an account with a second factor the password is not accepted and not checked: only a second factor or passkey completed in the last five minutes counts (at sign-in or at POST /api/auth/reproof), and without one the address comes back as `second_factor_required` (or 401 `profile.update.emailSecondFactorRequired` when it was the only field). A token cannot change the address of such an account. Attempts draw on the account's shared re-proof budget of five per fifteen minutes. Never stored.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -82,6 +82,7 @@ const REJECTION_REASON_KEYS: Record<string, string> = {
|
||||
// these say whether none was given or the one given was wrong.
|
||||
reproof_required: "settings.profileRejection.reproofRequired",
|
||||
reproof_failed: "settings.profileRejection.reproofFailed",
|
||||
second_factor_required: "settings.profileRejection.secondFactorRequired",
|
||||
too_big: "settings.profileRejection.tooBig",
|
||||
too_small: "settings.profileRejection.tooSmall",
|
||||
invalid_value: "settings.profileRejection.invalidValue",
|
||||
|
||||
Reference in new issue
Block a user