mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-16 12:46:34 +01:00
🔑 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:
parent
189cdf581d
commit
71a3b48504
14 changed files with 927 additions and 104 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue