2FA reset feature

This commit is contained in:
Francis Cao committed 2026-07-24 10:46:19 -07:00
1 parent 896d63df94
commit 3b4d5b9575
7 files changed
+262 -28

No files matched your search

+5 -1
View File
@@ -66,4 +66,8 @@ CREATE INDEX "two_factor_backup_code_user_id_idx" ON "two_factor_backup_code"("u
CREATE UNIQUE INDEX "two_factor_otp_used_user_id_otp_key" ON "two_factor_otp_used"("user_id", "otp");
-- CreateIndex
CREATE UNIQUE INDEX "two_factor_rate_limit_user_id_key" ON "two_factor_rate_limit"("user_id");
CREATE UNIQUE INDEX "two_factor_rate_limit_user_id_key" ON "two_factor_rate_limit"("user_id");
-- Historical catch-up: some environments already have this replay index,
-- but the current schema expects it everywhere.
CREATE INDEX IF NOT EXISTS "session_replay_visit_id_idx" ON "session_replay"("visit_id");
+3 -2
View File
@@ -419,10 +419,10 @@
"two-factor-require-global": "Require 2FA for all users",
"two-factor-require-user": "Require 2FA for this user",
"two-factor-require-team": "Require 2FA for all members of this team",
"two-factor-reset": "Reset 2FA",
"two-factor-global-active-tooltip": "Global 2FA is enabled",
"two-factor-verify": "Verify",
"two-factor-login": "Two-factor authentication",
"close": "Close"
"two-factor-login": "Two-factor authentication"
},
"message": {
"action-confirmation": "Type {confirmation} in the box below to confirm.",
@@ -492,6 +492,7 @@
"two-factor-require-user-description": "When enabled, this user will be prompted to set up 2FA on their next login if they haven't already.",
"two-factor-require-team-description": "When enabled, all members of this team will be prompted to set up 2FA on their next login if they haven't already.",
"two-factor-user-description": "Add an extra layer of security to your account. Each time you log in, you will need your password and a code from your authenticator app.",
"two-factor-reset-description": "Remove this user's current two-factor configuration. If 2FA is still required by policy, they will be asked to scan a new QR code on their next login.",
"two-factor-error-invalid-code": "Invalid verification code. Please try again.",
"two-factor-error-missing-token": "Authentication token is missing.",
"two-factor-error-invalid-partial-token": "Invalid or expired authentication token.",
+3 -2
View File
@@ -410,10 +410,10 @@
"two-factor-require-global": "Rendre l’authentification à deux facteurs obligatoire pour tous les utilisateurs",
"two-factor-require-user": "Rendre l’authentification à deux facteurs obligatoire pour cet utilisateur",
"two-factor-require-team": "Rendre l’authentification à deux facteurs oblitatoire pour tous les membres de cette équipe",
"two-factor-reset": "Reinitialiser la 2FA",
"two-factor-global-active-tooltip": "L’authentification à deux facteurs est obligatoire pour tous les utilisateurs",
"two-factor-verify": "Vérifier",
"two-factor-login": "Authentification à deux facteurs",
"close": "Fermer"
"two-factor-login": "Authentification à deux facteurs"
},
"message": {
"action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.",
@@ -483,6 +483,7 @@
"two-factor-require-user-description": "Lorsque cette option est activée, cet utilisateur sera invité à configurer l’authentification à deux facteurs lors de sa prochaine connexion s’il ne l'a pas déjà fait.",
"two-factor-require-team-description": "Lorsque cette option est activée, tous les membres de cette équipe seront invités à configurer l’authentification à deux facteurs lors de leur prochaine connexion s’ils ne l’ont pas déjà fait.",
"two-factor-user-description": "Ajoutez une couche de sécurité supplémentaire à votre compte. À chaque connexion, vous aurez besoin de votre mot de passe et d'un code provenant de votre application d’authentification.",
"two-factor-reset-description": "Supprime la configuration 2FA actuelle de cet utilisateur. Si la 2FA reste obligatoire par politique, il devra scanner un nouveau QR code lors de sa prochaine connexion.",
"two-factor-error-invalid-code": "Code de vérification invalide. Veuillez réessayer.",
"two-factor-error-missing-token": "Le jeton d'authentification est manquant.",
"two-factor-error-invalid-partial-token": "Jeton d'authentification invalide ou expiré.",
@@ -1,7 +1,10 @@
'use client';
import { Column, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { Button, Column, Dialog, Modal, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { useState } from 'react';
import { Badge } from '@/components/common/Badge';
import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
import {
useDeleteQuery,
useMessages,
useUpdateQuery,
useTwoFactorStatusQuery,
@@ -10,9 +13,12 @@ import {
} from '@/components/hooks';
import { useQueryClient } from '@tanstack/react-query';
const CONFIRM_VALUE = 'RESET';
export function UserTwoFactorSettings({ userId }: { userId: string }) {
const { t, labels, messages } = useMessages();
const queryClient = useQueryClient();
const [showReset, setShowReset] = useState(false);
const { data: globalStatus } = useTwoFactorStatusQuery(true);
const isGlobalRequired = globalStatus?.requiredReason === 'global';
@@ -21,6 +27,9 @@ export function UserTwoFactorSettings({ userId }: { userId: string }) {
const { data: userData } = useUserQuery(userId);
const { mutateAsync: setUserRequired } = useUpdateQuery(`/admin/users/${userId}/2fa`);
const { mutateAsync: resetUserTwoFactor, isPending: isResetting, error: resetError } = useDeleteQuery(
`/admin/users/${userId}/2fa`,
);
const twoFactorEnabled = !!(userTfaData as any)?.isEnabled;
const twoFactorRequired = !!(userData as any)?.twoFactorRequired;
@@ -30,30 +39,69 @@ export function UserTwoFactorSettings({ userId }: { userId: string }) {
queryClient.invalidateQueries({ queryKey: ['users', { userId }] });
};
const handleReset = async () => {
await resetUserTwoFactor(null, {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['user-2fa-status', userId] });
setShowReset(false);
},
});
};
return (
<Column gap="4">
<Row alignItems="center" gap="3">
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
<Badge variant={twoFactorEnabled ? 'good' : 'gray'}>
{twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)}
</Badge>
</Row>
<Text size="sm" color="muted">
{t(messages.twoFactorRequireUserDescription)}
</Text>
<TooltipTrigger isDisabled={!isGlobalRequired}>
<>
<Column gap="6">
<Row alignItems="center" gap="3">
<Switch
isSelected={isGlobalRequired || twoFactorRequired}
isDisabled={isGlobalRequired}
onChange={handleToggle}
/>
<Text>{t(labels.twoFactorRequireUser)}</Text>
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
<Badge variant={twoFactorEnabled ? 'good' : 'gray'}>
{twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)}
</Badge>
</Row>
<Tooltip>{t(labels.twoFactorGlobalActiveTooltip)}</Tooltip>
</TooltipTrigger>
</Column>
<Text size="sm" color="muted">
{t(messages.twoFactorRequireUserDescription)}
</Text>
<TooltipTrigger isDisabled={!isGlobalRequired}>
<Row alignItems="center" gap="3">
<Switch
isSelected={isGlobalRequired || twoFactorRequired}
isDisabled={isGlobalRequired}
onChange={handleToggle}
/>
<Text>{t(labels.twoFactorRequireUser)}</Text>
</Row>
<Tooltip>{t(labels.twoFactorGlobalActiveTooltip)}</Tooltip>
</TooltipTrigger>
{twoFactorEnabled && (
<Column gap="3">
<Text weight="bold">{t(labels.twoFactorReset)}</Text>
<Text size="sm" color="muted">
{t(messages.twoFactorResetDescription)}
</Text>
<Row>
<Button size="sm" variant="danger" onPress={() => setShowReset(true)}>
{t(labels.reset)}
</Button>
</Row>
</Column>
)}
</Column>
<Modal isOpen={showReset} isDismissable={true} onOpenChange={open => !open && setShowReset(false)}>
<Dialog title={t(labels.twoFactorReset)} style={{ width: 400 }}>
<TypeConfirmationForm
confirmationValue={CONFIRM_VALUE}
onConfirm={handleReset}
onClose={() => setShowReset(false)}
isLoading={isResetting}
error={resetError}
buttonLabel={t(labels.reset)}
buttonVariant="danger"
/>
</Dialog>
</Modal>
</>
);
}
@@ -0,0 +1,146 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { canEnforceTwoFactorAuthForUser } from '@/permissions';
import prisma from '@/lib/prisma';
import { parseRequest } from '@/lib/request';
import { updateUser } from '@/queries/prisma/user';
import { DELETE, GET, POST } from './route';
vi.mock('@/lib/request', () => ({
parseRequest: vi.fn(),
}));
vi.mock('@/permissions', () => ({
canEnforceTwoFactorAuthForUser: vi.fn(),
}));
vi.mock('@/queries/prisma/user', () => ({
updateUser: vi.fn(),
}));
vi.mock('@/lib/prisma', () => ({
default: {
client: {
twoFactorAuth: {
findUnique: vi.fn(),
deleteMany: vi.fn(),
},
twoFactorBackupCode: {
deleteMany: vi.fn(),
},
twoFactorOtpUsed: {
deleteMany: vi.fn(),
},
twoFactorRateLimit: {
deleteMany: vi.fn(),
},
},
transaction: vi.fn(),
},
}));
const parseRequestMock = vi.mocked(parseRequest);
const canEnforceTwoFactorAuthForUserMock = vi.mocked(canEnforceTwoFactorAuthForUser);
const updateUserMock = vi.mocked(updateUser);
const prismaMock = vi.mocked(prisma, true);
beforeEach(() => {
parseRequestMock.mockReset();
canEnforceTwoFactorAuthForUserMock.mockReset();
updateUserMock.mockReset();
prismaMock.client.twoFactorAuth.findUnique.mockReset();
prismaMock.client.twoFactorAuth.deleteMany.mockReset();
prismaMock.client.twoFactorBackupCode.deleteMany.mockReset();
prismaMock.client.twoFactorOtpUsed.deleteMany.mockReset();
prismaMock.client.twoFactorRateLimit.deleteMany.mockReset();
prismaMock.transaction.mockReset();
parseRequestMock.mockResolvedValue({
auth: {
user: {
id: 'admin-1',
isAdmin: true,
},
},
error: undefined,
});
canEnforceTwoFactorAuthForUserMock.mockResolvedValue(true);
});
test('GET returns whether 2FA is enabled for the target user', async () => {
prismaMock.client.twoFactorAuth.findUnique.mockResolvedValue({
userId: 'user-1',
isEnabled: true,
} as any);
const response = await GET(new Request('http://localhost/api/admin/users/user-1/2fa'), {
params: Promise.resolve({ userId: 'user-1' }),
});
await expect(response.json()).resolves.toEqual({ isEnabled: true });
expect(response.status).toBe(200);
});
test('POST updates the user-level 2FA requirement flag', async () => {
parseRequestMock.mockResolvedValue({
auth: {
user: {
id: 'admin-1',
isAdmin: true,
},
},
body: {
required: true,
},
error: undefined,
});
updateUserMock.mockResolvedValue({ id: 'user-1' } as any);
const response = await POST(new Request('http://localhost/api/admin/users/user-1/2fa', { method: 'POST' }), {
params: Promise.resolve({ userId: 'user-1' }),
});
expect(updateUserMock).toHaveBeenCalledWith('user-1', { twoFactorRequired: true });
await expect(response.json()).resolves.toEqual({
ok: true,
userId: 'user-1',
twoFactorRequired: true,
});
expect(response.status).toBe(200);
});
test('DELETE clears the user 2FA configuration and related support tables', async () => {
prismaMock.client.twoFactorAuth.deleteMany.mockResolvedValue({ count: 1 } as any);
prismaMock.client.twoFactorBackupCode.deleteMany.mockResolvedValue({ count: 8 } as any);
prismaMock.client.twoFactorOtpUsed.deleteMany.mockResolvedValue({ count: 2 } as any);
prismaMock.client.twoFactorRateLimit.deleteMany.mockResolvedValue({ count: 1 } as any);
prismaMock.transaction.mockImplementation(async operations => Promise.all(operations));
const response = await DELETE(
new Request('http://localhost/api/admin/users/user-1/2fa', { method: 'DELETE' }),
{
params: Promise.resolve({ userId: 'user-1' }),
},
);
expect(prismaMock.client.twoFactorAuth.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
expect(prismaMock.client.twoFactorBackupCode.deleteMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
});
expect(prismaMock.client.twoFactorOtpUsed.deleteMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
});
expect(prismaMock.client.twoFactorRateLimit.deleteMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
});
await expect(response.json()).resolves.toEqual({
ok: true,
userId: 'user-1',
reset: {
twoFactorAuth: 1,
backupCodes: 8,
otpUsed: 2,
rateLimit: 1,
},
});
expect(response.status).toBe(200);
});
@@ -43,3 +43,35 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
return json({ ok: true, userId: user.id, twoFactorRequired: required });
}
export async function DELETE(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
if (!(await canEnforceTwoFactorAuthForUser(auth))) {
return unauthorized();
}
const { userId } = await params;
const [twoFactorAuth, backupCodes, otpUsed, rateLimit] = await prisma.transaction([
prisma.client.twoFactorAuth.deleteMany({ where: { userId } }),
prisma.client.twoFactorBackupCode.deleteMany({ where: { userId } }),
prisma.client.twoFactorOtpUsed.deleteMany({ where: { userId } }),
prisma.client.twoFactorRateLimit.deleteMany({ where: { userId } }),
]);
return json({
ok: true,
userId,
reset: {
twoFactorAuth: twoFactorAuth.count,
backupCodes: backupCodes.count,
otpUsed: otpUsed.count,
rateLimit: rateLimit.count,
},
});
}
+2
View File
@@ -422,6 +422,7 @@ export const labels: Record<string, string> = {
twoFactorRequireGlobal: 'label.two-factor-require-global',
twoFactorRequireUser: 'label.two-factor-require-user',
twoFactorRequireTeam: 'label.two-factor-require-team',
twoFactorReset: 'label.two-factor-reset',
twoFactorGlobalActiveTooltip: 'label.two-factor-global-active-tooltip',
twoFactorVerify: 'label.two-factor-verify',
twoFactorLogin: 'label.two-factor-login',
@@ -492,6 +493,7 @@ export const messages: Record<string, string> = {
twoFactorUserDescription: 'message.two-factor-user-description',
twoFactorLockedMessage: 'message.two-factor-locked-message',
twoFactorActiveDescription: 'message.two-factor-active-description',
twoFactorResetDescription: 'message.two-factor-reset-description',
twoFactorErrorInvalidCode: 'message.two-factor-error-invalid-code',
twoFactorErrorMissingToken: 'message.two-factor-error-missing-token',
twoFactorErrorInvalidPartialToken: 'message.two-factor-error-invalid-partial-token',