2FA reset feature
This commit is contained in:
1 parent
896d63df94
commit
3b4d5b9575
7 files changed
+262
-28
No files matched your search
@@ -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");
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user