🔑 fix: Require OTP Verification for 2FA Re-Enrollment and Backup Code Regeneration (#12223)

* fix: require OTP verification for 2FA re-enrollment and backup code regeneration

* fix: require OTP verification for account deletion when 2FA is enabled

* refactor: Improve code formatting and readability in TwoFactorController and UserController

- Reformatted code in TwoFactorController and UserController for better readability by aligning parameters and breaking long lines.
- Updated test cases in deleteUser.spec.js and TwoFactorController.spec.js to enhance clarity by formatting object parameters consistently.

* refactor: Consolidate OTP and backup code verification logic in TwoFactorController and UserController

- Introduced a new `verifyOTPOrBackupCode` function to streamline the verification process for TOTP tokens and backup codes across multiple controllers.
- Updated the `enable2FA`, `disable2FA`, and `deleteUserController` methods to utilize the new verification function, enhancing code reusability and readability.
- Adjusted related tests to reflect the changes in verification logic, ensuring consistent behavior across different scenarios.
- Improved error handling and response messages for verification failures, providing clearer feedback to users.

* chore: linting

* refactor: Update BackupCodesItem component to enhance OTP verification logic

- Consolidated OTP input handling by moving the 2FA verification UI logic to a more consistent location within the component.
- Improved the state management for OTP readiness, ensuring the regenerate button is only enabled when the OTP is ready.
- Cleaned up imports by removing redundant type imports, enhancing code clarity and maintainability.

* chore: lint

* fix: stage 2FA re-enrollment in pending fields to prevent disarmament window

enable2FA now writes to pendingTotpSecret/pendingBackupCodes instead of
overwriting the live fields. confirm2FA performs the atomic swap only after
the new TOTP code is verified. If the user abandons mid-flow, their
existing 2FA remains active and intact.
This commit is contained in:
Danny Avila 2026-03-14 01:51:31 -04:00 committed by GitHub
parent 189cdf581d
commit 71a3b48504
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 927 additions and 104 deletions

View file

@ -1,12 +1,23 @@
import React, { useState } from 'react';
import { RefreshCcw } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { motion, AnimatePresence } from 'framer-motion';
import { TBackupCode, TRegenerateBackupCodesResponse, type TUser } from 'librechat-data-provider';
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
import type {
TRegenerateBackupCodesResponse,
TRegenerateBackupCodesRequest,
TBackupCode,
TUser,
} from 'librechat-data-provider';
import {
OGDialog,
InputOTPSeparator,
InputOTPGroup,
InputOTPSlot,
OGDialogContent,
OGDialogTitle,
OGDialogTrigger,
OGDialog,
InputOTP,
Button,
Label,
Spinner,
@ -15,7 +26,6 @@ import {
} from '@librechat/client';
import { useRegenerateBackupCodesMutation } from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import { useSetRecoilState } from 'recoil';
import store from '~/store';
const BackupCodesItem: React.FC = () => {
@ -24,25 +34,30 @@ const BackupCodesItem: React.FC = () => {
const { showToast } = useToastContext();
const setUser = useSetRecoilState(store.user);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [otpToken, setOtpToken] = useState('');
const [useBackup, setUseBackup] = useState(false);
const { mutate: regenerateBackupCodes, isLoading } = useRegenerateBackupCodesMutation();
const needs2FA = !!user?.twoFactorEnabled;
const fetchBackupCodes = (auto: boolean = false) => {
regenerateBackupCodes(undefined, {
let payload: TRegenerateBackupCodesRequest | undefined;
if (needs2FA && otpToken.trim()) {
payload = useBackup ? { backupCode: otpToken.trim() } : { token: otpToken.trim() };
}
regenerateBackupCodes(payload, {
onSuccess: (data: TRegenerateBackupCodesResponse) => {
const newBackupCodes: TBackupCode[] = data.backupCodesHash.map((codeHash) => ({
codeHash,
used: false,
usedAt: null,
}));
const newBackupCodes: TBackupCode[] = data.backupCodesHash;
setUser((prev) => ({ ...prev, backupCodes: newBackupCodes }) as TUser);
setOtpToken('');
showToast({
message: localize('com_ui_backup_codes_regenerated'),
status: 'success',
});
// Trigger file download only when user explicitly clicks the button.
if (!auto && newBackupCodes.length) {
const codesString = data.backupCodes.join('\n');
const blob = new Blob([codesString], { type: 'text/plain;charset=utf-8' });
@ -66,6 +81,8 @@ const BackupCodesItem: React.FC = () => {
fetchBackupCodes(false);
};
const otpReady = !needs2FA || otpToken.length === (useBackup ? 8 : 6);
return (
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
<div className="flex items-center justify-between">
@ -161,10 +178,10 @@ const BackupCodesItem: React.FC = () => {
);
})}
</div>
<div className="mt-12 flex justify-center">
<div className="mt-6 flex justify-center">
<Button
onClick={handleRegenerate}
disabled={isLoading}
disabled={isLoading || !otpReady}
variant="default"
className="px-8 py-3 transition-all disabled:opacity-50"
>
@ -183,7 +200,7 @@ const BackupCodesItem: React.FC = () => {
<div className="flex flex-col items-center gap-4 p-6 text-center">
<Button
onClick={handleRegenerate}
disabled={isLoading}
disabled={isLoading || !otpReady}
variant="default"
className="px-8 py-3 transition-all disabled:opacity-50"
>
@ -192,6 +209,59 @@ const BackupCodesItem: React.FC = () => {
</Button>
</div>
)}
{needs2FA && (
<div className="mt-6 space-y-3">
<Label className="text-sm font-medium">
{localize('com_ui_2fa_verification_required')}
</Label>
<div className="flex justify-center">
<InputOTP
value={otpToken}
onChange={setOtpToken}
maxLength={useBackup ? 8 : 6}
pattern={useBackup ? REGEXP_ONLY_DIGITS_AND_CHARS : REGEXP_ONLY_DIGITS}
className="gap-2"
>
{useBackup ? (
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
) : (
<>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</>
)}
</InputOTP>
</div>
<button
type="button"
onClick={() => {
setUseBackup(!useBackup);
setOtpToken('');
}}
className="text-sm text-primary hover:underline"
>
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
</button>
</div>
)}
</motion.div>
</AnimatePresence>
</OGDialogContent>

View file

@ -1,16 +1,22 @@
import { LockIcon, Trash } from 'lucide-react';
import React, { useState, useCallback } from 'react';
import { LockIcon, Trash } from 'lucide-react';
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
import {
Label,
Input,
Button,
Spinner,
OGDialog,
InputOTPSeparator,
OGDialogContent,
OGDialogTrigger,
OGDialogHeader,
InputOTPGroup,
OGDialogTitle,
InputOTPSlot,
OGDialog,
InputOTP,
Spinner,
Button,
Label,
Input,
} from '@librechat/client';
import type { TDeleteUserRequest } from 'librechat-data-provider';
import { useDeleteUserMutation } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
import { LocalizeFunction } from '~/common';
@ -21,16 +27,27 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
const localize = useLocalize();
const { user, logout } = useAuthContext();
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUserMutation({
onMutate: () => logout(),
onSuccess: () => logout(),
});
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [isLocked, setIsLocked] = useState(true);
const [otpToken, setOtpToken] = useState('');
const [useBackup, setUseBackup] = useState(false);
const needs2FA = !!user?.twoFactorEnabled;
const handleDeleteUser = () => {
if (!isLocked) {
deleteUser(undefined);
if (isLocked) {
return;
}
let payload: TDeleteUserRequest | undefined;
if (needs2FA && otpToken.trim()) {
payload = useBackup ? { backupCode: otpToken.trim() } : { token: otpToken.trim() };
}
deleteUser(payload);
};
const handleInputChange = useCallback(
@ -42,6 +59,8 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
[user?.email],
);
const otpReady = !needs2FA || otpToken.length === (useBackup ? 8 : 6);
return (
<>
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
@ -79,7 +98,60 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
(e) => handleInputChange(e.target.value),
)}
</div>
{renderDeleteButton(handleDeleteUser, isDeleting, isLocked, localize)}
{needs2FA && (
<div className="mb-4 space-y-3">
<Label className="text-sm font-medium">
{localize('com_ui_2fa_verification_required')}
</Label>
<div className="flex justify-center">
<InputOTP
value={otpToken}
onChange={setOtpToken}
maxLength={useBackup ? 8 : 6}
pattern={useBackup ? REGEXP_ONLY_DIGITS_AND_CHARS : REGEXP_ONLY_DIGITS}
className="gap-2"
>
{useBackup ? (
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
) : (
<>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</>
)}
</InputOTP>
</div>
<button
type="button"
onClick={() => {
setUseBackup(!useBackup);
setOtpToken('');
}}
className="text-sm text-primary hover:underline"
>
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
</button>
</div>
)}
{renderDeleteButton(handleDeleteUser, isDeleting, isLocked || !otpReady, localize)}
</div>
</OGDialogContent>
</OGDialog>