Merge pull request #4450 from NoiceHax/fix/issue-4443

fix(2fa): gate two-factor auth when the encryption key is missing
This commit is contained in:
Francis Cao authored and GitHub committed 2026-08-14 09:32:50 -07:00
commit f94a5cfc02
12 files changed
+183 -9

No files matched your search

+5 -1
View File
@@ -5,7 +5,7 @@
<h1 align="center">Umami</h1>
<p align="center">
<i>Umami is a simple, fast, privacy-focused alternative to Google Analytics.</i>
<i>Umami is a privacy-first analytics platform. Traffic, campaigns, behavior, conversions, and revenue in one place — no cookies, no surveillance, self-hosted or in the cloud.</i>
</p>
<p align="center">
@@ -50,6 +50,10 @@ Optional: set `API_URL` to change the base URL used by internal UI API calls.
Relative paths are served under `BASE_PATH`; absolute URLs are proxied through the local `/api` route.
For example, `API_URL=/internal-api` or `API_URL=https://api.example.com/api`.
Optional: set `TWO_FACTOR_ENCRYPTION_KEY` to a 64-character hex string to enable two-factor
authentication. Generate one with `openssl rand -hex 32`. Two-factor authentication is unavailable
and cannot be required until this key is set.
The connection URL format:
```bash
+2
View File
@@ -7,6 +7,8 @@ services:
environment:
DATABASE_URL: postgresql://umami:umami@db:5432/umami
APP_SECRET: replace-me-with-a-random-string
# Required for two-factor authentication, generate with: openssl rand -hex 32
TWO_FACTOR_ENCRYPTION_KEY: replace-me-with-a-64-character-hex-string
depends_on:
db:
condition: service_healthy
+1
View File
@@ -505,6 +505,7 @@
"two-factor-error-already-enabled": "Two-factor authentication is already enabled.",
"two-factor-error-not-enabled": "Two-factor authentication is not enabled.",
"two-factor-error-no-pending-setup": "No pending two-factor authentication setup found.",
"two-factor-error-not-configured": "Two-factor authentication is not available because the server is missing a valid TWO_FACTOR_ENCRYPTION_KEY. Contact your administrator for help.",
"two-factor-locked-message": "Too many failed attempts. Try again at {time}.",
"two-factor-active-description": "Your account is protected with two-factor authentication. Each login requires a code from your authenticator app."
}
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
upsert: vi.fn(),
generateTotpSecret: vi.fn(),
encryptSecret: vi.fn(),
isTwoFactorConfigured: vi.fn(),
generateOtpAuthUri: vi.fn(),
generateQrCodeDataUrl: vi.fn(),
}));
@@ -33,6 +34,7 @@ vi.mock('@/lib/prisma', () => ({
vi.mock('@/lib/two-factor/crypto', () => ({
encryptSecret: mocks.encryptSecret,
isTwoFactorConfigured: mocks.isTwoFactorConfigured,
}));
vi.mock('@/lib/two-factor/totp', () => ({
@@ -48,9 +50,11 @@ beforeEach(() => {
mocks.upsert.mockReset();
mocks.generateTotpSecret.mockReset();
mocks.encryptSecret.mockReset();
mocks.isTwoFactorConfigured.mockReset();
mocks.generateOtpAuthUri.mockReset();
mocks.generateQrCodeDataUrl.mockReset();
mocks.isTwoFactorConfigured.mockReturnValue(true);
mocks.parseRequest.mockResolvedValue({
auth: { user: { id: 'user-1' } },
error: undefined,
@@ -82,6 +86,22 @@ test('POST creates a pending 2FA setup and returns the manual key and QR data',
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/initiate', { method: 'POST' }),
);
expect(mocks.upsert).not.toHaveBeenCalled();
await expect(response.json()).resolves.toMatchObject({
error: {
code: 'two-factor-error-not-configured',
},
});
expect(response.status).toBe(503);
});
test('POST rejects setup when 2FA is already enabled for the user', async () => {
mocks.findUnique.mockResolvedValue({ userId: 'user-1', isEnabled: true });
+10 -2
View File
@@ -1,6 +1,6 @@
import { parseRequest } from '@/lib/request';
import { badRequest, json, notFound } from '@/lib/response';
import { encryptSecret } from '@/lib/two-factor/crypto';
import { badRequest, json, notFound, serviceUnavailable } from '@/lib/response';
import { encryptSecret, isTwoFactorConfigured } from '@/lib/two-factor/crypto';
import {
generateOtpAuthUri,
generateQrCodeDataUrl,
@@ -20,6 +20,14 @@ export async function POST(request: Request) {
return error();
}
// 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',
});
}
const userId = auth.user.id;
const user = await getUser(userId);
+89
View File
@@ -0,0 +1,89 @@
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);
vi.mock('@/lib/request', () => ({
parseRequest: vi.fn(),
}));
vi.mock('@/lib/prisma', () => ({
default: {
client: {
twoFactorAuth: {
findUnique: vi.fn(),
},
appSetting: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
teamUser: {
findMany: vi.fn(),
},
team: {
findMany: vi.fn(),
},
},
},
}));
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();
parseRequestMock.mockResolvedValue({
auth: {
user: {
id: 'user-1',
},
},
error: undefined,
});
prismaMock.client.twoFactorAuth.findUnique.mockResolvedValue(null as any);
prismaMock.client.appSetting.findUnique.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);
});
test('GET requires 2FA when it is globally enabled', async () => {
vi.stubEnv('TWO_FACTOR_ENCRYPTION_KEY', KEY);
const response = await GET(new Request('http://localhost/api/2fa/status'));
await expect(response.json()).resolves.toEqual({
isEnabled: false,
isRequired: true,
requiredReason: 'global',
});
expect(response.status).toBe(200);
});
test('GET does not require 2FA when the encryption key is missing', async () => {
vi.stubEnv('TWO_FACTOR_ENCRYPTION_KEY', '');
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,
requiredReason: null,
});
expect(response.status).toBe(200);
});
+6
View File
@@ -1,6 +1,7 @@
import prisma from '@/lib/prisma';
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { isTwoFactorConfigured } from '@/lib/two-factor/crypto';
export async function GET(request: Request) {
const { auth, error } = await parseRequest(request);
@@ -18,6 +19,11 @@ 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' },
+1
View File
@@ -506,5 +506,6 @@ export const messages: Record<string, string> = {
twoFactorErrorAlreadyEnabled: 'message.two-factor-error-already-enabled',
twoFactorErrorNotEnabled: 'message.two-factor-error-not-enabled',
twoFactorErrorNoPendingSetup: 'message.two-factor-error-no-pending-setup',
twoFactorErrorNotConfigured: 'message.two-factor-error-not-configured',
replayMinDurationDescription: 'message.replay-min-duration-description',
};
@@ -58,7 +58,7 @@ interface TwoFactorSetupModalProps {
}
export function TwoFactorSetupModal({ required, onClose }: TwoFactorSetupModalProps) {
const { t, labels, messages } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string | null>(null);
const [manualKey, setManualKey] = useState<string | null>(null);
const [otpValue, setOtpValue] = useState('');
@@ -79,7 +79,7 @@ export function TwoFactorSetupModal({ required, onClose }: TwoFactorSetupModalPr
setQrCodeDataUrl(data.qrCodeDataUrl);
setManualKey(data.manualKey);
},
onError: () => setError(t(messages.error)),
onError: (err: any) => setError(getErrorMessage(err) || t(messages.error)),
},
);
}, []);
@@ -92,7 +92,7 @@ export function TwoFactorSetupModal({ required, onClose }: TwoFactorSetupModalPr
const data: any = await confirm({ token });
setBackupCodes(data.backupCodes);
} catch (err: any) {
setError(err.message || t(messages.error));
setError(getErrorMessage(err) || t(messages.error));
}
};
+9
View File
@@ -54,6 +54,15 @@ export function notFound(error?: Record<string, any>) {
);
}
export function serviceUnavailable(error?: Record<string, any>) {
return Response.json(
{
error: { message: 'Service unavailable', code: 'service-unavailable', status: 503, ...error },
},
{ status: 503 },
);
}
export function serverError(error?: unknown) {
if (error && typeof error !== 'string') {
// eslint-disable-next-line no-console
+28
View File
@@ -51,3 +51,31 @@ describe('encryptSecret/decryptSecret', () => {
vi.unstubAllEnvs();
});
});
describe('isTwoFactorConfigured', () => {
test('returns true with a valid key', async () => {
vi.stubEnv('TWO_FACTOR_ENCRYPTION_KEY', KEY);
vi.resetModules();
const { isTwoFactorConfigured } = await import('./crypto');
expect(isTwoFactorConfigured()).toBe(true);
vi.unstubAllEnvs();
});
test('returns false when the key is missing or invalid', async () => {
vi.stubEnv('TWO_FACTOR_ENCRYPTION_KEY', '');
vi.resetModules();
const { isTwoFactorConfigured } = await import('./crypto');
expect(isTwoFactorConfigured()).toBe(false);
vi.stubEnv('TWO_FACTOR_ENCRYPTION_KEY', 'too-short');
expect(isTwoFactorConfigured()).toBe(false);
vi.stubEnv('TWO_FACTOR_ENCRYPTION_KEY', 'z'.repeat(64));
expect(isTwoFactorConfigured()).toBe(false);
vi.unstubAllEnvs();
});
});
+9 -3
View File
@@ -1,14 +1,20 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY_REGEX = /^[0-9a-fA-F]{64}$/;
/** Checks that TWO_FACTOR_ENCRYPTION_KEY holds a valid 256-bit key, without throwing. */
export function isTwoFactorConfigured(): boolean {
const hex = process.env.TWO_FACTOR_ENCRYPTION_KEY;
return !!hex && KEY_REGEX.test(hex);
}
/** Loads and validates the 256-bit encryption key from TWO_FACTOR_ENCRYPTION_KEY. */
function getKey(): Buffer {
const hex = process.env.TWO_FACTOR_ENCRYPTION_KEY;
if (!hex || hex.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(hex)) {
if (!isTwoFactorConfigured()) {
throw new Error('TWO_FACTOR_ENCRYPTION_KEY is missing or invalid');
}
return Buffer.from(hex, 'hex');
return Buffer.from(process.env.TWO_FACTOR_ENCRYPTION_KEY, 'hex');
}
/** Encrypts a TOTP secret using AES-256-GCM. Returns `ciphertext:iv:authTag` as hex. */