fix(server,security): add ban check to validate_token, replace rate_limit DB with TTLCache
This commit is contained in:
+52
-57
@@ -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)
|
||||||
|
|
||||||
try:
|
if entry is None:
|
||||||
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()
|
# 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):
|
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:
|
_rate_limit_cache.pop(ip, None)
|
||||||
conn.execute("DELETE FROM login_attempts WHERE ip = ?", (ip,))
|
return
|
||||||
else:
|
|
||||||
conn.execute("""
|
entry = _rate_limit_cache.get(ip, {"attempts": 0, "blocked_until": 0})
|
||||||
INSERT INTO login_attempts (ip, attempts, first_attempt, last_attempt)
|
entry["attempts"] += 1
|
||||||
VALUES (?, 1, ?, ?)
|
entry["last_attempt"] = now
|
||||||
ON CONFLICT(ip) DO UPDATE SET
|
|
||||||
attempts = attempts + 1,
|
if entry["attempts"] >= MAX_LOGIN_ATTEMPTS:
|
||||||
last_attempt = ?
|
entry["blocked_until"] = now + (LOGIN_BLOCK_MINUTES * 60)
|
||||||
""", (ip, now, now, now))
|
|
||||||
conn.commit()
|
_rate_limit_cache[ip] = entry
|
||||||
finally:
|
|
||||||
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user