Регистрация: throttle по IP от спама аккаунтов

throttle_register ограничивает частоту POST /api/auth/register с одного IP
через тот же LoginThrottle (считаются все попытки). Enumeration ников через
409 не закрываем — ники и так публичны в топе (отмечено в отчёте). #62

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
2026-09-13 17:16:49 +03:00
co-authored by Claude Opus 4.8
parent 038a788f99
commit 112b583264
3 changed files with 37 additions and 1 deletions
+14
View File
@@ -34,6 +34,8 @@ _IP_LIMIT = 20
_ACCOUNT_LIMIT = 50 _ACCOUNT_LIMIT = 50
# Неверный текущий пароль при смене — на аккаунт. # Неверный текущий пароль при смене — на аккаунт.
_CURRENT_PASSWORD_LIMIT = 5 _CURRENT_PASSWORD_LIMIT = 5
# Регистраций с одного IP за окно — против спама аккаунтов (#62).
_REGISTER_IP_LIMIT = 10
def validate_new_password(password: str) -> None: def validate_new_password(password: str) -> None:
@@ -87,6 +89,18 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None)
return user return user
def throttle_register(ip: str | None) -> None:
"""Ограничивает частоту регистраций с одного IP (спам аккаунтов, #62).
Считаем каждую попытку (и успешную, и нет), поэтому массовое создание аккаунтов
с уникальными никами упирается в предел так же, как повторы по занятому нику.
Enumeration ников через 409 NICKNAME_TAKEN не закрываем: ники и так публичны в топе."""
ip = ip or "unknown"
limits = {f"register-ip:{ip}": _REGISTER_IP_LIMIT}
login_throttle.check(limits)
login_throttle.fail(limits)
def check_current_password(user: User, current_password: str | None) -> None: def check_current_password(user: User, current_password: str | None) -> None:
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя: """Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем.""" иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
+2 -1
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Request, Response
from sqlmodel import Session from sqlmodel import Session
from app.auth.login import establish_session from app.auth.login import establish_session
from app.auth.password import login_player from app.auth.password import login_player, throttle_register
from app.auth.registry import enabled_methods from app.auth.registry import enabled_methods
from app.auth.telegram import TelegramProvider from app.auth.telegram import TelegramProvider
from app.core import security from app.core import security
@@ -39,6 +39,7 @@ def password_register(
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> s.MeRead: ) -> s.MeRead:
"""Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.""" """Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле."""
throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62)
user = user_service.register_local(session, body.nickname, body.password) user = user_service.register_local(session, body.nickname, body.password)
audit_service.record( audit_service.record(
session, session,
+21
View File
@@ -65,6 +65,27 @@ def test_register_rejects_bad_nickname(client: TestClient):
assert _register(client, nickname="x").status_code == 422 assert _register(client, nickname="x").status_code == 422
def test_register_is_throttled_per_ip(client: TestClient, monkeypatch):
"""Спам регистраций с одного IP упирается в лимит (#62)."""
import app.core.ratelimit as ratelimit
from app.auth.password import _REGISTER_IP_LIMIT
now = [4000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
for i in range(_REGISTER_IP_LIMIT):
client.cookies.clear()
assert _register(client, nickname=f"Ник{i}").status_code == 200
client.cookies.clear()
blocked = _register(client, nickname="Лишний")
assert blocked.status_code == 429
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
now[0] += 15 * 60 # окно истекло
client.cookies.clear()
assert _register(client, nickname="ПослеОкна").status_code == 200
# ─── Вход ──────────────────────────────────────────────────────────────────── # ─── Вход ────────────────────────────────────────────────────────────────────
def test_login_after_logout(client: TestClient): def test_login_after_logout(client: TestClient):