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)
This commit is contained in:
2026-09-08 00:46:57 +03:00
parent 0828003141
commit 5750a3106f
15 changed files with 525 additions and 45 deletions

303
tests/auth-security.test.ts Normal file
View File

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