Rate limiting и защита от перебора паролей для входа и API
Практика4 минРедакция Innovialabs
Коротко
Ограничивайте число попыток сразу по IP и по аккаунту, вместо жёсткой блокировки используйте растущие задержки и CAPTCHA после нескольких неудач, а лимиты храните в общем хранилище вроде Redis и дублируйте базовым лимитом в nginx.
Короткий ответ
Rate limiting — ограничение числа запросов от одного источника за период времени. Для входа и API это главная защита от перебора паролей, подбора кодов из SMS и злоупотребления API.
Рабочая схема для большинства проектов:
- Два счётчика: по IP и по аккаунту (логину, email, телефону).
- После нескольких неудачных попыток — CAPTCHA или растущая задержка, а не вечная блокировка.
- Лимиты хранятся в общем хранилище (Redis), чтобы работали на всех серверах.
- Поверх — грубый лимит в nginx или CDN, который отсекает явный мусор до приложения.
- Ответ при превышении — HTTP 429 с заголовком
Retry-After.
Алгоритмы
| Алгоритм | Как работает | Когда подходит |
|---|---|---|
| Fixed window | Счётчик на интервал (например, минуту), обнуляется в начале нового | Просто и дёшево; на стыке окон возможен двойной всплеск |
| Sliding window | Учитывает запросы за последние N секунд, а не за календарную минуту | Точнее для входа и чувствительных операций |
| Token bucket | «Ведро» пополняется с постоянной скоростью, каждый запрос забирает токен | API: допускает короткие всплески при средней ограниченной скорости |
| Leaky bucket | Запросы проходят с фиксированной скоростью, излишек ждёт в очереди или отбрасывается | Сглаживание нагрузки; так работает limit_req в nginx |
Для формы входа обычно хватает fixed или sliding window, для публичного API — token bucket.
По IP или по аккаунту
- Только по IP — злоумышленник перебирает пароль одного пользователя с тысяч адресов (ботнет, прокси) и не упирается в лимит. Плюс за одним IP могут сидеть целый офис или мобильный оператор.
- Только по аккаунту — атакующий пробует один популярный пароль на тысячах аккаунтов (password spraying), и на каждом аккаунте всего одна попытка.
Поэтому нужны оба лимита: по аккаунту — строгий (несколько неудач за 15 минут), по IP — мягче и шире (десятки попыток в минуту), плюс общий лимит неудачных входов на весь сервис для обнаружения массовой атаки.
Не раскрывайте, существует ли аккаунт: сообщение об ошибке и время ответа должны быть одинаковыми для «нет такого пользователя» и «неверный пароль».
Блокировка или задержка
- Жёсткая блокировка аккаунта после N ошибок проста, но опасна: любой может заблокировать чужой аккаунт, просто вводя неверный пароль. Это готовый инструмент для отказа в обслуживании.
- Прогрессивная задержка — после каждой неудачи пауза растёт (1, 2, 4, 8 секунд...). Перебор становится бесполезным, а настоящий пользователь почти ничего не замечает.
- Временная блокировка на 15-30 минут с уведомлением владельцу на почту — разумный компромисс.
Где ставить CAPTCHA
- Не на первую попытку входа: это раздражает всех ради немногих.
- Показывайте её после 2-3 неудач по аккаунту или IP, а также на регистрации, восстановлении пароля и отправке SMS.
- Предпочитайте «невидимые» проверки, которые показывают задачу только подозрительным посетителям.
- CAPTCHA — дополнение к лимитам, а не замена: её решают сервисы распознавания.
Реализация в nginx
Базовый слой — ограничить частоту запросов к входу с одного IP:
http {
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
limit_req_status 429;
server {
location = /api/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://app;
}
}
}
rate=10r/m — средняя скорость, burst=5 — допустимый короткий всплеск. Если сайт за CDN или балансировщиком, настройте модуль realip, иначе все запросы будут считаться с IP прокси.
Реализация на Redis
Счётчик по аккаунту в приложении. Lua-скрипт выполняется в Redis атомарно, поэтому счётчик не останется без срока жизни:
const script = `
local c = redis.call("INCR", KEYS[1])
if c == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end
return c
`;
async function tooManyAttempts(login) {
const key = `login:fail:${login.toLowerCase()}`;
const count = await redis.eval(script, 1, key, 900); // окно 15 минут
return count > 5;
}
Увеличивайте счётчик при неудачном входе, сбрасывайте при успешном. Такой же счётчик с ключом по IP — второй слой.
Частые ошибки
- Лимит хранится в памяти процесса — при нескольких серверах он не работает.
- Защищён только
/login, но не восстановление пароля, проверка SMS-кода и API-токены. - Нет лимита на ввод одноразового кода: короткий цифровой код подбирается быстро.
- Реальный IP клиента не определяется за прокси, и лимит блокирует всех сразу.