Добавление аутентификации логин/пароль (#24) #53

Merged
NotBigGhost merged 4 commits from issue-24-login-password into dev 2026-09-13 15:36:51 +03:00
11 changed files with 443 additions and 10 deletions
Showing only changes of commit 82cf7a3393 - Show all commits
+72
View File
@@ -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
+2 -2
View File
@@ -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
+29
View File
@@ -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()
+70
View File
@@ -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()
+41 -3
View File
@@ -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,
+1
View File
@@ -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,
) )
+20 -1
View File
@@ -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):
+22
View File
@@ -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):
+2
View File
@@ -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()
+5 -4
View File
@@ -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):
+179
View File
@@ -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