Files
ForbiddenStarsApp/backend/tests/test_password_auth.py
T
NotBigGhostandClaude Opus 5 c73b4519cb Пароль и привязка Telegram в профиле, пароль игрока из админки
PUT /api/users/me/password задаёт или меняет пароль. Первый раз текущий пароль
не нужен: так его задают аккаунты из Telegram и все, кто появился до паролей.
Если пароль уже есть, нужен текущий. Иначе оставленная открытой сессия позволила
бы отобрать аккаунт насовсем, поэтому подбор текущего тоже ограничен: 5 неудач
на аккаунт за 15 минут. Ошибка 403 WRONG_CURRENT_PASSWORD, а не 401, чтобы фронт
не принял её за истёкшую сессию.

POST /api/users/me/telegram привязывает Telegram к аккаунту, созданному по
паролю. Подпись виджета проверяется так же, как при входе, ник не меняется.
Связка пишется в auth_identity, как при регистрации через Telegram, поэтому
следующий вход через Telegram попадает в этот аккаунт. Telegram, привязанный к
другому аккаунту, даёт 409 TELEGRAM_TAKEN, повторная привязка — 409
TELEGRAM_ALREADY_LINKED.

PUT /api/admin/users/{id}/password — способ восстановить забытый пароль: почту
приложение не хранит. Работает только для игроков, пароль админа по-прежнему
задаётся в .env. В аудит пишется только факт смены, без пароля. Сборка
AdminUserRead вынесена в хелпер, в ответе появилось has_password.

#24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
2026-09-13 14:02:19 +03:00

361 lines
14 KiB
Python

"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
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
from tests.test_auth import _telegram_payload
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
# ─── Установка и смена пароля ────────────────────────────────────────────────
def _set_password(client: TestClient, new: str, current: str | None = None):
body = {"new_password": new}
if current is not None:
body["current_password"] = current
return client.put("/api/users/me/password", json=body, headers=csrf_headers(client))
def _telegram_login(client: TestClient, monkeypatch, **fields):
from app.core.config import settings
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
return client.post(
"/api/auth/telegram",
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
headers=csrf_headers(client),
)
def test_telegram_user_sets_password_then_logs_in(client: TestClient, monkeypatch):
"""Сценарий 1 и существующие аккаунты: без пароля → задаёт без текущего → входит по нику."""
r = _telegram_login(client, monkeypatch)
assert r.status_code == 200, r.text
me = r.json()
assert me["has_password"] is False
r2 = _set_password(client, PASSWORD)
assert r2.status_code == 200, r2.text
assert r2.json()["has_password"] is True
client.cookies.clear()
r3 = _login(client, nickname=me["nickname"])
assert r3.status_code == 200, r3.text
assert r3.json()["id"] == me["id"]
def test_change_password_requires_current(client: TestClient):
_register(client)
missing = _set_password(client, "new-password-1")
assert missing.status_code == 403
assert missing.json()["error"]["code"] == "WRONG_CURRENT_PASSWORD"
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
client.cookies.clear()
assert _login(client).status_code == 401
assert _login(client, password="new-password-1").status_code == 200
def test_change_password_validates_new(client: TestClient):
_register(client)
r = _set_password(client, "short", current=PASSWORD)
assert r.status_code == 422
client.cookies.clear()
assert _login(client).status_code == 200 # старый пароль не тронут
def test_current_password_guessing_is_throttled(client: TestClient):
_register(client)
for _ in range(5):
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
blocked = _set_password(client, "new-password-1", current=PASSWORD)
assert blocked.status_code == 429
def test_set_password_requires_session(client: TestClient):
assert _set_password(client, PASSWORD).status_code == 401
# ─── Привязка Telegram ───────────────────────────────────────────────────────
def _link_telegram(client: TestClient, monkeypatch, **fields):
from app.core.config import settings
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
return client.post(
"/api/users/me/telegram",
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
headers=csrf_headers(client),
)
def test_link_telegram_then_login_via_telegram(client: TestClient, monkeypatch):
"""Сценарий 2: аккаунт по паролю → привязал Telegram → вход через него в тот же аккаунт."""
uid = _register(client).json()["id"]
r = _link_telegram(client, monkeypatch) # id=777, тег ivan_tg
assert r.status_code == 200, r.text
assert r.json()["telegram_id"] == 777
assert r.json()["nickname"] == "Игрок" # ник не меняется на тег
client.cookies.clear()
r2 = _telegram_login(client, monkeypatch)
assert r2.status_code == 200, r2.text
assert r2.json()["id"] == uid
assert r2.json()["nickname"] == "Игрок"
def test_link_telegram_taken_by_other_account(client: TestClient, monkeypatch):
assert _telegram_login(client, monkeypatch).status_code == 200 # 777 уже чей-то
client.cookies.clear()
_register(client)
r = _link_telegram(client, monkeypatch)
assert r.status_code == 409
assert r.json()["error"]["code"] == "TELEGRAM_TAKEN"
def test_link_telegram_twice(client: TestClient, monkeypatch):
_register(client)
assert _link_telegram(client, monkeypatch).status_code == 200
r = _link_telegram(client, monkeypatch, id=778)
assert r.status_code == 409
assert r.json()["error"]["code"] == "TELEGRAM_ALREADY_LINKED"
def test_link_telegram_bad_signature(client: TestClient, monkeypatch):
from app.core.config import settings
_register(client)
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
payload = _telegram_payload("TEST_BOT_TOKEN")
payload["hash"] = "deadbeef"
r = client.post("/api/users/me/telegram", json=payload, headers=csrf_headers(client))
assert r.status_code == 401
assert client.get("/api/users/me").json()["telegram_id"] is None
# ─── Пароль игроку из админки ────────────────────────────────────────────────
def _admin_login(client: TestClient, make_admin):
make_admin("boss", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "boss", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
return r.json()["id"]
def test_admin_sets_player_password(client: TestClient, monkeypatch, make_admin):
player = _telegram_login(client, monkeypatch).json()
client.cookies.clear()
_admin_login(client, make_admin)
r = client.put(
f"/api/admin/users/{player['id']}/password",
json={"new_password": "from-admin-1"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["has_password"] is True
client.cookies.clear()
assert _login(client, nickname=player["nickname"], password="from-admin-1").status_code == 200
def test_admin_cannot_set_admin_password(client: TestClient, make_admin):
admin_id = _admin_login(client, make_admin)
r = client.put(
f"/api/admin/users/{admin_id}/password",
json={"new_password": "from-admin-1"},
headers=csrf_headers(client),
)
assert r.status_code == 422
def test_player_cannot_set_passwords_via_admin(client: TestClient):
uid = _register(client).json()["id"]
r = client.put(
f"/api/admin/users/{uid}/password",
json={"new_password": "from-admin-1"},
headers=csrf_headers(client),
)
assert r.status_code == 401