Server Fixes

This commit is contained in:
Sashegdev
2026-04-08 19:56:38 +00:00
parent bf26baaf93
commit 89c0057759
3 changed files with 160 additions and 150 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>1.0.6</version> <version>1.0.7</version>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>
+159 -105
View File
@@ -1,4 +1,3 @@
# auth.py
import base64 import base64
import json import json
import sqlite3 import sqlite3
@@ -38,9 +37,12 @@ def _get_secret() -> bytes:
_SECRET = _get_secret() _SECRET = _get_secret()
def create_jwt(payload: dict) -> str: def create_jwt(payload: dict) -> str:
import base64, json header = base64.urlsafe_b64encode(
header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).rstrip(b'=').decode() json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode() ).rstrip(b'=').decode()
body = base64.urlsafe_b64encode(
json.dumps(payload).encode()
).rstrip(b'=').decode()
msg = f"{header}.{body}".encode() msg = f"{header}.{body}".encode()
sig = hmac.new(_SECRET, msg, hashlib.sha256).digest() sig = hmac.new(_SECRET, msg, hashlib.sha256).digest()
return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}" 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 header, body, sig = parts
msg = f"{header}.{body}".encode() msg = f"{header}.{body}".encode()
expected = hmac.new(_SECRET, msg, hashlib.sha256).digest() 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 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(): if payload.get("exp", 0) < time.time():
return None return None
return payload return payload
except: except Exception:
return None return None
# ====================== БАЗА ДАННЫХ ====================== # ====================== БАЗА ДАННЫХ ======================
@@ -72,7 +83,7 @@ def init_db():
conn = get_db() conn = get_db()
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE COLLATE NOCASE, username TEXT UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
uuid TEXT UNIQUE NOT NULL, uuid TEXT UNIQUE NOT NULL,
@@ -81,16 +92,17 @@ def init_db():
); );
CREATE TABLE IF NOT EXISTS passes ( CREATE TABLE IF NOT EXISTS passes (
code TEXT PRIMARY KEY, -- ZERN-XXXXXX code TEXT PRIMARY KEY,
is_used BOOLEAN DEFAULT 0, owner TEXT,
is_active BOOLEAN DEFAULT 1,
activated_by INTEGER REFERENCES users(id), activated_by INTEGER REFERENCES users(id),
activated_at REAL, activated_at REAL,
expires_at REAL, -- NULL = без срока expires_at REAL,
max_uses INTEGER DEFAULT 1, -- пока 1, можно больше max_uses INTEGER DEFAULT 1,
uses INTEGER DEFAULT 0 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, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
pass_code TEXT REFERENCES passes(code), pass_code TEXT REFERENCES passes(code),
activated_at REAL NOT NULL, activated_at REAL NOT NULL,
@@ -111,15 +123,25 @@ def init_db():
# ====================== ХЕЛПЕРЫ ====================== # ====================== ХЕЛПЕРЫ ======================
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
salt = secrets.token_hex(16) 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()}" return f"{salt}${hash_obj.hex()}"
def verify_password(password: str, stored: str) -> bool: def verify_password(password: str, stored: str) -> bool:
try: try:
salt, stored_hash = stored.split('$') 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) return hmac.compare_digest(hash_obj.hex(), stored_hash)
except: except Exception:
return False return False
def generate_uuid() -> str: def generate_uuid() -> str:
@@ -145,58 +167,6 @@ class TokenResponse(BaseModel):
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
bearer = HTTPBearer(auto_error=False) 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: def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse:
now = time.time() 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() token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
# Удаляем старые refresh-токены пользователя
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,)) conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,))
conn.execute( conn.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)", "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 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") @router.post("/refresh")
async def refresh(body: dict): async def refresh(body: dict):
refresh_token = body.get("refresh_token") refresh_token = body.get("refresh_token")
@@ -256,7 +282,8 @@ async def refresh(body: dict):
raise HTTPException(401, "Refresh token истёк или недействителен") raise HTTPException(401, "Refresh token истёк или недействителен")
user_row = conn.execute( 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() ).fetchone()
if not user_row: if not user_row:
@@ -266,82 +293,106 @@ async def refresh(body: dict):
finally: finally:
conn.close() conn.close()
class ActivatePassRequest(BaseModel): @router.post("/logout")
pass_code: str = Field(..., min_length=8, max_length=20, pattern=r"^ZERN-[A-Z0-9]+$") 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 class ActivatePassRequest(BaseModel):
is_active: bool pass_code: str = Field(..., min_length=8, max_length=20)
@router.post("/pass/activate") @router.post("/pass/activate")
async def activate_pass(body: ActivatePassRequest, credentials: HTTPAuthorizationCredentials = Depends(bearer)): async def activate_pass_endpoint(
token = credentials.credentials if credentials else None body: ActivatePassRequest,
if not token: credentials: HTTPAuthorizationCredentials = Depends(bearer)
):
if not credentials:
raise HTTPException(401, "Требуется авторизация") raise HTTPException(401, "Требуется авторизация")
payload = verify_jwt(token) payload = verify_jwt(credentials.credentials)
if not payload or payload.get("type") != "access": if not payload or payload.get("type") != "access":
raise HTTPException(401, "Недействительный токен") raise HTTPException(401, "Недействительный токен")
user_id = payload["sub"] user_id = payload["sub"]
username = payload["username"]
pass_code = body.pass_code.upper().strip()
conn = get_db() conn = get_db()
try: try:
# Проверяем существование и доступность пасса
pass_row = conn.execute( pass_row = conn.execute(
"SELECT code, expires_at, uses, max_uses FROM passes WHERE code = ?", "SELECT code, expires_at, uses, max_uses, owner FROM passes WHERE code = ?",
(body.pass_code.upper(),) (pass_code,)
).fetchone() ).fetchone()
if not pass_row: if not pass_row:
raise HTTPException(404, "Проходка не найдена") raise HTTPException(404, "Проходка не найдена")
# Проверка срока
if pass_row["expires_at"] and pass_row["expires_at"] < time.time(): if pass_row["expires_at"] and pass_row["expires_at"] < time.time():
raise HTTPException(410, "Проходка истекла") raise HTTPException(410, "Проходка истекла")
# Проверка лимита использований
if pass_row["uses"] >= pass_row["max_uses"]: if pass_row["uses"] >= pass_row["max_uses"]:
raise HTTPException(410, "Проходка уже использована") raise HTTPException(410, "Проходка уже использована")
# Проверяем, не активировал ли уже этот пользователь # Проверка владельца
already = conn.execute( if pass_row["owner"] is not None:
"SELECT 1 FROM user_passes WHERE user_id = ? AND pass_code = ?", if pass_row["owner"] != username:
(user_id, body.pass_code.upper()) raise HTTPException(409, "Проходка уже активирована другим пользователем")
).fetchone()
# Уже активирована этим пользователем
if already: return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
raise HTTPException(409, "Эта проходка уже активирована на вашем аккаунте")
now = time.time() now = time.time()
# Активируем # Активация
conn.execute( conn.execute(
"INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)", "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( conn.execute(
"UPDATE passes SET uses = uses + 1, activated_by = ?, activated_at = ? WHERE code = ?", """UPDATE passes
(user_id, now, body.pass_code.upper()) SET uses = uses + 1,
owner = ?,
activated_by = ?,
activated_at = ?
WHERE code = ?""",
(username, user_id, now, pass_code)
) )
conn.commit() 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": "Проходка успешно активирована!"} 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: finally:
conn.close() conn.close()
@router.get("/pass/my") @router.get("/pass/my")
async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)): async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)):
token = credentials.credentials if credentials else None if not credentials:
if not token:
raise HTTPException(401, "Требуется авторизация") raise HTTPException(401, "Требуется авторизация")
payload = verify_jwt(token) payload = verify_jwt(credentials.credentials)
if not payload: if not payload:
raise HTTPException(401, "Недействительный токен") raise HTTPException(401, "Недействительный токен")
@@ -350,7 +401,7 @@ async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bear
conn = get_db() conn = get_db()
try: try:
rows = conn.execute(""" 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 FROM user_passes up
JOIN passes p ON up.pass_code = p.code JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? WHERE up.user_id = ?
@@ -360,7 +411,7 @@ async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bear
now = time.time() now = time.time()
for row in rows: for row in rows:
expires = row["expires_at"] 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({ passes.append({
"code": row["code"], "code": row["code"],
"activated_at": row["activated_at"], "activated_at": row["activated_at"],
@@ -368,6 +419,9 @@ async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bear
"is_active": is_active "is_active": is_active
}) })
return {"passes": passes} return {
"passes": passes,
"has_active": any(p["is_active"] for p in passes)
}
finally: finally:
conn.close() conn.close()
-44
View File
@@ -20,7 +20,6 @@ import base64
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from auth import router as auth_router, init_db, verify_jwt 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__) 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) @app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception): async def global_exception_handler(request: Request, exc: Exception):
logger.error("Unhandled exception", exc_info=True) logger.error("Unhandled exception", exc_info=True)