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

@@ -10,19 +10,24 @@ des cours et des devoirs se propagent pour déclencher le repli iCal.
from __future__ import annotations
import json
import logging
from datetime import date
from pathlib import Path
from typing import Any, Protocol
from uuid import uuid4
import pronotepy
import pronotepy.ent as pronotepy_ent
import requests
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.errors import PronoteAuthRotationError
from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.utils.redaction import redact_exception
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
from pronote_sync.utils.redaction import redact_exception, redact_secrets
from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid
logger = logging.getLogger(__name__)
@@ -91,6 +96,51 @@ def _resolve_ent(ent_name: str) -> Any:
return resolver
def _collect_auth_secrets(client: PronoteClient) -> list[str]:
"""Collecte toutes les valeurs sensibles d'authentification pour la redaction.
Rassemble le mot de passe, le PIN QR, le contenu du fichier QR (jeton,
login, url) et les credentials persistés (token, username) afin de les
transmettre comme ``extra_secrets`` aux fonctions de masquage. Une valeur
vide ou ``None`` est ignorée.
:param client: Le client Pronote dont on collecte les secrets.
:return: Liste des valeurs sensibles à expurger des logs.
:rtype: list[str]
"""
secrets: list[str] = []
settings = client._settings
# Mot de passe
if settings.password is not None:
secrets.append(settings.password.get_secret_value())
# PIN QR
if settings.qr_pin is not None:
secrets.append(settings.qr_pin.get_secret_value())
# Contenu du fichier QR (jeton, login, url)
if settings.qr_code_file is not None:
try:
qr_path = Path(settings.qr_code_file)
qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8"))
for key in ("jeton", "login", "url"):
val = qr_data.get(key)
if isinstance(val, str):
secrets.append(val)
except Exception as exc:
logger.debug(
"Impossible de lire le fichier QR %s : %s",
redact_secrets(settings.qr_code_file),
redact_exception(exc),
)
# Credentials persistés (token, username du fichier d'état)
if client._auth_state is not None:
creds = client._auth_state.load()
if creds is not None:
for val in creds.values():
if isinstance(val, str):
secrets.append(val)
return [s for s in secrets if s]
class PronoteClientProtocol(Protocol):
"""Interface du client Pronote consommée par la logique de repli."""
@@ -143,24 +193,56 @@ class PronoteClient:
exceptions se propager pour déclencher le repli iCal.
"""
def __init__(self, settings: PronoteSettings) -> None:
def __init__(
self,
settings: PronoteSettings,
auth_state: PronoteAuthState | None = None,
) -> None:
"""Initialise le client Pronote sans se connecter.
:param settings: Paramètres d'accès à Pronote (username, password, ent).
:param settings: Paramètres d'accès à Pronote (username, password, ent,
mode d'authentification, fichier QR et PIN).
:param auth_state: Gestionnaire de persistance du token
d'authentification (optionnel ; requis en mode ``qr_token`` pour
conserver le token entre les exécutions).
"""
self._settings: PronoteSettings = settings
self._auth_state: PronoteAuthState | None = auth_state
self._client: pronotepy.Client | None = None
def _connect(self) -> pronotepy.Client:
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
Le client est créé une seule fois puis réutilisé pour les appels
suivants. Le nom d'ENT, s'il est configuré, est résolu via
:func:`_resolve_ent` ; en l'absence d'ENT, ``ent=None`` est transmis
à ``pronotepy`` pour une connexion directe. Le type de compte
(``student`` ou ``parent``) détermine la classe de client utilisée.
L'erreur de connexion est relancée sans journalisation, la méthode
publique appelante étant responsable de la journaliser.
En mode ``password``, utilise l'authentification classique (URL,
username, password, ENT). En mode ``qr_token``, utilise le token
persisté via :class:`PronoteAuthState`, ou procède à l'enrôlement
initial par QR code si aucun token n'est présent.
:return: Le client ``pronotepy`` connecté.
:rtype: pronotepy.Client
:raises ValueError: Si les credentials requis sont manquants.
:raises PronoteAuthRotationError: Si le token persisté est invalide
(rotation requise) ou si l'enrôlement QR échoue.
:raises pronotepy.PronoteAPIError: Si la connexion échoue.
"""
if self._client is not None:
return self._client
if self._settings.auth_mode == "qr_token":
self._client = self._connect_qr_token()
else:
self._client = self._connect_password()
return self._client
def _connect_password(self) -> pronotepy.Client:
"""Connecte le client ``pronotepy`` en mode ``password``.
Le nom d'ENT, s'il est configuré, est résolu via :func:`_resolve_ent` ;
en l'absence d'ENT, ``ent=None`` est transmis à ``pronotepy`` pour une
connexion directe. Le type de compte (``student`` ou ``parent``)
détermine la classe de client utilisée. L'erreur de connexion est
relancée sans journalisation, la méthode publique appelante étant
responsable de la journaliser.
:return: Le client ``pronotepy`` connecté.
:rtype: pronotepy.Client
@@ -168,27 +250,153 @@ class PronoteClient:
est manquant, ou si l'ENT fourni est inconnu.
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
"""
if self._client is None:
url = self._settings.url
username = self._settings.username
password = self._settings.password
ent = self._settings.ent
if url is None or username is None or password is None:
raise ValueError("url, username et password sont requis pour pronotepy")
resolver = _resolve_ent(ent) if ent is not None else None
client_class: type[pronotepy.Client] = (
pronotepy.ParentClient
if self._settings.account_type == "parent"
else pronotepy.Client
)
self._client = client_class(
pronote_url=url,
username=username,
password=password.get_secret_value(),
ent=resolver,
)
url = self._settings.url
username = self._settings.username
password = self._settings.password
ent = self._settings.ent
if url is None or username is None or password is None:
raise ValueError("url, username et password sont requis pour pronotepy")
resolver = _resolve_ent(ent) if ent is not None else None
client_class: type[pronotepy.Client] = (
pronotepy.ParentClient if self._settings.account_type == "parent" else pronotepy.Client
)
self._client = client_class(
pronote_url=url,
username=username,
password=password.get_secret_value(),
ent=resolver,
)
return self._client
def _connect_qr_token(self) -> pronotepy.Client:
"""Connecte via token persisté ou enrôlement par QR code.
En premier lieu, les credentials persistés (``pronote_url``, username,
``password``/token, ``uuid``) sont rejoués via
``pronotepy.Client.token_login`` si :class:`PronoteAuthState` est
disponible et fournit un état. En cas d'échec du login par token
(exception ou client non connecté), une :class:`PronoteAuthRotationError`
est levée immédiatement, sans repli vers l'enrôlement QR : la rotation
du token doit être déclenchée par l'opérateur. L'enrôlement par QR code
n'est tenté que lorsqu'aucun credential n'est persisté (premier login) ;
le nouveau token est ensuite persisté immédiatement.
:return: Le client ``pronotepy`` connecté.
:rtype: pronotepy.Client
:raises PronoteAuthRotationError: Si le token persisté est invalide
(expiré ou refusé par Pronote), ou si l'enrôlement QR échoue
(fichier QR ou PIN manquant, fichier QR invalide ou expiré).
"""
client_class: type[pronotepy.Client] = (
pronotepy.ParentClient if self._settings.account_type == "parent" else pronotepy.Client
)
# Login par token avec les credentials persistés
if self._auth_state is not None:
creds = self._auth_state.load()
if creds is not None:
try:
client = client_class.token_login(**creds)
if client.logged_in:
self._auth_state.save(client.export_credentials())
return client
# logged_in est False — le token est invalide
raise PronoteAuthRotationError(
"Le token d'authentification Pronote est invalide (non connecté). "
"Action requise : supprimez le fichier .pronote_auth_state.json "
"et relancez avec un nouveau QR code."
) from None
except PronoteAuthRotationError:
raise
except Exception as exc:
logger.error(
"Échec du login par token pronotepy : %s",
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
)
# Token expiré/invalide — pas de repli vers l'enrôlement QR
raise PronoteAuthRotationError(
"Le token d'authentification Pronote est expiré ou invalide. "
"Action requise : supprimez le fichier .pronote_auth_state.json "
"et relancez avec un nouveau QR code (PRONOTE_QR_CODE_FILE + "
"PRONOTE_QR_PIN)."
) from None
# Enrôlement : premier login via QR code (aucun credential persisté)
client = self._enroll_qr_code(client_class)
# Persister le token rotaté immédiatement
if self._auth_state is not None:
self._auth_state.save(client.export_credentials())
return client
def _enroll_qr_code(self, client_class: type[pronotepy.Client]) -> pronotepy.Client:
"""Procède à l'enrôlement initial via QR code pronotepy.
Le fichier QR JSON doit contenir les clés ``login``, ``jeton`` et
``url``. Le PIN et le contenu du fichier ne sont jamais journalisés ;
les erreurs propagées sont expurgées.
:param client_class: Classe de client pronotepy à utiliser.
:return: Le client ``pronotepy`` connecté après enrôlement.
:rtype: pronotepy.Client
:raises PronoteAuthRotationError: Si le fichier QR ou le PIN est
manquant, si le fichier QR est illisible ou incomplet, ou si le
login par QR code échoue (PIN invalide ou QR code expiré).
"""
qr_file = self._settings.qr_code_file
qr_pin = self._settings.qr_pin
if qr_file is None or qr_pin is None:
raise PronoteAuthRotationError(
"Enrôlement QR requis : PRONOTE_QR_CODE_FILE et PRONOTE_QR_PIN sont "
"nécessaires pour le premier login en mode qr_token. Supprimez le "
"fichier .pronote_auth_state.json si présent et relancez avec un "
"QR code frais."
) from None
# Read and validate QR code JSON
try:
qr_path = Path(qr_file)
qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8"))
except Exception as exc:
logger.error(
"Fichier QR invalide %s : %s",
redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self)),
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
)
raise PronoteAuthRotationError(
"Impossible de lire le fichier QR code : "
f"{redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self))}"
) from None
# Validate required keys
for key in ("login", "jeton", "url"):
if key not in qr_data:
raise PronoteAuthRotationError(
f"Le fichier QR code ne contient pas la clé requise : {key}"
) from None
pin_value = qr_pin.get_secret_value()
app_uuid = f"pronote-sync-{uuid4().hex}"
try:
client = client_class.qrcode_login(
qr_code=qr_data,
pin=pin_value,
uuid=app_uuid,
)
except Exception as exc:
logger.error(
"Échec de l'enrôlement QR : %s",
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
)
raise PronoteAuthRotationError(
"Échec de l'enrôlement par QR code : PIN invalide ou QR code expiré. "
"Générez un nouveau QR code dans l'application Pronote et mettez à "
"jour PRONOTE_QR_CODE_FILE."
) from None
return client
def get_messages(self) -> list[Message]:
"""Récupère les messages des discussions Pronote.
@@ -284,6 +492,8 @@ class PronoteClient:
:param end: Date de fin de la fenêtre (incluse).
:return: Liste des cours.
:rtype: list[Lesson]
:raises PronoteAuthRotationError: Si le token persisté est invalide et
qu'aucun ré-enrôlement n'est possible (fichier QR ou PIN manquant).
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
:raises ValueError: Si la configuration ou l'ENT est invalide.
:raises requests.RequestException: Si une requête réseau échoue.
@@ -334,6 +544,8 @@ class PronoteClient:
:param end: Date de fin de la fenêtre (incluse).
:return: Liste des devoirs.
:rtype: list[Homework]
:raises PronoteAuthRotationError: Si le token persisté est invalide et
qu'aucun ré-enrôlement n'est possible (fichier QR ou PIN manquant).
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
:raises ValueError: Si la configuration ou l'ENT est invalide.
:raises requests.RequestException: Si une requête réseau échoue.