Harden 2FA APi routes and UI when 2FA env var missing. Closes #4443.
This commit is contained in:
1 parent
f94a5cfc02
commit
0a77e50da1
22 files changed
+511
-90
No files matched your search
@@ -11,7 +11,9 @@ export function AdminSecurityPage() {
|
||||
const { mutateAsync: setGlobal } = useUpdateQuery('/admin/2fa/global');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isGlobalRequired = status?.requiredReason === 'global' || false;
|
||||
const isConfigured = status?.isConfigured;
|
||||
const isGlobalRequired = status?.globalRequired ?? false;
|
||||
const showConfigurationError = isConfigured === false;
|
||||
|
||||
const handleToggle = async (value: boolean) => {
|
||||
await setGlobal({ required: value });
|
||||
@@ -27,15 +29,23 @@ export function AdminSecurityPage() {
|
||||
<Column gap="4">
|
||||
<Column gap="1">
|
||||
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
|
||||
<Text>{t(messages.twoFactorAdminGlobalDescription)}</Text>
|
||||
<Text>
|
||||
{showConfigurationError
|
||||
? t(messages.twoFactorErrorNotConfigured)
|
||||
: t(messages.twoFactorAdminGlobalDescription)}
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch isSelected={isGlobalRequired} onChange={handleToggle} />
|
||||
<Switch
|
||||
isSelected={isGlobalRequired}
|
||||
isDisabled={!isConfigured && !isGlobalRequired}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Text>{t(labels.twoFactorRequireGlobal)}</Text>
|
||||
</Row>
|
||||
|
||||
{isGlobalRequired && (
|
||||
{isConfigured === true && isGlobalRequired && (
|
||||
<Text size="sm" color="muted">
|
||||
{t(messages.twoFactorOverrideNote)}
|
||||
</Text>
|
||||
|
||||
@@ -32,7 +32,9 @@ export function UserTwoFactorSettings({ userId }: { userId: string }) {
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
|
||||
const { data: globalStatus } = useTwoFactorStatusQuery(true);
|
||||
const isGlobalRequired = globalStatus?.requiredReason === 'global';
|
||||
const isConfigured = globalStatus?.isConfigured;
|
||||
const isGlobalRequired = globalStatus?.globalRequired ?? false;
|
||||
const showConfigurationError = isConfigured === false;
|
||||
|
||||
const { data: userTfaData } = useTwoFactorUserStatusQuery(userId);
|
||||
const { data: userData } = useUserQuery(userId);
|
||||
@@ -52,6 +54,18 @@ export function UserTwoFactorSettings({ userId }: { userId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: ['users', { userId }] });
|
||||
};
|
||||
|
||||
const isToggleDisabled = isGlobalRequired || (!isConfigured && !twoFactorRequired);
|
||||
const toggle = (
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
isSelected={isGlobalRequired || twoFactorRequired}
|
||||
isDisabled={isToggleDisabled}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Text>{t(labels.twoFactorRequireUser)}</Text>
|
||||
</Row>
|
||||
);
|
||||
|
||||
const handleReset = async () => {
|
||||
await resetUserTwoFactor(null, {
|
||||
onSuccess: async () => {
|
||||
@@ -75,27 +89,19 @@ export function UserTwoFactorSettings({ userId }: { userId: string }) {
|
||||
{t(messages.twoFactorRequireUserDescription)}
|
||||
</Text>
|
||||
|
||||
{showConfigurationError && (
|
||||
<Text size="sm" color="muted">
|
||||
{t(messages.twoFactorErrorNotConfigured)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isGlobalRequired ? (
|
||||
<TooltipTrigger>
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
isSelected={isGlobalRequired || twoFactorRequired}
|
||||
isDisabled={isGlobalRequired}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Text>{t(labels.twoFactorRequireUser)}</Text>
|
||||
</Row>
|
||||
{toggle}
|
||||
<Tooltip>{t(labels.twoFactorGlobalActiveTooltip)}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
isSelected={isGlobalRequired || twoFactorRequired}
|
||||
isDisabled={isGlobalRequired}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Text>{t(labels.twoFactorRequireUser)}</Text>
|
||||
</Row>
|
||||
toggle
|
||||
)}
|
||||
|
||||
{twoFactorEnabled && (
|
||||
|
||||
@@ -19,6 +19,8 @@ export function UserSecurityPage() {
|
||||
|
||||
const isEnabled = status?.isEnabled ?? false;
|
||||
const isRequired = status?.isRequired ?? false;
|
||||
const isConfigured = status?.isConfigured;
|
||||
const showConfigurationError = isConfigured === false;
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
if (value) {
|
||||
@@ -55,13 +57,15 @@ export function UserSecurityPage() {
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{isEnabled ? (
|
||||
{showConfigurationError ? (
|
||||
<Text>{t(messages.twoFactorErrorNotConfigured)}</Text>
|
||||
) : isEnabled ? (
|
||||
<Text>{t(messages.twoFactorActiveDescription)}</Text>
|
||||
) : (
|
||||
<Text>{t(messages.twoFactorUserDescription)}</Text>
|
||||
)}
|
||||
|
||||
{isEnabled && isRequired ? (
|
||||
{isConfigured === true && isEnabled && isRequired ? (
|
||||
<TooltipTrigger>
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
@@ -71,9 +75,11 @@ export function UserSecurityPage() {
|
||||
/>
|
||||
<Text>{t(labels.twoFactorEnable)}</Text>
|
||||
</Row>
|
||||
<Tooltip>{t(messages.twoFactorRequiredMessage)}</Tooltip>
|
||||
<Tooltip>{t(messages.twoFactorRequiredMessage)}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{isConfigured === true && !isEnabled ? (
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
isSelected={isEnabled}
|
||||
@@ -82,9 +88,20 @@ export function UserSecurityPage() {
|
||||
/>
|
||||
<Text>{t(labels.twoFactorEnable)}</Text>
|
||||
</Row>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isEnabled && isRequired && (
|
||||
{isConfigured === true && isEnabled && !isRequired ? (
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
isSelected={isEnabled}
|
||||
isDisabled={isEnabled && isRequired}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Text>{t(labels.twoFactorEnable)}</Text>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{isConfigured === true && isEnabled && isRequired && (
|
||||
<Text size="sm" color="muted">
|
||||
{t(messages.twoFactorRequiredMessage)}
|
||||
</Text>
|
||||
|
||||
@@ -13,7 +13,9 @@ export function TeamTwoFactorSettings({ teamId }: { teamId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: globalStatus } = useTwoFactorStatusQuery(true);
|
||||
const isGlobalRequired = globalStatus?.requiredReason === 'global';
|
||||
const isConfigured = globalStatus?.isConfigured;
|
||||
const isGlobalRequired = globalStatus?.globalRequired ?? false;
|
||||
const showConfigurationError = isConfigured === false;
|
||||
|
||||
const { data: teamData } = useTeamQuery(teamId);
|
||||
const { mutateAsync: setTeamRequired } = useUpdateQuery(`/admin/teams/${teamId}/2fa`);
|
||||
@@ -25,31 +27,35 @@ export function TeamTwoFactorSettings({ teamId }: { teamId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: ['teams', { teamId }] });
|
||||
};
|
||||
|
||||
const isToggleDisabled = isGlobalRequired || (!isConfigured && !twoFactorRequired);
|
||||
const toggle = (
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
isSelected={isGlobalRequired || twoFactorRequired}
|
||||
isDisabled={isToggleDisabled}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Text>{t(labels.twoFactorRequireTeam)}</Text>
|
||||
</Row>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column gap="4">
|
||||
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
|
||||
|
||||
{showConfigurationError && (
|
||||
<Text size="sm" color="muted">
|
||||
{t(messages.twoFactorErrorNotConfigured)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isGlobalRequired ? (
|
||||
<TooltipTrigger>
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
isSelected={isGlobalRequired || twoFactorRequired}
|
||||
isDisabled={isGlobalRequired}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Text>{t(labels.twoFactorRequireTeam)}</Text>
|
||||
</Row>
|
||||
{toggle}
|
||||
<Tooltip>{t(labels.twoFactorGlobalActiveTooltip)}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
<Row alignItems="center" gap="3">
|
||||
<Switch
|
||||
isSelected={isGlobalRequired || twoFactorRequired}
|
||||
isDisabled={isGlobalRequired}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Text>{t(labels.twoFactorRequireTeam)}</Text>
|
||||
</Row>
|
||||
toggle
|
||||
)}
|
||||
|
||||
<Text size="sm" color="muted">
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
parseRequest: vi.fn(),
|
||||
isTwoFactorConfigured: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/request', () => ({
|
||||
parseRequest: mocks.parseRequest,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
default: {
|
||||
client: {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/two-factor/crypto', () => ({
|
||||
decryptSecret: vi.fn(),
|
||||
getTwoFactorConfigurationError: () => ({
|
||||
code: 'two-factor-error-not-configured',
|
||||
message: 'TWO_FACTOR_ENCRYPTION_KEY is missing or invalid',
|
||||
}),
|
||||
isTwoFactorConfigured: mocks.isTwoFactorConfigured,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/two-factor/rate-limit', () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
recordFailedAttempt: vi.fn(),
|
||||
resetRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/two-factor/replay-prevention', () => ({
|
||||
isOtpReplayed: vi.fn(),
|
||||
markOtpUsed: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/two-factor/totp', () => ({
|
||||
verifyTotp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/password', () => ({
|
||||
checkPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/queries/prisma/user', () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from './route';
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.parseRequest.mockReset();
|
||||
mocks.isTwoFactorConfigured.mockReset();
|
||||
|
||||
mocks.isTwoFactorConfigured.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('POST returns a configuration error after validating the caller', async () => {
|
||||
mocks.isTwoFactorConfigured.mockReturnValue(false);
|
||||
mocks.parseRequest.mockResolvedValue({
|
||||
auth: { user: { id: 'user-1' } },
|
||||
body: { password: 'secret', token: '123456' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const response = await POST(new Request('http://localhost/api/2fa/disable', { method: 'POST' }));
|
||||
|
||||
expect(mocks.parseRequest).toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 'two-factor-error-not-configured',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, forbidden, json, notFound } from '@/lib/response';
|
||||
import { decryptSecret } from '@/lib/two-factor/crypto';
|
||||
import { badRequest, forbidden, json, notFound, serviceUnavailable } from '@/lib/response';
|
||||
import { decryptSecret, getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import { checkRateLimit, recordFailedAttempt, resetRateLimit } from '@/lib/two-factor/rate-limit';
|
||||
import { isOtpReplayed, markOtpUsed } from '@/lib/two-factor/replay-prevention';
|
||||
import { verifyTotp } from '@/lib/two-factor/totp';
|
||||
@@ -25,6 +25,10 @@ export async function POST(request: Request) {
|
||||
return error();
|
||||
}
|
||||
|
||||
if (!isTwoFactorConfigured()) {
|
||||
return serviceUnavailable(getTwoFactorConfigurationError());
|
||||
}
|
||||
|
||||
const userId = auth.user.id;
|
||||
const { password, token } = body;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => {
|
||||
tx,
|
||||
generateBackupCodes: vi.fn(),
|
||||
decryptSecret: vi.fn(),
|
||||
isTwoFactorConfigured: vi.fn(),
|
||||
checkRateLimit: vi.fn(),
|
||||
recordFailedAttempt: vi.fn(),
|
||||
resetRateLimit: vi.fn(),
|
||||
@@ -49,6 +50,11 @@ vi.mock('@/lib/two-factor/backup-codes', () => ({
|
||||
|
||||
vi.mock('@/lib/two-factor/crypto', () => ({
|
||||
decryptSecret: mocks.decryptSecret,
|
||||
getTwoFactorConfigurationError: () => ({
|
||||
code: 'two-factor-error-not-configured',
|
||||
message: 'TWO_FACTOR_ENCRYPTION_KEY is missing or invalid',
|
||||
}),
|
||||
isTwoFactorConfigured: mocks.isTwoFactorConfigured,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/two-factor/rate-limit', () => ({
|
||||
@@ -75,6 +81,7 @@ beforeEach(() => {
|
||||
mocks.tx.twoFactorBackupCode.createMany.mockReset();
|
||||
mocks.generateBackupCodes.mockReset();
|
||||
mocks.decryptSecret.mockReset();
|
||||
mocks.isTwoFactorConfigured.mockReset();
|
||||
mocks.checkRateLimit.mockReset();
|
||||
mocks.recordFailedAttempt.mockReset();
|
||||
mocks.resetRateLimit.mockReset();
|
||||
@@ -94,6 +101,7 @@ beforeEach(() => {
|
||||
hashed: ['hash-1', 'hash-2'],
|
||||
});
|
||||
mocks.decryptSecret.mockReturnValue('plain-secret');
|
||||
mocks.isTwoFactorConfigured.mockReturnValue(true);
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.recordFailedAttempt.mockResolvedValue({ lockedUntil: undefined });
|
||||
mocks.resetRateLimit.mockResolvedValue(undefined);
|
||||
@@ -134,6 +142,23 @@ test('POST confirms setup, enables 2FA, stores backup codes, and resets the rate
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('POST reports a configuration error when the encryption key is missing', async () => {
|
||||
mocks.isTwoFactorConfigured.mockReturnValue(false);
|
||||
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/2fa/setup/confirm', { method: 'POST' }),
|
||||
);
|
||||
|
||||
expect(mocks.checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(mocks.decryptSecret).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 'two-factor-error-not-configured',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
|
||||
test('POST records a failed attempt and skips writes when the token is invalid', async () => {
|
||||
mocks.verifyTotp.mockResolvedValue(false);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, notFound } from '@/lib/response';
|
||||
import { badRequest, json, notFound, serviceUnavailable } from '@/lib/response';
|
||||
import { generateBackupCodes } from '@/lib/two-factor/backup-codes';
|
||||
import { decryptSecret } from '@/lib/two-factor/crypto';
|
||||
import { decryptSecret, getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import { checkRateLimit, recordFailedAttempt, resetRateLimit } from '@/lib/two-factor/rate-limit';
|
||||
import { isOtpReplayed, markOtpUsed } from '@/lib/two-factor/replay-prevention';
|
||||
import { verifyTotp } from '@/lib/two-factor/totp';
|
||||
@@ -21,6 +21,10 @@ export async function POST(request: Request) {
|
||||
return error();
|
||||
}
|
||||
|
||||
if (!isTwoFactorConfigured()) {
|
||||
return serviceUnavailable(getTwoFactorConfigurationError());
|
||||
}
|
||||
|
||||
const userId = auth.user.id;
|
||||
const { token } = body;
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ vi.mock('@/lib/prisma', () => ({
|
||||
|
||||
vi.mock('@/lib/two-factor/crypto', () => ({
|
||||
encryptSecret: mocks.encryptSecret,
|
||||
getTwoFactorConfigurationError: () => ({
|
||||
code: 'two-factor-error-not-configured',
|
||||
message: 'TWO_FACTOR_ENCRYPTION_KEY is missing or invalid',
|
||||
}),
|
||||
isTwoFactorConfigured: mocks.isTwoFactorConfigured,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, notFound, serviceUnavailable } from '@/lib/response';
|
||||
import { encryptSecret, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import {
|
||||
encryptSecret,
|
||||
getTwoFactorConfigurationError,
|
||||
isTwoFactorConfigured,
|
||||
} from '@/lib/two-factor/crypto';
|
||||
import {
|
||||
generateOtpAuthUri,
|
||||
generateQrCodeDataUrl,
|
||||
@@ -22,10 +26,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// Secrets cannot be stored without an encryption key
|
||||
if (!isTwoFactorConfigured()) {
|
||||
return serviceUnavailable({
|
||||
code: 'two-factor-error-not-configured',
|
||||
message: 'TWO_FACTOR_ENCRYPTION_KEY is missing or invalid',
|
||||
});
|
||||
return serviceUnavailable(getTwoFactorConfigurationError());
|
||||
}
|
||||
|
||||
const userId = auth.user.id;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { GET } from './route';
|
||||
|
||||
const KEY = 'a'.repeat(64);
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findTwoFactorAuth: vi.fn(),
|
||||
findAppSetting: vi.fn(),
|
||||
findUser: vi.fn(),
|
||||
findTeamUsers: vi.fn(),
|
||||
findTeams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/request', () => ({
|
||||
parseRequest: vi.fn(),
|
||||
@@ -13,35 +19,34 @@ vi.mock('@/lib/prisma', () => ({
|
||||
default: {
|
||||
client: {
|
||||
twoFactorAuth: {
|
||||
findUnique: vi.fn(),
|
||||
findUnique: mocks.findTwoFactorAuth,
|
||||
},
|
||||
appSetting: {
|
||||
findUnique: vi.fn(),
|
||||
findUnique: mocks.findAppSetting,
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
findUnique: mocks.findUser,
|
||||
},
|
||||
teamUser: {
|
||||
findMany: vi.fn(),
|
||||
findMany: mocks.findTeamUsers,
|
||||
},
|
||||
team: {
|
||||
findMany: vi.fn(),
|
||||
findMany: mocks.findTeams,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const parseRequestMock = vi.mocked(parseRequest);
|
||||
const prismaMock = vi.mocked(prisma, true);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
parseRequestMock.mockReset();
|
||||
prismaMock.client.twoFactorAuth.findUnique.mockReset();
|
||||
prismaMock.client.appSetting.findUnique.mockReset();
|
||||
prismaMock.client.user.findUnique.mockReset();
|
||||
prismaMock.client.teamUser.findMany.mockReset();
|
||||
prismaMock.client.team.findMany.mockReset();
|
||||
mocks.findTwoFactorAuth.mockReset();
|
||||
mocks.findAppSetting.mockReset();
|
||||
mocks.findUser.mockReset();
|
||||
mocks.findTeamUsers.mockReset();
|
||||
mocks.findTeams.mockReset();
|
||||
|
||||
parseRequestMock.mockResolvedValue({
|
||||
auth: {
|
||||
@@ -51,14 +56,14 @@ beforeEach(() => {
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
prismaMock.client.twoFactorAuth.findUnique.mockResolvedValue(null as any);
|
||||
prismaMock.client.appSetting.findUnique.mockResolvedValue({
|
||||
mocks.findTwoFactorAuth.mockResolvedValue(null as any);
|
||||
mocks.findAppSetting.mockResolvedValue({
|
||||
key: 'twoFactorRequiredGlobal',
|
||||
value: 'true',
|
||||
} as any);
|
||||
prismaMock.client.user.findUnique.mockResolvedValue({ twoFactorRequired: false } as any);
|
||||
prismaMock.client.teamUser.findMany.mockResolvedValue([] as any);
|
||||
prismaMock.client.team.findMany.mockResolvedValue([] as any);
|
||||
mocks.findUser.mockResolvedValue({ twoFactorRequired: false } as any);
|
||||
mocks.findTeamUsers.mockResolvedValue([] as any);
|
||||
mocks.findTeams.mockResolvedValue([] as any);
|
||||
});
|
||||
|
||||
test('GET requires 2FA when it is globally enabled', async () => {
|
||||
@@ -69,6 +74,8 @@ test('GET requires 2FA when it is globally enabled', async () => {
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
isEnabled: false,
|
||||
isRequired: true,
|
||||
isConfigured: true,
|
||||
globalRequired: true,
|
||||
requiredReason: 'global',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
@@ -79,10 +86,11 @@ test('GET does not require 2FA when the encryption key is missing', async () =>
|
||||
|
||||
const response = await GET(new Request('http://localhost/api/2fa/status'));
|
||||
|
||||
expect(prismaMock.client.appSetting.findUnique).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
isEnabled: false,
|
||||
isRequired: false,
|
||||
isConfigured: false,
|
||||
globalRequired: true,
|
||||
requiredReason: null,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -11,7 +11,13 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
if (process.env.CLOUD_MODE) {
|
||||
return json({ isEnabled: false, isRequired: false, requiredReason: null });
|
||||
return json({
|
||||
isEnabled: false,
|
||||
isRequired: false,
|
||||
requiredReason: null,
|
||||
isConfigured: false,
|
||||
globalRequired: false,
|
||||
});
|
||||
}
|
||||
|
||||
const userId = auth.user.id;
|
||||
@@ -19,17 +25,30 @@ export async function GET(request: Request) {
|
||||
const twoFactor = await prisma.client.twoFactorAuth.findUnique({ where: { userId } });
|
||||
const isEnabled = twoFactor?.isEnabled ?? false;
|
||||
|
||||
// 2FA cannot be set up without an encryption key, so it is never required
|
||||
if (!isTwoFactorConfigured()) {
|
||||
return json({ isEnabled, isRequired: false, requiredReason: null });
|
||||
}
|
||||
|
||||
// Globally required
|
||||
const globalSetting = await prisma.client.appSetting.findUnique({
|
||||
where: { key: 'twoFactorRequiredGlobal' },
|
||||
});
|
||||
const isGlobalRequired = globalSetting?.value === 'true';
|
||||
if (isGlobalRequired) return json({ isEnabled, isRequired: true, requiredReason: 'global' });
|
||||
|
||||
// 2FA cannot be set up without an encryption key, so it is never required
|
||||
if (!isTwoFactorConfigured()) {
|
||||
return json({
|
||||
isEnabled,
|
||||
isRequired: false,
|
||||
requiredReason: null,
|
||||
isConfigured: false,
|
||||
globalRequired: isGlobalRequired,
|
||||
});
|
||||
}
|
||||
if (isGlobalRequired) {
|
||||
return json({
|
||||
isEnabled,
|
||||
isRequired: true,
|
||||
requiredReason: 'global',
|
||||
isConfigured: true,
|
||||
globalRequired: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Required for this user
|
||||
const userRecord = await prisma.client.user.findUnique({
|
||||
@@ -37,7 +56,15 @@ export async function GET(request: Request) {
|
||||
select: { twoFactorRequired: true },
|
||||
});
|
||||
const isUserRequired = userRecord?.twoFactorRequired ?? false;
|
||||
if (isUserRequired) return json({ isEnabled, isRequired: true, requiredReason: 'user' });
|
||||
if (isUserRequired) {
|
||||
return json({
|
||||
isEnabled,
|
||||
isRequired: true,
|
||||
requiredReason: 'user',
|
||||
isConfigured: true,
|
||||
globalRequired: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Required for this user's teams
|
||||
const userTeams = await prisma.client.teamUser.findMany({ where: { userId } });
|
||||
@@ -48,7 +75,21 @@ export async function GET(request: Request) {
|
||||
})
|
||||
: [];
|
||||
const isTeamRequired = teamsWithRequirement.length > 0;
|
||||
if (isTeamRequired) return json({ isEnabled, isRequired: true, requiredReason: 'team' });
|
||||
if (isTeamRequired) {
|
||||
return json({
|
||||
isEnabled,
|
||||
isRequired: true,
|
||||
requiredReason: 'team',
|
||||
isConfigured: true,
|
||||
globalRequired: false,
|
||||
});
|
||||
}
|
||||
|
||||
return json({ isEnabled, isRequired: false, requiredReason: null });
|
||||
return json({
|
||||
isEnabled,
|
||||
isRequired: false,
|
||||
requiredReason: null,
|
||||
isConfigured: true,
|
||||
globalRequired: false,
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
|
||||
updateBackupCodes: vi.fn(),
|
||||
verifyBackupCode: vi.fn(),
|
||||
decryptSecret: vi.fn(),
|
||||
isTwoFactorConfigured: vi.fn(),
|
||||
checkRateLimit: vi.fn(),
|
||||
recordFailedAttempt: vi.fn(),
|
||||
resetRateLimit: vi.fn(),
|
||||
@@ -60,6 +61,11 @@ vi.mock('@/lib/two-factor/backup-codes', () => ({
|
||||
|
||||
vi.mock('@/lib/two-factor/crypto', () => ({
|
||||
decryptSecret: mocks.decryptSecret,
|
||||
getTwoFactorConfigurationError: () => ({
|
||||
code: 'two-factor-error-not-configured',
|
||||
message: 'TWO_FACTOR_ENCRYPTION_KEY is missing or invalid',
|
||||
}),
|
||||
isTwoFactorConfigured: mocks.isTwoFactorConfigured,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/two-factor/rate-limit', () => ({
|
||||
@@ -97,6 +103,7 @@ beforeEach(() => {
|
||||
mocks.updateBackupCodes.mockReset();
|
||||
mocks.verifyBackupCode.mockReset();
|
||||
mocks.decryptSecret.mockReset();
|
||||
mocks.isTwoFactorConfigured.mockReset();
|
||||
mocks.checkRateLimit.mockReset();
|
||||
mocks.recordFailedAttempt.mockReset();
|
||||
mocks.resetRateLimit.mockReset();
|
||||
@@ -118,6 +125,7 @@ beforeEach(() => {
|
||||
mocks.findTwoFactorAuth.mockResolvedValue({ userId: 'user-1', isEnabled: true, secret: 'encrypted' });
|
||||
mocks.createSecureToken.mockReturnValue('full-auth-token');
|
||||
mocks.decryptSecret.mockReturnValue('plain-secret');
|
||||
mocks.isTwoFactorConfigured.mockReturnValue(true);
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.recordFailedAttempt.mockResolvedValue({ lockedUntil: undefined });
|
||||
mocks.resetRateLimit.mockResolvedValue(undefined);
|
||||
@@ -153,3 +161,26 @@ test('POST accepts a token-only payload and completes 2FA verification', async (
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('POST returns a configuration error when the encryption key is missing', async () => {
|
||||
mocks.isTwoFactorConfigured.mockReturnValue(false);
|
||||
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/2fa/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer partial-token',
|
||||
},
|
||||
body: JSON.stringify({ token: '123456' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.findTwoFactorAuth).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 'two-factor-error-not-configured',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
@@ -5,9 +5,9 @@ import { ROLES } from '@/lib/constants';
|
||||
import { secret } from '@/lib/crypto';
|
||||
import { createSecureToken, parseSecureToken } from '@/lib/jwt';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, notFound, unauthorized } from '@/lib/response';
|
||||
import { badRequest, json, notFound, serviceUnavailable, unauthorized } from '@/lib/response';
|
||||
import { verifyBackupCode } from '@/lib/two-factor/backup-codes';
|
||||
import { decryptSecret } from '@/lib/two-factor/crypto';
|
||||
import { decryptSecret, getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import { checkRateLimit, recordFailedAttempt, resetRateLimit } from '@/lib/two-factor/rate-limit';
|
||||
import { isOtpReplayed, markOtpUsed } from '@/lib/two-factor/replay-prevention';
|
||||
import { verifyTotp } from '@/lib/two-factor/totp';
|
||||
@@ -34,6 +34,10 @@ export async function POST(request: Request) {
|
||||
return unauthorized({ code: 'two-factor-error-invalid-partial-token' });
|
||||
}
|
||||
|
||||
if (!isTwoFactorConfigured()) {
|
||||
return serviceUnavailable(getTwoFactorConfigurationError());
|
||||
}
|
||||
|
||||
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
||||
if (error) {
|
||||
return error();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, notFound, unauthorized } from '@/lib/response';
|
||||
import { json, notFound, serviceUnavailable, unauthorized } from '@/lib/response';
|
||||
import { getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import { canEnforceTwoFactorAuthForEveryone } from '@/permissions';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -23,6 +24,10 @@ export async function POST(request: Request) {
|
||||
|
||||
const { required } = body;
|
||||
|
||||
if (required && !isTwoFactorConfigured()) {
|
||||
return serviceUnavailable(getTwoFactorConfigurationError());
|
||||
}
|
||||
|
||||
await prisma.client.appSetting.upsert({
|
||||
where: { key: 'twoFactorRequiredGlobal' },
|
||||
update: { value: String(required) },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, notFound, unauthorized } from '@/lib/response';
|
||||
import { json, notFound, serviceUnavailable, unauthorized } from '@/lib/response';
|
||||
import { getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import { canEnforceTwoFactorAuthForTeam } from '@/permissions';
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
|
||||
@@ -24,6 +25,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ tea
|
||||
}
|
||||
const { required } = body;
|
||||
|
||||
if (required && !isTwoFactorConfigured()) {
|
||||
return serviceUnavailable(getTwoFactorConfigurationError());
|
||||
}
|
||||
|
||||
await prisma.client.team.update({
|
||||
where: { id: teamId },
|
||||
data: { twoFactorRequired: required },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { canEnforceTwoFactorAuthForUser } from '@/permissions';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import { updateUser } from '@/queries/prisma/user';
|
||||
import { DELETE, GET, POST } from './route';
|
||||
|
||||
@@ -17,6 +18,14 @@ vi.mock('@/queries/prisma/user', () => ({
|
||||
updateUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/two-factor/crypto', () => ({
|
||||
getTwoFactorConfigurationError: () => ({
|
||||
code: 'two-factor-error-not-configured',
|
||||
message: 'TWO_FACTOR_ENCRYPTION_KEY is missing or invalid',
|
||||
}),
|
||||
isTwoFactorConfigured: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
default: {
|
||||
client: {
|
||||
@@ -40,12 +49,14 @@ vi.mock('@/lib/prisma', () => ({
|
||||
|
||||
const parseRequestMock = vi.mocked(parseRequest);
|
||||
const canEnforceTwoFactorAuthForUserMock = vi.mocked(canEnforceTwoFactorAuthForUser);
|
||||
const isTwoFactorConfiguredMock = vi.mocked(isTwoFactorConfigured);
|
||||
const updateUserMock = vi.mocked(updateUser);
|
||||
const prismaMock = vi.mocked(prisma, true);
|
||||
|
||||
beforeEach(() => {
|
||||
parseRequestMock.mockReset();
|
||||
canEnforceTwoFactorAuthForUserMock.mockReset();
|
||||
isTwoFactorConfiguredMock.mockReset();
|
||||
updateUserMock.mockReset();
|
||||
prismaMock.client.twoFactorAuth.findUnique.mockReset();
|
||||
prismaMock.client.twoFactorAuth.deleteMany.mockReset();
|
||||
@@ -64,6 +75,7 @@ beforeEach(() => {
|
||||
error: undefined,
|
||||
});
|
||||
canEnforceTwoFactorAuthForUserMock.mockResolvedValue(true);
|
||||
isTwoFactorConfiguredMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('GET returns whether 2FA is enabled for the target user', async () => {
|
||||
@@ -108,6 +120,34 @@ test('POST updates the user-level 2FA requirement flag', async () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('POST rejects enabling a user-level 2FA requirement when the encryption key is missing', async () => {
|
||||
parseRequestMock.mockResolvedValue({
|
||||
auth: {
|
||||
user: {
|
||||
id: 'admin-1',
|
||||
isAdmin: true,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
required: true,
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
isTwoFactorConfiguredMock.mockReturnValue(false);
|
||||
|
||||
const response = await POST(new Request('http://localhost/api/admin/users/user-1/2fa', { method: 'POST' }), {
|
||||
params: Promise.resolve({ userId: 'user-1' }),
|
||||
});
|
||||
|
||||
expect(updateUserMock).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 'two-factor-error-not-configured',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, notFound, unauthorized } from '@/lib/response';
|
||||
import { json, notFound, serviceUnavailable, unauthorized } from '@/lib/response';
|
||||
import { getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import { updateUser } from '@/queries/prisma/user';
|
||||
import { canEnforceTwoFactorAuthForUser } from '@/permissions';
|
||||
|
||||
@@ -47,6 +48,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
|
||||
const { userId } = await params;
|
||||
const { required } = body;
|
||||
|
||||
if (required && !isTwoFactorConfigured()) {
|
||||
return serviceUnavailable(getTwoFactorConfigurationError());
|
||||
}
|
||||
|
||||
const user = await updateUser(userId, { twoFactorRequired: required });
|
||||
|
||||
return json({ ok: true, userId: user.id, twoFactorRequired: required });
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
parseRequest: vi.fn(),
|
||||
getUserByUsername: vi.fn(),
|
||||
checkPassword: vi.fn(),
|
||||
findTwoFactorAuth: vi.fn(),
|
||||
createSecureToken: vi.fn(),
|
||||
secret: vi.fn(),
|
||||
hash: vi.fn(),
|
||||
getAllUserTeams: vi.fn(),
|
||||
saveAuth: vi.fn(),
|
||||
isTwoFactorConfigured: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/request', () => ({
|
||||
parseRequest: mocks.parseRequest,
|
||||
}));
|
||||
|
||||
vi.mock('@/queries/prisma', () => ({
|
||||
getAllUserTeams: mocks.getAllUserTeams,
|
||||
getUserByUsername: mocks.getUserByUsername,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/password', () => ({
|
||||
checkPassword: mocks.checkPassword,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
default: {
|
||||
client: {
|
||||
twoFactorAuth: {
|
||||
findUnique: mocks.findTwoFactorAuth,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/jwt', () => ({
|
||||
createSecureToken: mocks.createSecureToken,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/crypto', () => ({
|
||||
hash: mocks.hash,
|
||||
secret: mocks.secret,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
saveAuth: mocks.saveAuth,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/redis', () => ({
|
||||
default: {
|
||||
enabled: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/two-factor/crypto', () => ({
|
||||
getTwoFactorConfigurationError: () => ({
|
||||
code: 'two-factor-error-not-configured',
|
||||
message: 'TWO_FACTOR_ENCRYPTION_KEY is missing or invalid',
|
||||
}),
|
||||
isTwoFactorConfigured: mocks.isTwoFactorConfigured,
|
||||
}));
|
||||
|
||||
import { POST } from './route';
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.parseRequest.mockReset();
|
||||
mocks.getUserByUsername.mockReset();
|
||||
mocks.checkPassword.mockReset();
|
||||
mocks.findTwoFactorAuth.mockReset();
|
||||
mocks.createSecureToken.mockReset();
|
||||
mocks.secret.mockReset();
|
||||
mocks.hash.mockReset();
|
||||
mocks.getAllUserTeams.mockReset();
|
||||
mocks.saveAuth.mockReset();
|
||||
mocks.isTwoFactorConfigured.mockReset();
|
||||
|
||||
mocks.parseRequest.mockResolvedValue({
|
||||
body: { username: 'alice', password: 'secret' },
|
||||
error: undefined,
|
||||
});
|
||||
mocks.getUserByUsername.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
password: 'hashed-password',
|
||||
role: 'admin',
|
||||
createdAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||
});
|
||||
mocks.checkPassword.mockReturnValue(true);
|
||||
mocks.findTwoFactorAuth.mockResolvedValue({ userId: 'user-1', isEnabled: true });
|
||||
mocks.isTwoFactorConfigured.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('POST returns a configuration error instead of partial auth when 2FA is enabled but unavailable', async () => {
|
||||
mocks.isTwoFactorConfigured.mockReturnValue(false);
|
||||
|
||||
const response = await POST(new Request('http://localhost/api/auth/login', { method: 'POST' }));
|
||||
|
||||
expect(mocks.createSecureToken).not.toHaveBeenCalled();
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 'two-factor-error-not-configured',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
@@ -7,7 +7,8 @@ import { checkPassword } from '@/lib/password';
|
||||
import prisma from '@/lib/prisma';
|
||||
import redis from '@/lib/redis';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { json, serviceUnavailable, unauthorized } from '@/lib/response';
|
||||
import { getTwoFactorConfigurationError, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
|
||||
import { getAllUserTeams, getUserByUsername } from '@/queries/prisma';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -39,6 +40,10 @@ export async function POST(request: Request) {
|
||||
: null;
|
||||
|
||||
if (twoFactor?.isEnabled) {
|
||||
if (!isTwoFactorConfigured()) {
|
||||
return serviceUnavailable(getTwoFactorConfigurationError());
|
||||
}
|
||||
|
||||
const partialToken = createSecureToken({ userId: id, type: 'partial-auth' }, secret(), {
|
||||
expiresIn: '5m',
|
||||
});
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useApi } from '../useApi';
|
||||
|
||||
export interface TwoFactorStatusData {
|
||||
isEnabled: boolean;
|
||||
isRequired: boolean;
|
||||
isConfigured: boolean;
|
||||
globalRequired: boolean;
|
||||
requiredReason: 'global' | 'user' | 'team' | null;
|
||||
}
|
||||
|
||||
export function useTwoFactorStatusQuery(enabled: boolean) {
|
||||
const { get, useQuery } = useApi();
|
||||
|
||||
return useQuery({
|
||||
return useQuery<TwoFactorStatusData>({
|
||||
queryKey: ['2fa-status'],
|
||||
queryFn: () => get('/2fa/status'),
|
||||
enabled,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||
const TWO_FACTOR_CONFIGURATION_ERROR = {
|
||||
code: 'two-factor-error-not-configured',
|
||||
message: 'TWO_FACTOR_ENCRYPTION_KEY is missing or invalid',
|
||||
} as const;
|
||||
|
||||
/** Checks that TWO_FACTOR_ENCRYPTION_KEY holds a valid 256-bit key, without throwing. */
|
||||
export function isTwoFactorConfigured(): boolean {
|
||||
@@ -9,10 +13,14 @@ export function isTwoFactorConfigured(): boolean {
|
||||
return !!hex && KEY_REGEX.test(hex);
|
||||
}
|
||||
|
||||
export function getTwoFactorConfigurationError() {
|
||||
return TWO_FACTOR_CONFIGURATION_ERROR;
|
||||
}
|
||||
|
||||
/** Loads and validates the 256-bit encryption key from TWO_FACTOR_ENCRYPTION_KEY. */
|
||||
function getKey(): Buffer {
|
||||
if (!isTwoFactorConfigured()) {
|
||||
throw new Error('TWO_FACTOR_ENCRYPTION_KEY is missing or invalid');
|
||||
throw new Error(TWO_FACTOR_CONFIGURATION_ERROR.message);
|
||||
}
|
||||
return Buffer.from(process.env.TWO_FACTOR_ENCRYPTION_KEY, 'hex');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user