Invalidate auth sessions after password changes
This commit is contained in:
1 parent
2e74b3ebae
commit
727490269b
4 files changed
+135
-7
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+13
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user