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:
303
tests/auth-security.test.ts
Normal file
303
tests/auth-security.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user