From 3b4d5b95756eec7e0864383c98c59f2e1fbb9785 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Fri, 24 Jul 2026 10:46:19 -0700 Subject: [PATCH] 2FA reset feature --- prisma/migrations/22_add_2fa/migration.sql | 6 +- public/intl/messages/en-US.json | 5 +- public/intl/messages/fr-FR.json | 5 +- .../users/[userId]/UserTwoFactorSettings.tsx | 94 ++++++++--- .../admin/users/[userId]/2fa/route.test.ts | 146 ++++++++++++++++++ src/app/api/admin/users/[userId]/2fa/route.ts | 32 ++++ src/components/messages.ts | 2 + 7 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 src/app/api/admin/users/[userId]/2fa/route.test.ts diff --git a/prisma/migrations/22_add_2fa/migration.sql b/prisma/migrations/22_add_2fa/migration.sql index 04bb955e4..1bffd9d16 100644 --- a/prisma/migrations/22_add_2fa/migration.sql +++ b/prisma/migrations/22_add_2fa/migration.sql @@ -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"); \ No newline at end of file +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"); \ No newline at end of file diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index f35cf4522..97078ba27 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -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.", diff --git a/public/intl/messages/fr-FR.json b/public/intl/messages/fr-FR.json index 7431e9c84..42de24570 100644 --- a/public/intl/messages/fr-FR.json +++ b/public/intl/messages/fr-FR.json @@ -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é.", diff --git a/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx b/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx index f2cecfe58..a577bab7a 100644 --- a/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx +++ b/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx @@ -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 ( - - - {t(labels.twoFactorAuth)} - - {twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)} - - - - - {t(messages.twoFactorRequireUserDescription)} - - - + <> + - - {t(labels.twoFactorRequireUser)} + {t(labels.twoFactorAuth)} + + {twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)} + - {t(labels.twoFactorGlobalActiveTooltip)} - - + + + {t(messages.twoFactorRequireUserDescription)} + + + + + + {t(labels.twoFactorRequireUser)} + + {t(labels.twoFactorGlobalActiveTooltip)} + + + {twoFactorEnabled && ( + + {t(labels.twoFactorReset)} + + {t(messages.twoFactorResetDescription)} + + + + + + )} + + + !open && setShowReset(false)}> + + setShowReset(false)} + isLoading={isResetting} + error={resetError} + buttonLabel={t(labels.reset)} + buttonVariant="danger" + /> + + + ); } diff --git a/src/app/api/admin/users/[userId]/2fa/route.test.ts b/src/app/api/admin/users/[userId]/2fa/route.test.ts new file mode 100644 index 000000000..763e6e0fa --- /dev/null +++ b/src/app/api/admin/users/[userId]/2fa/route.test.ts @@ -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); +}); diff --git a/src/app/api/admin/users/[userId]/2fa/route.ts b/src/app/api/admin/users/[userId]/2fa/route.ts index 7cb09cd64..b110a9e00 100644 --- a/src/app/api/admin/users/[userId]/2fa/route.ts +++ b/src/app/api/admin/users/[userId]/2fa/route.ts @@ -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, + }, + }); +} diff --git a/src/components/messages.ts b/src/components/messages.ts index 4e474e56a..14feeafdd 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -422,6 +422,7 @@ export const labels: Record = { 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 = { 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',