Вход и регистрация по логину и паролю
Игроки входили только через Telegram, а вход по нику без пароля (stub) есть лишь в dev. Теперь основной вход во всех окружениях: POST /api/auth/register и POST /api/auth/login, логин — это ник. Stub в прод не переносится: он пускает без секрета и по-прежнему живёт только в dev. Новый код лежит в прод-модуле auth/password.py и dev-модули не импортирует. Схема БД не меняется: колонка password_hash и провайдер local есть с первой миграции, на них построен вход админа. Пароль от 8 символов и не длиннее 72 байт: дальше bcrypt 5 бросает ValueError. Схема API режет тело длиннее 128 символов ещё до bcrypt. Игроком входит только role='player', так что учётка админа не открывает сессию игрока, и наоборот. Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем и получают ту же 401 INVALID_CREDENTIALS: по ответу и его времени нельзя понять, есть ли логин. От перебора — скользящее окно 15 минут в памяти процесса (рассчитано на один воркер, как SSE-шина): 5 неудач на пару «IP + логин» и 20 на IP, дальше 429 TOO_MANY_ATTEMPTS с retry_after. Пока блок стоит, пароль не проверяется вовсе. Успешный вход сбрасывает счётчик пары, но не IP. В MeRead появилось has_password: по нему фронт попросит задать пароль тех, у кого его нет. Метод password добавлен в /auth/config. #24 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Доступные методы входа по окружению.
|
"""Доступные методы входа по окружению.
|
||||||
|
|
||||||
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
|
Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development.
|
||||||
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -9,7 +9,7 @@ from app.core.config import settings
|
|||||||
|
|
||||||
|
|
||||||
def enabled_methods() -> list[str]:
|
def enabled_methods() -> list[str]:
|
||||||
methods = ["telegram"]
|
methods = ["password", "telegram"]
|
||||||
if settings.is_development:
|
if settings.is_development:
|
||||||
methods.append("stub")
|
methods.append("stub")
|
||||||
return methods
|
return methods
|
||||||
|
|||||||
@@ -136,5 +136,34 @@ class InvalidCredentialsError(AuthError):
|
|||||||
super().__init__("Неверный логин или пароль.")
|
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:
|
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||||
return exc.to_response()
|
return exc.to_response()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
|
"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход.
|
||||||
|
|
||||||
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,15 +8,17 @@ 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.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
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import TelegramNicknameRequiredError
|
from app.core.errors import TelegramNicknameRequiredError
|
||||||
|
from app.core.security import client_ip
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.routers.users import build_me
|
from app.routers.users import build_me
|
||||||
from app.schemas import api as s
|
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"])
|
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)
|
@router.post("/telegram", response_model=s.MeRead)
|
||||||
def telegram_login(
|
def telegram_login(
|
||||||
body: s.TelegramAuthPayload,
|
body: s.TelegramAuthPayload,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
|||||||
history_mode=user.history_mode,
|
history_mode=user.history_mode,
|
||||||
history_detail=user.history_detail,
|
history_detail=user.history_detail,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
|
has_password=user.password_hash is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,27 @@ WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
|||||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AuthConfig(BaseModel):
|
class AuthConfig(BaseModel):
|
||||||
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
|
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||||
methods: list[str] = []
|
methods: list[str] = []
|
||||||
telegram_bot_username: str | None = None
|
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):
|
class TelegramAuthPayload(BaseModel):
|
||||||
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
@@ -99,6 +115,9 @@ class UserRead(BaseModel):
|
|||||||
|
|
||||||
class MeRead(UserRead):
|
class MeRead(UserRead):
|
||||||
groups: list[GroupBrief] = []
|
groups: list[GroupBrief] = []
|
||||||
|
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
|
||||||
|
# фронт не пускает дальше окна установки пароля.
|
||||||
|
has_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
class NicknameUpdate(BaseModel):
|
class NicknameUpdate(BaseModel):
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth.password import validate_new_password
|
||||||
from app.auth.provider import ExternalIdentity
|
from app.auth.provider import ExternalIdentity
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||||
|
from app.core.security import hash_password
|
||||||
from app.core.timeutil import utcnow
|
from app.core.timeutil import utcnow
|
||||||
from app.models import AuthIdentity, Faction, User
|
from app.models import AuthIdentity, Faction, User
|
||||||
|
|
||||||
@@ -132,6 +134,26 @@ def register_from_identity(
|
|||||||
return _create_from_identity(session, identity, nickname)
|
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:
|
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||||
new_nickname = (new_nickname or "").strip()
|
new_nickname = (new_nickname or "").strip()
|
||||||
if not nickname_format_ok(new_nickname):
|
if not nickname_format_ok(new_nickname):
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from sqlalchemy.pool import StaticPool # noqa: E402
|
|||||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
||||||
|
|
||||||
import app.models # noqa: F401,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.core.security import hash_password # noqa: E402
|
||||||
from app.db.session import get_session # noqa: E402
|
from app.db.session import get_session # noqa: E402
|
||||||
from app.main import app # noqa: E402
|
from app.main import app # noqa: E402
|
||||||
@@ -48,6 +49,7 @@ def client(engine):
|
|||||||
yield s
|
yield s
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = _get_session
|
app.dependency_overrides[get_session] = _get_session
|
||||||
|
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from fastapi.testclient import TestClient
|
|||||||
from tests.conftest import csrf_headers
|
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()
|
cfg = client.get("/api/auth/config").json()
|
||||||
|
assert "password" in cfg["methods"]
|
||||||
assert "telegram" in cfg["methods"]
|
assert "telegram" in cfg["methods"]
|
||||||
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
||||||
|
|
||||||
@@ -21,11 +22,11 @@ def test_enabled_methods_by_env(monkeypatch):
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "app_env", "development")
|
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")
|
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")
|
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):
|
def test_env_flags_and_db_path(monkeypatch):
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user