Reject partial 2FA tokens and bind sessions to password fingerprints
This commit is contained in:
1 parent
d1cf63d04b
commit
8c2ce02770
6 files changed
+91
-15
No files matched your search
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user