Harden auth flows and add backend quality gate
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user