- /api/auth/me при 401 делает одну попытку refresh (раньше refresh-retry был
отключён для всех /api/auth/* — холодный старт после 15 мин простоя = логаут)
- ротация: повтор старого refresh-токена в пределах RACE_TOLERANCE_MS (5 мин)
выдаёт новую пару от successor вместо deny; cookie больше не стираются при
stale-токенах (разлогинивались все вкладки устройства); reuse после окна —
revoke family как раньше (тесты обновлены)
- refreshSession различает 401/403/400 (сессия мертва) vs 429/5xx/сеть
(transient — нет logout, отложенный retry)
- единый рефреш: useAuth keep-alive и useOfflineSync переведены на refreshSession;
межвкладочная дедупликация через BroadcastChannel('auth-refresh')
- refreshLimiter: keyGenerator buildRateLimitKey (per-user, не общий IP-бакет офиса)
482 lines
20 KiB
TypeScript
482 lines
20 KiB
TypeScript
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('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.markSessionReplaced.mockResolvedValue(undefined);
|
||
|
||
const result = await authService.refreshToken(oldPlain);
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.tokens?.refreshToken).toBeTruthy();
|
||
// Ротируется successor (активная сессия family), family НЕ отзывается
|
||
expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(successor.id, 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();
|
||
});
|
||
});
|