Files
ohm_streaming/app/auth.py
T
Roman 28f1bbde31 Préférence de contenus par compte : animés / séries / les deux
- Colonne users.content_preference (anime|serie|both, défaut both) avec
  migration automatique des bases existantes.
- GET /auth/me expose la préférence ; PUT /auth/preferences la met à
  jour (422 si valeur invalide).
- /api/search : filtre les résultats par media_type ET ignore les
  sources hors périmètre (une source anime-only n'est pas interrogée
  en mode séries). Les films restent associés aux animés.
- /api/discover : nouveautés filtrées selon la même règle.
- Sélecteur compact dans la topbar (⛩/📺/✨), persisté serveur.
- 7 tests dédiés (roundtrip, rejet invalide, filtrage search/discover,
  skip de sources) — 114 au total, ruff clean.

Prête pour la première source de séries : il ne reste qu'un module
SourceScraper avec media_types=("serie",) à écrire.
2026-09-23 15:02:59 +00:00

162 lines
5.1 KiB
Python

"""Authentification : JWT court + refresh token longue durée, rôles admin/user."""
import hashlib
import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import jwt
from pwdlib import PasswordHash
from app.config import get_settings
from app.db import db
logger = logging.getLogger(__name__)
password_hash = PasswordHash.recommended()
ALGORITHM = "HS256"
@dataclass
class User:
id: int
username: str
is_admin: bool
is_active: bool
content_preference: str = "both" # anime | serie | both
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
# ---------------------------------------------------------------- mots de passe
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return password_hash.verify(password, hashed)
# ---------------------------------------------------------------- utilisateurs
async def create_user(username: str, password: str) -> User:
"""Crée un compte ; le tout premier utilisateur devient admin."""
row = await db.fetchone("SELECT COUNT(*) AS n FROM users")
is_first = row["n"] == 0
cursor = await db.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
(username.strip(), hash_password(password), int(is_first)),
)
user = User(id=cursor.lastrowid, username=username.strip(), is_admin=is_first, is_active=True)
logger.info("Utilisateur créé : %s (admin=%s)", user.username, user.is_admin)
return user
async def authenticate(username: str, password: str) -> User | None:
row = await db.fetchone(
"SELECT id, username, password_hash, is_admin, is_active, content_preference "
"FROM users WHERE username = ?",
(username.strip(),),
)
if row is None or not verify_password(password, row["password_hash"]):
logger.warning("Échec d'authentification pour %r", username)
return None
if not row["is_active"]:
logger.warning("Compte désactivé : %r", username)
return None
return User(
id=row["id"],
username=row["username"],
is_admin=bool(row["is_admin"]),
is_active=True,
content_preference=row["content_preference"],
)
async def get_user(user_id: int) -> User | None:
row = await db.fetchone(
"SELECT id, username, is_admin, is_active, content_preference FROM users WHERE id = ?",
(user_id,),
)
if row is None or not row["is_active"]:
return None
return User(
id=row["id"],
username=row["username"],
is_admin=bool(row["is_admin"]),
is_active=True,
content_preference=row["content_preference"],
)
async def set_content_preference(user_id: int, preference: str) -> None:
if preference not in VALID_CONTENT_PREFERENCES:
raise ValueError(f"Préférence invalide : {preference!r}")
await db.execute(
"UPDATE users SET content_preference = ? WHERE id = ?", (preference, user_id)
)
# ---------------------------------------------------------------- tokens
def create_access_token(user: User) -> str:
settings = get_settings()
payload = {
"sub": str(user.id),
"username": user.username,
"admin": user.is_admin,
"exp": datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict | None:
try:
return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM])
except jwt.PyJWTError as exc:
logger.debug("Token d'accès invalide : %s", exc)
return None
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def create_refresh_token(user_id: int) -> str:
"""Refresh token opaque ; seul son hash est stocké en DB (révocable)."""
token = secrets.token_urlsafe(48)
expires = datetime.now(UTC) + timedelta(days=get_settings().refresh_token_ttl_days)
await db.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
(user_id, _hash_token(token), expires.strftime("%Y-%m-%d %H:%M:%S")),
)
return token
async def use_refresh_token(token: str) -> User | None:
"""Valide un refresh token, le révoque (rotation) et retourne l'utilisateur."""
row = await db.fetchone(
"SELECT id, user_id FROM refresh_tokens "
"WHERE token_hash = ? AND revoked = 0 AND expires_at > datetime('now')",
(_hash_token(token),),
)
if row is None:
logger.warning("Refresh token invalide ou expiré")
return None
await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?", (row["id"],))
user = await get_user(row["user_id"])
if user is None:
logger.warning("Refresh token d'un compte supprimé/désactivé (user_id=%s)", row["user_id"])
return user
async def revoke_all_refresh_tokens(user_id: int) -> None:
await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", (user_id,))