Harden auth flows and add backend quality gate

This commit is contained in:
2026-03-04 12:57:42 +13:00
parent 1ad4823830
commit c6bc31f27e
24 changed files with 811 additions and 137 deletions

View File

@@ -32,8 +32,11 @@ def _db_path() -> str:
def _apply_connection_pragmas(conn: sqlite3.Connection) -> None:
journal_mode = str(getattr(settings, "sqlite_journal_mode", "DELETE") or "DELETE").strip().upper()
if journal_mode not in {"DELETE", "WAL", "TRUNCATE", "PERSIST", "MEMORY", "OFF"}:
journal_mode = "DELETE"
pragmas = (
("journal_mode", "WAL"),
("journal_mode", journal_mode),
("synchronous", "NORMAL"),
("temp_store", "MEMORY"),
("cache_size", -SQLITE_CACHE_SIZE_KIB),
@@ -165,6 +168,15 @@ def _extract_tmdb_from_payload(payload_json: Optional[str]) -> tuple[Optional[in
return tmdb_id, media_type
def _normalize_stored_email(value: Optional[Any]) -> Optional[str]:
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def init_db() -> None:
with _connect() as conn:
conn.execute(
@@ -197,6 +209,7 @@ def init_db() -> None:
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
auth_provider TEXT NOT NULL DEFAULT 'local',
@@ -422,6 +435,10 @@ def init_db() -> None:
ON user_activity (last_seen_at)
"""
)
try:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
except sqlite3.OperationalError:
@@ -501,6 +518,15 @@ def init_db() -> None:
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError:
@@ -625,6 +651,7 @@ def create_user(
username: str,
password: str,
role: str = "user",
email: Optional[str] = None,
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
auto_search_enabled: bool = True,
@@ -635,11 +662,13 @@ def create_user(
) -> None:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
normalized_email = _normalize_stored_email(email)
with _connect() as conn:
conn.execute(
"""
INSERT INTO users (
username,
email,
password_hash,
role,
auth_provider,
@@ -652,10 +681,11 @@ def create_user(
invited_by_code,
invited_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
username,
normalized_email,
password_hash,
role,
auth_provider,
@@ -675,6 +705,7 @@ def create_user_if_missing(
username: str,
password: str,
role: str = "user",
email: Optional[str] = None,
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
auto_search_enabled: bool = True,
@@ -685,11 +716,13 @@ def create_user_if_missing(
) -> bool:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
normalized_email = _normalize_stored_email(email)
with _connect() as conn:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO users (
username,
email,
password_hash,
role,
auth_provider,
@@ -702,10 +735,11 @@ def create_user_if_missing(
invited_by_code,
invited_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
username,
normalized_email,
password_hash,
role,
auth_provider,
@@ -739,7 +773,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
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
@@ -753,22 +787,23 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"invite_management_enabled": bool(row[10]),
"profile_id": row[11],
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16],
"email": row[2],
"password_hash": row[3],
"role": row[4],
"auth_provider": row[5],
"jellyseerr_user_id": row[6],
"created_at": row[7],
"last_login_at": row[8],
"is_blocked": bool(row[9]),
"auto_search_enabled": bool(row[10]),
"invite_management_enabled": bool(row[11]),
"profile_id": row[12],
"expires_at": row[13],
"invited_by_code": row[14],
"invited_at": row[15],
"is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17],
}
@@ -776,7 +811,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
with _connect() as conn:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
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
@@ -792,22 +827,23 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"invite_management_enabled": bool(row[10]),
"profile_id": row[11],
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16],
"email": row[2],
"password_hash": row[3],
"role": row[4],
"auth_provider": row[5],
"jellyseerr_user_id": row[6],
"created_at": row[7],
"last_login_at": row[8],
"is_blocked": bool(row[9]),
"auto_search_enabled": bool(row[10]),
"invite_management_enabled": bool(row[11]),
"profile_id": row[12],
"expires_at": row[13],
"invited_by_code": row[14],
"invited_at": row[15],
"is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17],
}
@@ -815,7 +851,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
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
@@ -829,29 +865,30 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"invite_management_enabled": bool(row[10]),
"profile_id": row[11],
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16],
"email": row[2],
"password_hash": row[3],
"role": row[4],
"auth_provider": row[5],
"jellyseerr_user_id": row[6],
"created_at": row[7],
"last_login_at": row[8],
"is_blocked": bool(row[9]),
"auto_search_enabled": bool(row[10]),
"invite_management_enabled": bool(row[11]),
"profile_id": row[12],
"expires_at": row[13],
"invited_by_code": row[14],
"invited_at": row[15],
"is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17],
}
def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at,
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
FROM users
@@ -864,19 +901,20 @@ def get_all_users() -> list[Dict[str, Any]]:
{
"id": row[0],
"username": row[1],
"role": row[2],
"auth_provider": row[3],
"jellyseerr_user_id": row[4],
"created_at": row[5],
"last_login_at": row[6],
"is_blocked": bool(row[7]),
"auto_search_enabled": bool(row[8]),
"invite_management_enabled": bool(row[9]),
"profile_id": row[10],
"expires_at": row[11],
"invited_by_code": row[12],
"invited_at": row[13],
"is_expired": _is_datetime_in_past(row[11]),
"email": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"invite_management_enabled": bool(row[10]),
"profile_id": row[11],
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"is_expired": _is_datetime_in_past(row[12]),
}
)
# Admin user management uses Jellyfin as the source of truth for non-admin
@@ -945,7 +983,7 @@ def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) ->
with _connect() as conn:
conn.execute(
"""
UPDATE users SET jellyseerr_user_id = ? WHERE username = ?
UPDATE users SET jellyseerr_user_id = ? WHERE username = ? COLLATE NOCASE
""",
(jellyseerr_user_id, username),
)
@@ -956,7 +994,7 @@ def set_user_auth_provider(username: str, auth_provider: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET auth_provider = ? WHERE username = ?
UPDATE users SET auth_provider = ? WHERE username = ? COLLATE NOCASE
""",
(provider, username),
)
@@ -967,7 +1005,7 @@ def set_last_login(username: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET last_login_at = ? WHERE username = ?
UPDATE users SET last_login_at = ? WHERE username = ? COLLATE NOCASE
""",
(timestamp, username),
)
@@ -1026,7 +1064,7 @@ def set_user_role(username: str, role: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET role = ? WHERE username = ?
UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE
""",
(role, username),
)
@@ -1037,7 +1075,7 @@ def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET auto_search_enabled = ? WHERE username = ?
UPDATE users SET auto_search_enabled = ? WHERE username = ? COLLATE NOCASE
""",
(1 if enabled else 0, username),
)
@@ -1480,7 +1518,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
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
@@ -1498,33 +1536,53 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
{
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"invite_management_enabled": bool(row[10]),
"profile_id": row[11],
"expires_at": row[12],
"invited_by_code": row[13],
"invited_at": row[14],
"is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16],
"email": row[2],
"password_hash": row[3],
"role": row[4],
"auth_provider": row[5],
"jellyseerr_user_id": row[6],
"created_at": row[7],
"last_login_at": row[8],
"is_blocked": bool(row[9]),
"auto_search_enabled": bool(row[10]),
"invite_management_enabled": bool(row[11]),
"profile_id": row[12],
"expires_at": row[13],
"invited_by_code": row[14],
"invited_at": row[15],
"is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17],
}
)
return results
def set_user_email(username: str, email: Optional[str]) -> bool:
normalized_email = _normalize_stored_email(email)
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE users
SET email = ?
WHERE username = ? COLLATE NOCASE
""",
(normalized_email, username),
)
updated = cursor.rowcount > 0
if updated:
logger.info("user email updated username=%s email=%s", username, normalized_email)
else:
logger.debug("user email update skipped username=%s", username)
return updated
def set_user_password(username: str, password: str) -> None:
password_hash = hash_password(password)
with _connect() as conn:
conn.execute(
"""
UPDATE users SET password_hash = ? WHERE username = ?
UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE
""",
(password_hash, username),
)