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