Files
iistwin/tests/token-security.test.ts
Ильяс Султанов 06bfd2aa75
Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run
feat(auth): шаг 0.6 — TTL access 15 минут + хэширование refresh/reset-токенов в БД
- server/utils/jwt.ts: дефолт JWT_ACCESS_EXPIRES 30d → 15m (env сохранено), hashToken() (sha256 hex)
- migrations/0082_token_hashes.sql: *_hash колонки в user_sessions/users, DELETE FROM user_sessions (глобальный разлогин), legacy plain-колонки сохранены, но не пишутся
- storage: lookup/отзыв сессий и reset-токенов по хэшу; markSessionReplaced по id сессии
- auth.service: ротация в rotateFamilySession(), grace-period ротирует активную сессию вместо возврата plain-токена
- auth.core.routes: forgot/reset-password пишут/ищут sha256-хэш, plain только в письме
- tests/token-security.test.ts: 12 тестов (выпуск по хэшу, ротация, reuse detection, grace, reset)
- .env.example, swagger, IMPLEMENTATION_LOG.md обновлены

Проверки: npm run check чисто, vitest 118/118, lint 0 errors, build собирается
2026-09-08 11:35:32 +03:00

362 lines
14 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(),
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.markSessionReplaced.mockResolvedValue(undefined);
const result = await authService.refreshToken(oldPlain);
expect(result.success).toBe(true);
expect(result.tokens?.refreshToken).toBeTruthy();
// NB: JWT с одинаковым payload и iat (в секундах) детерминирован — при ротации
// в ту же секунду новый токен может совпасть со старым; это свойство JWT, не логики.
// Поиск сессии шёл по переданному plain-токену (хэширование — внутри storage)
expect(mockStorage.getUserSessionByToken).toHaveBeenCalledWith(oldPlain);
// Старая сессия помечена заменённой по id, новый токен передан plain (хэширует storage)
const newPlain = result.tokens!.refreshToken;
expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(session.id, newPlain);
// Новая сессия: только хэши, связь с родителем — по хэшу старого токена
const newSessionArg = mockStorage.createUserSession.mock.calls.find(
(c) => c[0].parentRefreshTokenHash,
)?.[0];
expect(newSessionArg).toBeTruthy();
expect(newSessionArg.refreshTokenHash).toBe(hashToken(newPlain));
expect(newSessionArg.parentRefreshTokenHash).toBe(hashToken(oldPlain));
expect(newSessionArg.refreshToken).toBeUndefined();
expect(newSessionArg.parentRefreshToken).toBeUndefined();
expect(newSessionArg.familyId).toBe('family-1');
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.markSessionReplaced.mockResolvedValue(undefined);
const result = await authService.refreshToken('stale-plain');
expect(result.success).toBe(true);
// Plain-токен активной сессии в БД не хранится — выдаётся пара через ротацию
expect(result.tokens?.refreshToken).toBeTruthy();
expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(activeSession.id, result.tokens!.refreshToken);
});
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();
});
});