Reject partial 2FA tokens and bind sessions to password fingerprints

This commit is contained in:
Mike Cao committed 2026-09-12 21:47:21 -07:00
1 parent d1cf63d04b
commit 8c2ce02770
6 files changed
+91 -15

No files matched your search

+19 -2
View File
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
markOtpUsed: vi.fn(), markOtpUsed: vi.fn(),
verifyTotp: vi.fn(), verifyTotp: vi.fn(),
secret: vi.fn(), secret: vi.fn(),
hash: vi.fn(),
})); }));
vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/auth', () => ({
@@ -29,6 +30,7 @@ vi.mock('@/lib/auth', () => ({
vi.mock('@/lib/crypto', () => ({ vi.mock('@/lib/crypto', () => ({
secret: mocks.secret, secret: mocks.secret,
hash: mocks.hash,
})); }));
vi.mock('@/lib/jwt', () => ({ vi.mock('@/lib/jwt', () => ({
@@ -111,18 +113,25 @@ beforeEach(() => {
mocks.markOtpUsed.mockReset(); mocks.markOtpUsed.mockReset();
mocks.verifyTotp.mockReset(); mocks.verifyTotp.mockReset();
mocks.secret.mockReset(); mocks.secret.mockReset();
mocks.hash.mockReset();
mocks.getBearerToken.mockReturnValue('partial-token'); mocks.getBearerToken.mockReturnValue('partial-token');
mocks.secret.mockReturnValue('app-secret'); mocks.secret.mockReturnValue('app-secret');
mocks.hash.mockReturnValue('pwd-fingerprint');
mocks.parseSecureToken.mockReturnValue({ type: 'partial-auth', userId: 'user-1' }); mocks.parseSecureToken.mockReturnValue({ type: 'partial-auth', userId: 'user-1' });
mocks.getUser.mockResolvedValue({ mocks.getUser.mockResolvedValue({
id: 'user-1', id: 'user-1',
username: 'alice', username: 'alice',
role: 'admin', role: 'admin',
createdAt: new Date('2026-07-23T00:00:00.000Z'), createdAt: new Date('2026-07-23T00:00:00.000Z'),
password: '$2b$10$hashedpassword',
}); });
mocks.getAllUserTeams.mockResolvedValue([]); 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.createSecureToken.mockReturnValue('full-auth-token');
mocks.decryptSecret.mockReturnValue('plain-secret'); mocks.decryptSecret.mockReturnValue('plain-secret');
mocks.isTwoFactorConfigured.mockReturnValue(true); 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.verifyTotp).toHaveBeenCalledWith('123456', 'plain-secret');
expect(mocks.markOtpUsed).toHaveBeenCalledWith('user-1', '123456'); expect(mocks.markOtpUsed).toHaveBeenCalledWith('user-1', '123456');
expect(mocks.resetRateLimit).toHaveBeenCalledWith('user-1'); 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', token: 'full-auth-token',
user: { user: {
id: 'user-1', id: 'user-1',
username: 'alice', username: 'alice',
}, },
}); });
expect(data.user).not.toHaveProperty('password');
expect(response.status).toBe(200); expect(response.status).toBe(200);
}); });
+15 -9
View File
@@ -1,18 +1,22 @@
import { z } from 'zod'; import { z } from 'zod';
import prisma from '@/lib/prisma';
import { getBearerToken, saveAuth } from '@/lib/auth'; import { getBearerToken, saveAuth } from '@/lib/auth';
import { ROLES } from '@/lib/constants'; import { PARTIAL_AUTH_TOKEN_TYPE, ROLES } from '@/lib/constants';
import { secret } from '@/lib/crypto'; import { hash, secret } from '@/lib/crypto';
import { createSecureToken, parseSecureToken } from '@/lib/jwt'; import { createSecureToken, parseSecureToken } from '@/lib/jwt';
import prisma from '@/lib/prisma';
import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, notFound, serviceUnavailable, unauthorized } from '@/lib/response'; import { badRequest, json, notFound, serviceUnavailable, unauthorized } from '@/lib/response';
import { verifyBackupCode } from '@/lib/two-factor/backup-codes'; 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 { checkRateLimit, recordFailedAttempt, resetRateLimit } from '@/lib/two-factor/rate-limit';
import { isOtpReplayed, markOtpUsed } from '@/lib/two-factor/replay-prevention'; import { isOtpReplayed, markOtpUsed } from '@/lib/two-factor/replay-prevention';
import { verifyTotp } from '@/lib/two-factor/totp'; import { verifyTotp } from '@/lib/two-factor/totp';
import { getAllUserTeams, getUser } from '@/queries/prisma'; import { getAllUserTeams, getUser } from '@/queries/prisma';
import redis from '@/lib/redis';
export async function POST(request: Request) { export async function POST(request: Request) {
if (process.env.CLOUD_MODE) { if (process.env.CLOUD_MODE) {
@@ -30,7 +34,7 @@ export async function POST(request: Request) {
} }
const payload = parseSecureToken(rawToken, secret()) as any; 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' }); 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 userId = payload.userId as string;
const user = await getUser(userId); const user = await getUser(userId, { includePassword: true });
if (!user) { if (!user) {
return unauthorized(); return unauthorized();
@@ -127,12 +131,14 @@ export async function POST(request: Request) {
} }
const { id, role, createdAt, username } = user; 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; let fullToken: string;
if (redis.enabled) { if (redis.enabled) {
fullToken = await saveAuth({ userId: id, role }); fullToken = await saveAuth({ userId: id, role, pwd });
} else { } else {
fullToken = createSecureToken({ userId: id, role }, secret()); fullToken = createSecureToken({ userId: id, role, pwd }, secret());
} }
const teams = await getAllUserTeams(id); const teams = await getAllUserTeams(id);
+8 -4
View File
@@ -1,5 +1,5 @@
import { saveAuth } from '@/lib/auth'; 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 { hash, secret } from '@/lib/crypto';
import { createSecureToken } from '@/lib/jwt'; import { createSecureToken } from '@/lib/jwt';
import { checkPassword } from '@/lib/password'; import { checkPassword } from '@/lib/password';
@@ -39,9 +39,13 @@ export async function POST(request: Request) {
return serviceUnavailable(getTwoFactorConfigurationError()); return serviceUnavailable(getTwoFactorConfigurationError());
} }
const partialToken = createSecureToken({ userId: id, type: 'partial-auth' }, secret(), { const partialToken = createSecureToken(
expiresIn: '5m', { userId: id, type: PARTIAL_AUTH_TOKEN_TYPE },
}); secret(),
{
expiresIn: '5m',
},
);
return json({ requiresTwoFactor: true, partialToken }); return json({ requiresTwoFactor: true, partialToken });
} }
// Bind token to password hash so a password change invalidates old tokens. // Bind token to password hash so a password change invalidates old tokens.
+38
View File
@@ -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', () => { describe('checkAuth password fingerprint', () => {
test('authorizes a stateless token whose fingerprint matches the current password', async () => { test('authorizes a stateless token whose fingerprint matches the current password', async () => {
parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) } as any); parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) } as any);
+10
View File
@@ -7,6 +7,7 @@ import {
isApiKeyEnabled, isApiKeyEnabled,
} from '@/lib/api-key'; } from '@/lib/api-key';
import { import {
PARTIAL_AUTH_TOKEN_TYPE,
ROLE_PERMISSIONS, ROLE_PERMISSIONS,
ROLES, ROLES,
SHARE_CONTEXT_HEADER, SHARE_CONTEXT_HEADER,
@@ -75,6 +76,15 @@ export async function checkAuth(request: Request) {
} }
const payload = parseSecureToken(token, secret()); 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); const shareToken = await parseShareToken(request);
let user = null; let user = null;
+1
View File
@@ -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_CONTEXT_HEADER = 'x-umami-share-context';
export const SHARE_TOKEN_TYPE = 'share'; export const SHARE_TOKEN_TYPE = 'share';
export const CACHE_TOKEN_TYPE = 'cache'; export const CACHE_TOKEN_TYPE = 'cache';
export const PARTIAL_AUTH_TOKEN_TYPE = 'partial-auth';
export const HOMEPAGE_URL = 'https://umami.is'; export const HOMEPAGE_URL = 'https://umami.is';
export const DOCS_URL = 'https://umami.is/docs'; export const DOCS_URL = 'https://umami.is/docs';
export const REPO_URL = 'https://github.com/umami-software/umami'; export const REPO_URL = 'https://github.com/umami-software/umami';