Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML). Nouveaux éléments : - PronoteSettings : auth_mode, qr_code_file, qr_pin (SecretStr) - PronoteAuthState : persistance du token rotatif dans .pronote_auth_state.json (écriture atomique, permissions 0600, symlink-safe via O_EXCL|O_NOFOLLOW) - PronoteClient._connect_qr_token() : token_login avec creds persistés, qrcode_login pour l'enrôlement initial, export_credentials persisté après chaque login réussi - PronoteAuthRotationError : levée en cas d'échec de rotation du token, propagée sans wrapping à travers PronoteFetcher et fetch_step jusqu'à PipelineRunner.run() qui notifie via XMPP (si canal disponible et dry_run inactif) - _is_pronotepy_configured() mode-aware : qr_token ne requiert que PRONOTE_URL - _collect_auth_secrets() : redaction des secrets explicites (token, PIN, jeton QR) dans tous les logs du chemin d'authentification Documentation : - .env.example : PRONOTE_AUTH_MODE, PRONOTE_QR_CODE_FILE, PRONOTE_QR_PIN - AGENTS.md : contrat d'authentification QR code / token - Wiki GuidePronote : section enrôlement, exécutions suivantes, ré-enrôlement Tests (686 passés, couverture 94.87%) : - 5 tests config QR, 9 tests auth_state, 10 tests client QR, 3 tests propagation, 4 tests intégration rotation end-to-end, 4 tests fallback mode-aware - Tests de non-fuite : sentinelles distinctes pour token, PIN, jeton QR Co-authored-by: coder/litellm/coder <coder@agents.invalid>
196 lines
8.3 KiB
Python
196 lines
8.3 KiB
Python
"""Persistance des credentials d'authentification par token pronotepy.
|
|
|
|
Ce module fournit :class:`PronoteAuthState`, qui stocke et charge les credentials
|
|
d'authentification par QR code / token entre les exécutions du pipeline. Le token
|
|
pronotepy rotate à chaque session : le fichier d'état doit être mis à jour après
|
|
chaque login réussi via :meth:`PronoteAuthState.save`.
|
|
|
|
Le fichier d'état est créé avec des permissions ``0600`` car il contient un token
|
|
d'authentification vivant. Son contenu n'est jamais journalisé.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pronote_sync.errors import PronoteSyncError
|
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_STATE_VERSION = 1
|
|
|
|
|
|
class PronoteAuthState:
|
|
"""Persiste les credentials d'authentification par token pronotepy entre
|
|
les exécutions du pipeline.
|
|
|
|
Le fichier d'état contient un dict au format :
|
|
{"version": 1, "credentials": {"pronote_url": "...", "username": "...", "password": "<token>", "uuid": "..."}}
|
|
|
|
Les credentials sont le retour de pronotepy.Client.export_credentials(), utilisé tel quel
|
|
pour token_login(**credentials). Le token rotate à chaque session — le fichier doit être
|
|
mis à jour après chaque login réussi.
|
|
|
|
:param state_file: Chemin du fichier d'état JSON (``str`` ou
|
|
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut.
|
|
"""
|
|
|
|
def __init__(self, state_file: Path | str = ".pronote_auth_state.json") -> None:
|
|
"""Initialise le gestionnaire d'état d'authentification Pronote.
|
|
|
|
Le fichier d'état n'est pas créé à l'initialisation : il n'est écrit
|
|
qu'à la première sauvegarde réussie via :meth:`save`.
|
|
|
|
:param state_file: Chemin du fichier d'état JSON (``str`` ou
|
|
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut.
|
|
"""
|
|
self._state_file = Path(state_file)
|
|
|
|
def load(self) -> dict[str, str] | None:
|
|
"""Charge les credentials d'authentification depuis le fichier d'état.
|
|
|
|
Un fichier absent renvoie ``None`` (journalisé en debug). Un fichier
|
|
corrompu, une version absente ou non supportée, ou un champ
|
|
``credentials`` invalide renvoient ``None`` avec un avertissement.
|
|
Le contenu des credentials n'est jamais journalisé.
|
|
|
|
:return: Dict des credentials (``pronote_url``, ``username``,
|
|
``password``, ``uuid``) prêt pour
|
|
``pronotepy.Client.token_login(**credentials)``, ou ``None`` si
|
|
aucun état valide n'est disponible.
|
|
:rtype: dict[str, str] | None
|
|
"""
|
|
if not self._state_file.exists():
|
|
logger.debug(
|
|
"Fichier d'état d'authentification Pronote %s absent, aucun token à charger.",
|
|
redact_secrets(str(self._state_file)),
|
|
)
|
|
return None
|
|
try:
|
|
data: Any = json.loads(self._state_file.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Impossible de charger le fichier d'état d'authentification Pronote %s : %s, "
|
|
"aucun token chargé.",
|
|
redact_secrets(str(self._state_file)),
|
|
redact_exception(exc),
|
|
)
|
|
return None
|
|
if not isinstance(data, dict) or data.get("version") != _STATE_VERSION:
|
|
logger.warning(
|
|
"Fichier d'état d'authentification Pronote %s : version absente ou non supportée, "
|
|
"aucun token chargé.",
|
|
redact_secrets(str(self._state_file)),
|
|
)
|
|
return None
|
|
credentials_data = data.get("credentials")
|
|
if not isinstance(credentials_data, dict):
|
|
logger.warning(
|
|
"Fichier d'état d'authentification Pronote %s : champ credentials absent ou invalide, "
|
|
"aucun token chargé.",
|
|
redact_secrets(str(self._state_file)),
|
|
)
|
|
return None
|
|
credentials: dict[str, str] = {}
|
|
for key, value in credentials_data.items():
|
|
if not isinstance(key, str) or not isinstance(value, str):
|
|
logger.warning(
|
|
"Fichier d'état d'authentification Pronote %s : champ credentials invalide, "
|
|
"aucun token chargé.",
|
|
redact_secrets(str(self._state_file)),
|
|
)
|
|
return None
|
|
credentials[key] = value
|
|
return credentials
|
|
|
|
def save(self, credentials: dict[str, str]) -> None:
|
|
"""Sauvegarde les credentials dans le fichier d'état, de manière atomique.
|
|
|
|
Le fichier contient ``{"version": 1, "credentials": ...}``. Le JSON est
|
|
d'abord écrit dans un fichier temporaire du même répertoire, créé avec
|
|
les permissions ``0600`` (lecture seule pour le propriétaire) dès son
|
|
ouverture via :func:`os.open` (avec ``O_EXCL`` et ``O_NOFOLLOW`` pour
|
|
résister aux attaques par lien symbolique), puis verrouillé via
|
|
:func:`os.fchmod` avant toute écriture ; le fichier temporaire remplace
|
|
ensuite atomiquement le fichier d'état via :func:`os.replace`. Un
|
|
éventuel fichier temporaire stale d'une exécution interrompue est
|
|
supprimé avant l'ouverture. Les credentials ne sont jamais journalisés.
|
|
|
|
:param credentials: Dict des credentials pronotepy, tel que retourné
|
|
par ``pronotepy.Client.export_credentials()``.
|
|
:raises PronoteSyncError: Si l'écriture ou le remplacement du fichier
|
|
échoue.
|
|
"""
|
|
payload: dict[str, Any] = {
|
|
"version": _STATE_VERSION,
|
|
"credentials": credentials,
|
|
}
|
|
tmp_file = self._state_file.with_suffix(".tmp")
|
|
fd: int | None = None
|
|
try:
|
|
# Nettoie un éventuel fichier temporaire stale laissé par une exécution interrompue.
|
|
if tmp_file.exists():
|
|
try:
|
|
tmp_file.unlink()
|
|
except OSError:
|
|
logger.debug(
|
|
"Impossible de supprimer le fichier temporaire stale %s, "
|
|
"l'ouverture en O_EXCL échouera.",
|
|
redact_secrets(str(tmp_file)),
|
|
)
|
|
# O_EXCL empêche de créer par-dessus un fichier existant (attaque par lien
|
|
# symbolique) et O_NOFOLLOW refuse de suivre un lien symbolique.
|
|
fd = os.open(
|
|
str(tmp_file),
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
0o600,
|
|
)
|
|
# Verrouille les permissions en 0600 avant toute écriture, indépendamment de l'umask.
|
|
os.fchmod(fd, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2)
|
|
os.replace(tmp_file, self._state_file)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Impossible d'écrire le fichier d'état d'authentification Pronote %s : %s.",
|
|
redact_secrets(str(self._state_file)),
|
|
redact_exception(exc),
|
|
)
|
|
if fd is not None:
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
tmp_file.unlink(missing_ok=True)
|
|
except Exception as cleanup_exc:
|
|
logger.debug(
|
|
"Nettoyage du fichier temporaire d'état d'authentification Pronote échoué : %s",
|
|
redact_exception(cleanup_exc),
|
|
)
|
|
raise PronoteSyncError(
|
|
f"Impossible d'écrire le fichier d'état d'authentification Pronote "
|
|
f"{redact_secrets(str(self._state_file))}."
|
|
) from None
|
|
|
|
def clear(self) -> None:
|
|
"""Supprime le fichier d'état d'authentification.
|
|
|
|
Si le fichier n'existe pas, la méthode ne fait rien et aucune erreur
|
|
n'est levée.
|
|
|
|
:raises OSError: Si la suppression du fichier existant échoue.
|
|
"""
|
|
if not self._state_file.exists():
|
|
return
|
|
logger.debug(
|
|
"Suppression du fichier d'état d'authentification Pronote %s.",
|
|
redact_secrets(str(self._state_file)),
|
|
)
|
|
self._state_file.unlink()
|