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(),
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in new issue
Block a user