fix(server,security): add ban check to validate_token, replace rate_limit DB with TTLCache

This commit is contained in:
SashegDev
2026-05-04 21:12:35 +00:00
parent bfcffdd88d
commit c96b502ad4
+51 -56
View File
@@ -11,6 +11,7 @@ from typing import Optional
from contextlib import contextmanager from contextlib import contextmanager
import structlog import structlog
from cachetools import TTLCache
from fastapi import APIRouter, HTTPException, Request, Depends, status from fastapi import APIRouter, HTTPException, Request, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
@@ -22,7 +23,6 @@ logger = structlog.get_logger(__name__)
AUTH_DB = Path("data/auth.db") AUTH_DB = Path("data/auth.db")
AUTH_DB.parent.mkdir(exist_ok=True) AUTH_DB.parent.mkdir(exist_ok=True)
SECRET_KEY = Path("data/.secret_key") SECRET_KEY = Path("data/.secret_key")
RATE_LIMIT_DB = Path("data/rate_limit.db")
# Токены # Токены
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
@@ -32,6 +32,9 @@ REFRESH_TOKEN_EXPIRE_DAYS = 30
MAX_LOGIN_ATTEMPTS = 5 MAX_LOGIN_ATTEMPTS = 5
LOGIN_BLOCK_MINUTES = 15 LOGIN_BLOCK_MINUTES = 15
# Rate limiting — in-memory TTL cache (1 hour TTL, max 1000 IPs)
_rate_limit_cache: TTLCache = TTLCache(maxsize=1000, ttl=LOGIN_BLOCK_MINUTES * 60 * 4)
# ====================== СЕКРЕТНЫЙ КЛЮЧ ====================== # ====================== СЕКРЕТНЫЙ КЛЮЧ ======================
def _get_secret() -> bytes: def _get_secret() -> bytes:
"""Безопасное получение/создание секретного ключа""" """Безопасное получение/создание секретного ключа"""
@@ -112,21 +115,6 @@ def get_db():
finally: finally:
conn.close() conn.close()
def init_rate_limit_db():
"""Инициализация БД для rate limiting"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
conn.executescript("""
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT PRIMARY KEY,
attempts INTEGER DEFAULT 1,
first_attempt REAL NOT NULL,
last_attempt REAL NOT NULL,
blocked_until REAL
);
""")
conn.commit()
conn.close()
def init_db(): def init_db():
"""Инициализация основной БД""" """Инициализация основной БД"""
with get_db() as conn: with get_db() as conn:
@@ -217,7 +205,6 @@ def init_db():
conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0") conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0")
logger.info("Added role column to users table") logger.info("Added role column to users table")
init_rate_limit_db()
logger.info("Auth database initialized") logger.info("Auth database initialized")
# ====================== ХЕЛПЕРЫ ====================== # ====================== ХЕЛПЕРЫ ======================
@@ -251,52 +238,42 @@ def generate_uuid() -> str:
return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}" return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}"
def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]: def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]:
"""Проверка rate limiting""" """Check rate limiting — blocks IP after MAX_LOGIN_ATTEMPTS failed attempts"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
now = time.time() now = time.time()
entry = _rate_limit_cache.get(ip)
if entry is None:
return True, None
# Check if currently blocked
if entry.get("blocked_until", 0) > now:
remaining = int(entry["blocked_until"] - now)
return False, remaining
# Reset block if expired
if entry["attempts"] >= MAX_LOGIN_ATTEMPTS:
entry["blocked_until"] = now + (LOGIN_BLOCK_MINUTES * 60)
_rate_limit_cache[ip] = entry
return False, LOGIN_BLOCK_MINUTES * 60
try:
row = conn.execute(
"SELECT attempts, blocked_until FROM login_attempts WHERE ip = ?",
(ip,)
).fetchone()
if row:
blocked_until = row[1]
if blocked_until and blocked_until > now:
return False, int(blocked_until - now)
if row[0] >= MAX_LOGIN_ATTEMPTS:
blocked_until = now + (LOGIN_BLOCK_MINUTES * 60)
conn.execute(
"UPDATE login_attempts SET blocked_until = ? WHERE ip = ?",
(blocked_until, ip)
)
conn.commit()
return False, LOGIN_BLOCK_MINUTES * 60
return True, None return True, None
finally:
conn.close()
def record_login_attempt(ip: str, success: bool): def record_login_attempt(ip: str, success: bool):
"""Запись попытки входа""" """Record login attempt — resets on success, increments on failure"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
now = time.time() now = time.time()
try:
if success: if success:
conn.execute("DELETE FROM login_attempts WHERE ip = ?", (ip,)) _rate_limit_cache.pop(ip, None)
else: return
conn.execute("""
INSERT INTO login_attempts (ip, attempts, first_attempt, last_attempt) entry = _rate_limit_cache.get(ip, {"attempts": 0, "blocked_until": 0})
VALUES (?, 1, ?, ?) entry["attempts"] += 1
ON CONFLICT(ip) DO UPDATE SET entry["last_attempt"] = now
attempts = attempts + 1,
last_attempt = ? if entry["attempts"] >= MAX_LOGIN_ATTEMPTS:
""", (ip, now, now, now)) entry["blocked_until"] = now + (LOGIN_BLOCK_MINUTES * 60)
conn.commit()
finally: _rate_limit_cache[ip] = entry
conn.close()
def log_audit(user_id: int, action: str, details: str, ip_address: str): def log_audit(user_id: int, action: str, details: str, ip_address: str):
"""Логирование действий""" """Логирование действий"""
@@ -621,7 +598,7 @@ async def refresh(body: dict, request: Request):
@router.post("/validate") @router.post("/validate")
async def validate_token(request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer)): async def validate_token(request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer)):
"""Validate token endpoint for Minecraft server""" """Validate token endpoint for Minecraft server — checks ban status"""
if not credentials: if not credentials:
raise HTTPException(401, "Требуется авторизация") raise HTTPException(401, "Требуется авторизация")
@@ -638,8 +615,26 @@ async def validate_token(request: Request, credentials: HTTPAuthorizationCredent
if payload.get("username") != username or payload.get("uuid") != uuid: if payload.get("username") != username or payload.get("uuid") != uuid:
raise HTTPException(403, "Token does not match user") raise HTTPException(403, "Token does not match user")
# Check ban status in DB
with get_db() as conn:
user = conn.execute(
"SELECT is_active, banned_until FROM users WHERE id = ?",
(payload["sub"],),
).fetchone()
if not user:
return {"valid": False, "reason": "User not found"}
if not user["is_active"]:
return {"valid": False, "reason": "Account deactivated"}
if user["banned_until"] and user["banned_until"] > time.time():
return {"valid": False, "reason": "Account banned"}
return {"valid": True, "username": username, "uuid": uuid} return {"valid": True, "username": username, "uuid": uuid}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Token validation error: {e}") logger.error(f"Token validation error: {e}")
raise HTTPException(400, "Invalid request") raise HTTPException(400, "Invalid request")