Hotfix: add logged-out password reset flow

This commit is contained in:
2026-03-02 20:44:58 +13:00
parent 9c69d9fd17
commit 5f2dc52771
11 changed files with 943 additions and 7 deletions

View File

@@ -2,6 +2,7 @@ import json
import os
import sqlite3
import logging
from hashlib import sha256
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, Optional
@@ -292,6 +293,22 @@ def init_db() -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
recipient_email TEXT NOT NULL,
auth_provider TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
requested_by_ip TEXT,
requested_user_agent TEXT
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -316,6 +333,18 @@ def init_db() -> None:
ON seerr_media_failures (suppress_until)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_username
ON password_reset_tokens (username)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
ON password_reset_tokens (expires_at)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_activity (
@@ -679,6 +708,45 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
}
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,
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
FROM users
WHERE jellyseerr_user_id = ?
ORDER BY id ASC
LIMIT 1
""",
(jellyseerr_user_id,),
).fetchone()
if not row:
return None
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],
}
def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
@@ -2271,6 +2339,138 @@ def get_settings_overrides() -> Dict[str, str]:
return overrides
def _hash_password_reset_token(token_value: str) -> str:
return sha256(str(token_value).encode("utf-8")).hexdigest()
def _password_reset_token_row_to_dict(row: Any) -> Dict[str, Any]:
return {
"id": row[0],
"token_hash": row[1],
"username": row[2],
"recipient_email": row[3],
"auth_provider": row[4],
"created_at": row[5],
"expires_at": row[6],
"used_at": row[7],
"requested_by_ip": row[8],
"requested_user_agent": row[9],
"is_expired": _is_datetime_in_past(row[6]),
"is_used": bool(row[7]),
}
def delete_expired_password_reset_tokens() -> int:
now_iso = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
DELETE FROM password_reset_tokens
WHERE expires_at <= ? OR used_at IS NOT NULL
""",
(now_iso,),
)
return int(cursor.rowcount or 0)
def create_password_reset_token(
token_value: str,
username: str,
recipient_email: str,
auth_provider: str,
expires_at: str,
*,
requested_by_ip: Optional[str] = None,
requested_user_agent: Optional[str] = None,
) -> Dict[str, Any]:
created_at = datetime.now(timezone.utc).isoformat()
token_hash = _hash_password_reset_token(token_value)
delete_expired_password_reset_tokens()
with _connect() as conn:
conn.execute(
"""
DELETE FROM password_reset_tokens
WHERE username = ? AND used_at IS NULL
""",
(username,),
)
conn.execute(
"""
INSERT INTO password_reset_tokens (
token_hash,
username,
recipient_email,
auth_provider,
created_at,
expires_at,
used_at,
requested_by_ip,
requested_user_agent
)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)
""",
(
token_hash,
username,
recipient_email,
auth_provider,
created_at,
expires_at,
requested_by_ip,
requested_user_agent,
),
)
logger.info(
"password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s",
username,
auth_provider,
recipient_email,
expires_at,
requested_by_ip,
)
return {
"username": username,
"recipient_email": recipient_email,
"auth_provider": auth_provider,
"created_at": created_at,
"expires_at": expires_at,
"requested_by_ip": requested_by_ip,
"requested_user_agent": requested_user_agent,
}
def get_password_reset_token(token_value: str) -> Optional[Dict[str, Any]]:
token_hash = _hash_password_reset_token(token_value)
with _connect() as conn:
row = conn.execute(
"""
SELECT id, token_hash, username, recipient_email, auth_provider, created_at,
expires_at, used_at, requested_by_ip, requested_user_agent
FROM password_reset_tokens
WHERE token_hash = ?
""",
(token_hash,),
).fetchone()
if not row:
return None
return _password_reset_token_row_to_dict(row)
def mark_password_reset_token_used(token_value: str) -> None:
token_hash = _hash_password_reset_token(token_value)
used_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE password_reset_tokens
SET used_at = ?
WHERE token_hash = ? AND used_at IS NULL
""",
(used_at, token_hash),
)
logger.info("password reset token marked used token_hash=%s", token_hash[:12])
def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
if not media_type or not tmdb_id:
return None