security: harden data auth and deployment

This commit is contained in:
2026-09-17 18:31:35 +12:00
parent a6d1c73837
commit 5639dbcb83
32 changed files with 1401 additions and 378 deletions
+476 -70
View File
@@ -1,15 +1,18 @@
import json
import hmac
import os
import sqlite3
import logging
from contextlib import suppress
from hashlib import sha256
from datetime import datetime, timezone, timedelta
from time import perf_counter
from time import perf_counter, time as unix_time
from typing import Any, Dict, Optional
from .config import settings
from .models import Snapshot
from .security import hash_password, verify_password
from .security import hash_password, verify_and_update_password, verify_password
from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting
logger = logging.getLogger(__name__)
@@ -30,7 +33,10 @@ def _db_path() -> str:
if not os.path.isabs(path):
app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
path = os.path.join(app_root, path)
os.makedirs(os.path.dirname(path), exist_ok=True)
directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
with suppress(OSError):
os.chmod(directory, 0o700)
return path
@@ -53,12 +59,23 @@ def _apply_connection_pragmas(conn: sqlite3.Connection) -> None:
logger.debug("sqlite pragma skipped: %s=%s", pragma, value, exc_info=True)
class _ClosingConnection(sqlite3.Connection):
def __exit__(self, exc_type, exc_value, traceback) -> bool:
try:
return super().__exit__(exc_type, exc_value, traceback)
finally:
self.close()
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(
_db_path(),
timeout=SQLITE_BUSY_TIMEOUT_MS / 1000,
cached_statements=512,
factory=_ClosingConnection,
)
with suppress(OSError):
os.chmod(_db_path(), 0o600)
_apply_connection_pragmas(conn)
return conn
@@ -185,6 +202,63 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
_INVITE_HASH_PREFIX = "sha256:"
def _normalize_invite_secret(value: str) -> str:
return "".join(character for character in str(value or "").strip().upper() if character.isalnum())
def _hash_signup_invite_code(value: str) -> str:
normalized = _normalize_invite_secret(value)
return _INVITE_HASH_PREFIX + sha256(normalized.encode("utf-8")).hexdigest()
def _invite_code_hint(value: str) -> str:
normalized = _normalize_invite_secret(value)
return normalized[-4:] if normalized else ""
def _masked_invite_code(hint: Optional[str]) -> str:
return f"••••{str(hint or '').upper()}" if hint else "Protected invite"
def _protect_legacy_signup_invite_codes(conn: sqlite3.Connection) -> None:
rows = conn.execute(
"SELECT id, code, code_hint FROM signup_invites ORDER BY id"
).fetchall()
for invite_id, stored_code, stored_hint in rows:
if not isinstance(stored_code, str) or stored_code.startswith(_INVITE_HASH_PREFIX):
continue
code_hash = _hash_signup_invite_code(stored_code)
duplicate = conn.execute(
"SELECT id FROM signup_invites WHERE code = ? AND id != ?",
(code_hash, invite_id),
).fetchone()
if duplicate:
code_hash = _INVITE_HASH_PREFIX + sha256(
f"duplicate:{invite_id}:{stored_code}".encode("utf-8")
).hexdigest()
conn.execute(
"UPDATE users SET invited_by_code = ? WHERE invited_by_code = ? COLLATE NOCASE",
(f"invite:{invite_id}", stored_code),
)
conn.execute(
"UPDATE signup_invites SET code = ?, code_hint = ? WHERE id = ?",
(code_hash, stored_hint or _invite_code_hint(stored_code), invite_id),
)
def _encrypt_legacy_sensitive_settings(conn: sqlite3.Connection) -> None:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
for key, value in rows:
if value is None or not is_sensitive_setting(str(key)):
continue
encrypted = encrypt_setting_value(str(key), str(value))
if encrypted != value:
conn.execute("UPDATE settings SET value = ? WHERE key = ?", (encrypted, key))
def init_db() -> None:
with _connect() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS request_stage_cache (request_id INTEGER PRIMARY KEY, source_updated TEXT, ready INTEGER NOT NULL, checked_at REAL NOT NULL)")
@@ -278,7 +352,8 @@ def init_db() -> None:
invited_by_code TEXT,
invited_at TEXT,
jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT
last_jellyfin_auth_at TEXT,
auth_version INTEGER NOT NULL DEFAULT 1
)
"""
)
@@ -297,6 +372,18 @@ def init_db() -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS auth_rate_limits (
scope TEXT NOT NULL,
key_hash TEXT NOT NULL,
occurred_at REAL NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_auth_rate_limits_lookup ON auth_rate_limits (scope, key_hash, occurred_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS signup_invites (
@@ -651,10 +738,20 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN code_hint TEXT")
except sqlite3.OperationalError:
pass
_protect_legacy_signup_invite_codes(conn)
_encrypt_legacy_sensitive_settings(conn)
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
except sqlite3.OperationalError:
@@ -1142,7 +1239,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY id
@@ -1171,6 +1268,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
}
@@ -1181,7 +1279,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users
WHERE jellyseerr_user_id = ?
ORDER BY id ASC
@@ -1211,6 +1309,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
"is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
}
@@ -1221,7 +1320,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users
WHERE id = ?
""",
@@ -1249,6 +1348,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
}
def get_all_users() -> list[Dict[str, Any]]:
@@ -1257,7 +1357,7 @@ def get_all_users() -> list[Dict[str, Any]]:
"""
SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at,
last_login_at, is_blocked, auto_search_enabled, invite_management_enabled,
profile_id, expires_at, invited_by_code, invited_at
profile_id, expires_at, invited_by_code, invited_at, auth_version
FROM users
ORDER BY username COLLATE NOCASE
"""
@@ -1281,6 +1381,7 @@ def get_all_users() -> list[Dict[str, Any]]:
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"auth_version": int(row[15] or 1),
"is_expired": _is_datetime_in_past(row[12]),
}
)
@@ -1375,24 +1476,181 @@ def set_user_blocked(username: str, blocked: bool) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET is_blocked = ? WHERE username = ?
UPDATE users SET is_blocked = ?, auth_version = auth_version + 1 WHERE username = ?
""",
(1 if blocked else 0, username),
)
logger.info("user blocked state updated username=%s blocked=%s", username, blocked)
def delete_user_by_username(username: str) -> bool:
def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table_name,),
).fetchone()
return bool(row)
def _redact_user_json(value: Any, identifiers: set[str]) -> Any:
if isinstance(value, dict):
return {key: _redact_user_json(item, identifiers) for key, item in value.items()}
if isinstance(value, list):
return [_redact_user_json(item, identifiers) for item in value]
if isinstance(value, str) and value.strip().casefold() in identifiers:
return "Deleted user"
return value
def delete_user_data_by_username(username: str) -> Dict[str, int | bool]:
with _connect() as conn:
cursor = conn.execute(
"""
DELETE FROM users WHERE username = ? COLLATE NOCASE
""",
user = conn.execute(
"SELECT id, username, email FROM users WHERE username = ? COLLATE NOCASE",
(username,),
).fetchone()
if not user:
return {"deleted": False}
user_id, canonical_username, email = int(user[0]), str(user[1]), user[2]
pseudonym = f"deleted-user-{user_id}"
identifiers = {canonical_username.casefold()}
if isinstance(email, str) and email.strip():
identifiers.add(email.strip().casefold())
counts: Dict[str, int | bool] = {"deleted": False}
request_rows = conn.execute(
"""
SELECT request_id, payload_json FROM requests_cache
WHERE requested_by_id = ? OR requested_by_norm = ? OR requested_by = ? COLLATE NOCASE
""",
(user_id, canonical_username.casefold(), canonical_username),
).fetchall()
for request_id, payload_json in request_rows:
try:
payload = _redact_user_json(json.loads(payload_json), identifiers)
sanitized_payload = json.dumps(payload, separators=(",", ":"))
except (TypeError, json.JSONDecodeError):
sanitized_payload = "{}"
conn.execute(
"""
UPDATE requests_cache
SET requested_by = 'Deleted user', requested_by_norm = NULL,
requested_by_id = NULL, payload_json = ?
WHERE request_id = ?
""",
(sanitized_payload, request_id),
)
snapshot_rows = conn.execute(
"SELECT id, payload_json FROM snapshots WHERE request_id = ?",
(str(request_id),),
).fetchall()
for snapshot_id, snapshot_json in snapshot_rows:
try:
snapshot_payload = _redact_user_json(
json.loads(snapshot_json), identifiers
)
sanitized_snapshot = json.dumps(
snapshot_payload, separators=(",", ":")
)
except (TypeError, json.JSONDecodeError):
sanitized_snapshot = "{}"
conn.execute(
"UPDATE snapshots SET payload_json = ? WHERE id = ?",
(sanitized_snapshot, snapshot_id),
)
conn.execute(
"UPDATE actions SET message = REPLACE(message, ?, 'Deleted user') WHERE request_id = ? AND message IS NOT NULL",
(canonical_username, str(request_id)),
)
if email:
conn.execute(
"UPDATE actions SET message = REPLACE(message, ?, '[deleted email]') WHERE request_id = ? AND message IS NOT NULL",
(email, str(request_id)),
)
counts["requests_anonymized"] = len(request_rows)
direct_operations = (
("DELETE FROM user_activity WHERE username = ? COLLATE NOCASE", (canonical_username,), "activity_deleted"),
("DELETE FROM password_reset_tokens WHERE username = ? COLLATE NOCASE", (canonical_username,), "reset_tokens_deleted"),
("DELETE FROM user_feature_permissions WHERE user_id = ?", (user_id,), "feature_rows_deleted"),
("DELETE FROM jellyfin_user_links WHERE local_user_id = ?", (user_id,), "identity_links_deleted"),
("DELETE FROM user_identity_confirmations WHERE local_user_id = ?", (user_id,), "identity_confirmations_deleted"),
("DELETE FROM user_identity_repairs WHERE local_user_id = ?", (user_id,), "identity_repairs_deleted"),
("DELETE FROM user_duplicate_repairs WHERE kept_user_id = ?", (user_id,), "duplicate_repairs_deleted"),
)
deleted = cursor.rowcount > 0
logger.warning("user delete username=%s deleted=%s", username, deleted)
return deleted
for sql, params, label in direct_operations:
counts[label] = int(conn.execute(sql, params).rowcount or 0)
conn.execute(
"UPDATE signup_invites SET enabled = 0, created_by = ? WHERE created_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
if email:
conn.execute(
"UPDATE signup_invites SET recipient_email = NULL WHERE recipient_email = ? COLLATE NOCASE",
(email,),
)
conn.execute(
"UPDATE portal_items SET created_by_username = ?, created_by_id = NULL WHERE created_by_id = ? OR created_by_username = ? COLLATE NOCASE",
(pseudonym, user_id, canonical_username),
)
conn.execute(
"UPDATE portal_items SET assignee_username = NULL WHERE assignee_username = ? COLLATE NOCASE",
(canonical_username,),
)
conn.execute(
"UPDATE portal_comments SET author_username = ? WHERE author_username = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE portal_item_activity SET actor_username = ? WHERE actor_username = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE user_identity_confirmations SET confirmed_by = ? WHERE confirmed_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE user_identity_repairs SET repaired_by = ? WHERE repaired_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
duplicate_rows = conn.execute(
"SELECT id, archive_json FROM user_duplicate_repairs"
).fetchall()
for repair_id, archive_json in duplicate_rows:
try:
archive_payload = _redact_user_json(
json.loads(archive_json), identifiers
)
except (TypeError, json.JSONDecodeError):
continue
conn.execute(
"UPDATE user_duplicate_repairs SET archive_json = ?, repaired_by = CASE WHEN repaired_by = ? COLLATE NOCASE THEN ? ELSE repaired_by END WHERE id = ?",
(
json.dumps(archive_payload, separators=(",", ":")),
canonical_username,
pseudonym,
repair_id,
),
)
for table in ("email_recap_subscriptions", "email_recap_deliveries", "newsletter_subscriptions", "newsletter_deliveries"):
if _table_exists(conn, table):
counts[f"{table}_deleted"] = int(
conn.execute(f"DELETE FROM {table} WHERE user_id = ?", (user_id,)).rowcount or 0
)
if _table_exists(conn, "newsletter_editions"):
conn.execute(
"UPDATE newsletter_editions SET created_by = ? WHERE created_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
deleted = conn.execute("DELETE FROM users WHERE id = ?", (user_id,)).rowcount > 0
counts["deleted"] = deleted
logger.warning("user data deleted user_id=%s deleted=%s", user_id, deleted)
return counts
def delete_user_by_username(username: str) -> bool:
return bool(delete_user_data_by_username(username).get("deleted"))
def delete_user_activity_by_username(username: str) -> int:
@@ -1424,7 +1682,7 @@ def set_user_role(username: str, role: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE
UPDATE users SET role = ?, auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE
""",
(role, username),
)
@@ -1635,29 +1893,31 @@ def delete_user_profile(profile_id: int) -> bool:
def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
max_uses = 1 if row[10] else row[6]
use_count = int(row[7] or 0)
expires_at = row[9]
max_uses = 1 if row[11] else row[7]
use_count = int(row[8] or 0)
expires_at = row[10]
is_expired = _is_datetime_in_past(expires_at)
remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
return {
"id": row[0],
"code": row[1],
"label": row[2],
"description": row[3],
"profile_id": row[4],
"role": row[5],
"code": _masked_invite_code(row[2]),
"code_hint": row[2],
"code_available": False,
"label": row[3],
"description": row[4],
"profile_id": row[5],
"role": row[6],
"max_uses": max_uses,
"use_count": use_count,
"enabled": bool(row[8]),
"enabled": bool(row[9]),
"expires_at": expires_at,
"recipient_email": row[10],
"created_by": row[11],
"created_at": row[12],
"updated_at": row[13],
"recipient_email": row[11],
"created_by": row[12],
"created_at": row[13],
"updated_at": row[14],
"is_expired": is_expired,
"remaining_uses": remaining_uses,
"is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
"is_usable": bool(row[9]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
}
@@ -1665,7 +1925,7 @@ def list_signup_invites() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites
ORDER BY created_at DESC, id DESC
@@ -1678,7 +1938,7 @@ def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites
WHERE id = ?
@@ -1694,16 +1954,19 @@ def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites
WHERE code = ? COLLATE NOCASE
WHERE code = ?
""",
(code,),
(_hash_signup_invite_code(code),),
).fetchone()
if not row:
return None
return _row_to_signup_invite(row)
invite = _row_to_signup_invite(row)
invite["code"] = _normalize_invite_secret(code)
invite["code_available"] = True
return invite
def create_signup_invite(
@@ -1719,6 +1982,9 @@ def create_signup_invite(
recipient_email: Optional[str] = None,
created_by: Optional[str] = None,
) -> Dict[str, Any]:
normalized_code = _normalize_invite_secret(code)
if not normalized_code:
raise ValueError("Invite code is required")
if recipient_email:
max_uses = 1
timestamp = datetime.now(timezone.utc).isoformat()
@@ -1726,13 +1992,14 @@ def create_signup_invite(
cursor = conn.execute(
"""
INSERT INTO signup_invites (
code, label, description, profile_id, role, max_uses, use_count, enabled,
code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
""",
(
code,
_hash_signup_invite_code(normalized_code),
_invite_code_hint(normalized_code),
label,
description,
profile_id,
@@ -1748,20 +2015,21 @@ def create_signup_invite(
)
invite_id = int(cursor.lastrowid)
logger.info(
"signup invite created invite_id=%s code=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s recipient_email=%s created_by=%s",
"signup invite created invite_id=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s has_recipient=%s created_by=%s",
invite_id,
code,
role,
profile_id,
max_uses,
enabled,
expires_at,
recipient_email,
bool(recipient_email),
created_by,
)
invite = get_signup_invite_by_id(invite_id)
if not invite:
raise RuntimeError("Invite creation failed")
invite["code"] = normalized_code
invite["code_available"] = True
return invite
@@ -1784,31 +2052,68 @@ def update_signup_invite(
if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'):
raise ValueError('A used email invitation cannot be reassigned.')
timestamp = datetime.now(timezone.utc).isoformat()
requested_code = str(code or "").strip()
rotate_code = bool(requested_code) and not requested_code.startswith("••••") and requested_code != "Protected invite"
with _connect() as conn:
if rotate_code:
normalized_code = _normalize_invite_secret(requested_code)
cursor = conn.execute(
"""
UPDATE signup_invites
SET code = ?, code_hint = ?, label = ?, description = ?, profile_id = ?, role = ?,
max_uses = ?, enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
WHERE id = ?
""",
(
_hash_signup_invite_code(normalized_code), _invite_code_hint(normalized_code),
label, description, profile_id, role, max_uses, 1 if enabled else 0,
expires_at, recipient_email, timestamp, invite_id,
),
)
else:
cursor = conn.execute(
"""
UPDATE signup_invites
SET label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
WHERE id = ?
""",
(
label, description, profile_id, role, max_uses, 1 if enabled else 0,
expires_at, recipient_email, timestamp, invite_id,
),
)
if cursor.rowcount <= 0:
return None
return get_signup_invite_by_id(invite_id)
def rotate_signup_invite_code(invite_id: int, code: str) -> Optional[Dict[str, Any]]:
normalized_code = _normalize_invite_secret(code)
if not normalized_code:
raise ValueError("Invite code is required")
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE signup_invites
SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
WHERE id = ?
SET code = ?, code_hint = ?, updated_at = ?
WHERE id = ? AND enabled = 1
""",
(
code,
label,
description,
profile_id,
role,
max_uses,
1 if enabled else 0,
expires_at,
recipient_email,
_hash_signup_invite_code(normalized_code),
_invite_code_hint(normalized_code),
timestamp,
invite_id,
),
)
if cursor.rowcount <= 0:
return None
return get_signup_invite_by_id(invite_id)
invite = get_signup_invite_by_id(invite_id)
if invite:
invite["code"] = normalized_code
invite["code_available"] = True
return invite
def delete_signup_invite(invite_id: int) -> bool:
@@ -1859,7 +2164,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
@@ -1874,8 +2179,15 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
provider = str(row[4] or "local").lower()
if provider != "local":
continue
if not verify_password(password, row[2]):
verified, updated_hash = verify_and_update_password(password, row[2])
if not verified:
continue
if updated_hash:
with _connect() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(updated_hash, row[0]),
)
return {
"id": row[0],
"username": row[1],
@@ -1895,6 +2207,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
"is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16],
"auth_version": int(row[17] or 1),
}
return None
@@ -1906,7 +2219,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
@@ -1938,6 +2251,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
}
)
return results
@@ -1956,7 +2270,7 @@ def set_user_email(username: str, email: Optional[str]) -> bool:
)
updated = cursor.rowcount > 0
if updated:
logger.info("user email updated username=%s email=%s", username, normalized_email)
logger.info("user email updated username=%s email_set=%s", username, bool(normalized_email))
else:
logger.debug("user email update skipped username=%s", username)
return updated
@@ -1967,12 +2281,74 @@ def set_user_password(username: str, password: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE
UPDATE users
SET password_hash = ?, auth_version = auth_version + 1
WHERE username = ? COLLATE NOCASE
""",
(password_hash, username),
)
def increment_user_auth_version(username: str) -> int:
with _connect() as conn:
conn.execute(
"UPDATE users SET auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE",
(username,),
)
row = conn.execute(
"SELECT auth_version FROM users WHERE username = ? COLLATE NOCASE",
(username,),
).fetchone()
return int(row[0] or 1) if row else 0
def _rate_limit_key_hash(key: str) -> str:
key_material = str(
settings.jwt_secret or settings.settings_encryption_key or "magent-rate-limit"
).encode("utf-8")
return hmac.new(
key_material, str(key or "").encode("utf-8"), sha256
).hexdigest()
def get_rate_limit_status(
scope: str, key: str, window_seconds: int, maximum: int
) -> tuple[bool, int]:
now = unix_time()
cutoff = now - max(1, int(window_seconds))
key_hash = _rate_limit_key_hash(key)
with _connect() as conn:
conn.execute("DELETE FROM auth_rate_limits WHERE occurred_at < ?", (cutoff,))
row = conn.execute(
"""
SELECT COUNT(*), MIN(occurred_at)
FROM auth_rate_limits
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?
""",
(scope, key_hash, cutoff),
).fetchone()
count = int((row or [0])[0] or 0)
oldest = float(row[1]) if row and row[1] is not None else now
retry_after = max(1, int(window_seconds - (now - oldest)))
return count >= max(1, int(maximum)), retry_after
def record_rate_limit_event(scope: str, key: str) -> None:
with _connect() as conn:
conn.execute(
"INSERT INTO auth_rate_limits (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
(scope, _rate_limit_key_hash(key), unix_time()),
)
def clear_rate_limit_events(scope: str, key: str) -> None:
with _connect() as conn:
conn.execute(
"DELETE FROM auth_rate_limits WHERE scope = ? AND key_hash = ?",
(scope, _rate_limit_key_hash(key)),
)
def sync_jellyfin_password_state(username: str, password: str) -> None:
if not username or not password:
return
@@ -2943,11 +3319,12 @@ def get_setting(key: str) -> Optional[str]:
).fetchone()
if not row:
return None
return row[0]
return decrypt_setting_value(key, row[0])
def set_setting(key: str, value: Optional[str]) -> None:
updated_at = datetime.now(timezone.utc).isoformat()
stored_value = encrypt_setting_value(key, value)
with _connect() as conn:
conn.execute(
"""
@@ -2955,7 +3332,7 @@ def set_setting(key: str, value: Optional[str]) -> None:
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
""",
(key, value, updated_at),
(key, stored_value, updated_at),
)
@@ -2981,7 +3358,7 @@ def get_settings_overrides() -> Dict[str, str]:
key = row[0]
value = row[1]
if key:
overrides[key] = value
overrides[key] = decrypt_setting_value(key, value)
return overrides
@@ -3067,12 +3444,10 @@ def create_password_reset_token(
),
)
logger.info(
"password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s",
"password reset token created username=%s provider=%s expires_at=%s",
username,
auth_provider,
recipient_email,
expires_at,
requested_by_ip,
)
return {
"username": username,
@@ -3114,7 +3489,7 @@ def mark_password_reset_token_used(token_value: str) -> None:
""",
(used_at, token_hash),
)
logger.info("password reset token marked used token_hash=%s", token_hash[:12])
logger.info("password reset token marked used")
def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
@@ -4020,6 +4395,7 @@ def cleanup_history(days: int) -> Dict[str, int]:
if days <= 0:
return {"actions": 0, "snapshots": 0}
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
cutoff_epoch = (datetime.now(timezone.utc) - timedelta(days=days)).timestamp()
with _connect() as conn:
actions = conn.execute(
"DELETE FROM actions WHERE created_at < ?",
@@ -4029,7 +4405,37 @@ def cleanup_history(days: int) -> Dict[str, int]:
"DELETE FROM snapshots WHERE created_at < ?",
(cutoff,),
).rowcount
return {"actions": actions, "snapshots": snapshots}
reset_tokens = conn.execute(
"DELETE FROM password_reset_tokens WHERE expires_at < ? OR (used_at IS NOT NULL AND used_at < ?)",
(cutoff, cutoff),
).rowcount
invites = conn.execute(
"""
DELETE FROM signup_invites
WHERE updated_at < ?
AND (enabled = 0 OR expires_at < ? OR (max_uses IS NOT NULL AND use_count >= max_uses))
AND id != COALESCE((SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'self_service_invite_master_id'), -1)
""",
(cutoff, cutoff),
).rowcount
rate_limits = conn.execute(
"DELETE FROM auth_rate_limits WHERE occurred_at < ?",
(unix_time() - 86400,),
).rowcount
email_deliveries = 0
for table in ("email_recap_deliveries", "newsletter_deliveries"):
if _table_exists(conn, table):
email_deliveries += int(
conn.execute(f"DELETE FROM {table} WHERE created_at < ?", (cutoff_epoch,)).rowcount or 0
)
return {
"actions": int(actions or 0),
"snapshots": int(snapshots or 0),
"password_reset_tokens": int(reset_tokens or 0),
"invites": int(invites or 0),
"rate_limits": int(rate_limits or 0),
"email_deliveries": email_deliveries,
}
def get_request_stage_cache():