Hotfix: add logged-out password reset flow
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user