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

Merged
NotBigGhost merged 4 commits from issue-24-login-password into dev 2026-09-13 15:36:51 +03:00
8 changed files with 365 additions and 22 deletions
Showing only changes of commit c73b4519cb - Show all commits
+19 -1
View File
@@ -9,7 +9,11 @@ from functools import lru_cache
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.errors import InvalidCredentialsError, ValidationError from app.core.errors import (
InvalidCredentialsError,
ValidationError,
WrongCurrentPasswordError,
)
from app.core.ratelimit import login_throttle from app.core.ratelimit import login_throttle
from app.core.security import hash_password, verify_password from app.core.security import hash_password, verify_password
from app.models import User from app.models import User
@@ -23,6 +27,8 @@ _BCRYPT_MAX_BYTES = 72
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса. # одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
_PAIR_LIMIT = 5 _PAIR_LIMIT = 5
_IP_LIMIT = 20 _IP_LIMIT = 20
# Неверный текущий пароль при смене — на аккаунт.
_CURRENT_PASSWORD_LIMIT = 5
def validate_new_password(password: str) -> None: def validate_new_password(password: str) -> None:
@@ -70,3 +76,15 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None)
raise raise
login_throttle.reset(pair_key) login_throttle.reset(pair_key)
return user return user
def check_current_password(user: User, current_password: str | None) -> None:
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
key = f"current-password:{user.id}"
limits = {key: _CURRENT_PASSWORD_LIMIT}
login_throttle.check(limits)
if not current_password or not verify_password(current_password, user.password_hash or ""):
login_throttle.fail(limits)
raise WrongCurrentPasswordError()
login_throttle.reset(key)
+7
View File
@@ -151,6 +151,13 @@ class TooManyAttemptsError(AppError):
) )
class WrongCurrentPasswordError(ForbiddenError):
code = "WRONG_CURRENT_PASSWORD"
def __init__(self) -> None:
super().__init__("Текущий пароль неверен.")
class TelegramAlreadyLinkedError(ConflictError): class TelegramAlreadyLinkedError(ConflictError):
code = "TELEGRAM_ALREADY_LINKED" code = "TELEGRAM_ALREADY_LINKED"
+35 -18
View File
@@ -68,14 +68,8 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
# ─── Пользователи ──────────────────────────────────────────────────────────── # ─── Пользователи ────────────────────────────────────────────────────────────
@router.get("/users", response_model=list[s.AdminUserRead]) def _admin_user_read(u: User) -> s.AdminUserRead:
def list_users( return s.AdminUserRead(
query: str | None = Query(None),
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AdminUserRead]:
return [
s.AdminUserRead(
id=u.id, # type: ignore[arg-type] id=u.id, # type: ignore[arg-type]
nickname=u.nickname, nickname=u.nickname,
role=u.role, role=u.role,
@@ -83,9 +77,17 @@ def list_users(
auth_provider=u.auth_provider, auth_provider=u.auth_provider,
telegram_id=u.telegram_id, telegram_id=u.telegram_id,
created_at=iso_utc(u.created_at), created_at=iso_utc(u.created_at),
has_password=u.password_hash is not None,
) )
for u in admin_service.list_users(session, query)
]
@router.get("/users", response_model=list[s.AdminUserRead])
def list_users(
query: str | None = Query(None),
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AdminUserRead]:
return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
@router.patch("/users/{user_id}", response_model=s.AdminUserRead) @router.patch("/users/{user_id}", response_model=s.AdminUserRead)
@@ -107,15 +109,30 @@ def update_user(
ip=client_ip(request), ip=client_ip(request),
) )
session.commit() session.commit()
return s.AdminUserRead( return _admin_user_read(u)
id=u.id, # type: ignore[arg-type]
nickname=u.nickname,
role=u.role, @router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
is_active=u.is_active, def set_user_password(
auth_provider=u.auth_provider, user_id: int,
telegram_id=u.telegram_id, body: s.AdminPasswordSet,
created_at=iso_utc(u.created_at), request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminUserRead:
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
u = admin_service.set_player_password(session, user_id, body.new_password)
audit_service.record(
session,
actor_id=admin.id,
action="update",
entity_type="user",
entity_id=user_id,
payload={"password": "set_by_admin"},
ip=client_ip(request),
) )
session.commit()
return _admin_user_read(u)
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
+52
View File
@@ -6,6 +6,8 @@ from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.auth.password import check_current_password
from app.auth.telegram import TelegramProvider
from app.core.security import client_ip from app.core.security import client_ip
from app.core.errors import NotFoundError from app.core.errors import NotFoundError
from app.db.session import get_session from app.db.session import get_session
@@ -116,6 +118,56 @@ def update_my_profile(
return build_me(session, user) return build_me(session, user)
@router.put("/me/password", response_model=s.MeRead)
def change_my_password(
body: s.PasswordChange,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
"""Задать пароль (первый раз — без текущего) или сменить его (нужен текущий)."""
had_password = user.password_hash is not None
if had_password:
check_current_password(user, body.current_password)
user_service.set_password(session, user, body.new_password)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"password": "changed" if had_password else "set"},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
return build_me(session, user)
@router.post("/me/telegram", response_model=s.MeRead)
def link_my_telegram(
body: s.TelegramAuthPayload,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
"""Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе."""
identity = TelegramProvider().authenticate(body.model_dump())
user_service.link_telegram(session, user, identity)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"telegram": "linked"},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
return build_me(session, user)
@router.put("/me/avatar", response_model=s.MeRead) @router.put("/me/avatar", response_model=s.MeRead)
def upload_my_avatar( def upload_my_avatar(
file: UploadFile = File(...), file: UploadFile = File(...),
+12
View File
@@ -33,6 +33,12 @@ class PasswordRegister(BaseModel):
password: str = Field(max_length=_PASSWORD_MAX_CHARS) password: str = Field(max_length=_PASSWORD_MAX_CHARS)
class PasswordChange(BaseModel):
# current_password нужен, только если пароль уже задан; первая установка — без него.
current_password: str | None = Field(default=None, max_length=_PASSWORD_MAX_CHARS)
new_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")
@@ -512,6 +518,7 @@ class AdminUserRead(BaseModel):
auth_provider: str auth_provider: str
telegram_id: int | None = None telegram_id: int | None = None
created_at: str created_at: str
has_password: bool = False
class AdminUserUpdate(BaseModel): class AdminUserUpdate(BaseModel):
@@ -519,6 +526,11 @@ class AdminUserUpdate(BaseModel):
is_active: bool | None = None is_active: bool | None = None
class AdminPasswordSet(BaseModel):
# Новый пароль игроку от админа — способ восстановить забытый пароль.
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
class AdminGroupRead(BaseModel): class AdminGroupRead(BaseModel):
id: int id: int
name: str name: str
+11
View File
@@ -62,6 +62,17 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
return user return user
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
он задаётся ADMIN_PASSWORD в .env."""
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
if user.role != "player":
raise ValidationError("Пароль администратора здесь не меняется.")
return user_service.set_password(session, user, new_password)
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: # Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). # логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
+46 -1
View File
@@ -10,7 +10,13 @@ from sqlmodel import Session, select
from app.auth.password import validate_new_password 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,
TelegramAlreadyLinkedError,
TelegramTakenError,
ValidationError,
)
from app.core.security import hash_password 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
@@ -154,6 +160,45 @@ def register_local(session: Session, nickname: str, password: str) -> User:
return user return user
def set_password(session: Session, user: User, new_password: str) -> User:
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет)."""
validate_new_password(new_password)
user.password_hash = hash_password(new_password)
session.add(user)
session.commit()
session.refresh(user)
return user
def link_telegram(session: Session, user: User, identity: ExternalIdentity) -> User:
"""Привязать Telegram к существующему аккаунту; ник не меняется.
После привязки вход через Telegram попадает в этот аккаунт: find_by_identity находит
его по той же связке provider+external_id, что создаёт регистрация через Telegram."""
already = session.exec(
select(AuthIdentity).where(
AuthIdentity.user_id == user.id, AuthIdentity.provider == identity.provider
)
).first()
if user.telegram_id is not None or already is not None:
raise TelegramAlreadyLinkedError()
taken_by_id = session.exec(select(User).where(User.telegram_id == identity.telegram_id)).first()
if find_by_identity(session, identity) is not None or taken_by_id is not None:
raise TelegramTakenError()
user.telegram_id = identity.telegram_id
session.add(user)
session.add(
AuthIdentity(
user_id=user.id, # type: ignore[arg-type]
provider=identity.provider,
external_id=identity.external_id,
)
)
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):
+181
View File
@@ -6,6 +6,7 @@ from sqlmodel import Session, select
from app.models import User from app.models import User
from tests.conftest import csrf_headers from tests.conftest import csrf_headers
from tests.test_auth import _telegram_payload
PASSWORD = "correct-horse" PASSWORD = "correct-horse"
@@ -177,3 +178,183 @@ def test_throttle_per_ip_across_logins(client: TestClient):
for i in range(20): for i in range(20):
assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401 assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429 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