Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree, French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration, découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
140 lines
4.5 KiB
Python
140 lines
4.5 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
|
|
|
|
|
|
# ---------------------------------------------------------------- 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 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
|
|
)
|
|
|
|
|
|
async def get_user(user_id: int) -> User | None:
|
|
row = await db.fetchone(
|
|
"SELECT id, username, is_admin, is_active 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
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------- 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,))
|