import base64 import json import sqlite3 import hashlib import hmac import secrets import time from datetime import datetime from pathlib import Path 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 import re logger = structlog.get_logger(__name__) # ====================== КОНФИГ ====================== AUTH_DB = Path("data/auth.db") AUTH_DB.parent.mkdir(exist_ok=True) SECRET_KEY = Path("data/.secret_key") # Токены ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа 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: """Безопасное получение/создание секретного ключа""" if SECRET_KEY.exists(): return SECRET_KEY.read_bytes() key = secrets.token_bytes(64) SECRET_KEY.write_bytes(key) SECRET_KEY.chmod(0o600) return key _SECRET = _get_secret() # ====================== JWT ФУНКЦИИ ====================== def create_jwt(payload: dict, expires_in: int = None) -> str: """Создание JWT токена""" if expires_in is None: expires_in = ACCESS_TOKEN_EXPIRE_MINUTES * 60 payload = payload.copy() payload["exp"] = time.time() + expires_in payload["iat"] = time.time() payload["jti"] = secrets.token_hex(16) header = base64.urlsafe_b64encode( json.dumps({"alg": "HS256", "typ": "JWT"}).encode() ).rstrip(b'=').decode() body = base64.urlsafe_b64encode( json.dumps(payload).encode() ).rstrip(b'=').decode() msg = f"{header}.{body}".encode() sig = hmac.new(_SECRET, msg, hashlib.sha256).digest() return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}" def verify_jwt(token: str) -> Optional[dict]: """Верификация JWT токена""" try: parts = token.split(".") if len(parts) != 3: return None header, body, sig = parts sig_padded = sig + '=' * (4 - len(sig) % 4) expected_sig = base64.urlsafe_b64decode(sig_padded) msg = f"{header}.{body}".encode() if not hmac.compare_digest( hmac.new(_SECRET, msg, hashlib.sha256).digest(), expected_sig ): return None body_padded = body + '=' * (4 - len(body) % 4) payload = json.loads(base64.urlsafe_b64decode(body_padded)) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None # ====================== БАЗА ДАННЫХ ====================== @contextmanager def get_db(): """Контекстный менеджер для БД""" conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False, timeout=10) conn.row_factory = sqlite3.Row try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() def init_db(): """Инициализация основной БД""" with get_db() as conn: conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, uuid TEXT UNIQUE NOT NULL, role INTEGER DEFAULT 0, created_at REAL NOT NULL, last_login REAL, is_active BOOLEAN DEFAULT 1, banned_until REAL ); CREATE TABLE IF NOT EXISTS refresh_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL, jti TEXT NOT NULL, expires_at REAL NOT NULL, revoked BOOLEAN DEFAULT 0, created_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS user_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, session_token TEXT UNIQUE NOT NULL, ip_address TEXT, user_agent TEXT, created_at REAL NOT NULL, expires_at REAL NOT NULL, is_active BOOLEAN DEFAULT 1 ); CREATE TABLE IF NOT EXISTS passes ( code TEXT PRIMARY KEY, owner TEXT, is_active BOOLEAN DEFAULT 1, activated_by INTEGER REFERENCES users(id), activated_at REAL, expires_at REAL, max_uses INTEGER DEFAULT 1, uses INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS user_passes ( user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, pass_code TEXT REFERENCES passes(code), activated_at REAL NOT NULL, PRIMARY KEY (user_id, pass_code) ); CREATE TABLE IF NOT EXISTS pass_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, requester_id INTEGER NOT NULL REFERENCES users(id), target_username TEXT NOT NULL, reason TEXT, status TEXT DEFAULT 'pending', decision_reason TEXT, created_at REAL NOT NULL, reviewed_by INTEGER REFERENCES users(id), reviewed_at REAL ); CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), action TEXT NOT NULL, details TEXT, ip_address TEXT, timestamp REAL NOT NULL ); CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id); """) # Добавляем колонку role если её нет cursor = conn.execute("PRAGMA table_info(users)") columns = [col[1] for col in cursor.fetchall()] if "role" not in columns: conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0") logger.info("Added role column to users table") logger.info("Auth database initialized") # ====================== ХЕЛПЕРЫ ====================== def hash_password(password: str) -> str: """Хэширование пароля""" salt = secrets.token_hex(32) hash_obj = hashlib.pbkdf2_hmac( 'sha256', password.encode('utf-8'), salt.encode('utf-8'), 300000 ) return f"{salt}${hash_obj.hex()}" def verify_password(password: str, stored: str) -> bool: """Верификация пароля""" try: salt, stored_hash = stored.split('$') hash_obj = hashlib.pbkdf2_hmac( 'sha256', password.encode('utf-8'), salt.encode('utf-8'), 300000 ) return hmac.compare_digest(hash_obj.hex(), stored_hash) except Exception: return False def generate_uuid() -> str: """Генерация UUID""" 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]]: """Check rate limiting — blocks IP after MAX_LOGIN_ATTEMPTS failed attempts""" 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 return True, None def record_login_attempt(ip: str, success: bool): """Record login attempt — resets on success, increments on failure""" now = time.time() 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): """Логирование действий""" with get_db() as conn: conn.execute( "INSERT INTO audit_log (user_id, action, details, ip_address, timestamp) VALUES (?, ?, ?, ?, ?)", (user_id, action, details, ip_address, time.time()) ) # ====================== МОДЕЛИ ====================== class LoginRequest(BaseModel): username: str = Field(..., min_length=3, max_length=32) password: str = Field(..., min_length=6, max_length=128) @field_validator('username') def validate_username(cls, v): if not re.match(r'^[a-zA-Z0-9_]+$', v): raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания') return v.lower() class RegisterRequest(BaseModel): username: str = Field(..., min_length=3, max_length=32) password: str = Field(..., min_length=6, max_length=128) @field_validator('username') def validate_username(cls, v): if not re.match(r'^[a-zA-Z0-9_]+$', v): raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания') return v.lower() class TokenResponse(BaseModel): access_token: str refresh_token: str expires_in: int token_type: str = "bearer" username: str uuid: str role: int role_name: str # ====================== DEPENDENCIES ====================== router = APIRouter(prefix="/auth", tags=["auth"]) bearer = HTTPBearer(auto_error=False) async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(bearer), request: Request = None ) -> dict: """Получение текущего пользователя""" if not credentials: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Не авторизован", headers={"WWW-Authenticate": "Bearer"}, ) payload = verify_jwt(credentials.credentials) if not payload or payload.get("type") != "access": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен" ) with get_db() as conn: user = conn.execute( "SELECT id, username, uuid, role, is_active, banned_until FROM users WHERE id = ?", (payload["sub"],) ).fetchone() if not user: raise HTTPException(401, "Пользователь не найден") if not user["is_active"]: raise HTTPException(403, "Аккаунт деактивирован") if user["banned_until"] and user["banned_until"] > time.time(): raise HTTPException(403, "Аккаунт забанен") return { "id": user["id"], "username": user["username"], "uuid": user["uuid"], "role": user["role"] } def require_role(min_role: int): """Dependency for checking minimum required role""" from roles import UserRole async def dependency(current_user: dict = Depends(get_current_user)): if current_user["role"] < min_role: from roles import ROLE_NAMES raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Требуется роль {ROLE_NAMES.get(min_role, 'неизвестная')}" ) return current_user return dependency # ====================== ЭНДПОИНТЫ ====================== @router.post("/register", response_model=TokenResponse) async def register(body: RegisterRequest, request: Request): """Регистрация нового пользователя""" ip = request.client.host if request.client else "unknown" allowed, wait = check_rate_limit(ip) if not allowed: raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") with get_db() as conn: existing = conn.execute( "SELECT username FROM users WHERE username = ?", (body.username,) ).fetchone() if existing: raise HTTPException(409, "Пользователь с таким именем уже существует") uuid = generate_uuid() pw_hash = hash_password(body.password) now = time.time() cursor = conn.execute( """INSERT INTO users (username, password_hash, uuid, created_at, role) VALUES (?, ?, ?, ?, ?)""", (body.username, pw_hash, uuid, now, 0) # role 0 = обычный пользователь ) user_id = cursor.lastrowid # Создаем сессию session_token = secrets.token_urlsafe(32) conn.execute( "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", (user_id, session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) ) # Токены access_token = create_jwt({ "sub": user_id, "username": body.username, "uuid": uuid, "role": 0, "type": "access", "jti": session_token }) refresh_token = create_jwt({ "sub": user_id, "type": "refresh", "jti": secrets.token_hex(16) }, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400) refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest() conn.execute( "INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", (user_id, refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now) ) log_audit(user_id, "register", f"User registered from {ip}", ip) logger.info("User registered", username=body.username, user_id=user_id, ip=ip) from roles import ROLE_NAMES return TokenResponse( access_token=access_token, refresh_token=refresh_token, expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60, username=body.username, uuid=uuid, role=0, role_name=ROLE_NAMES[0] ) @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest, request: Request): """Вход в систему""" ip = request.client.host if request.client else "unknown" allowed, wait = check_rate_limit(ip) if not allowed: raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") with get_db() as conn: user = conn.execute( "SELECT id, username, uuid, password_hash, role, is_active, banned_until FROM users WHERE username = ?", (body.username,) ).fetchone() if not user or not verify_password(body.password, user["password_hash"]): record_login_attempt(ip, False) log_audit(0, "login_failed", f"Failed login for {body.username} from {ip}", ip) raise HTTPException(401, "Неверное имя пользователя или пароль") if not user["is_active"]: raise HTTPException(403, "Аккаунт деактивирован") if user["banned_until"] and user["banned_until"] > time.time(): raise HTTPException(403, "Аккаунт забанен") record_login_attempt(ip, True) now = time.time() conn.execute( "UPDATE users SET last_login = ? WHERE id = ?", (now, user["id"]) ) # Создаем сессию session_token = secrets.token_urlsafe(32) conn.execute( "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", (user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) ) access_token = create_jwt({ "sub": user["id"], "username": user["username"], "uuid": user["uuid"], "role": user["role"], "type": "access", "jti": session_token }) refresh_token = create_jwt({ "sub": user["id"], "type": "refresh", "jti": secrets.token_hex(16) }, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400) refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest() conn.execute( "INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", (user["id"], refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now) ) log_audit(user["id"], "login", f"User logged in from {ip}", ip) logger.info("User logged in", username=user["username"], user_id=user["id"], ip=ip) from roles import ROLE_NAMES return TokenResponse( access_token=access_token, refresh_token=refresh_token, expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60, username=user["username"], uuid=user["uuid"], role=user["role"], role_name=ROLE_NAMES.get(user["role"], "Неизвестно") ) @router.post("/logout") async def logout(current_user: dict = Depends(get_current_user), request: Request = None): """Выход из системы""" ip = request.client.host if request.client else "unknown" with get_db() as conn: conn.execute( "UPDATE user_sessions SET is_active = 0 WHERE user_id = ?", (current_user["id"],) ) conn.execute( "UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", (current_user["id"],) ) log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip) logger.info("User logged out", user_id=current_user["id"], ip=ip) return {"success": True} @router.post("/refresh") async def refresh(body: dict, request: Request): """Обновление access токена""" refresh_token = body.get("refresh_token") if not refresh_token: raise HTTPException(400, "refresh_token обязателен") payload = verify_jwt(refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException(401, "Недействительный refresh token") ip = request.client.host if request.client else "unknown" with get_db() as conn: token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() token_row = conn.execute( "SELECT user_id, revoked FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?", (token_hash, time.time()) ).fetchone() if not token_row or token_row["revoked"]: raise HTTPException(401, "Refresh token истёк или недействителен") user = conn.execute( "SELECT id, username, uuid, role FROM users WHERE id = ? AND is_active = 1", (token_row["user_id"],) ).fetchone() if not user: raise HTTPException(401, "Пользователь не найден или заблокирован") now = time.time() session_token = secrets.token_urlsafe(32) conn.execute( "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", (user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) ) new_access_token = create_jwt({ "sub": user["id"], "username": user["username"], "uuid": user["uuid"], "role": user["role"], "type": "access", "jti": session_token }) log_audit(user["id"], "refresh_token", f"Token refreshed from {ip}", ip) return { "access_token": new_access_token, "token_type": "bearer" } @router.post("/validate") async def validate_token(request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer)): """Validate token endpoint for Minecraft server — checks ban status""" if not credentials: raise HTTPException(401, "Требуется авторизация") payload = verify_jwt(credentials.credentials) if not payload or payload.get("type") != "access": raise HTTPException(401, "Недействительный токен") try: body = await request.json() username = body.get("username") uuid = body.get("uuid") # Verify that token belongs to this user 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") class ActivatePassRequest(BaseModel): pass_code: str = Field(..., min_length=3, max_length=64) @router.post("/pass/activate") async def activate_pass( body: ActivatePassRequest, current_user: dict = Depends(get_current_user), request: Request = None, ): """Activate a pass code for the current user""" ip = request.client.host if request.client else "unknown" with get_db() as conn: # Check if pass exists and is active pass_row = conn.execute( "SELECT code, owner, is_active, expires_at, max_uses, uses, activated_by FROM passes WHERE code = ?", (body.pass_code,), ).fetchone() if not pass_row: raise HTTPException(404, "Проходка не найдена") if not pass_row["is_active"]: raise HTTPException(400, "Проходка уже использована или отозвана") if pass_row["uses"] >= pass_row["max_uses"]: raise HTTPException(400, "Проходка достигла лимита использований") if pass_row["expires_at"] and pass_row["expires_at"] < time.time(): raise HTTPException(400, "Проходка истекла") # Check if user already has an active pass existing = conn.execute( "SELECT 1 FROM user_passes WHERE user_id = ? AND pass_code = ?", (current_user["id"], body.pass_code), ).fetchone() if existing: raise HTTPException(409, "Эта проходка уже активирована вами") existing_pass = conn.execute(""" SELECT 1 FROM user_passes up JOIN passes p ON up.pass_code = p.code WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) """, (current_user["id"], time.time())).fetchone() if existing_pass: raise HTTPException(409, "У вас уже есть активная проходка") now = time.time() # Link pass to user conn.execute( "INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)", (current_user["id"], body.pass_code, now), ) # Increment usage count conn.execute( "UPDATE passes SET uses = uses + 1, activated_by = ?, activated_at = ? WHERE code = ?", (current_user["id"], now, body.pass_code), ) # Upgrade user role if they don't have a higher role if current_user["role"] < 1: conn.execute( "UPDATE users SET role = 1 WHERE id = ?", (current_user["id"],), ) conn.commit() log_audit( current_user["id"], "pass_activated", f"Pass activated: {body.pass_code[:8]}...", ip, ) logger.info( "Pass activated", user=current_user["username"], user_id=current_user["id"], pass_code=body.pass_code, ip=ip, ) return { "success": True, "message": f"Проходка активирована для {current_user['username']}", "role": 1, }