security: harden data auth and deployment
This commit is contained in:
+476
-70
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user