diff --git a/server/auth.py b/server/auth.py index 6b81c12..82e282b 100644 --- a/server/auth.py +++ b/server/auth.py @@ -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")