Хардненинг по итогам пен-теста #25 (#56–#62) #63
@@ -0,0 +1,49 @@
|
|||||||
|
"""Вход администратора под защитой от перебора (#56).
|
||||||
|
|
||||||
|
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
||||||
|
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
||||||
|
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
|
||||||
|
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.errors import InvalidCredentialsError
|
||||||
|
from app.core.ratelimit import login_throttle
|
||||||
|
from app.models import User
|
||||||
|
from app.services import admin_service
|
||||||
|
|
||||||
|
# Неудач за окно LoginThrottle (15 минут):
|
||||||
|
_PAIR_LIMIT = 5 # на пару «IP + логин» — против перебора пароля с одного адреса
|
||||||
|
_IP_LIMIT = 20 # на IP — против перебора по разным логинам с одного адреса
|
||||||
|
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
||||||
|
# упирается в этот предел. Щедрее пары, чтобы случайный поток ошибок не запирал вход
|
||||||
|
# админа насовсем (лимит на аккаунт — вектор lockout-DoS, поэтому не слишком строгий).
|
||||||
|
_ACCOUNT_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _keys(ip: str, username: str) -> dict[str, int]:
|
||||||
|
pair_key = f"admin-login:{ip}:{username.casefold()}"
|
||||||
|
return {
|
||||||
|
pair_key: _PAIR_LIMIT,
|
||||||
|
f"admin-login-ip:{ip}": _IP_LIMIT,
|
||||||
|
f"admin-login-user:{username.casefold()}": _ACCOUNT_LIMIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def login_admin(session: Session, username: str, password: str, ip: str | None) -> User:
|
||||||
|
"""authenticate_admin под защитой от перебора. Сессию открывает вызывающий."""
|
||||||
|
username = (username or "").strip()
|
||||||
|
ip = ip or "unknown"
|
||||||
|
limits = _keys(ip, username)
|
||||||
|
login_throttle.check(limits)
|
||||||
|
try:
|
||||||
|
admin = admin_service.authenticate_admin(session, username, password)
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
raise
|
||||||
|
# Успех: снимаем счётчики этого аккаунта, чтобы законный вход не копил лимит.
|
||||||
|
for key in limits:
|
||||||
|
login_throttle.reset(key)
|
||||||
|
return admin
|
||||||
@@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.auth.admin_login import login_admin
|
||||||
from app.auth.deps import get_current_admin
|
from app.auth.deps import get_current_admin
|
||||||
from app.core import security
|
from app.core import security
|
||||||
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 InvalidCredentialsError, NotFoundError
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
@@ -40,7 +41,22 @@ def admin_login(
|
|||||||
response: Response,
|
response: Response,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> s.AdminMe:
|
) -> s.AdminMe:
|
||||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
try:
|
||||||
|
admin = login_admin(session, body.username, body.password, client_ip(request))
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
# Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к
|
||||||
|
# полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56).
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=None,
|
||||||
|
action="login_failed",
|
||||||
|
entity_type="admin",
|
||||||
|
payload={"username": (body.username or "")[:64]},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
raise
|
||||||
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
|
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -417,3 +417,41 @@ def test_player_cannot_set_passwords_via_admin(client: TestClient):
|
|||||||
headers=csrf_headers(client),
|
headers=csrf_headers(client),
|
||||||
)
|
)
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Защита от перебора пароля администратора (#56, F1) ───────────────────────
|
||||||
|
|
||||||
|
def test_admin_login_throttled_after_failures(client: TestClient, make_admin, monkeypatch):
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
|
||||||
|
now = [2000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post("/api/admin/auth/login", json={"username": "boss", "password": "nope"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
blocked = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
|
||||||
|
assert blocked.status_code == 429 # даже верный пароль не проверяется
|
||||||
|
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
|
||||||
|
|
||||||
|
now[0] += 15 * 60 # окно истекло
|
||||||
|
ok = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_admin_login_is_audited_without_password(client: TestClient, make_admin, engine):
|
||||||
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
assert client.post(
|
||||||
|
"/api/admin/auth/login", json={"username": "boss", "password": "nope-secret-guess"}
|
||||||
|
).status_code == 401
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
logs = s.exec(select(AuditLog).where(AuditLog.action == "login_failed")).all()
|
||||||
|
assert any(
|
||||||
|
log.entity_type == "admin" and (log.payload or {}).get("username") == "boss" for log in logs
|
||||||
|
)
|
||||||
|
assert all("nope-secret-guess" not in str(log.payload) for log in logs)
|
||||||
|
|||||||
Reference in New Issue
Block a user