perf: paginate docs list, lazy thumbnails, static cache headers
This commit is contained in:
+120
-12
@@ -4,47 +4,148 @@ import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
SESSION_TTL_SECONDS = 86400 # 24h
|
||||
PBKDF2_ITERATIONS = 600_000 # OWASP 2023+ guidance for PBKDF2-HMAC-SHA256
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
# Login rate-limit tuning
|
||||
LOGIN_WINDOW_SECONDS = 900 # 15 min sliding window
|
||||
LOGIN_MAX_FAILS_USER = 5 # per username
|
||||
LOGIN_MAX_FAILS_IP = 20 # per IP (higher because NATs)
|
||||
|
||||
def hash_password(password: str, iterations: int = PBKDF2_ITERATIONS) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
hashed = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 100000).hex()
|
||||
return f"{salt}${hashed}"
|
||||
hashed = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), iterations).hex()
|
||||
return f"pbkdf2${iterations}${salt}${hashed}"
|
||||
|
||||
def verify_password(stored_password: str, provided_password: str) -> bool:
|
||||
"""Supports both the legacy 'salt$hash' (100k iter) format and the new
|
||||
'pbkdf2$<iter>$salt$hash' format."""
|
||||
try:
|
||||
salt, stored_hash = stored_password.split('$')
|
||||
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), 100000).hex()
|
||||
parts = stored_password.split('$')
|
||||
if len(parts) == 4 and parts[0] == 'pbkdf2':
|
||||
iterations = int(parts[1])
|
||||
salt = parts[2]
|
||||
stored_hash = parts[3]
|
||||
elif len(parts) == 2:
|
||||
iterations = 100_000 # legacy
|
||||
salt, stored_hash = parts
|
||||
else:
|
||||
return False
|
||||
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), iterations).hex()
|
||||
return hmac.compare_digest(hashed, stored_hash)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(stored_password: str) -> bool:
|
||||
"""Return True if the stored hash uses weaker params than current defaults."""
|
||||
try:
|
||||
parts = stored_password.split('$')
|
||||
if len(parts) == 4 and parts[0] == 'pbkdf2':
|
||||
return int(parts[1]) < PBKDF2_ITERATIONS
|
||||
return True # legacy format → rehash
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def get_user_by_username(username: str):
|
||||
from database.connection import get_db
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute("SELECT * FROM users WHERE username = ?", (username,))
|
||||
return cursor.fetchone()
|
||||
|
||||
def create_user(username: str, password: str):
|
||||
def create_user(username: str, password: str, role: str = "user"):
|
||||
from database.connection import get_db
|
||||
if role not in {"admin", "user"}:
|
||||
role = "user"
|
||||
with get_db() as conn:
|
||||
hashed = hash_password(password)
|
||||
conn.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (username, hashed))
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
|
||||
(username, hashed, role),
|
||||
)
|
||||
|
||||
def delete_user(user_id: int):
|
||||
from database.connection import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||
|
||||
|
||||
def update_user_password(user_id: int, new_password: str) -> None:
|
||||
from database.connection import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=? WHERE id=?",
|
||||
(hash_password(new_password), user_id),
|
||||
)
|
||||
# Rotate any active sessions for this user.
|
||||
conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
|
||||
|
||||
|
||||
def set_user_role(user_id: int, role: str) -> None:
|
||||
from database.connection import get_db
|
||||
if role not in {"admin", "user"}:
|
||||
raise ValueError("invalid role")
|
||||
with get_db() as conn:
|
||||
conn.execute("UPDATE users SET role=? WHERE id=?", (role, user_id))
|
||||
|
||||
|
||||
def count_admins() -> int:
|
||||
from database.connection import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE role='admin'").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
|
||||
def get_all_users():
|
||||
from database.connection import get_db
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute("SELECT id, username, created_at FROM users ORDER BY created_at DESC")
|
||||
cursor = conn.execute(
|
||||
"SELECT id, username, role, created_at FROM users ORDER BY created_at DESC"
|
||||
)
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
# ─── Login rate-limiting ──────────────────────────────────────────
|
||||
|
||||
def record_login_attempt(username: str, ip: str, success: bool) -> None:
|
||||
from database.connection import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO login_attempts (username, ip, success) VALUES (?, ?, ?)",
|
||||
(username, ip, 1 if success else 0),
|
||||
)
|
||||
|
||||
|
||||
def is_login_blocked(username: str, ip: str) -> bool:
|
||||
"""Return True if too many recent failures for this username OR this IP."""
|
||||
from database.connection import get_db
|
||||
cutoff = (datetime.utcnow() - timedelta(seconds=LOGIN_WINDOW_SECONDS)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_db() as conn:
|
||||
by_user = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM login_attempts WHERE username=? AND success=0 AND created_at > ?",
|
||||
(username, cutoff),
|
||||
).fetchone()["n"]
|
||||
if by_user >= LOGIN_MAX_FAILS_USER:
|
||||
return True
|
||||
by_ip = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM login_attempts WHERE ip=? AND success=0 AND created_at > ?",
|
||||
(ip, cutoff),
|
||||
).fetchone()["n"]
|
||||
return by_ip >= LOGIN_MAX_FAILS_IP
|
||||
|
||||
|
||||
def cleanup_old_login_attempts() -> int:
|
||||
from database.connection import get_db
|
||||
cutoff = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_db() as conn:
|
||||
cur = conn.execute("DELETE FROM login_attempts WHERE created_at < ?", (cutoff,))
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
# ─── Sessions (DB-backed) ─────────────────────────────────────────
|
||||
|
||||
def create_session(user_id: int, username: str, ttl_seconds: int = SESSION_TTL_SECONDS) -> str:
|
||||
def create_session(user_id: int, username: str, role: str = "user", ttl_seconds: int = SESSION_TTL_SECONDS) -> str:
|
||||
from database.connection import get_db
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
expires_at = (datetime.utcnow() + timedelta(seconds=ttl_seconds)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -57,17 +158,24 @@ def create_session(user_id: int, username: str, ttl_seconds: int = SESSION_TTL_S
|
||||
|
||||
|
||||
def get_session(session_id: str):
|
||||
"""Return dict with user_id/username if session is valid and unexpired, else None."""
|
||||
"""Return dict with user_id/username/role if valid and unexpired, else None."""
|
||||
if not session_id:
|
||||
return None
|
||||
from database.connection import get_db
|
||||
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, user_id, username, expires_at FROM sessions WHERE id = ? AND expires_at > ?",
|
||||
"""SELECT s.id, s.user_id, s.username, s.expires_at, u.role
|
||||
FROM sessions s
|
||||
LEFT JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id = ? AND s.expires_at > ?""",
|
||||
(session_id, now),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["role"] = d.get("role") or "user"
|
||||
return d
|
||||
|
||||
|
||||
def delete_session(session_id: str) -> None:
|
||||
|
||||
Reference in New Issue
Block a user