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
361 lines
14 KiB
Python
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
|