feat: authentification QR code / token pour Pronote

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>
This commit is contained in:
2026-09-08 23:15:06 +02:00
parent 4a6207f716
commit 0363898669
16 changed files with 2128 additions and 39 deletions

View File

@@ -0,0 +1,195 @@
"""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()