diff --git a/README.md b/README.md index 474c75b3f..e0ebef01b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
- Umami is a simple, fast, privacy-focused alternative to Google Analytics. + 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.
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 348c294ca..53f73b8de 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json
index 552381ef1..0ed5102da 100644
--- a/public/intl/messages/en-US.json
+++ b/public/intl/messages/en-US.json
@@ -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."
}
diff --git a/src/app/api/2fa/setup/initiate/route.test.ts b/src/app/api/2fa/setup/initiate/route.test.ts
index 04d605e2b..055cb8d3f 100644
--- a/src/app/api/2fa/setup/initiate/route.test.ts
+++ b/src/app/api/2fa/setup/initiate/route.test.ts
@@ -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 });
diff --git a/src/app/api/2fa/setup/initiate/route.ts b/src/app/api/2fa/setup/initiate/route.ts
index 01288601e..2ad68da61 100644
--- a/src/app/api/2fa/setup/initiate/route.ts
+++ b/src/app/api/2fa/setup/initiate/route.ts
@@ -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);
diff --git a/src/app/api/2fa/status/route.test.ts b/src/app/api/2fa/status/route.test.ts
new file mode 100644
index 000000000..c1e4f090d
--- /dev/null
+++ b/src/app/api/2fa/status/route.test.ts
@@ -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);
+});
diff --git a/src/app/api/2fa/status/route.ts b/src/app/api/2fa/status/route.ts
index d7dc9a44f..57aa781bf 100644
--- a/src/app/api/2fa/status/route.ts
+++ b/src/app/api/2fa/status/route.ts
@@ -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' },
diff --git a/src/components/messages.ts b/src/components/messages.ts
index cc0d69d4b..b717200d4 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -506,5 +506,6 @@ export const messages: Record