diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py new file mode 100644 index 0000000..8dd5377 --- /dev/null +++ b/backend/app/auth/password.py @@ -0,0 +1,72 @@ +"""Вход игрока по логину (нику) и паролю. + +Прод-модуль: основной способ входа во всех окружениях. НЕ путать с dev-stub (вход по нику +без пароля) — тот живёт в dev_stub.py и в прод-образ не попадает. Сюда dev-код не импортировать. +""" +from __future__ import annotations + +from functools import lru_cache + +from sqlmodel import Session, select + +from app.core.errors import InvalidCredentialsError, ValidationError +from app.core.ratelimit import login_throttle +from app.core.security import hash_password, verify_password +from app.models import User + +PASSWORD_MIN_CHARS = 8 +# bcrypt учитывает только первые 72 байта, а bcrypt 5 на более длинном пароле бросает +# ValueError — ограничиваем явно, с понятным сообщением. +_BCRYPT_MAX_BYTES = 72 + +# Лимиты неудач за окно LoginThrottle (15 минут): на пару «IP + логин» — против перебора +# одного аккаунта; на IP — против перебора по многим логинам с одного адреса. +_PAIR_LIMIT = 5 +_IP_LIMIT = 20 + + +def validate_new_password(password: str) -> None: + if len(password) < PASSWORD_MIN_CHARS: + raise ValidationError(f"Пароль: не короче {PASSWORD_MIN_CHARS} символов.") + if not password.strip(): + raise ValidationError("Пароль не может состоять из одних пробелов.") + if len(password.encode("utf-8")) > _BCRYPT_MAX_BYTES: + raise ValidationError( + "Пароль слишком длинный: до 72 байт (72 латинских или 36 русских букв)." + ) + + +@lru_cache +def _dummy_hash() -> str: + return hash_password("dummy-password-for-timing") + + +def authenticate_player(session: Session, nickname: str, password: str) -> User: + """Игрок по нику и паролю. Админ так не входит: у него отдельный вход и cookie. + + Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем: ответ занимает + столько же, сколько неверный пароль, и по времени нельзя узнать, есть ли такой логин.""" + user = session.exec( + select(User).where(User.nickname == nickname, User.role == "player") + ).first() + stored = user.password_hash if user is not None else None + valid = verify_password(password, stored or _dummy_hash()) + if user is None or not stored or not valid: + raise InvalidCredentialsError() + return user + + +def login_player(session: Session, nickname: str, password: str, ip: str | None) -> User: + """authenticate_player под защитой от перебора. Сессию открывает вызывающий.""" + nickname = (nickname or "").strip() + ip = ip or "unknown" + pair_key = f"login:{ip}:{nickname.casefold()}" + limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT} + login_throttle.check(limits) + try: + user = authenticate_player(session, nickname, password) + except InvalidCredentialsError: + login_throttle.fail(limits) + raise + login_throttle.reset(pair_key) + return user diff --git a/backend/app/auth/registry.py b/backend/app/auth/registry.py index 952782a..2af0588 100644 --- a/backend/app/auth/registry.py +++ b/backend/app/auth/registry.py @@ -1,6 +1,6 @@ """Доступные методы входа по окружению. -Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG). +Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development. Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода. """ from __future__ import annotations @@ -9,7 +9,7 @@ from app.core.config import settings def enabled_methods() -> list[str]: - methods = ["telegram"] + methods = ["password", "telegram"] if settings.is_development: methods.append("stub") return methods diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index b82911a..ad21e09 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -136,5 +136,34 @@ class InvalidCredentialsError(AuthError): super().__init__("Неверный логин или пароль.") +class TooManyAttemptsError(AppError): + """Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд + ближайшая попытка снова будет принята.""" + + status_code = 429 + code = "TOO_MANY_ATTEMPTS" + + def __init__(self, retry_after: int) -> None: + minutes = max(1, -(-retry_after // 60)) + super().__init__( + f"Слишком много неудачных попыток. Повторите через {minutes} мин.", + details={"retry_after": retry_after}, + ) + + +class TelegramAlreadyLinkedError(ConflictError): + code = "TELEGRAM_ALREADY_LINKED" + + def __init__(self) -> None: + super().__init__("К аккаунту уже привязан Telegram.") + + +class TelegramTakenError(ConflictError): + code = "TELEGRAM_TAKEN" + + def __init__(self) -> None: + super().__init__("Этот Telegram уже привязан к другому аккаунту.") + + async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: return exc.to_response() diff --git a/backend/app/core/ratelimit.py b/backend/app/core/ratelimit.py new file mode 100644 index 0000000..0a6176a --- /dev/null +++ b/backend/app/core/ratelimit.py @@ -0,0 +1,70 @@ +"""Ограничение неудачных попыток ввода пароля (защита от перебора). + +Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn. +Перезапуск их обнуляет; для окна в 15 минут это приемлемо. +""" +from __future__ import annotations + +import threading +import time +from collections import deque + +from app.core.errors import TooManyAttemptsError + +_WINDOW_SECONDS = 15 * 60 +# Выше этого числа ключей при записи неудачи вычищаем протухшие, чтобы поток попыток +# с разных адресов не копил память бесконечно. +_PRUNE_ABOVE = 10_000 + + +class LoginThrottle: + """Скользящее окно неудач по произвольным ключам (IP, пара «IP + логин», id игрока). + + Роуты синхронные и выполняются в пуле потоков, поэтому доступ под замком.""" + + def __init__(self, window_seconds: int = _WINDOW_SECONDS) -> None: + self.window = window_seconds + self._fails: dict[str, deque[float]] = {} + self._lock = threading.Lock() + + def _recent(self, key: str, now: float) -> deque[float]: + attempts = self._fails.get(key) + if attempts is None: + return deque() + while attempts and now - attempts[0] >= self.window: + attempts.popleft() + if not attempts: + del self._fails[key] + return attempts + + def check(self, limits: dict[str, int]) -> None: + """Бросает TooManyAttemptsError, если хотя бы по одному ключу лимит исчерпан.""" + now = time.monotonic() + with self._lock: + for key, limit in limits.items(): + attempts = self._recent(key, now) + if len(attempts) >= limit: + # Попытка снова примется, когда из окна выпадет неудача, после которой + # в нём остаётся limit-1 записей. + frees_at = attempts[len(attempts) - limit] + self.window + raise TooManyAttemptsError(retry_after=int(frees_at - now) + 1) + + def fail(self, keys: dict[str, int]) -> None: + now = time.monotonic() + with self._lock: + if len(self._fails) > _PRUNE_ABOVE: + for key in list(self._fails): + self._recent(key, now) + for key in keys: + self._fails.setdefault(key, deque()).append(now) + + def reset(self, key: str) -> None: + with self._lock: + self._fails.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._fails.clear() + + +login_throttle = LoginThrottle() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index d3c0ba2..5508402 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,6 @@ -"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход. +"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход. -Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev. +Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev. """ from __future__ import annotations @@ -8,15 +8,17 @@ from fastapi import APIRouter, Depends, Request, Response from sqlmodel import Session from app.auth.login import establish_session +from app.auth.password import login_player from app.auth.registry import enabled_methods from app.auth.telegram import TelegramProvider from app.core import security from app.core.config import settings from app.core.errors import TelegramNicknameRequiredError +from app.core.security import client_ip from app.db.session import get_session from app.routers.users import build_me from app.schemas import api as s -from app.services import user_service +from app.services import audit_service, user_service router = APIRouter(prefix="/auth", tags=["auth"]) @@ -29,6 +31,42 @@ def auth_config() -> s.AuthConfig: ) +@router.post("/register", response_model=s.MeRead) +def password_register( + body: s.PasswordRegister, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.""" + user = user_service.register_local(session, body.nickname, body.password) + audit_service.record( + session, + actor_id=user.id, + action="create", + entity_type="user", + entity_id=user.id, + payload={"provider": "local"}, + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + ) + establish_session(session, response, request, user, "local") + return build_me(session, user) + + +@router.post("/login", response_model=s.MeRead) +def password_login( + body: s.PasswordLogin, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429.""" + user = login_player(session, body.nickname, body.password, client_ip(request)) + establish_session(session, response, request, user, "local") + return build_me(session, user) + + @router.post("/telegram", response_model=s.MeRead) def telegram_login( body: s.TelegramAuthPayload, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index cb0e36a..917a16f 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -36,6 +36,7 @@ def build_me(session: Session, user: User) -> s.MeRead: history_mode=user.history_mode, history_detail=user.history_detail, groups=groups, + has_password=user.password_hash is not None, ) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 4a3288e..352a836 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -12,11 +12,27 @@ WinReason = Literal["objectives", "worlds", "plastic", "resources"] # ─── Auth ──────────────────────────────────────────────────────────────────── class AuthConfig(BaseModel): - # Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве. + # Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве. methods: list[str] = [] telegram_bot_username: str | None = None +# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt. +# Точное правило для нового пароля (8 символов .. 72 байта) — в app/auth/password.py. +_PASSWORD_MAX_CHARS = 128 + + +class PasswordLogin(BaseModel): + # Логин — это ник игрока. + nickname: str + password: str = Field(max_length=_PASSWORD_MAX_CHARS) + + +class PasswordRegister(BaseModel): + nickname: str + password: str = Field(max_length=_PASSWORD_MAX_CHARS) + + class TelegramAuthPayload(BaseModel): # Полезная нагрузка Telegram Login Widget (проверяется по HMAC). model_config = ConfigDict(extra="allow") @@ -99,6 +115,9 @@ class UserRead(BaseModel): class MeRead(UserRead): groups: list[GroupBrief] = [] + # False — пароль ещё не задан (аккаунт из Telegram или до появления паролей): + # фронт не пускает дальше окна установки пароля. + has_password: bool = False class NicknameUpdate(BaseModel): diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 987eeda..545b242 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -7,9 +7,11 @@ from pathlib import Path from sqlmodel import Session, select +from app.auth.password import validate_new_password from app.auth.provider import ExternalIdentity from app.core.config import settings from app.core.errors import NicknameTakenError, NotFoundError, ValidationError +from app.core.security import hash_password from app.core.timeutil import utcnow from app.models import AuthIdentity, Faction, User @@ -132,6 +134,26 @@ def register_from_identity( return _create_from_identity(session, identity, nickname) +def register_local(session: Session, nickname: str, password: str) -> User: + """Регистрация по логину и паролю. Логин — это ник; Telegram можно привязать позже.""" + nickname = (nickname or "").strip() + if not nickname_format_ok(nickname): + raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") + if not nickname_available(session, nickname): + raise NicknameTakenError() + validate_new_password(password) + user = User( + nickname=nickname, + role="player", + auth_provider="local", + password_hash=hash_password(password), + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + def update_nickname(session: Session, user: User, new_nickname: str) -> User: new_nickname = (new_nickname or "").strip() if not nickname_format_ok(new_nickname): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 27cc48f..8d51281 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -18,6 +18,7 @@ from sqlalchemy.pool import StaticPool # noqa: E402 from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402 import app.models # noqa: F401,E402 (регистрация моделей) +from app.core.ratelimit import login_throttle # noqa: E402 from app.core.security import hash_password # noqa: E402 from app.db.session import get_session # noqa: E402 from app.main import app # noqa: E402 @@ -48,6 +49,7 @@ def client(engine): yield s app.dependency_overrides[get_session] = _get_session + login_throttle.clear() # счётчики неудачных входов глобальны для процесса with TestClient(app) as c: yield c app.dependency_overrides.clear() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 184be38..7079c1b 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -10,8 +10,9 @@ from fastapi.testclient import TestClient from tests.conftest import csrf_headers -def test_auth_config_dev_has_both_methods(client: TestClient): +def test_auth_config_dev_has_all_methods(client: TestClient): cfg = client.get("/api/auth/config").json() + assert "password" in cfg["methods"] assert "telegram" in cfg["methods"] assert "stub" in cfg["methods"] # dev → доступен вход по нику @@ -21,11 +22,11 @@ def test_enabled_methods_by_env(monkeypatch): from app.core.config import settings monkeypatch.setattr(settings, "app_env", "development") - assert set(enabled_methods()) == {"telegram", "stub"} + assert set(enabled_methods()) == {"password", "telegram", "stub"} monkeypatch.setattr(settings, "app_env", "test") - assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram + assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub monkeypatch.setattr(settings, "app_env", "production") - assert enabled_methods() == ["telegram"] # prod → только Telegram + assert enabled_methods() == ["password", "telegram"] # prod → без stub def test_env_flags_and_db_path(monkeypatch): diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py new file mode 100644 index 0000000..e00e31e --- /dev/null +++ b/backend/tests/test_password_auth.py @@ -0,0 +1,179 @@ +"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора.""" +from __future__ import annotations + +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app.models import User +from tests.conftest import csrf_headers + +PASSWORD = "correct-horse" + + +def _register(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD): + return client.post( + "/api/auth/register", + json={"nickname": nickname, "password": password}, + headers=csrf_headers(client), + ) + + +def _login(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD): + return client.post( + "/api/auth/login", + json={"nickname": nickname, "password": password}, + headers=csrf_headers(client), + ) + + +# ─── Регистрация ───────────────────────────────────────────────────────────── + +def test_register_opens_session(client: TestClient, engine): + r = _register(client) + assert r.status_code == 200, r.text + me = r.json() + assert me["nickname"] == "Игрок" + assert me["auth_provider"] == "local" + assert me["has_password"] is True + assert client.cookies.get("fs_session") + + assert client.get("/api/users/me").json()["id"] == me["id"] + with Session(engine) as s: + user = s.get(User, me["id"]) + assert user.password_hash and PASSWORD not in user.password_hash + + +def test_register_taken_nickname(client: TestClient): + assert _register(client).status_code == 200 + client.cookies.clear() + r = _register(client, password="another-pass") + assert r.status_code == 409 + assert r.json()["error"]["code"] == "NICKNAME_TAKEN" + + +def test_register_rejects_bad_passwords(client: TestClient): + for bad in ["short", " ", "я" * 37]: # короткий, пробелы, 74 байта UTF-8 + r = _register(client, password=bad) + assert r.status_code == 422, (bad, r.text) + assert _register(client, password="x" * 129).status_code == 422 # предел схемы + + +def test_register_rejects_bad_nickname(client: TestClient): + assert _register(client, nickname="x").status_code == 422 + + +# ─── Вход ──────────────────────────────────────────────────────────────────── + +def test_login_after_logout(client: TestClient): + uid = _register(client).json()["id"] + assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200 + client.cookies.clear() + + r = _login(client) + assert r.status_code == 200, r.text + assert r.json()["id"] == uid + + +def test_wrong_password_and_unknown_login_look_the_same(client: TestClient): + _register(client) + client.cookies.clear() + + wrong = _login(client, password="wrong-password") + unknown = _login(client, nickname="Никто") + assert wrong.status_code == unknown.status_code == 401 + assert wrong.json() == unknown.json() + assert wrong.json()["error"]["code"] == "INVALID_CREDENTIALS" + assert "fs_session" not in client.cookies + + +def test_admin_credentials_do_not_open_player_session(client: TestClient, make_admin): + make_admin("boss", "secret123") + r = _login(client, nickname="boss", password="secret123") + assert r.status_code == 401 + assert "fs_session" not in client.cookies + + +def test_player_password_does_not_open_admin_session(client: TestClient): + _register(client) + client.cookies.clear() + r = client.post("/api/admin/auth/login", json={"username": "Игрок", "password": PASSWORD}) + assert r.status_code == 401 + + +def test_account_without_password_cannot_log_in(client: TestClient, engine): + with Session(engine) as s: + s.add(User(nickname="Телеграмщик", role="player", auth_provider="telegram")) + s.commit() + r = _login(client, nickname="Телеграмщик", password="anything-at-all") + assert r.status_code == 401 + + +def test_disabled_account_cannot_log_in(client: TestClient, engine): + uid = _register(client).json()["id"] + client.cookies.clear() + with Session(engine) as s: + user = s.get(User, uid) + user.is_active = False + s.add(user) + s.commit() + + r = _login(client) + assert r.status_code == 403 + assert r.json()["error"]["code"] == "ACCOUNT_DISABLED" + + +def test_login_is_audited_without_secrets(client: TestClient, engine): + from app.models import AuditLog + + uid = _register(client).json()["id"] + with Session(engine) as s: + logs = s.exec(select(AuditLog).where(AuditLog.entity_id == uid)).all() + assert {(log.action, (log.payload or {}).get("provider")) for log in logs} >= { + ("create", "local"), + ("login", "local"), + } + assert all(PASSWORD not in str(log.payload) for log in logs) + + +# ─── Защита от перебора ────────────────────────────────────────────────────── + +def test_throttle_blocks_after_five_failures(client: TestClient, monkeypatch): + import app.core.ratelimit as ratelimit + + now = [1000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + + _register(client) + client.cookies.clear() + for _ in range(5): + assert _login(client, password="wrong-password").status_code == 401 + + blocked = _login(client) # даже верный пароль не проверяется + assert blocked.status_code == 429 + err = blocked.json()["error"] + assert err["code"] == "TOO_MANY_ATTEMPTS" + assert 0 < err["details"]["retry_after"] <= 15 * 60 + 1 + assert "fs_session" not in client.cookies + + now[0] += 15 * 60 # окно истекло + assert _login(client).status_code == 200 + + +def test_success_resets_pair_counter(client: TestClient): + _register(client) + client.cookies.clear() + for _ in range(4): + assert _login(client, password="wrong-password").status_code == 401 + assert _login(client).status_code == 200 + + client.cookies.clear() + for _ in range(4): + assert _login(client, password="wrong-password").status_code == 401 + assert _login(client).status_code == 200 + + +def test_throttle_per_ip_across_logins(client: TestClient): + """С одного адреса нельзя перебирать пароли по многим логинам: 20 неудач — блок.""" + for i in range(20): + assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401 + assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429