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

@@ -11,7 +11,12 @@ from typing import Protocol, runtime_checkable
from pronote_sync.channels import get_channel
from pronote_sync.channels.protocol import Channel
from pronote_sync.config.settings import Settings
from pronote_sync.errors import PipelineCriticalError, PipelineError, PipelineWarning
from pronote_sync.errors import (
PipelineCriticalError,
PipelineError,
PipelineWarning,
PronoteAuthRotationError,
)
from pronote_sync.models.blog import ExternalInfo
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
@@ -26,6 +31,7 @@ from pronote_sync.pipeline.steps.send import send_step
from pronote_sync.pipeline.steps.synthesis import synthesis_step
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
from pronote_sync.sources.pronote.client import PronoteClient
from pronote_sync.sources.pronote.fallback import PronoteFetcher, PronoteFetcherProtocol
from pronote_sync.sources.theoretical import get_theoretical_provider
@@ -133,7 +139,15 @@ class PipelineRunner:
blog_state = BlogRSSState() if settings.blog.enabled else None
return cls(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, PronoteClient(settings.pronote)),
pronote_fetcher=PronoteFetcher(
settings,
PronoteClient(
settings.pronote,
auth_state=(
PronoteAuthState() if settings.pronote.auth_mode == "qr_token" else None
),
),
),
agenda_comparator=comparator,
synthesis_provider=get_synthesis_provider(settings.ai),
channel=get_channel(settings.xmpp, dry_run=effective_dry_run),
@@ -190,6 +204,11 @@ class PipelineRunner:
des étapes facultatives sont converties en :class:`PipelineWarning` afin
que les étapes suivantes, notamment XMPP, restent exécutées.
Une :class:`PronoteAuthRotationError` interrompt également l'exécution :
l'erreur est journalisée expurgée, une notification XMPP actionnable est
envoyée (sauf en dry-run ou sans canal), puis un résultat dégradé est
retourné.
:return: Données Pronote normalisées ou ``None``, puis erreurs et avertissements.
:rtype: tuple[PronoteData | None, list[PipelineError]]
"""
@@ -271,6 +290,28 @@ class PipelineRunner:
except Exception as exc:
self._warn("send", self._redact(exc))
return data, [*self._errors, *self._warnings]
except PronoteAuthRotationError as exc:
error = PipelineCriticalError(self._redact(exc), step="pronote")
logger.error("Erreur critique du pipeline : %s", error.message)
if self._channel is not None and not self._dry_run:
message = XmppMessage(
target_date=now.date(),
synthesis=(
"⚠️ Rotation du token Pronote échouée. Le token d'authentification est "
"expiré ou invalide. Action requise : supprimez le fichier "
".pronote_auth_state.json et relancez le pipeline avec un nouveau QR "
"code (PRONOTE_QR_CODE_FILE + PRONOTE_QR_PIN)."
),
external_info=None,
)
try:
if not send_step(self._channel, message):
self._warn("send", "Le canal XMPP a refusé l'envoi")
except Exception as send_exc:
# L'envoi de la notification est un dernier avertissement : son échec
# ne doit pas masquer l'erreur de rotation, déjà critique.
self._warn("send", self._redact(send_exc))
self._errors.append(error)
except PipelineCriticalError as exc:
logger.error("Erreur critique du pipeline : %s", exc.message)
self._errors.append(exc)