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
+52 -57
View File
@@ -11,6 +11,7 @@ from typing import Optional
from contextlib import contextmanager
import structlog
from cachetools import TTLCache
from fastapi import APIRouter, HTTPException, Request, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field, field_validator
@@ -22,7 +23,6 @@ logger = structlog.get_logger(__name__)
AUTH_DB = Path("data/auth.db")
AUTH_DB.parent.mkdir(exist_ok=True)
SECRET_KEY = Path("data/.secret_key")
RATE_LIMIT_DB = Path("data/rate_limit.db")
# Токены
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
@@ -32,6 +32,9 @@ REFRESH_TOKEN_EXPIRE_DAYS = 30
MAX_LOGIN_ATTEMPTS = 5
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:
"""Безопасное получение/создание секретного ключа"""
@@ -112,21 +115,6 @@ def get_db():
finally:
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():
"""Инициализация основной БД"""
with get_db() as conn:
@@ -217,7 +205,6 @@ def init_db():
conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0")
logger.info("Added role column to users table")
init_rate_limit_db()
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)}"
def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]:
"""Проверка rate limiting"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
"""Check rate limiting — blocks IP after MAX_LOGIN_ATTEMPTS failed attempts"""
now = time.time()
entry = _rate_limit_cache.get(ip)
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
if entry is None:
return True, None
finally:
conn.close()
# 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
return True, None
def record_login_attempt(ip: str, success: bool):
"""Запись попытки входа"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
"""Record login attempt — resets on success, increments on failure"""
now = time.time()
try:
if success:
conn.execute("DELETE FROM login_attempts WHERE ip = ?", (ip,))
else:
conn.execute("""
INSERT INTO login_attempts (ip, attempts, first_attempt, last_attempt)
VALUES (?, 1, ?, ?)
ON CONFLICT(ip) DO UPDATE SET
attempts = attempts + 1,
last_attempt = ?
""", (ip, now, now, now))
conn.commit()
finally:
conn.close()
if success:
_rate_limit_cache.pop(ip, None)
return
entry = _rate_limit_cache.get(ip, {"attempts": 0, "blocked_until": 0})
entry["attempts"] += 1
entry["last_attempt"] = now
if entry["attempts"] >= MAX_LOGIN_ATTEMPTS:
entry["blocked_until"] = now + (LOGIN_BLOCK_MINUTES * 60)
_rate_limit_cache[ip] = entry
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")
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:
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:
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}
except HTTPException:
raise
except Exception as e:
logger.error(f"Token validation error: {e}")
raise HTTPException(400, "Invalid request")