fix(security & perf): implement bearer session tokens, salted pbkdf2, object pooling and background caching
This commit is contained in:
+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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user