Files
iistwin/tests/auth-security.test.ts
Ильяс Султанов 5750a3106f security(auth): anti-enumeration, returnTo-валидация, лимит попыток логина
Шаг 2.1 плана production-готовности:
- единый 401 при любом отказе логина + constant-time bcrypt
- forgot-password: идентичный ответ независимо от существования email
- sanitizeReturnTo (open redirect fix в /api/documents/generate-link)
- 10 неудачных попыток → блок 15 мин (in-memory, аудит)
- DATABASE_SSL_REJECT_UNAUTHORIZED env (дефолт true)
- доделка 0.7: статичные тексты в llm-providers/rag/finance-di2
- 8 новых тестов (104/104)
2026-09-08 00:46:57 +03:00

304 lines
11 KiB
TypeScript
Raw Permalink 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';
const mockStorage = vi.hoisted(() => ({
getUserByEmail: vi.fn(),
getUserByEmailAndSlug: vi.fn(),
getUserWithOrganization: vi.fn(),
getUser: vi.fn(),
updateUser: vi.fn(),
createUserSession: vi.fn(),
getAppRolePermissions: vi.fn(),
getOrganization: vi.fn(),
getUserByResetPasswordToken: vi.fn(),
getInvitationByToken: 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().mockResolvedValue(undefined),
},
EVENT_TYPES: {},
}));
// Глобальные rate-limit'еры отключены, чтобы не мешать сериям запросов в тестах.
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 { sanitizeReturnTo } from '../server/utils/return-to';
import {
clearLoginAttempts,
MAX_FAILED_ATTEMPTS,
} 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 buildApp() {
const app = express();
app.use(express.json());
const router = Router();
registerAuthCoreRoutes(router);
app.use(router);
return app;
}
describe('auth anti-enumeration', () => {
beforeEach(() => {
vi.clearAllMocks();
clearLoginAttempts();
});
it('несуществующий email, неверный пароль и неактивный аккаунт дают одинаковый 401 и текст', async () => {
const app = buildApp();
// 1. Пользователь не найден
mockStorage.getUserByEmail.mockResolvedValue(undefined);
const notFound = await request(app)
.post('/api/auth/login')
.send({ email: 'ghost@example.com', password: PASSWORD });
// 2. Неверный пароль
setupExistingUser();
const wrongPassword = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'WrongPass1!' });
// 3. Аккаунт деактивирован
setupExistingUser(makeUser({ isActive: false }));
const inactive = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: PASSWORD });
for (const res of [notFound, wrongPassword, inactive]) {
expect(res.status).toBe(401);
expect(res.body).toEqual({ success: false, error: 'Неверный email или пароль' });
}
});
it('успешный логин возвращает 200, пользователя и токены', 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);
expect(res.body.success).toBe(true);
expect(res.body.user.email).toBe('user@example.com');
expect(res.body.tokens.accessToken).toBeTruthy();
expect(res.body.tokens.refreshToken).toBeTruthy();
});
});
describe('per-account лимит попыток логина', () => {
beforeEach(() => {
vi.clearAllMocks();
clearLoginAttempts();
setupExistingUser();
});
it(`после ${MAX_FAILED_ATTEMPTS} неудачных попыток — блокировка, даже с верным паролем`, async () => {
const app = buildApp();
for (let i = 1; i < MAX_FAILED_ATTEMPTS; i++) {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'WrongPass1!' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('Неверный email или пароль');
}
// Попытка, на которой срабатывает блокировка
const locking = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'WrongPass1!' });
expect(locking.status).toBe(401);
expect(locking.body.error).toContain('Слишком много неудачных попыток');
// Верный пароль во время блокировки тоже отклоняется
const duringLock = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: PASSWORD });
expect(duringLock.status).toBe(401);
expect(duringLock.body.error).toContain('Слишком много неудачных попыток');
});
it('успешный вход сбрасывает счётчик неудачных попыток', async () => {
const app = buildApp();
for (let i = 0; i < MAX_FAILED_ATTEMPTS - 1; i++) {
await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'WrongPass1!' });
}
const ok = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: PASSWORD });
expect(ok.status).toBe(200);
// Счётчик сброшен: ещё MAX-1 неудачных попыток не блокируют аккаунт
for (let i = 0; i < MAX_FAILED_ATTEMPTS - 1; i++) {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'WrongPass1!' });
expect(res.body.error).toBe('Неверный email или пароль');
}
});
});
describe('forgot-password anti-enumeration', () => {
beforeEach(() => {
vi.clearAllMocks();
clearLoginAttempts();
});
it('ответ одинаковый для существующего и несуществующего email', async () => {
const app = buildApp();
mockStorage.getUserByEmail.mockResolvedValue(undefined);
const missing = await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'ghost@example.com' });
setupExistingUser();
mockStorage.getOrganization.mockResolvedValue({ id: 1, name: 'Тест', displayName: 'Тест' });
const existing = await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'user@example.com' });
expect(missing.status).toBe(200);
expect(existing.status).toBe(200);
expect(missing.body).toEqual(existing.body);
expect(missing.body.success).toBe(true);
expect(missing.body.message).toContain('Если аккаунт');
// Письмо отправлено только для существующего аккаунта
expect(mockEmailService.sendPasswordResetEmail).toHaveBeenCalledTimes(1);
});
it('returnTo с open redirect отбрасывается, относительный путь проходит', async () => {
const app = buildApp();
setupExistingUser();
mockStorage.getOrganization.mockResolvedValue({ id: 1, name: 'Тест', displayName: 'Тест' });
await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'user@example.com', returnTo: '//evil.com' });
expect(mockEmailService.sendPasswordResetEmail).toHaveBeenLastCalledWith(
'user@example.com', 'Иван', expect.any(String), 'Тест', undefined,
);
await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'user@example.com', returnTo: 'https://evil.com/x' });
expect(mockEmailService.sendPasswordResetEmail).toHaveBeenLastCalledWith(
'user@example.com', 'Иван', expect.any(String), 'Тест', undefined,
);
await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'user@example.com', returnTo: '/home' });
expect(mockEmailService.sendPasswordResetEmail).toHaveBeenLastCalledWith(
'user@example.com', 'Иван', expect.any(String), 'Тест', '/home',
);
});
});
describe('sanitizeReturnTo', () => {
it('принимает относительные пути', () => {
expect(sanitizeReturnTo('/')).toBe('/');
expect(sanitizeReturnTo('/home')).toBe('/home');
expect(sanitizeReturnTo('/forms/1/tasks/2?tab=chat')).toBe('/forms/1/tasks/2?tab=chat');
});
it('отклоняет open redirect варианты', () => {
expect(sanitizeReturnTo(undefined)).toBeUndefined();
expect(sanitizeReturnTo('')).toBeUndefined();
expect(sanitizeReturnTo('https://evil.com')).toBeUndefined();
expect(sanitizeReturnTo('//evil.com')).toBeUndefined();
expect(sanitizeReturnTo('/\\evil.com')).toBeUndefined();
expect(sanitizeReturnTo('javascript:alert(1)')).toBeUndefined();
expect(sanitizeReturnTo('evil.com')).toBeUndefined();
expect(sanitizeReturnTo(42)).toBeUndefined();
});
});