fix(auth): regenerate backup codes with the emailed challenge (#2436)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-03 21:29:16 +02:00
1 parent add0a3dfc6
commit 16ae98e189
13 files changed
+427 -29

No files matched your search

@@ -163,6 +163,7 @@ export const Endpoints = {
USER_MFA_BACKUP_CODES_CHALLENGE_START: '/users/@me/mfa/backup-codes/challenge',
USER_MFA_BACKUP_CODES_CHALLENGE_RESEND: '/users/@me/mfa/backup-codes/challenge/resend',
USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY: '/users/@me/mfa/backup-codes/challenge/verify',
USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE: '/users/@me/mfa/backup-codes/challenge/regenerate',
USER_MFA_TOTP_DISABLE: '/users/@me/mfa/totp/disable',
USER_MFA_TOTP_ENABLE: '/users/@me/mfa/totp/enable',
USER_AUTHORIZED_IPS: '/users/@me/authorized-ips',
@@ -18,6 +18,25 @@ interface BackupCodesChallengeResponse {
resend_available_at: string | null;
}
interface BackupCodesChallengeVerifyResponse extends BackupCodesResponse {
verification_proof: string;
}
export interface BackupCodesChallenge {
ticket: string;
verificationProof: string;
}
const CHALLENGE_CREDENTIAL_PATHS = new Set(['ticket', 'verification_proof']);
export function isExpiredBackupCodesChallengeError(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('body' in error)) {
return false;
}
const body = (error as {body?: {errors?: Array<{path?: string}>}}).body;
return body?.errors?.some((validationError) => CHALLENGE_CREDENTIAL_PATHS.has(validationError.path ?? '')) ?? false;
}
type BackupCodeMode = 'fetch' | 'regenerate';
function backupCodeMode(regenerate: boolean): BackupCodeMode {
@@ -53,9 +72,23 @@ async function requestBackupCodesChallengeResend(ticket: string): Promise<void>
await http.post(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_RESEND, {body: {ticket}});
}
async function requestBackupCodesChallengeVerify(ticket: string, code: string): Promise<Array<BackupCode>> {
const response = await http.post<BackupCodesResponse>(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY, {
body: {ticket, code},
async function requestBackupCodesChallengeVerify(
ticket: string,
code: string,
): Promise<BackupCodesChallengeVerifyResponse> {
const response = await http.post<BackupCodesChallengeVerifyResponse>(
Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY,
{body: {ticket, code}},
);
return response.body;
}
async function requestBackupCodesChallengeRegenerate(
ticket: string,
verificationProof: string,
): Promise<Array<BackupCode>> {
const response = await http.post<BackupCodesResponse>(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE, {
body: {ticket, verification_proof: verificationProof},
});
return response.body.backup_codes;
}
@@ -120,13 +153,27 @@ export async function resendBackupCodesChallengeCode(ticket: string): Promise<vo
}
}
export async function verifyBackupCodesChallenge(ticket: string, code: string): Promise<Array<BackupCode>> {
export async function verifyBackupCodesChallenge(
ticket: string,
code: string,
): Promise<BackupCodesChallengeVerifyResponse> {
try {
logger.debug('Verifying MFA backup codes challenge code');
const backupCodes = await requestBackupCodesChallengeVerify(ticket, code);
const result = await requestBackupCodesChallengeVerify(ticket, code);
logger.debug('Successfully verified MFA backup codes challenge code');
return backupCodes;
return result;
} catch (error) {
rethrowMfaFailure('Failed to verify MFA backup codes challenge code:', error);
}
}
export async function regenerateBackupCodesWithChallenge(challenge: BackupCodesChallenge): Promise<Array<BackupCode>> {
try {
logger.debug('Regenerating MFA backup codes with a verified challenge');
const backupCodes = await requestBackupCodesChallengeRegenerate(challenge.ticket, challenge.verificationProof);
logger.debug('Successfully regenerated MFA backup codes with a verified challenge');
return backupCodes;
} catch (error) {
rethrowMfaFailure('Failed to regenerate MFA backup codes with a verified challenge:', error);
}
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import type {BackupCodesChallenge} from '@app/features/auth/commands/MfaCommands';
import styles from '@app/features/auth/components/modals/BackupCodesModal.module.css';
import {BackupCodesRegenerateModal} from '@app/features/auth/components/modals/BackupCodesRegenerateModal';
import {Button} from '@app/features/ui/button/Button';
@@ -22,9 +23,10 @@ const BACKUP_CODES_DESCRIPTOR = msg({
interface BackupCodesModalProps {
backupCodes: ReadonlyArray<BackupCode>;
user: User;
challenge?: BackupCodesChallenge;
}
export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalProps) => {
export const BackupCodesModal = observer(({backupCodes, user, challenge}: BackupCodesModalProps) => {
const {i18n} = useLingui();
return (
<Modal.Root size="small" centered data-flx="auth.backup-codes-modal.modal-root">
@@ -101,6 +103,7 @@ export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalP
modal(() => (
<BackupCodesRegenerateModal
user={user}
challenge={challenge}
data-flx="auth.backup-codes-modal.backup-codes-regenerate-modal"
/>
)),
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import type {BackupCodesChallenge} from '@app/features/auth/commands/MfaCommands';
import * as MfaCommands from '@app/features/auth/commands/MfaCommands';
import {BackupCodesModal} from '@app/features/auth/components/modals/BackupCodesModal';
import {Button} from '@app/features/ui/button/Button';
@@ -9,6 +10,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import type {User} from '@app/features/user/models/User';
import * as FormUtils from '@app/lib/forms';
import type {BackupCode} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
@@ -25,21 +27,41 @@ const REGENERATE_BACKUP_CODES_DESCRIPTOR = msg({
interface BackupCodesRegenerateModalProps {
user: User;
challenge?: BackupCodesChallenge;
}
export const BackupCodesRegenerateModal = observer(({user}: BackupCodesRegenerateModalProps) => {
interface RegenerateResult {
backupCodes: Array<BackupCode>;
challenge?: BackupCodesChallenge;
}
async function regenerateBackupCodes(challenge?: BackupCodesChallenge): Promise<RegenerateResult> {
if (challenge) {
try {
return {backupCodes: await MfaCommands.regenerateBackupCodesWithChallenge(challenge), challenge};
} catch (error) {
if (!MfaCommands.isExpiredBackupCodesChallengeError(error)) {
throw error;
}
}
}
return {backupCodes: await MfaCommands.getBackupCodes(true)};
}
export const BackupCodesRegenerateModal = observer(({user, challenge}: BackupCodesRegenerateModalProps) => {
const {i18n} = useLingui();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleConfirm = async () => {
setIsSubmitting(true);
try {
const backupCodes = await MfaCommands.getBackupCodes(true);
const result = await regenerateBackupCodes(challenge);
ModalCommands.pop();
ModalCommands.update('backup-codes', () =>
modal(() => (
<BackupCodesModal
backupCodes={backupCodes}
backupCodes={result.backupCodes}
user={user}
challenge={result.challenge}
data-flx="auth.backup-codes-regenerate-modal.handle-confirm.backup-codes-modal"
/>
)),
@@ -99,13 +99,15 @@ export const BackupCodesViewModal = observer(({user}: BackupCodesViewModalProps)
setSubmitting(true);
setCodeError(null);
try {
const backupCodes = await MfaCommands.verifyBackupCodesChallenge(ticket, code);
const result = await MfaCommands.verifyBackupCodesChallenge(ticket, code);
const challenge = {ticket, verificationProof: result.verification_proof};
ModalCommands.pop();
ModalCommands.pushWithKey(
modal(() => (
<BackupCodesModal
backupCodes={backupCodes}
backupCodes={result.backup_codes}
user={user}
challenge={challenge}
data-flx="auth.backup-codes-view-modal.handle-verify.backup-codes-modal"
/>
)),
@@ -80,7 +80,7 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
autoCapitalize="none"
autoCorrect="off"
enterKeyHint="done"
inputMode="numeric"
inputMode="text"
spellCheck={false}
autoFocus
data-step-focus="true"