diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index 9eda3db..802bf51 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -309,3 +309,19 @@ - Как проверялось: `npm run check` чисто; vitest зелёные; lint 0 errors; `npm run build` собирается. - Влияние на поиск/UX: нет. - Подводные камни: dompurify используется в client/src/lib/htmlUtils.ts для rich-text — обновление патчевое, API совместим. + +--- + +## [2.1] Средние security-фиксы + +- Статус: ✅ done +- Зачем: anti-enumeration, open redirect, брутфорс, явный SSL, доделка 0.7 (Фаза 2). +- Что изменено: + - Anti-enumeration: `server/services/auth.service.ts` — bcrypt-сравнение всегда (dummy-хэш), единый 401 «Неверный email или пароль» для всех причин отказа; forgot-password — идентичный 200 «Если аккаунт существует…» независимо от email. + - returnTo: `server/utils/return-to.ts` + `client/src/lib/return-to.ts` — только относительные пути с одиночным `/` (отброс //host, /\host, ctrl-символов); применено в auth и /api/documents/generate-link (был open redirect через res.redirect). + - Лимит логина: `server/utils/login-attempts.ts` — 10 неудачных → блок 15 мин (in-memory, сброс при успехе), аудит auth.login.locked; поверх authLimiter по IP. + - SSL: `server/db.ts` — rejectUnauthorized через env DATABASE_SSL_REJECT_UNAUTHORIZED (дефолт true). + - Доделка 0.7: 200+success:false с err.message зачищены в llm-providers/rag/finance-di2. +- Как проверялось: 8 новых тестов (tests/auth-security.test.ts) — vitest 104/104; check/lint чисто. +- Влияние на поиск/UX: нет; UX логина: деактивированный аккаунт видит «Неверный email или пароль» (цель anti-enumeration), после 10 попыток — сообщение о 15-минутной блокировке. +- Подводные камни: блокировки in-memory (рестарт обнуляет; multi-instance делит лимит — как остальные кэши). diff --git a/client/src/lib/return-to.ts b/client/src/lib/return-to.ts new file mode 100644 index 0000000..7e2ae39 --- /dev/null +++ b/client/src/lib/return-to.ts @@ -0,0 +1,10 @@ +// Валидация returnTo против open redirect: принимаем только относительные +// пути с одиночным '/' в начале. '//host' (protocol-relative) и '/\host' +// (браузеры трактуют '\' как '/') отклоняются. Невалидное значение → ''. +export function sanitizeReturnTo(value: string | null | undefined): string { + if (!value) return ''; + if (!value.startsWith('/')) return ''; + if (value.length > 1 && (value[1] === '/' || value[1] === '\\')) return ''; + if (value.includes('://')) return ''; + return value; +} diff --git a/client/src/pages/ForgotPassword.tsx b/client/src/pages/ForgotPassword.tsx index 59ab0e8..1da2576 100644 --- a/client/src/pages/ForgotPassword.tsx +++ b/client/src/pages/ForgotPassword.tsx @@ -3,11 +3,12 @@ import { useLocation } from 'wouter'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { CheckSquare, ArrowLeft, CheckCircle, Loader2 } from 'lucide-react'; +import { sanitizeReturnTo } from '@/lib/return-to'; function getReturnTo(): string { const params = new URLSearchParams(window.location.search); - const returnTo = params.get('returnTo'); - if (returnTo && returnTo.startsWith('/') && !returnTo.startsWith('/login')) { + const returnTo = sanitizeReturnTo(params.get('returnTo')); + if (returnTo && !returnTo.startsWith('/login')) { return returnTo; } return ''; diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index dfb757b..23deac1 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -17,19 +17,14 @@ import { FormMessage, } from '@/components/ui/form'; import { CheckSquare, Eye, EyeOff, CloudOff } from 'lucide-react'; +import { sanitizeReturnTo } from '@/lib/return-to'; function getReturnTo(): string { const params = new URLSearchParams(window.location.search); - const returnTo = params.get('returnTo'); // Разрешаем только относительные пути внутри приложения. - // Блокируем open redirect: //evil.com, /login, javascript:, https:// и т.п. - if ( - returnTo && - returnTo.startsWith('/') && - !returnTo.startsWith('//') && - !returnTo.includes('://') && - !returnTo.startsWith('/login') - ) { + // Блокируем open redirect: //evil.com, /\evil.com, /login, javascript:, https:// и т.п. + const returnTo = sanitizeReturnTo(params.get('returnTo')); + if (returnTo && !returnTo.startsWith('/login')) { return returnTo; } return '/home'; diff --git a/client/src/pages/ResetPassword.tsx b/client/src/pages/ResetPassword.tsx index 8bced9c..c674f7b 100644 --- a/client/src/pages/ResetPassword.tsx +++ b/client/src/pages/ResetPassword.tsx @@ -3,13 +3,13 @@ import { useLocation } from 'wouter'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { CheckSquare, Eye, EyeOff, Loader2, ArrowLeft, CheckCircle } from 'lucide-react'; +import { sanitizeReturnTo } from '@/lib/return-to'; function getParams(): { token: string; returnTo: string } { const params = new URLSearchParams(window.location.search); const token = params.get('token') || ''; - const returnTo = params.get('returnTo') || ''; - const safeReturnTo = - returnTo && returnTo.startsWith('/') && !returnTo.startsWith('/login') ? returnTo : ''; + const returnTo = sanitizeReturnTo(params.get('returnTo')); + const safeReturnTo = returnTo && !returnTo.startsWith('/login') ? returnTo : ''; return { token, returnTo: safeReturnTo }; } diff --git a/server/db.ts b/server/db.ts index 9baed1f..8f5b483 100644 --- a/server/db.ts +++ b/server/db.ts @@ -27,16 +27,20 @@ function getPool(): pg.Pool { throw new Error(msg); } + // SSL включается для sslmode=require и Neon. rejectUnauthorized управляется + // env DATABASE_SSL_REJECT_UNAUTHORIZED (дефолт true — проверка сертификата); + // 'false' допустимо только с самоподписанными сертификатами (dev-стенды). + const sslRequired = + process.env.DATABASE_URL.includes("sslmode=require") || + process.env.DATABASE_URL.includes("neon.tech"); + const sslRejectUnauthorized = process.env.DATABASE_SSL_REJECT_UNAUTHORIZED !== "false"; + _pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 50, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, - ssl: - process.env.DATABASE_URL.includes("sslmode=require") || - process.env.DATABASE_URL.includes("neon.tech") - ? { rejectUnauthorized: false } - : undefined, + ssl: sslRequired ? { rejectUnauthorized: sslRejectUnauthorized } : undefined, }); _pool.on("error", (err) => { diff --git a/server/documents/routes.ts b/server/documents/routes.ts index a537625..76ac9d5 100644 --- a/server/documents/routes.ts +++ b/server/documents/routes.ts @@ -10,6 +10,7 @@ import { SignatureService } from "./signature.service"; import { DocxImporterService } from "./docx-importer.service"; import * as storage from "../storage/documents.storage"; import { storage as appStorage } from "../storage/index"; +import { sanitizeReturnTo } from "../utils/return-to"; export async function registerDocumentRoutes(app: Express) { const templateService = new DocumentTemplateService(); @@ -326,7 +327,7 @@ export async function registerDocumentRoutes(app: Express) { const templateId = Number(req.query.templateId); const taskId = Number(req.query.taskId); const format = (req.query.format as string) || 'both'; - const returnTo = (req.query.returnTo as string) || ''; + const returnTo = sanitizeReturnTo(req.query.returnTo); if (!templateId || !taskId) { return res.status(400).json({ error: "templateId и taskId обязательны" }); diff --git a/server/finance-di2/routes.ts b/server/finance-di2/routes.ts index 1478f95..6149345 100644 --- a/server/finance-di2/routes.ts +++ b/server/finance-di2/routes.ts @@ -5545,7 +5545,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< results.push({ id: area.id, name: area.name, months: months.filter(m => m.employees.length > 0) }); } catch (err: any) { console.error(`Error fetching area ${area.name}:`, err?.message); - results.push({ id: area.id, name: area.name, months: [], error: err?.message || "Ошибка загрузки" }); + results.push({ id: area.id, name: area.name, months: [], error: "Ошибка загрузки" }); } await sleep(600); } diff --git a/server/routes/auth.core.routes.ts b/server/routes/auth.core.routes.ts index fa1292e..2c9231a 100644 --- a/server/routes/auth.core.routes.ts +++ b/server/routes/auth.core.routes.ts @@ -12,6 +12,17 @@ import { emailService } from "../services/email.service"; import { notificationService } from "../services/notification.service"; import { authLimiter, refreshLimiter } from "./shared"; import { logAudit, getClientIp } from "../utils/audit"; +import { sanitizeReturnTo } from "../utils/return-to"; +import { + getLoginLockRemainingMs, + recordFailedLoginAttempt, + resetLoginAttempts, +} from "../utils/login-attempts"; + +// Единый ответ forgot-password: не раскрывает, существует ли аккаунт. +const FORGOT_PASSWORD_MESSAGE = 'Если аккаунт с таким email существует, письмо со ссылкой для сброса пароля отправлено'; +// Текст при временной блокировке после серии неудачных попыток входа. +const LOGIN_LOCKED_MESSAGE = 'Слишком много неудачных попыток входа. Попробуйте снова через 15 минут'; export function registerAuthCoreRoutes(router: Router): void { /** @@ -75,8 +86,23 @@ export function registerAuthCoreRoutes(router: Router): void { validateRequest(loginSchema), async (req, res) => { try { + const email: string = req.body?.email ?? ''; + + // Per-account лимит: после серии неудачных попыток по этому email + // вход временно блокируется (защита от brute-force конкретного аккаунта). + if (getLoginLockRemainingMs(email) > 0) { + logAudit({ + action: 'auth.login.locked', + details: { email }, + ip: getClientIp(req), + userAgent: req.headers['user-agent'] ?? null, + }); + return res.status(401).json({ success: false, error: LOGIN_LOCKED_MESSAGE }); + } + const result = await authService.login(req.body, req); if (result.success && result.user && result.tokens) { + resetLoginAttempts(email); logAudit({ action: 'auth.login.success', userId: result.user.id, @@ -106,9 +132,19 @@ export function registerAuthCoreRoutes(router: Router): void { tokens: result.tokens }); } else { + const attempt = recordFailedLoginAttempt(email); + if (attempt.justLocked) { + logAudit({ + action: 'auth.login.locked', + details: { email, reason: 'max_failed_attempts' }, + ip: getClientIp(req), + userAgent: req.headers['user-agent'] ?? null, + }); + return res.status(401).json({ success: false, error: LOGIN_LOCKED_MESSAGE }); + } logAudit({ action: 'auth.login.failed', - details: { email: req.body?.email, reason: result.error }, + details: { email, reason: result.error }, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null, }); @@ -295,7 +331,8 @@ export function registerAuthCoreRoutes(router: Router): void { const user = await storage.getUserByEmail(email); if (!user) { - return res.json({ success: true }); + // Ответ идентичен случаю существующего email — anti-enumeration. + return res.json({ success: true, message: FORGOT_PASSWORD_MESSAGE }); } const organization = await storage.getOrganization(user.organizationId); @@ -307,9 +344,10 @@ export function registerAuthCoreRoutes(router: Router): void { resetPasswordExpires: expires, }); - const safeReturnTo = returnTo && typeof returnTo === 'string' && returnTo.startsWith('/') && !returnTo.startsWith('/login') - ? returnTo - : undefined; + // returnTo принимаем только как относительный путь (open redirect защита); + // /login исключаем как раньше, чтобы не было циклов редиректов. + const sanitized = sanitizeReturnTo(returnTo); + const safeReturnTo = sanitized && !sanitized.startsWith('/login') ? sanitized : undefined; await emailService.sendPasswordResetEmail( user.email, @@ -319,7 +357,7 @@ export function registerAuthCoreRoutes(router: Router): void { safeReturnTo ); - return res.json({ success: true }); + return res.json({ success: true, message: FORGOT_PASSWORD_MESSAGE }); } catch (error) { console.error('Forgot password error:', error); return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); diff --git a/server/routes/llm-providers.routes.ts b/server/routes/llm-providers.routes.ts index bde4a2d..b924005 100644 --- a/server/routes/llm-providers.routes.ts +++ b/server/routes/llm-providers.routes.ts @@ -279,7 +279,8 @@ router.get( const tagsData = await tagsRes.json() as { models?: Array<{ name: string; size: number; digest: string; modified_at: string }> }; return res.json({ success: true, models: tagsData.models ?? [] }); } catch (err: any) { - return res.json({ success: false, error: `Ошибка получения моделей: ${err?.message ?? err}` }); + console.error("[LLM-Providers] ollama models error:", err); + return res.json({ success: false, error: "Не удалось получить список моделей Ollama" }); } } ); @@ -377,7 +378,7 @@ router.delete( return res.json({ success: true }); } catch (err: any) { console.error("[LLM-Providers] ollama delete model error:", err); - return res.json({ success: false, error: `Ошибка удаления модели: ${err?.message ?? err}` }); + return res.json({ success: false, error: "Не удалось удалить модель" }); } } ); @@ -454,7 +455,8 @@ router.post( const models = (tagsData.models ?? []).map((m) => m.name); return res.json({ success: true, models }); } catch (err: any) { - return res.json({ success: false, error: `Ошибка подключения к Ollama: ${err?.message ?? err}` }); + console.error("[LLM-Providers] ollama test connection error:", err); + return res.json({ success: false, error: "Не удалось подключиться к Ollama" }); } } @@ -475,11 +477,12 @@ router.post( const models = (modelsData.data ?? []).map((m) => m.id).sort(); return res.json({ success: true, models }); } catch (err: any) { - return res.json({ success: false, error: `Ошибка подключения: ${err?.message ?? err}` }); + console.error("[LLM-Providers] provider test connection error:", err); + return res.json({ success: false, error: "Не удалось подключиться к провайдеру" }); } } catch (err: any) { console.error("[LLM-Providers] test error:", err); - return res.json({ success: false, error: `Внутренняя ошибка: ${err?.message ?? err}` }); + return res.json({ success: false, error: "Внутренняя ошибка сервера" }); } } ); diff --git a/server/routes/rag.routes.ts b/server/routes/rag.routes.ts index a47e5ec..0683d65 100644 --- a/server/routes/rag.routes.ts +++ b/server/routes/rag.routes.ts @@ -313,7 +313,8 @@ router.post( }); if (!response.ok) { const text = await response.text(); - return res.json({ success: false, error: `Ошибка Ollama при генерации эмбеддинга (${response.status}): ${text.slice(0, 200)}` }); + console.error(`[RAG] Ollama embeddings error (${response.status}):`, text.slice(0, 200)); + return res.json({ success: false, error: `Ошибка Ollama при генерации эмбеддинга (${response.status})` }); } return res.json({ success: true, message: "Подключение успешно, модель доступна" }); } @@ -335,14 +336,15 @@ router.post( if (!response.ok) { const text = await response.text(); - return res.json({ success: false, error: `Ошибка API (${response.status}): ${text.slice(0, 300)}` }); + console.error(`[RAG] embeddings API error (${response.status}):`, text.slice(0, 300)); + return res.json({ success: false, error: `Ошибка API (${response.status})` }); } return res.json({ success: true, message: "Подключение успешно" }); } catch (err: any) { const msg = err?.message ?? String(err); console.error("[RAG] test connection error:", msg); - return res.json({ success: false, error: `Ошибка соединения: ${msg.slice(0, 200)}` }); + return res.json({ success: false, error: "Ошибка соединения с сервисом эмбеддингов" }); } } ); diff --git a/server/services/auth.service.ts b/server/services/auth.service.ts index df1d266..19de86a 100644 --- a/server/services/auth.service.ts +++ b/server/services/auth.service.ts @@ -42,6 +42,10 @@ const SESSION_LIFETIME_DAYS = 30; const REMEMBER_LIFETIME_DAYS = 90; const RACE_TOLERANCE_MS = 300_000; // 5 минут — защита от rotation-гонок между фоновыми вкладками/PWA +// Фиктивный bcrypt-хэш (cost 12) для constant-time ответа при входе с +// несуществующим email — см. anti-enumeration в login(). +const DUMMY_PASSWORD_HASH = '$2b$12$AKA6k01zovdEWcbsfyUu6eYQyakr7Z.COfTbva8MXWD4jn5u9OgQy'; + function isSecureCookie(): boolean { // In production we always serve over HTTPS behind Traefik. return process.env.NODE_ENV === 'production'; @@ -190,23 +194,23 @@ export class AuthService { } } - if (!user) { - return { success: false, error: 'Неверный email или пароль' }; - } + // Anti-enumeration: bcrypt-сравнение выполняется всегда — при + // несуществующем пользователе с захардкоженным хэшем, чтобы по + // времени ответа нельзя было отличить состояние аккаунта. + // Текст ошибки единый для «не найден» / «неактивен» / «неверный пароль». + const isValidPassword = await verifyPassword( + data.password, + user?.passwordHash ?? DUMMY_PASSWORD_HASH + ); - if (!user.isActive) { - return { success: false, error: 'Аккаунт заблокирован' }; + if (!user || !user.isActive || !isValidPassword) { + return { success: false, error: 'Неверный email или пароль' }; } if (user.organization && !user.organization.isActive) { return { success: false, error: 'Доступ организации заблокирован' }; } - const isValidPassword = await verifyPassword(data.password, user.passwordHash); - if (!isValidPassword) { - return { success: false, error: 'Неверный email или пароль' }; - } - const remember = data.remember ?? false; const tokens = await this.createSession(user, remember, req); diff --git a/server/utils/login-attempts.ts b/server/utils/login-attempts.ts new file mode 100644 index 0000000..9920551 --- /dev/null +++ b/server/utils/login-attempts.ts @@ -0,0 +1,92 @@ +// In-memory счётчик неудачных попыток входа по email (anti brute-force +// per-account). После MAX_FAILED_ATTEMPTS подряд — временная блокировка +// попыток на LOCK_DURATION_MS. При успешном входе счётчик сбрасывается. +// Блокировка по email (а не IP), чтобы коллеги за одним офисным NAT +// не блокировали друг друга; перечисление аккаунтов не раскрывается, +// т.к. лимит применяется к любому email независимо от его существования. +// ОГОВОРКА: при multi-instance деплое счётчики не общие между процессами +// (как и остальные in-memory кэши) — для общего лимита нужен Redis/БД. + +export const MAX_FAILED_ATTEMPTS = 10; +export const LOCK_DURATION_MS = 15 * 60 * 1000; + +const MAX_ENTRIES = 10_000; + +interface AttemptEntry { + count: number; + lockedUntil: number; + /** Момент, после которого запись можно удалить (для очистки Map). */ + expiresAt: number; +} + +const attempts = new Map(); + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function getFreshEntry(email: string): AttemptEntry | undefined { + const entry = attempts.get(email); + if (!entry) return undefined; + const now = Date.now(); + if (now > entry.expiresAt) { + attempts.delete(email); + return undefined; + } + return entry; +} + +/** Оставшееся время блокировки в мс (0 — не заблокирован). */ +export function getLoginLockRemainingMs(email: string): number { + const entry = getFreshEntry(normalizeEmail(email)); + if (!entry) return 0; + return Math.max(0, entry.lockedUntil - Date.now()); +} + +export interface FailedAttemptResult { + /** Аккаунт заблокирован (либо уже был, либо блокировка сработала на этой попытке). */ + locked: boolean; + /** Блокировка сработала именно на этой попытке — повод записать аудит. */ + justLocked: boolean; + remainingMs: number; +} + +export function recordFailedLoginAttempt(email: string): FailedAttemptResult { + const key = normalizeEmail(email); + const now = Date.now(); + const existing = getFreshEntry(key); + + if (existing && existing.lockedUntil > now) { + // Продлеваем жизнь записи, чтобы блокировка не «протухла» раньше времени. + existing.expiresAt = existing.lockedUntil; + return { locked: true, justLocked: false, remainingMs: existing.lockedUntil - now }; + } + + const count = (existing?.count ?? 0) + 1; + const locked = count >= MAX_FAILED_ATTEMPTS; + const entry: AttemptEntry = { + count: locked ? 0 : count, + lockedUntil: locked ? now + LOCK_DURATION_MS : 0, + expiresAt: now + LOCK_DURATION_MS, + }; + + if (!attempts.has(key) && attempts.size >= MAX_ENTRIES) { + // Вытесняем самую старую запись (Map хранит порядок вставки). + const oldestKey = attempts.keys().next().value; + if (oldestKey !== undefined) attempts.delete(oldestKey); + } + attempts.delete(key); + attempts.set(key, entry); + + return { locked, justLocked: locked, remainingMs: locked ? LOCK_DURATION_MS : 0 }; +} + +/** Сброс счётчика при успешном входе. */ +export function resetLoginAttempts(email: string): void { + attempts.delete(normalizeEmail(email)); +} + +/** Полная очистка (для тестов). */ +export function clearLoginAttempts(): void { + attempts.clear(); +} diff --git a/server/utils/return-to.ts b/server/utils/return-to.ts new file mode 100644 index 0000000..7063640 --- /dev/null +++ b/server/utils/return-to.ts @@ -0,0 +1,11 @@ +// Валидация returnTo/redirect-параметров против open redirect: +// принимаем только относительные пути, начинающиеся с одиночного '/'. +// '//evil.com' (protocol-relative), '/\evil.com' (backslash-trick, +// браузеры трактуют '\' как '/') и управляющие символы отклоняются. +export function sanitizeReturnTo(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length === 0) return undefined; + if (!value.startsWith('/')) return undefined; + if (value.length > 1 && (value[1] === '/' || value[1] === '\\')) return undefined; + if (/[\r\n\0]/.test(value)) return undefined; + return value; +} diff --git a/tests/auth-security.test.ts b/tests/auth-security.test.ts new file mode 100644 index 0000000..f1b9577 --- /dev/null +++ b/tests/auth-security.test.ts @@ -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 = {}) { + 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(); + }); +});