feat(auth): шаг 0.6 — TTL access 15 минут + хэширование refresh/reset-токенов в БД
Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run

- 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 собирается
This commit is contained in:
2026-09-08 11:35:32 +03:00
parent 639036c87e
commit 06bfd2aa75
11 changed files with 506 additions and 60 deletions

View File

@@ -0,0 +1,361 @@
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();
});
});