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:
195
pronote_sync/sources/pronote/auth_state.py
Normal file
195
pronote_sync/sources/pronote/auth_state.py
Normal 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()
|
||||
Reference in New Issue
Block a user