Files
iistwin/tests/token-security.test.ts

482 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { vi, describe, it, expect, beforeEach } from 'vitest';
import crypto from 'crypto';
const mockStorage = vi.hoisted(() => ({
getUserByEmail: vi.fn(),
getUserByEmailAndSlug: vi.fn(),
getUserWithOrganization: vi.fn(),
getUser: vi.fn(),
updateUser: vi.fn(),
createUserSession: vi.fn(),
getUserSessionByToken: vi.fn(),
getUserSessionByParentToken: vi.fn(),
getActiveSessionInFamily: vi.fn(),
rotateUserSession: vi.fn(),
markSessionReplaced: vi.fn(),
revokeSession: vi.fn(),
revokeSessionFamily: vi.fn(),
revokeAllUserSessions: vi.fn(),
getAppRolePermissions: vi.fn(),
getOrganization: vi.fn(),
getUserByResetPasswordToken: vi.fn(),
}));
const mockEmailService = vi.hoisted(() => ({
sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../server/db', () => ({
db: {},
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
withTenant: (_orgId: number, fn: () => unknown) => fn(),
withSuperAdmin: (fn: (db: unknown) => unknown) => fn({}),
openTenantCtx: vi.fn().mockResolvedValue({
run: (fn: () => void) => fn(),
release: vi.fn(),
}),
openSuperAdminCtx: vi.fn().mockResolvedValue(undefined),
_tenantCtx: { getStore: vi.fn().mockReturnValue(null) },
}));
vi.mock('../server/storage', () => ({ storage: mockStorage }));
// bcrypt замокан ради скорости тестов: хэш = 'hash:<пароль>'.
vi.mock('../server/utils/password', () => ({
hashPassword: vi.fn(async (p: string) => `hash:${p}`),
verifyPassword: vi.fn(async (p: string, h: string) => h === `hash:${p}`),
generateTempPassword: vi.fn(() => 'Temp1234!'),
}));
vi.mock('../server/services/email.service', () => ({
emailService: mockEmailService,
EmailService: class {},
}));
vi.mock('../server/services/notification.service', () => ({
notificationService: {
emit: vi.fn(),
on: vi.fn(),
sendNotification: vi.fn().mockResolvedValue(undefined),
createDefaultSubscriptions: vi.fn().mockResolvedValue(undefined),
processEvent: vi.fn(),
},
EVENT_TYPES: {},
}));
vi.mock('../server/routes/shared', () => ({
authLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
refreshLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
}));
vi.mock('../server/utils/audit', () => ({
logAudit: vi.fn().mockResolvedValue(undefined),
getClientIp: vi.fn().mockReturnValue('127.0.0.1'),
}));
import express from 'express';
import request from 'supertest';
import { Router } from 'express';
import { registerAuthCoreRoutes } from '../server/routes/auth.core.routes';
import { authService } from '../server/services/auth.service';
import { generateTokens, hashToken } from '../server/utils/jwt';
import { clearLoginAttempts } from '../server/utils/login-attempts';
const PASSWORD = 'Secret123!';
function makeUser(overrides: Record<string, unknown> = {}) {
return {
id: 1,
organizationId: 1,
email: 'user@example.com',
passwordHash: `hash:${PASSWORD}`,
firstName: 'Иван',
lastName: 'Иванов',
middleName: null,
position: null,
appRole: 'user',
isActive: true,
organization: {
id: 1,
name: 'Тест',
slug: 'test',
displayName: 'Тест',
isActive: true,
},
...overrides,
};
}
function setupExistingUser(user = makeUser()) {
mockStorage.getUserByEmail.mockResolvedValue(user);
mockStorage.getUserWithOrganization.mockResolvedValue(user);
mockStorage.getAppRolePermissions.mockResolvedValue([]);
mockStorage.updateUser.mockResolvedValue(user);
mockStorage.createUserSession.mockResolvedValue({});
return user;
}
function makeSession(plainRefreshToken: string, overrides: Record<string, unknown> = {}) {
return {
id: 10,
userId: 1,
refreshToken: null,
refreshTokenHash: hashToken(plainRefreshToken),
familyId: 'family-1',
familyCreatedAt: new Date(),
parentRefreshToken: null,
parentRefreshTokenHash: null,
isReplaced: false,
isRevoked: false,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
remember: false,
createdAt: new Date(),
...overrides,
};
}
function buildApp() {
const app = express();
app.use(express.json());
const router = Router();
registerAuthCoreRoutes(router);
app.use(router);
return app;
}
describe('hashToken', () => {
it('возвращает детерминированный sha256-хэш в hex (64 символа)', () => {
const token = 'some-refresh-token';
const expected = crypto.createHash('sha256').update(token).digest('hex');
expect(hashToken(token)).toBe(expected);
expect(hashToken(token)).toMatch(/^[0-9a-f]{64}$/);
expect(hashToken(token)).toBe(hashToken(token));
expect(hashToken(token)).not.toBe(hashToken(token + 'x'));
});
});
describe('refresh-токены: выпуск по хэшу (plain не сохраняется)', () => {
beforeEach(() => {
vi.clearAllMocks();
clearLoginAttempts();
});
it('при логине в БД уходит только refreshTokenHash, plain refreshToken не передаётся', async () => {
const app = buildApp();
setupExistingUser();
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: PASSWORD });
expect(res.status).toBe(200);
const plainRefresh: string = res.body.tokens.refreshToken;
expect(plainRefresh).toBeTruthy();
const sessionArg = mockStorage.createUserSession.mock.calls[0][0];
expect(sessionArg.refreshTokenHash).toBe(hashToken(plainRefresh));
expect(sessionArg.refreshToken).toBeUndefined();
});
it('refresh по валидному токену: ротация с хэшами, plain в createUserSession не уходит', async () => {
const user = setupExistingUser();
const { refreshToken: oldPlain } = generateTokens({ userId: 1, organizationId: 1, appRole: 'user' }, false, 'family-1');
const session = makeSession(oldPlain);
mockStorage.getUserSessionByToken.mockResolvedValue(session);
mockStorage.rotateUserSession.mockResolvedValue({ id: 2 });
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(true);
expect(result.tokens?.refreshToken).toBeTruthy();
// jti в payload: токены уникальны всегда, даже при ротации в ту же секунду
// Поиск сессии шёл по переданному plain-токену (хэширование — внутри storage)
expect(mockStorage.getUserSessionByToken).toHaveBeenCalledWith(oldPlain);
// Атомарная ротация: replaced + insert в одной транзакции (storage.rotateUserSession)
const newPlain = result.tokens!.refreshToken;
expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(session.id, expect.objectContaining({
refreshTokenHash: hashToken(newPlain),
parentRefreshTokenHash: hashToken(oldPlain),
familyId: 'family-1',
}));
const newSessionArg = mockStorage.rotateUserSession.mock.calls[0][1];
expect(newSessionArg.refreshToken).toBeUndefined();
expect(newSessionArg.parentRefreshToken).toBeUndefined();
expect(user.id).toBe(1);
});
it('невалидный токен не находится: ни как текущий, ни как родительский', async () => {
setupExistingUser();
mockStorage.getUserSessionByToken.mockResolvedValue(undefined);
mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined);
const result = await authService.refreshToken('forged-token');
expect(result.success).toBe(false);
expect(result.error).toContain('Недействительный');
expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled();
expect(mockStorage.createUserSession).not.toHaveBeenCalled();
});
it('reuse detection: отозванная/старая rotated-сессия → отзыв всей family', async () => {
setupExistingUser();
mockStorage.getUserSessionByToken.mockResolvedValue(undefined);
// Rotated-сессия старая (grace period 5 минут истёк) или отозванная
const rotated = makeSession('old-plain', {
isRevoked: false,
createdAt: new Date(Date.now() - 10 * 60 * 1000),
});
mockStorage.getUserSessionByParentToken.mockResolvedValue(rotated);
mockStorage.revokeSessionFamily.mockResolvedValue(undefined);
const result = await authService.refreshToken('old-plain');
expect(result.success).toBe(false);
expect(mockStorage.revokeSessionFamily).toHaveBeenCalledWith('family-1');
});
it('grace period: свежая rotated-сессия → активная сессия family ротируется, клиент получает новую пару', async () => {
setupExistingUser();
const { refreshToken: activePlain } = generateTokens({ userId: 1, organizationId: 1, appRole: 'user' }, false, 'family-1');
const activeSession = makeSession(activePlain, { id: 11 });
mockStorage.getUserSessionByToken.mockResolvedValue(undefined);
mockStorage.getUserSessionByParentToken.mockResolvedValue(
makeSession('stale-plain', { createdAt: new Date() }),
);
mockStorage.getActiveSessionInFamily.mockResolvedValue(activeSession);
mockStorage.rotateUserSession.mockResolvedValue({ id: 12 });
const result = await authService.refreshToken('stale-plain');
expect(result.success).toBe(true);
// Plain-токен активной сессии в БД не хранится — выдаётся пара через ротацию
expect(result.tokens?.refreshToken).toBeTruthy();
expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(activeSession.id, expect.objectContaining({
refreshTokenHash: hashToken(result.tokens!.refreshToken),
}));
});
it('grace period: повтор СТАРОГО токена, replaced-сессия найдена по hash → successor ротируется, новая пара, без revoke', async () => {
setupExistingUser();
const oldPlain = 'old-plain-race';
// Старая сессия всё ещё находится по refreshTokenHash (markSessionReplaced
// hash не очищает) и помечена isReplaced
const replaced = makeSession(oldPlain, { id: 10, isReplaced: true });
// Successor-сессия, созданная winner-запросом чуть раньше (в пределах окна)
const successor = makeSession('new-plain-race', { id: 11, createdAt: new Date() });
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(successor);
mockStorage.getActiveSessionInFamily.mockResolvedValue(successor);
mockStorage.rotateUserSession.mockResolvedValue({ id: 12 });
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(true);
expect(result.tokens?.refreshToken).toBeTruthy();
// Ротируется successor (активная сессия family), family НЕ отзывается
expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(successor.id, expect.objectContaining({
refreshTokenHash: hashToken(result.tokens!.refreshToken),
}));
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
expect(result.clearCookies).toBeUndefined();
});
it('replaced-сессия без successor: 401 БЕЗ стирания cookie и БЕЗ revoke family', async () => {
setupExistingUser();
const oldPlain = 'old-plain-no-successor';
const replaced = makeSession(oldPlain, { id: 10, isReplaced: true });
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined);
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(false);
expect(result.clearCookies).toBe(false);
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled();
});
it('reuse attack: повтор старого токена ПОСЛЕ grace-окна → revoke family (isReplaced-ветка)', async () => {
setupExistingUser();
const oldPlain = 'old-plain-after-window';
const replaced = makeSession(oldPlain, { id: 10, isReplaced: true });
const successor = makeSession('new-plain-after-window', {
id: 11,
createdAt: new Date(Date.now() - 10 * 60 * 1000), // 10 минут назад — окно истекло
});
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(successor);
mockStorage.revokeSessionFamily.mockResolvedValue(undefined);
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(false);
expect(mockStorage.revokeSessionFamily).toHaveBeenCalledWith('family-1');
expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled();
});
it('route: повтор в grace-окне → 200 и новая пара токенов + cookie', async () => {
const app = buildApp();
setupExistingUser();
const oldPlain = 'old-plain-route';
const replaced = makeSession(oldPlain, { id: 10, isReplaced: true });
const successor = makeSession('new-plain-route', { id: 11, createdAt: new Date() });
mockStorage.getUserSessionByToken.mockResolvedValue(replaced);
mockStorage.getUserSessionByParentToken.mockResolvedValue(successor);
mockStorage.getActiveSessionInFamily.mockResolvedValue(successor);
mockStorage.markSessionReplaced.mockResolvedValue(undefined);
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: oldPlain });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.tokens?.refreshToken).toBeTruthy();
// Сервер выставил новые auth-cookie
const setCookie = res.headers['set-cookie'];
expect(setCookie).toBeTruthy();
expect(String(setCookie)).toContain('access_token=');
expect(String(setCookie)).toContain('refresh_token=');
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
});
it('route: отказ без successor не стирает cookie, при revoked-сессии — стирает', async () => {
const app = buildApp();
setupExistingUser();
// Случай 1: replaced без successor → 401 без set-cookie (cookie не трогаем)
mockStorage.getUserSessionByToken.mockResolvedValue(
makeSession('p1', { id: 10, isReplaced: true }),
);
mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined);
const res1 = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'p1' });
expect(res1.status).toBe(401);
expect(res1.headers['set-cookie']).toBeUndefined();
expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled();
// Случай 2: revoked-сессия → 401 со стиранием cookie
mockStorage.getUserSessionByToken.mockResolvedValue(
makeSession('p2', { id: 12, isRevoked: true }),
);
const res2 = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'p2' });
expect(res2.status).toBe(401);
const cleared = String(res2.headers['set-cookie']);
expect(cleared).toContain('access_token=;');
expect(cleared).toContain('refresh_token=;');
});
it('отзыв сессии при logout: revokeSession вызывается с plain-токеном (хэширует storage)', async () => {
mockStorage.revokeSession.mockResolvedValue(undefined);
await authService.logout('some-refresh-token');
expect(mockStorage.revokeSession).toHaveBeenCalledWith('some-refresh-token');
});
});
describe('reset-токены по хэшу', () => {
beforeEach(() => {
vi.clearAllMocks();
clearLoginAttempts();
});
it('forgot-password: в БД уходит resetPasswordTokenHash и resetPasswordToken=null, plain — только в письмо', async () => {
const app = buildApp();
setupExistingUser();
mockStorage.getOrganization.mockResolvedValue({ id: 1, name: 'Тест', displayName: 'Тест' });
const res = await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'user@example.com' });
expect(res.status).toBe(200);
// Plain-токен ушёл в письмо
const emailedToken: string = mockEmailService.sendPasswordResetEmail.mock.calls[0][2];
expect(emailedToken).toMatch(/^[0-9a-f]{64}$/);
// В БД — только хэш, plain-колонка явно обнулена
const updateArg = mockStorage.updateUser.mock.calls.find(
(c) => c[1].resetPasswordTokenHash,
)?.[1];
expect(updateArg).toBeTruthy();
expect(updateArg.resetPasswordTokenHash).toBe(hashToken(emailedToken));
expect(updateArg.resetPasswordToken).toBeNull();
expect(updateArg.resetPasswordExpires).toBeInstanceOf(Date);
});
it('reset-password: поиск пользователя по токену из ссылки, после сброса хэш очищается', async () => {
const app = buildApp();
const user = makeUser({ resetPasswordExpires: new Date(Date.now() + 60 * 60 * 1000) });
mockStorage.getUserByResetPasswordToken.mockResolvedValue(user);
mockStorage.revokeAllUserSessions.mockResolvedValue(undefined);
const res = await request(app)
.post('/api/auth/reset-password')
.send({ token: 'a'.repeat(64), password: 'NewSecret123!' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Токен из ссылки передан в storage как есть — поиск по хэшу внутри storage
expect(mockStorage.getUserByResetPasswordToken).toHaveBeenCalledWith('a'.repeat(64));
const updateArg = mockStorage.updateUser.mock.calls.find(
(c) => c[1].passwordHash,
)?.[1];
expect(updateArg.passwordHash).toBe('hash:NewSecret123!');
expect(updateArg.resetPasswordToken).toBeNull();
expect(updateArg.resetPasswordTokenHash).toBeNull();
expect(updateArg.resetPasswordExpires).toBeNull();
// Все сессии пользователя инвалидируются
expect(mockStorage.revokeAllUserSessions).toHaveBeenCalledWith(user.id);
});
it('reset-password: невалидный токен не находится → 400', async () => {
const app = buildApp();
mockStorage.getUserByResetPasswordToken.mockResolvedValue(undefined);
const res = await request(app)
.post('/api/auth/reset-password')
.send({ token: 'b'.repeat(64), password: 'NewSecret123!' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Неверная или устаревшая ссылка');
expect(mockStorage.updateUser).not.toHaveBeenCalled();
});
it('reset-password: истёкший токен → 400, хэш очищается', async () => {
const app = buildApp();
const user = makeUser({ resetPasswordExpires: new Date(Date.now() - 1000) });
mockStorage.getUserByResetPasswordToken.mockResolvedValue(user);
const res = await request(app)
.post('/api/auth/reset-password')
.send({ token: 'c'.repeat(64), password: 'NewSecret123!' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('истёк');
const updateArg = mockStorage.updateUser.mock.calls[0][1];
expect(updateArg.resetPasswordTokenHash).toBeNull();
expect(updateArg.resetPasswordToken).toBeNull();
});
});