diff --git a/src/app/api/2fa/verify/route.test.ts b/src/app/api/2fa/verify/route.test.ts index 650d98fb9..dbc924f39 100644 --- a/src/app/api/2fa/verify/route.test.ts +++ b/src/app/api/2fa/verify/route.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ markOtpUsed: vi.fn(), verifyTotp: vi.fn(), secret: vi.fn(), + hash: vi.fn(), })); vi.mock('@/lib/auth', () => ({ @@ -29,6 +30,7 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/crypto', () => ({ secret: mocks.secret, + hash: mocks.hash, })); vi.mock('@/lib/jwt', () => ({ @@ -111,18 +113,25 @@ beforeEach(() => { mocks.markOtpUsed.mockReset(); mocks.verifyTotp.mockReset(); mocks.secret.mockReset(); + mocks.hash.mockReset(); mocks.getBearerToken.mockReturnValue('partial-token'); mocks.secret.mockReturnValue('app-secret'); + mocks.hash.mockReturnValue('pwd-fingerprint'); mocks.parseSecureToken.mockReturnValue({ type: 'partial-auth', userId: 'user-1' }); mocks.getUser.mockResolvedValue({ id: 'user-1', username: 'alice', role: 'admin', createdAt: new Date('2026-07-23T00:00:00.000Z'), + password: '$2b$10$hashedpassword', }); mocks.getAllUserTeams.mockResolvedValue([]); - mocks.findTwoFactorAuth.mockResolvedValue({ userId: 'user-1', isEnabled: true, secret: 'encrypted' }); + 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); @@ -152,13 +161,21 @@ test('POST accepts a token-only payload and completes 2FA verification', async ( expect(mocks.verifyTotp).toHaveBeenCalledWith('123456', 'plain-secret'); expect(mocks.markOtpUsed).toHaveBeenCalledWith('user-1', '123456'); expect(mocks.resetRateLimit).toHaveBeenCalledWith('user-1'); - await expect(response.json()).resolves.toMatchObject({ + // Full session must not carry the partial-auth type and must be bound to the password. + expect(mocks.hash).toHaveBeenCalledWith('$2b$10$hashedpassword'); + expect(mocks.createSecureToken).toHaveBeenCalledWith( + { userId: 'user-1', role: 'admin', pwd: 'pwd-fingerprint' }, + 'app-secret', + ); + const data = await response.json(); + expect(data).toMatchObject({ token: 'full-auth-token', user: { id: 'user-1', username: 'alice', }, }); + expect(data.user).not.toHaveProperty('password'); expect(response.status).toBe(200); }); diff --git a/src/app/api/2fa/verify/route.ts b/src/app/api/2fa/verify/route.ts index b3c92fad1..421cf27b2 100644 --- a/src/app/api/2fa/verify/route.ts +++ b/src/app/api/2fa/verify/route.ts @@ -1,18 +1,22 @@ import { z } from 'zod'; -import prisma from '@/lib/prisma'; import { getBearerToken, saveAuth } from '@/lib/auth'; -import { ROLES } from '@/lib/constants'; -import { secret } from '@/lib/crypto'; +import { PARTIAL_AUTH_TOKEN_TYPE, ROLES } from '@/lib/constants'; +import { hash, secret } from '@/lib/crypto'; import { createSecureToken, parseSecureToken } from '@/lib/jwt'; +import prisma from '@/lib/prisma'; +import redis from '@/lib/redis'; import { parseRequest } from '@/lib/request'; import { badRequest, json, notFound, serviceUnavailable, unauthorized } from '@/lib/response'; import { verifyBackupCode } from '@/lib/two-factor/backup-codes'; -import { decryptSecret, getTwoFactorConfigurationError, isTwoFactorConfigured } 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'; import { getAllUserTeams, getUser } from '@/queries/prisma'; -import redis from '@/lib/redis'; export async function POST(request: Request) { if (process.env.CLOUD_MODE) { @@ -30,7 +34,7 @@ export async function POST(request: Request) { } const payload = parseSecureToken(rawToken, secret()) as any; - if (!payload || payload.type !== 'partial-auth' || !payload.userId) { + if (!payload || payload.type !== PARTIAL_AUTH_TOKEN_TYPE || !payload.userId) { return unauthorized({ code: 'two-factor-error-invalid-partial-token' }); } @@ -44,7 +48,7 @@ export async function POST(request: Request) { } const userId = payload.userId as string; - const user = await getUser(userId); + const user = await getUser(userId, { includePassword: true }); if (!user) { return unauthorized(); @@ -127,12 +131,14 @@ export async function POST(request: Request) { } const { id, role, createdAt, username } = user; + // Bind token to password hash so a password change invalidates old tokens. + const pwd = hash(user.password); let fullToken: string; if (redis.enabled) { - fullToken = await saveAuth({ userId: id, role }); + fullToken = await saveAuth({ userId: id, role, pwd }); } else { - fullToken = createSecureToken({ userId: id, role }, secret()); + fullToken = createSecureToken({ userId: id, role, pwd }, secret()); } const teams = await getAllUserTeams(id); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index fb7cdd21a..2c3635c17 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,5 +1,5 @@ import { saveAuth } from '@/lib/auth'; -import { ROLES } from '@/lib/constants'; +import { PARTIAL_AUTH_TOKEN_TYPE, ROLES } from '@/lib/constants'; import { hash, secret } from '@/lib/crypto'; import { createSecureToken } from '@/lib/jwt'; import { checkPassword } from '@/lib/password'; @@ -39,9 +39,13 @@ export async function POST(request: Request) { return serviceUnavailable(getTwoFactorConfigurationError()); } - const partialToken = createSecureToken({ userId: id, type: 'partial-auth' }, secret(), { - expiresIn: '5m', - }); + const partialToken = createSecureToken( + { userId: id, type: PARTIAL_AUTH_TOKEN_TYPE }, + secret(), + { + expiresIn: '5m', + }, + ); return json({ requiresTwoFactor: true, partialToken }); } // Bind token to password hash so a password change invalidates old tokens. diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts index 9db0b8567..7501c32c5 100644 --- a/src/lib/auth.test.ts +++ b/src/lib/auth.test.ts @@ -182,6 +182,44 @@ describe('checkAuth api keys', () => { }); }); +describe('checkAuth 2FA partial token', () => { + // GHSA-vj8c-fvjv-rpg2: the partial token issued after the password step of a + // 2FA login must not be usable as a session on protected routes. + test('rejects a partial-auth token on a protected route', async () => { + parseSecureTokenMock.mockReturnValue({ userId: 'user-1', type: 'partial-auth' } as any); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result).toBeNull(); + expect(getUserMock).not.toHaveBeenCalled(); + }); + + test('rejects a partial-auth token even when it carries a matching password fingerprint', async () => { + parseSecureTokenMock.mockReturnValue({ + userId: 'user-1', + type: 'partial-auth', + pwd: hash(PASSWORD_HASH), + } as any); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result).toBeNull(); + }); + + test('rejects a partial-auth token in Redis mode', async () => { + redisMock.enabled = true; + parseSecureTokenMock.mockReturnValue({ userId: 'user-1', type: 'partial-auth' } as any); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result).toBeNull(); + expect(redisMock.client.get).not.toHaveBeenCalled(); + }); +}); + describe('checkAuth password fingerprint', () => { test('authorizes a stateless token whose fingerprint matches the current password', async () => { parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) } as any); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8533ea11c..203ebd9a5 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -7,6 +7,7 @@ import { isApiKeyEnabled, } from '@/lib/api-key'; import { + PARTIAL_AUTH_TOKEN_TYPE, ROLE_PERMISSIONS, ROLES, SHARE_CONTEXT_HEADER, @@ -75,6 +76,15 @@ export async function checkAuth(request: Request) { } const payload = parseSecureToken(token, secret()); + + // The partial token issued after the password step of a 2FA login only proves + // knowledge of the password. It must never be accepted as a session; it is + // only valid for completing the challenge at /api/2fa/verify. + if (payload?.type === PARTIAL_AUTH_TOKEN_TYPE) { + log('Partial auth token rejected'); + return null; + } + const shareToken = await parseShareToken(request); let user = null; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7519525f7..b4a65a892 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -12,6 +12,7 @@ export const SHARE_TOKEN_HEADER = 'x-umami-share-token'; export const SHARE_CONTEXT_HEADER = 'x-umami-share-context'; export const SHARE_TOKEN_TYPE = 'share'; export const CACHE_TOKEN_TYPE = 'cache'; +export const PARTIAL_AUTH_TOKEN_TYPE = 'partial-auth'; export const HOMEPAGE_URL = 'https://umami.is'; export const DOCS_URL = 'https://umami.is/docs'; export const REPO_URL = 'https://github.com/umami-software/umami';