From 89c005775940f4058c91c08fd6d166d13864eb98 Mon Sep 17 00:00:00 2001 From: Sashegdev Date: Wed, 8 Apr 2026 19:56:38 +0000 Subject: [PATCH] Server Fixes --- launcher/dependency-reduced-pom.xml | 2 +- server/auth.py | 264 +++++++++++++++++----------- server/main.py | 44 ----- 3 files changed, 160 insertions(+), 150 deletions(-) diff --git a/launcher/dependency-reduced-pom.xml b/launcher/dependency-reduced-pom.xml index ed4b885..41197c7 100644 --- a/launcher/dependency-reduced-pom.xml +++ b/launcher/dependency-reduced-pom.xml @@ -3,7 +3,7 @@ 4.0.0 me.sashegdev ZernMCLauncher - 1.0.6 + 1.0.7 diff --git a/server/auth.py b/server/auth.py index 1c61c82..eef9827 100644 --- a/server/auth.py +++ b/server/auth.py @@ -1,4 +1,3 @@ -# auth.py import base64 import json import sqlite3 @@ -38,9 +37,12 @@ def _get_secret() -> bytes: _SECRET = _get_secret() def create_jwt(payload: dict) -> str: - import base64, json - 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() + 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()}" @@ -53,13 +55,22 @@ def verify_jwt(token: str) -> Optional[dict]: header, body, sig = parts msg = f"{header}.{body}".encode() expected = hmac.new(_SECRET, msg, hashlib.sha256).digest() - if not hmac.compare_digest(base64.urlsafe_b64decode(sig + '==='[:3]), expected): + + # Исправлено: правильный паддинг для base64url + sig_padded = sig + '=' * (4 - len(sig) % 4) + if not hmac.compare_digest( + base64.urlsafe_b64decode(sig_padded), + expected + ): return None - payload = json.loads(base64.urlsafe_b64decode(body + '==='[:3])) + + 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: + except Exception: return None # ====================== БАЗА ДАННЫХ ====================== @@ -72,7 +83,7 @@ def init_db(): conn = get_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, uuid TEXT UNIQUE NOT NULL, @@ -81,16 +92,17 @@ def init_db(): ); CREATE TABLE IF NOT EXISTS passes ( - code TEXT PRIMARY KEY, -- ZERN-XXXXXX - is_used BOOLEAN DEFAULT 0, + code TEXT PRIMARY KEY, + owner TEXT, + is_active BOOLEAN DEFAULT 1, activated_by INTEGER REFERENCES users(id), activated_at REAL, - expires_at REAL, -- NULL = без срока - max_uses INTEGER DEFAULT 1, -- пока 1, можно больше + expires_at REAL, + max_uses INTEGER DEFAULT 1, uses INTEGER DEFAULT 0 ); - CREATE TABLE IF NOT EXISTS user_passes ( -- Связь пользователь-пасс + 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, @@ -111,15 +123,25 @@ def init_db(): # ====================== ХЕЛПЕРЫ ====================== def hash_password(password: str) -> str: salt = secrets.token_hex(16) - hash_obj = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 300000) + hash_obj = hashlib.pbkdf2_hmac( + 'sha256', + password.encode(), + salt.encode(), + 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(), salt.encode(), 300000) + hash_obj = hashlib.pbkdf2_hmac( + 'sha256', + password.encode(), + salt.encode(), + 300000 + ) return hmac.compare_digest(hash_obj.hex(), stored_hash) - except: + except Exception: return False def generate_uuid() -> str: @@ -145,58 +167,6 @@ class TokenResponse(BaseModel): router = APIRouter(prefix="/auth", tags=["auth"]) bearer = HTTPBearer(auto_error=False) -@router.post("/register", response_model=TokenResponse) -async def register(body: RegisterRequest, request: Request): - conn = get_db() - try: - if conn.execute("SELECT 1 FROM users WHERE username = ? COLLATE NOCASE", (body.username,)).fetchone(): - raise HTTPException(status_code=409, detail="Имя пользователя уже занято") - - uuid = generate_uuid() - pw_hash = hash_password(body.password) - now = time.time() - - conn.execute( - "INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)", - (body.username, pw_hash, uuid, now) - ) - user_id = conn.lastrowid - conn.commit() - - # Вызываем функцию и явно преобразуем в dict, чтобы избежать проблем сериализации - tokens = _issue_tokens(conn, user_id, body.username, uuid) - return tokens.model_dump() if hasattr(tokens, "model_dump") else tokens - - except HTTPException: - raise - except Exception as e: - logger.error("Register error", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") - finally: - conn.close() - - - -@router.post("/login", response_model=TokenResponse) -async def login(body: LoginRequest, request: Request): - conn = get_db() - try: - row = conn.execute( - "SELECT id, username, password_hash, uuid FROM users WHERE username = ? COLLATE NOCASE", - (body.username,) - ).fetchone() - - if not row or not verify_password(body.password, row["password_hash"]): - raise HTTPException(401, "Неверное имя пользователя или пароль") - - conn.execute("UPDATE users SET last_login = ? WHERE id = ?", (time.time(), row["id"])) - conn.commit() - - return _issue_tokens(conn, row["id"], row["username"], row["uuid"]) - finally: - conn.close() - - def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse: now = time.time() @@ -216,7 +186,6 @@ def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() - # Удаляем старые refresh-токены пользователя conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,)) conn.execute( "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)", @@ -232,8 +201,65 @@ def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse uuid=uuid ) +@router.post("/register", response_model=TokenResponse) +async def register(body: RegisterRequest, request: Request): + conn = get_db() + try: + existing = conn.execute( + "SELECT 1 FROM users WHERE username = ? COLLATE NOCASE", + (body.username,) + ).fetchone() + + if existing: + raise HTTPException(status_code=409, detail="Имя пользователя уже занято") + + uuid = generate_uuid() + pw_hash = hash_password(body.password) + now = time.time() + + cursor = conn.execute( + "INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)", + (body.username, pw_hash, uuid, now) + ) + conn.commit() + + user_id = cursor.lastrowid + tokens = _issue_tokens(conn, user_id, body.username, uuid) + + logger.info("User registered", username=body.username, user_id=user_id) + return tokens + + except HTTPException: + raise + except Exception as e: + logger.error("Register error", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + finally: + conn.close() + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest, request: Request): + conn = get_db() + try: + row = conn.execute( + "SELECT id, username, password_hash, uuid FROM users WHERE username = ? COLLATE NOCASE", + (body.username,) + ).fetchone() + + if not row or not verify_password(body.password, row["password_hash"]): + raise HTTPException(401, "Неверное имя пользователя или пароль") + + conn.execute( + "UPDATE users SET last_login = ? WHERE id = ?", + (time.time(), row["id"]) + ) + conn.commit() + + logger.info("User logged in", username=body.username, user_id=row["id"]) + return _issue_tokens(conn, row["id"], row["username"], row["uuid"]) + finally: + conn.close() -# Добавь этот эндпоинт, он используется в tryRefresh() @router.post("/refresh") async def refresh(body: dict): refresh_token = body.get("refresh_token") @@ -256,7 +282,8 @@ async def refresh(body: dict): raise HTTPException(401, "Refresh token истёк или недействителен") user_row = conn.execute( - "SELECT id, username, uuid FROM users WHERE id = ?", (row["user_id"],) + "SELECT id, username, uuid FROM users WHERE id = ?", + (row["user_id"],) ).fetchone() if not user_row: @@ -266,82 +293,106 @@ async def refresh(body: dict): finally: conn.close() -class ActivatePassRequest(BaseModel): - pass_code: str = Field(..., min_length=8, max_length=20, pattern=r"^ZERN-[A-Z0-9]+$") +@router.post("/logout") +async def logout(body: dict): + refresh_token = body.get("refresh_token") + if refresh_token: + conn = get_db() + try: + token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + conn.execute( + "DELETE FROM refresh_tokens WHERE token_hash = ?", + (token_hash,) + ) + conn.commit() + finally: + conn.close() + return {"success": True} -class PassInfo(BaseModel): - code: str - expires_at: Optional[float] = None - is_active: bool +# ====================== ПРОХОДКИ ====================== + +class ActivatePassRequest(BaseModel): + pass_code: str = Field(..., min_length=8, max_length=20) @router.post("/pass/activate") -async def activate_pass(body: ActivatePassRequest, credentials: HTTPAuthorizationCredentials = Depends(bearer)): - token = credentials.credentials if credentials else None - if not token: +async def activate_pass_endpoint( + body: ActivatePassRequest, + credentials: HTTPAuthorizationCredentials = Depends(bearer) +): + if not credentials: raise HTTPException(401, "Требуется авторизация") - payload = verify_jwt(token) + payload = verify_jwt(credentials.credentials) if not payload or payload.get("type") != "access": raise HTTPException(401, "Недействительный токен") user_id = payload["sub"] + username = payload["username"] + pass_code = body.pass_code.upper().strip() conn = get_db() try: - # Проверяем существование и доступность пасса pass_row = conn.execute( - "SELECT code, expires_at, uses, max_uses FROM passes WHERE code = ?", - (body.pass_code.upper(),) + "SELECT code, expires_at, uses, max_uses, owner FROM passes WHERE code = ?", + (pass_code,) ).fetchone() if not pass_row: raise HTTPException(404, "Проходка не найдена") + # Проверка срока if pass_row["expires_at"] and pass_row["expires_at"] < time.time(): raise HTTPException(410, "Проходка истекла") + # Проверка лимита использований if pass_row["uses"] >= pass_row["max_uses"]: raise HTTPException(410, "Проходка уже использована") - # Проверяем, не активировал ли уже этот пользователь - already = conn.execute( - "SELECT 1 FROM user_passes WHERE user_id = ? AND pass_code = ?", - (user_id, body.pass_code.upper()) - ).fetchone() - - if already: - raise HTTPException(409, "Эта проходка уже активирована на вашем аккаунте") + # Проверка владельца + if pass_row["owner"] is not None: + if pass_row["owner"] != username: + raise HTTPException(409, "Проходка уже активирована другим пользователем") + + # Уже активирована этим пользователем + return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"} now = time.time() - # Активируем + # Активация conn.execute( "INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)", - (user_id, body.pass_code.upper(), now) + (user_id, pass_code, now) ) - # Увеличиваем счётчик использований conn.execute( - "UPDATE passes SET uses = uses + 1, activated_by = ?, activated_at = ? WHERE code = ?", - (user_id, now, body.pass_code.upper()) + """UPDATE passes + SET uses = uses + 1, + owner = ?, + activated_by = ?, + activated_at = ? + WHERE code = ?""", + (username, user_id, now, pass_code) ) conn.commit() - logger.info("Pass activated", user_id=user_id, pass_code=body.pass_code) + logger.info("Pass activated", user_id=user_id, username=username, pass_code=pass_code) return {"success": True, "message": "Проходка успешно активирована!"} + except HTTPException: + raise + except Exception as e: + logger.error("Pass activation error", exc_info=True) + raise HTTPException(500, f"Ошибка сервера: {str(e)}") finally: conn.close() - @router.get("/pass/my") async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)): - token = credentials.credentials if credentials else None - if not token: + if not credentials: raise HTTPException(401, "Требуется авторизация") - payload = verify_jwt(token) + payload = verify_jwt(credentials.credentials) if not payload: raise HTTPException(401, "Недействительный токен") @@ -350,7 +401,7 @@ async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bear conn = get_db() try: rows = conn.execute(""" - SELECT p.code, p.expires_at, up.activated_at + SELECT p.code, p.expires_at, p.is_active, up.activated_at FROM user_passes up JOIN passes p ON up.pass_code = p.code WHERE up.user_id = ? @@ -360,7 +411,7 @@ async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bear now = time.time() for row in rows: expires = row["expires_at"] - is_active = expires is None or expires > now + is_active = row["is_active"] and (expires is None or expires > now) passes.append({ "code": row["code"], "activated_at": row["activated_at"], @@ -368,6 +419,9 @@ async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bear "is_active": is_active }) - return {"passes": passes} + return { + "passes": passes, + "has_active": any(p["is_active"] for p in passes) + } finally: conn.close() \ No newline at end of file diff --git a/server/main.py b/server/main.py index c9e9df5..0e4b013 100644 --- a/server/main.py +++ b/server/main.py @@ -20,7 +20,6 @@ import base64 from fastapi.responses import StreamingResponse from auth import router as auth_router, init_db, verify_jwt -from pass_manager import activate_pass, has_active_pass, get_user_passes logger = structlog.get_logger(__name__) @@ -826,49 +825,6 @@ async def proxy_status(): } -@app.post("/auth/pass/activate") -async def api_activate_pass(request: Request): - try: - body = await request.json() - pass_code = body.get("pass_code") - if not pass_code: - raise HTTPException(400, "pass_code обязателен") - except: - raise HTTPException(400, "Неверный JSON") - - # Получаем текущего пользователя из токена - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - raise HTTPException(401, "Требуется авторизация") - - token = auth_header.split(" ")[1] - payload = verify_jwt(token) # функция из auth.py - if not payload: - raise HTTPException(401, "Недействительный токен") - - result = activate_pass(pass_code, payload["username"], payload["sub"]) - - if result["success"]: - return result - else: - raise HTTPException(400, result["error"]) - - -@app.get("/auth/pass/my") -async def api_my_passes(request: Request): - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - raise HTTPException(401, "Требуется авторизация") - - token = auth_header.split(" ")[1] - payload = verify_jwt(token) - if not payload: - raise HTTPException(401, "Недействительный токен") - - passes = get_user_passes(payload["username"]) - return {"passes": passes, "has_active": any(p["is_active"] for p in passes)} - - @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): logger.error("Unhandled exception", exc_info=True)