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

View File

@@ -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: 'Внутренняя ошибка сервера' });