fix(security & perf): implement bearer session tokens, salted pbkdf2, object pooling and background caching
This commit is contained in:
1 parent
258b1a9cb1
commit
7cd0792799
15 files changed
+1264
-105
No files matched your search
+156
-11
@@ -1,13 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite Database Manager for Plants vs. Zombies Game (ai.qpid.pro)
|
||||
Zero external dependencies. Manages users, game progress profiles, leaderboards, and bug reports.
|
||||
Zero external dependencies. Manages users, game progress profiles, leaderboards, sessions, and bug reports.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
@@ -20,6 +23,33 @@ def get_connection():
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def hash_password(password, salt=None):
|
||||
"""Secure server-side salted PBKDF2 password hashing."""
|
||||
if not password:
|
||||
return ""
|
||||
salt = salt or secrets.token_hex(16)
|
||||
iterations = 100_000
|
||||
derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations).hex()
|
||||
return f"pbkdf2:sha256:{iterations}:{salt}:{derived}"
|
||||
|
||||
def verify_password(password, stored_hash):
|
||||
"""Verifies a password against stored hash with backward compatibility for legacy hashes."""
|
||||
if not password or not stored_hash:
|
||||
return False
|
||||
if stored_hash.startswith("pbkdf2:sha256:"):
|
||||
try:
|
||||
parts = stored_hash.split(":")
|
||||
if len(parts) == 5:
|
||||
iterations = int(parts[2])
|
||||
salt = parts[3]
|
||||
expected = parts[4]
|
||||
derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations).hex()
|
||||
return hmac.compare_digest(derived, expected)
|
||||
except Exception:
|
||||
return False
|
||||
# Backward compatibility for plain/legacy hashes
|
||||
return hmac.compare_digest(str(password), str(stored_hash))
|
||||
|
||||
def init_db():
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
@@ -34,6 +64,17 @@ def init_db():
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
@@ -81,6 +122,79 @@ def init_db():
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def create_session(user_id, duration_days=30):
|
||||
"""Generates a secure 256-bit token and creates a session record."""
|
||||
token = secrets.token_hex(32)
|
||||
now = datetime.now()
|
||||
created_at = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
expires_at = (now + timedelta(days=duration_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
(token, user_id, created_at, expires_at)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return token
|
||||
|
||||
def validate_session(token):
|
||||
"""Validates session token. Returns user dict if valid, else None."""
|
||||
if not token or not isinstance(token, str):
|
||||
return None
|
||||
token = token.strip()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT u.id, u.username, u.created_at, u.last_login, s.expires_at, s.token
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token = ?
|
||||
""", (token,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
try:
|
||||
expires_at = datetime.strptime(row["expires_at"], "%Y-%m-%d %H:%M:%S")
|
||||
if datetime.now() > expires_at:
|
||||
cursor.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return None
|
||||
except Exception:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
user_info = {
|
||||
"id": row["id"],
|
||||
"user_id": row["id"],
|
||||
"username": row["username"],
|
||||
"createdAt": row["created_at"],
|
||||
"lastLogin": row["last_login"],
|
||||
"token": row["token"]
|
||||
}
|
||||
conn.close()
|
||||
return user_info
|
||||
|
||||
def delete_session(token):
|
||||
"""Deletes/invalidates a session by token."""
|
||||
if not token:
|
||||
return True
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM sessions WHERE token = ?", (token.strip(),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
def get_default_profile_dict():
|
||||
return {
|
||||
"coins": 150,
|
||||
@@ -133,10 +247,12 @@ def register_user(username, password_hash, initial_data=None):
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
now = datetime.now()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
secure_hash = hash_password(password_hash)
|
||||
cursor.execute(
|
||||
"INSERT INTO users (username, password_hash, created_at, last_login) VALUES (?, ?, ?, ?)",
|
||||
(username, password_hash, now_str, now_str)
|
||||
(username, secure_hash, now_str, now_str)
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
|
||||
@@ -168,9 +284,17 @@ def register_user(username, password_hash, initial_data=None):
|
||||
now_str
|
||||
))
|
||||
|
||||
# Create session token
|
||||
token = secrets.token_hex(32)
|
||||
expires_at = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor.execute(
|
||||
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
(token, user_id, now_str, expires_at)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "user_id": user_id, "username": username, "data": profile_data}
|
||||
return {"success": True, "token": token, "user_id": user_id, "username": username, "data": profile_data}
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
return {"success": False, "message": "Пользователь с таким именем уже существует"}
|
||||
@@ -189,13 +313,27 @@ def authenticate_user(username, password_hash):
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
if user["password_hash"] != password_hash:
|
||||
if not verify_password(password_hash, user["password_hash"]):
|
||||
conn.close()
|
||||
return {"success": False, "message": "Неверный пароль"}
|
||||
|
||||
# Update last login
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"]))
|
||||
now = datetime.now()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Seamless upgrade from legacy plaintext/djb2 hash to salted PBKDF2
|
||||
if not user["password_hash"].startswith("pbkdf2:"):
|
||||
new_secure_hash = hash_password(password_hash)
|
||||
cursor.execute("UPDATE users SET password_hash = ?, last_login = ? WHERE id = ?", (new_secure_hash, now_str, user["id"]))
|
||||
else:
|
||||
cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"]))
|
||||
|
||||
# Create session token
|
||||
token = secrets.token_hex(32)
|
||||
expires_at = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor.execute(
|
||||
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
(token, user["id"], now_str, expires_at)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Fetch profile
|
||||
@@ -227,10 +365,17 @@ def authenticate_user(username, password_hash):
|
||||
"achievements": json.loads(p["achievements"] or "{}")
|
||||
}
|
||||
|
||||
return {"success": True, "user_id": user["id"], "username": user["username"], "data": profile_dict}
|
||||
return {"success": True, "token": token, "user_id": user["id"], "username": user["username"], "data": profile_dict}
|
||||
|
||||
def save_user_profile(username, data):
|
||||
def save_user_profile(username, data, token=None):
|
||||
username = username.strip()
|
||||
|
||||
# If token is provided, verify it belongs to username
|
||||
if token is not None:
|
||||
session_user = validate_session(token)
|
||||
if not session_user or session_user["username"].lower() != username.lower():
|
||||
return {"success": False, "message": "Недействительный токен авторизации или доступ запрещен"}
|
||||
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
+159
-22
@@ -3,7 +3,7 @@
|
||||
Backend Server for Plants vs. Zombies (ai.qpid.pro)
|
||||
Zero external dependencies.
|
||||
Manages:
|
||||
- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/save, /api/users)
|
||||
- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/register, /api/auth/save, /api/auth/sync, /api/users)
|
||||
- Community Bug Reports & Feedback (/api/bugs)
|
||||
"""
|
||||
|
||||
@@ -11,6 +11,8 @@ import http.server
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import html
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -19,6 +21,7 @@ sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
|
||||
import db
|
||||
|
||||
PORT = 8001
|
||||
MAX_BODY_SIZE = 1 * 1024 * 1024 # 1MB
|
||||
REPORTS_DIR = os.path.join(BASE_DIR, "reports")
|
||||
BUGS_JSON = os.path.join(REPORTS_DIR, "bugs.json")
|
||||
BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md")
|
||||
@@ -26,6 +29,24 @@ BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md")
|
||||
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||||
db.init_db()
|
||||
|
||||
# In-memory rate limiter for failed login attempts
|
||||
FAILED_LOGIN_ATTEMPTS = {} # ip -> list of timestamps
|
||||
|
||||
def is_login_rate_limited(ip, max_attempts=5, window_seconds=60):
|
||||
now = datetime.now().timestamp()
|
||||
attempts = [t for t in FAILED_LOGIN_ATTEMPTS.get(ip, []) if now - t < window_seconds]
|
||||
FAILED_LOGIN_ATTEMPTS[ip] = attempts
|
||||
return len(attempts) >= max_attempts
|
||||
|
||||
def record_failed_login(ip):
|
||||
now = datetime.now().timestamp()
|
||||
attempts = [t for t in FAILED_LOGIN_ATTEMPTS.get(ip, []) if now - t < 60]
|
||||
attempts.append(now)
|
||||
FAILED_LOGIN_ATTEMPTS[ip] = attempts
|
||||
|
||||
def clear_failed_logins(ip):
|
||||
FAILED_LOGIN_ATTEMPTS.pop(ip, None)
|
||||
|
||||
DEFAULT_BUGS = [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -86,13 +107,13 @@ def sync_markdown(bugs):
|
||||
for b in reversed(bugs):
|
||||
bid = b.get("id", 0)
|
||||
ts = b.get("timestamp", "")
|
||||
author = b.get("author", "Аноним").replace("|", "/")
|
||||
author = str(b.get("author", "Аноним")).replace("|", "/")
|
||||
cat = b.get("category", "bug")
|
||||
cat_icon = "🔴 Баг" if cat == "bug" else ("💡 Идея" if cat == "feature" else ("🎨 UI" if cat == "visual" else "⚖️ Баланс"))
|
||||
st = b.get("status", "новый")
|
||||
st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else "🔴 Новый")
|
||||
text = b.get("text", "").replace("|", "/").replace("\n", " ")
|
||||
reply = (b.get("adminReply") or "—").replace("|", "/").replace("\n", " ")
|
||||
text = str(b.get("text", "")).replace("|", "/").replace("\n", " ")
|
||||
reply = str(b.get("adminReply") or "—").replace("|", "/").replace("\n", " ")
|
||||
lines.append(f"| {bid} | {ts} | {author} | {cat_icon} | {st_badge} | {text} | {reply} |")
|
||||
|
||||
lines.append("")
|
||||
@@ -100,10 +121,32 @@ def sync_markdown(bugs):
|
||||
f.write("\n".join(lines))
|
||||
|
||||
class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
def get_client_ip(self):
|
||||
xff = self.headers.get("X-Forwarded-For")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
x_real = self.headers.get("X-Real-IP")
|
||||
if x_real:
|
||||
return x_real.strip()
|
||||
return self.client_address[0]
|
||||
|
||||
def _set_cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
origin = self.headers.get("Origin", "")
|
||||
if not origin:
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
else:
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(origin)
|
||||
host = parsed.hostname or ""
|
||||
if host in ["localhost", "127.0.0.1", "ai.qpid.pro", "qpid.pro", "ai", "racks"] or host.endswith(".qpid.pro") or host.endswith(".ai") or host.endswith(".racks"):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
else:
|
||||
self.send_header("Access-Control-Allow-Origin", "https://ai.qpid.pro")
|
||||
except Exception:
|
||||
self.send_header("Access-Control-Allow-Origin", "https://ai.qpid.pro")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
self.send_header("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
@@ -153,6 +196,18 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
|
||||
# Check payload size limit (Max 1MB)
|
||||
if length > MAX_BODY_SIZE:
|
||||
self.send_response(413)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Payload Too Large (Max 1MB)"}).encode("utf-8"))
|
||||
self.close_connection = True
|
||||
return
|
||||
|
||||
body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
||||
|
||||
try:
|
||||
@@ -160,46 +215,68 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
# 1a. User Login
|
||||
# 1a. User Login with Rate Limiting
|
||||
if path in ["/api/auth/login", "/api/bugs/auth/login"]:
|
||||
client_ip = self.get_client_ip()
|
||||
if is_login_rate_limited(client_ip):
|
||||
self.send_response(429)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({
|
||||
"success": False,
|
||||
"message": "Слишком много неудачных попыток входа. Пожалуйста, подождите 1 минуту."
|
||||
}).encode("utf-8"))
|
||||
return
|
||||
|
||||
username = str(payload.get("username", "")).strip()
|
||||
password_hash = str(payload.get("passwordHash", "")).strip()
|
||||
|
||||
if not username or not password_hash:
|
||||
record_failed_login(client_ip)
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
import re
|
||||
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
|
||||
record_failed_login(client_ip)
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
user = db.get_user_by_username(username)
|
||||
if not user:
|
||||
record_failed_login(client_ip)
|
||||
self.send_response(404)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}).encode("utf-8"))
|
||||
return
|
||||
|
||||
res = db.authenticate_user(username, password_hash)
|
||||
if not res or not res.get("success"):
|
||||
record_failed_login(client_ip)
|
||||
self.send_response(401)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный логин или пароль"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
clear_failed_logins(client_ip)
|
||||
data = json.dumps(res, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self._set_cors()
|
||||
@@ -218,6 +295,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
if not username or not password_hash:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль для регистрации"}).encode("utf-8"))
|
||||
return
|
||||
@@ -226,6 +304,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8"))
|
||||
return
|
||||
@@ -233,6 +312,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
if len(password_hash) < 1:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Пароль не может быть пустым"}).encode("utf-8"))
|
||||
return
|
||||
@@ -240,6 +320,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
if username.lower() in ["гость", "guest", "admin", "null", "undefined", "system", "anonymous"]:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Это имя пользователя зарезервировано"}).encode("utf-8"))
|
||||
return
|
||||
@@ -248,6 +329,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
if user:
|
||||
self.send_response(409)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином уже существует"}).encode("utf-8"))
|
||||
return
|
||||
@@ -262,21 +344,72 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.wfile.write(data)
|
||||
return
|
||||
|
||||
# 2. User Progress Profile Save & Sync
|
||||
if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]:
|
||||
username = str(payload.get("username", "")).strip()
|
||||
user_data = payload.get("data", {})
|
||||
# 1c. User Logout
|
||||
if path in ["/api/auth/logout", "/api/bugs/auth/logout"]:
|
||||
auth_header = self.headers.get("Authorization", "")
|
||||
token = None
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:].strip()
|
||||
elif "token" in payload and payload.get("token"):
|
||||
token = str(payload.get("token")).strip()
|
||||
|
||||
if not username or not user_data:
|
||||
self.send_response(400)
|
||||
if token:
|
||||
db.delete_session(token)
|
||||
|
||||
self.send_response(200)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": True, "message": "Сессия успешно завершена"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
# 2. User Progress Profile Save & Sync (Token Authenticated)
|
||||
if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]:
|
||||
auth_header = self.headers.get("Authorization", "")
|
||||
token = None
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:].strip()
|
||||
elif "token" in payload and payload.get("token"):
|
||||
token = str(payload.get("token")).strip()
|
||||
|
||||
if not token:
|
||||
self.send_response(401)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Username and data are required"}).encode("utf-8"))
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Требуется авторизация (отсутствует токен сессии)"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
res = db.save_user_profile(username, user_data)
|
||||
session_user = db.validate_session(token)
|
||||
if not session_user:
|
||||
self.send_response(401)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Недействительный или истекший токен сессии"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
username = str(payload.get("username", "")).strip() or session_user["username"]
|
||||
if session_user["username"].lower() != username.lower():
|
||||
self.send_response(403)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Доступ запрещен (несоответствие пользователя)"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
user_data = payload.get("data", {})
|
||||
if not user_data:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Отсутствуют данные профиля для сохранения"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
res = db.save_user_profile(username, user_data, token=token)
|
||||
data = json.dumps(res, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_response(200 if res.get("success") else 400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
@@ -284,28 +417,32 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.wfile.write(data)
|
||||
return
|
||||
|
||||
# 3. Bug Report Submission
|
||||
# 3. Bug Report Submission (Sanitized)
|
||||
if path.startswith("/api/bugs") or path.startswith("/bugs"):
|
||||
text = str(payload.get("text", "")).strip()
|
||||
if not text:
|
||||
raw_text = str(payload.get("text", "")).strip()
|
||||
if not raw_text:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": "Empty report text"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
author = str(payload.get("author", "Гость")).strip() or "Гость"
|
||||
raw_author = str(payload.get("author", "Гость")).strip() or "Гость"
|
||||
category = str(payload.get("category", "bug")).strip()
|
||||
if category not in ["bug", "feature", "visual", "balance"]:
|
||||
category = "bug"
|
||||
|
||||
clean_author = html.escape(raw_author)[:30]
|
||||
clean_text = html.escape(raw_text)[:1000]
|
||||
|
||||
bugs = load_bugs()
|
||||
new_id = max([b.get("id", 0) for b in bugs] + [0]) + 1
|
||||
new_bug = {
|
||||
"id": new_id,
|
||||
"author": author[:30],
|
||||
"author": clean_author,
|
||||
"category": category,
|
||||
"text": text[:1000],
|
||||
"text": clean_text,
|
||||
"status": "новый",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"adminReply": None
|
||||
|
||||
+33
-4
@@ -195,7 +195,10 @@ def test_auth_system():
|
||||
"scheduleSave",
|
||||
"apiPost",
|
||||
"validateUsername",
|
||||
"validatePassword"
|
||||
"validatePassword",
|
||||
"getToken",
|
||||
"setToken",
|
||||
"clearToken"
|
||||
]
|
||||
for m in auth_methods:
|
||||
if m in auth_code:
|
||||
@@ -203,8 +206,10 @@ def test_auth_system():
|
||||
else:
|
||||
record(f"PvZAuth.{m} method implemented", "FAIL", f"Missing {m}")
|
||||
|
||||
record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL",
|
||||
"Server-backed auth endpoint missing" if "/api/auth" not in auth_code else "")
|
||||
record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL")
|
||||
record("auth.js has pvz_token session storage", "PASS" if "pvz_token" in auth_code else "FAIL")
|
||||
record("auth.js sends Authorization Bearer header", "PASS" if "Authorization" in auth_code and "Bearer" in auth_code else "FAIL")
|
||||
record("auth.js handles 401 Unauthorized clearing token", "PASS" if "401" in auth_code and "clearToken" in auth_code else "FAIL")
|
||||
record("auth.js has debounced save (scheduleSave)", "PASS" if "scheduleSave" in auth_code else "FAIL")
|
||||
record("auth.js has flushSave for game-over sync", "PASS" if "flushSave" in auth_code else "FAIL")
|
||||
|
||||
@@ -243,6 +248,12 @@ def test_auth_system():
|
||||
auth_res = pvz_db.authenticate_user("kopri", "c63bc483")
|
||||
record("SQLite DB: authenticate 'kopri' with correct hash", "PASS" if auth_res and auth_res.get("success") else "FAIL",
|
||||
str(auth_res.get("message", "")) if auth_res and not auth_res.get("success") else "")
|
||||
record("SQLite DB: authenticate returns session token", "PASS" if auth_res and auth_res.get("token") and len(auth_res["token"]) == 64 else "FAIL")
|
||||
|
||||
# Test session validation
|
||||
kopri_token = auth_res.get("token")
|
||||
sess_val = pvz_db.validate_session(kopri_token)
|
||||
record("SQLite DB: validate_session(token) succeeds", "PASS" if sess_val and sess_val.get("username") == "kopri" else "FAIL")
|
||||
|
||||
# Test wrong password
|
||||
bad_auth = pvz_db.authenticate_user("kopri", "wronghash")
|
||||
@@ -253,10 +264,18 @@ def test_auth_system():
|
||||
dup_reg = pvz_db.register_user("kopri", "c63bc483")
|
||||
record("SQLite DB: reject duplicate user registration", "PASS" if not dup_reg.get("success") else "FAIL")
|
||||
|
||||
# Test save profile (update newbie coins)
|
||||
# Test save profile with valid token
|
||||
save_res = pvz_db.save_user_profile("newbie_test", {"coins": 999, "level": 3, "highScore": 2500})
|
||||
record("SQLite DB: save_user_profile() succeeds", "PASS" if save_res.get("success") else "FAIL")
|
||||
|
||||
# Test save profile with invalid token rejection
|
||||
bad_save = pvz_db.save_user_profile("newbie_test", {"coins": 999}, token="invalid_token_123")
|
||||
record("SQLite DB: save_user_profile rejects invalid token", "PASS" if not bad_save.get("success") else "FAIL")
|
||||
|
||||
# Test save profile with mismatching token
|
||||
mismatch_save = pvz_db.save_user_profile("newbie_test", {"coins": 999}, token=kopri_token)
|
||||
record("SQLite DB: save_user_profile rejects mismatched user token", "PASS" if not mismatch_save.get("success") else "FAIL")
|
||||
|
||||
# Verify saved
|
||||
verify = pvz_db.authenticate_user("newbie_test", "bb94b3d4")
|
||||
verify_ok = verify and verify.get("data", {}).get("coins") == 999
|
||||
@@ -277,6 +296,16 @@ def test_auth_system():
|
||||
else:
|
||||
record("SQLite DB: kopri profile loaded", "FAIL", "Could not load kopri profile")
|
||||
|
||||
# Test session deletion
|
||||
pvz_db.delete_session(kopri_token)
|
||||
record("SQLite DB: delete_session invalidates session token", "PASS" if pvz_db.validate_session(kopri_token) is None else "FAIL")
|
||||
|
||||
# Test password hashing function
|
||||
h = pvz_db.hash_password("mysecretpassword")
|
||||
record("SQLite DB: hash_password produces salted pbkdf2 format", "PASS" if h.startswith("pbkdf2:sha256:100000:") else "FAIL")
|
||||
record("SQLite DB: verify_password matches correct password", "PASS" if pvz_db.verify_password("mysecretpassword", h) else "FAIL")
|
||||
record("SQLite DB: verify_password rejects wrong password", "PASS" if not pvz_db.verify_password("wrongpassword", h) else "FAIL")
|
||||
|
||||
except Exception as e:
|
||||
record("SQLite DB tests", "FAIL", str(e))
|
||||
|
||||
|
||||
Reference in new issue
Block a user