diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 17ca2f7d9..9b2ad9482 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { saveAuth } from '@/lib/auth'; import { ROLES } from '@/lib/constants'; -import { secret } from '@/lib/crypto'; +import { hash, secret } from '@/lib/crypto'; import { createSecureToken } from '@/lib/jwt'; import { checkPassword } from '@/lib/password'; import redis from '@/lib/redis'; @@ -31,12 +31,15 @@ export async function POST(request: Request) { const { id, role, createdAt } = user; + // Bind token to password hash so a password change invalidates old tokens. + const pwd = hash(user.password); + let token: string; if (redis.enabled) { - token = await saveAuth({ userId: id, role }); + token = await saveAuth({ userId: id, role, pwd }); } else { - token = createSecureToken({ userId: user.id, role }, secret()); + token = createSecureToken({ userId: user.id, role, pwd }, secret()); } const teams = await getAllUserTeams(id); diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts index bf4bc2209..a6d4b464f 100644 --- a/src/app/api/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -1,7 +1,9 @@ import { saveAuth } from '@/lib/auth'; +import { hash } from '@/lib/crypto'; import redis from '@/lib/redis'; import { parseRequest } from '@/lib/request'; import { json, serverError } from '@/lib/response'; +import { getUser } from '@/queries/prisma'; export async function POST(request: Request) { const { auth, error } = await parseRequest(request); @@ -14,7 +16,8 @@ export async function POST(request: Request) { return serverError('Redis is disabled'); } - const token = await saveAuth({ userId: auth.user.id }, 86400); + const user = await getUser(auth.user.id, { includePassword: true }); + const token = await saveAuth({ userId: auth.user.id, pwd: hash(user.password) }, 86400); return json({ user: auth.user, token }); } diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts new file mode 100644 index 000000000..ff1c0b25c --- /dev/null +++ b/src/lib/auth.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { hash } from '@/lib/crypto'; +import { parseSecureToken } from '@/lib/jwt'; +import redis from '@/lib/redis'; +import { getUser } from '@/queries/prisma/user'; +import { checkAuth } from './auth'; + +vi.mock('@/lib/jwt', () => ({ + parseSecureToken: vi.fn(), + parseToken: vi.fn(() => null), +})); + +vi.mock('@/queries/prisma/user', () => ({ + getUser: vi.fn(), +})); + +vi.mock('@/lib/redis', () => ({ + default: { + enabled: false, + client: { + get: vi.fn(), + }, + }, +})); + +const parseSecureTokenMock = vi.mocked(parseSecureToken); +const getUserMock = vi.mocked(getUser); +const redisMock = redis as unknown as { + enabled: boolean; + client: { + get: ReturnType; + }; +}; + +const PASSWORD_HASH = '$2b$10$currentpasswordhashvalue'; + +function authedRequest() { + return new Request('http://localhost/api/test', { + headers: { authorization: 'Bearer secure-token' }, + }); +} + +function mockUser() { + getUserMock.mockResolvedValue({ + id: 'user-1', + username: 'bob', + role: 'user', + password: PASSWORD_HASH, + } as any); +} + +beforeEach(() => { + parseSecureTokenMock.mockReset(); + getUserMock.mockReset(); + redisMock.enabled = false; + redisMock.client.get.mockReset(); +}); + +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); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result?.user?.id).toBe('user-1'); + }); + + test('rejects a stateless token whose fingerprint predates a password change', async () => { + // Token minted against the old password must stop working once the password changes. + parseSecureTokenMock.mockReturnValue({ + userId: 'user-1', + pwd: hash('old-password-hash'), + } as any); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result).toBeNull(); + }); + + test('does not expose the password hash on the returned user', async () => { + parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) } as any); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result?.user).not.toHaveProperty('password'); + }); + + test('authorizes a Redis session whose fingerprint matches the current password', async () => { + redisMock.enabled = true; + parseSecureTokenMock.mockReturnValue({ authKey: 'auth:session-key' } as any); + redisMock.client.get.mockResolvedValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) }); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result?.user?.id).toBe('user-1'); + }); + + test('rejects a Redis session whose fingerprint predates a password change', async () => { + redisMock.enabled = true; + parseSecureTokenMock.mockReturnValue({ authKey: 'auth:session-key' } as any); + redisMock.client.get.mockResolvedValue({ userId: 'user-1', pwd: hash('old-password-hash') }); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result).toBeNull(); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 45144b5c3..70eab7047 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,7 +6,7 @@ import { SHARE_TOKEN_HEADER, SHARE_TOKEN_TYPE, } from '@/lib/constants'; -import { createAuthKey, secret } from '@/lib/crypto'; +import { createAuthKey, hash, secret } from '@/lib/crypto'; import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; import redis from '@/lib/redis'; import { ensureArray } from '@/lib/utils'; @@ -29,12 +29,21 @@ export async function checkAuth(request: Request) { const { userId, authKey } = payload || {}; if (userId) { - user = await getUser(userId); + user = await getUser(userId, { includePassword: true }); + + // Reject tokens issued before the current password. + if (user && hash(user.password) !== payload.pwd) { + user = null; + } } else if (redis.enabled && authKey) { const key = await redis.client.get(authKey); if (key?.userId) { - user = await getUser(key.userId); + user = await getUser(key.userId, { includePassword: true }); + + if (user && hash(user.password) !== key.pwd) { + user = null; + } } } @@ -60,6 +69,7 @@ export async function checkAuth(request: Request) { } if (user) { + delete user.password; user.isAdmin = user.role === ROLES.admin; }