🛡️ fix: OTP Verification For 2FA Disable Operation (#8975)

This commit is contained in:
Danny Avila 2025-08-10 15:05:16 -04:00 committed by GitHub
parent edf33bedcb
commit 7e4c8a5d0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 58 additions and 30 deletions

View file

@ -99,10 +99,36 @@ const confirm2FA = async (req, res) => {
/** /**
* Disable 2FA by clearing the stored secret and backup codes. * Disable 2FA by clearing the stored secret and backup codes.
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
*/ */
const disable2FA = async (req, res) => { const disable2FA = async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId);
if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA is not setup for this user' });
}
if (user.twoFactorEnabled) {
const secret = await getTOTPSecret(user.totpSecret);
let isVerified = false;
if (token) {
isVerified = await verifyTOTP(secret, token);
} else if (backupCode) {
isVerified = await verifyBackupCode({ user, backupCode });
} else {
return res
.status(400)
.json({ message: 'Either token or backup code is required to disable 2FA' });
}
if (!isVerified) {
return res.status(401).json({ message: 'Invalid token or backup code' });
}
}
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false }); await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
return res.status(200).json(); return res.status(200).json();
} catch (err) { } catch (err) {

View file

@ -7,7 +7,7 @@ import BackupCodesItem from './BackupCodesItem';
import { useAuthContext } from '~/hooks'; import { useAuthContext } from '~/hooks';
function Account() { function Account() {
const user = useAuthContext(); const { user } = useAuthContext();
return ( return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary"> <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
@ -17,12 +17,12 @@ function Account() {
<div className="pb-3"> <div className="pb-3">
<Avatar /> <Avatar />
</div> </div>
{user?.user?.provider === 'local' && ( {user?.provider === 'local' && (
<> <>
<div className="pb-3"> <div className="pb-3">
<EnableTwoFactorItem /> <EnableTwoFactorItem />
</div> </div>
{user?.user?.twoFactorEnabled && ( {user?.twoFactorEnabled && (
<div className="pb-3"> <div className="pb-3">
<BackupCodesItem /> <BackupCodesItem />
</div> </div>

View file

@ -39,8 +39,8 @@ const TwoFactorAuthentication: React.FC = () => {
const [secret, setSecret] = useState<string>(''); const [secret, setSecret] = useState<string>('');
const [otpauthUrl, setOtpauthUrl] = useState<string>(''); const [otpauthUrl, setOtpauthUrl] = useState<string>('');
const [downloaded, setDownloaded] = useState<boolean>(false); const [downloaded, setDownloaded] = useState<boolean>(false);
const [disableToken, setDisableToken] = useState<string>('');
const [backupCodes, setBackupCodes] = useState<string[]>([]); const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [_disableToken, setDisableToken] = useState<string>('');
const [isDialogOpen, setDialogOpen] = useState<boolean>(false); const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [verificationToken, setVerificationToken] = useState<string>(''); const [verificationToken, setVerificationToken] = useState<string>('');
const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup'); const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup');
@ -166,32 +166,26 @@ const TwoFactorAuthentication: React.FC = () => {
payload.token = token.trim(); payload.token = token.trim();
} }
verify2FAMutate(payload, { disable2FAMutate(payload, {
onSuccess: () => { onSuccess: () => {
disable2FAMutate(undefined, { showToast({ message: localize('com_ui_2fa_disabled') });
onSuccess: () => { setDialogOpen(false);
showToast({ message: localize('com_ui_2fa_disabled') }); setUser(
setDialogOpen(false); (prev) =>
setUser( ({
(prev) => ...prev,
({ totpSecret: '',
...prev, backupCodes: [],
totpSecret: '', twoFactorEnabled: false,
backupCodes: [], }) as TUser,
twoFactorEnabled: false, );
}) as TUser, setPhase('setup');
); setOtpauthUrl('');
setPhase('setup');
setOtpauthUrl('');
},
onError: () =>
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
});
}, },
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }), onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
}); });
}, },
[verify2FAMutate, disable2FAMutate, showToast, localize, setUser], [disable2FAMutate, showToast, localize, setUser],
); );
return ( return (

View file

@ -134,12 +134,12 @@ export const useConfirmTwoFactorMutation = (): UseMutationResult<
export const useDisableTwoFactorMutation = (): UseMutationResult< export const useDisableTwoFactorMutation = (): UseMutationResult<
t.TDisable2FAResponse, t.TDisable2FAResponse,
unknown, unknown,
void, t.TDisable2FARequest | undefined,
unknown unknown
> => { > => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation(() => dataService.disableTwoFactor(), { return useMutation((payload?: t.TDisable2FARequest) => dataService.disableTwoFactor(payload), {
onSuccess: (data) => { onSuccess: () => {
queryClient.setQueryData([QueryKeys.user, '2fa'], null); queryClient.setQueryData([QueryKeys.user, '2fa'], null);
}, },
}); });

View file

@ -815,8 +815,8 @@ export function confirmTwoFactor(payload: t.TVerify2FARequest): Promise<t.TVerif
return request.post(endpoints.confirmTwoFactor(), payload); return request.post(endpoints.confirmTwoFactor(), payload);
} }
export function disableTwoFactor(): Promise<t.TDisable2FAResponse> { export function disableTwoFactor(payload?: t.TDisable2FARequest): Promise<t.TDisable2FAResponse> {
return request.post(endpoints.disableTwoFactor()); return request.post(endpoints.disableTwoFactor(), payload);
} }
export function regenerateBackupCodes(): Promise<t.TRegenerateBackupCodesResponse> { export function regenerateBackupCodes(): Promise<t.TRegenerateBackupCodesResponse> {

View file

@ -413,6 +413,14 @@ export type TVerify2FATempResponse = {
message?: string; message?: string;
}; };
/**
* Request for disabling 2FA.
*/
export type TDisable2FARequest = {
token?: string;
backupCode?: string;
};
/** /**
* Response from disabling 2FA. * Response from disabling 2FA.
*/ */