Files
college-infos/pronote_sync/config/settings.py
Antoine Van Elstraete 0363898669 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>
2026-09-08 23:15:06 +02:00

301 lines
11 KiB
Python

"""Configuration de l'application via ``pydantic-settings``.
Chaque sous-groupe de configuration est un modèle ``BaseSettings`` dédié, chargé
depuis les variables d'environnement (préfixées par groupe) et le fichier
``.env``. Tous les champs disposent de valeurs par défaut sûres afin que
``Settings()`` fonctionne même sans fichier de configuration présent.
"""
from __future__ import annotations
from datetime import date
from typing import Literal
from urllib.parse import urlparse
from pydantic import (
Field,
SecretStr,
ValidationInfo,
field_serializer,
field_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
from pronote_sync.utils.redaction import redact_url
class PronoteSettings(BaseSettings):
"""Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``).
Les variables d'environnement correspondantes sont préfixées par
``PRONOTE_``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
ical_url: SecretStr | None = None
username: str | None = None
password: SecretStr | None = None
ent: str | None = None
url: str | None = None
account_type: Literal["student", "parent"] = "parent"
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
messages_source: Literal["pronotepy"] = "pronotepy"
auth_mode: Literal["password", "qr_token"] = "password"
qr_code_file: str | None = None
qr_pin: SecretStr | None = None
@field_serializer("ical_url")
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL iCal lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``ical_url``.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
"""
if value is None:
return None
return "**********"
@field_serializer("qr_pin")
def _serialize_qr_pin(self, value: SecretStr | None) -> str | None:
"""Masque le code PIN QR lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``qr_pin``.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
"""
if value is None:
return None
return "**********"
class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination.
Les variables d'environnement correspondantes sont préfixées par
``CALDAV_``. L'URL est traitée comme potentiellement sensible (au même
titre que ``PRONOTE_ICAL_URL``) : elle est de type ``SecretStr`` et
masquée lors de la sérialisation. Par défaut, seul HTTPS est accepté ;
HTTP n'est toléré que pour un hôte de boucle locale (``localhost``,
``127.0.0.1``, ``::1``) lorsque ``allow_insecure_http`` vaut ``True``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_")
allow_insecure_http: bool = False
url: SecretStr | None = None
username: str | None = None
password: SecretStr | None = None
calendar_path: str = "/pronote-sync/"
@field_serializer("url")
def _serialize_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL CalDAV lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``url`` (secret potentiel).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``None`` si la valeur est absente.
:rtype: str | None
"""
if value is None:
return None
return redact_url(value.get_secret_value())
@field_validator("url")
@classmethod
def _validate_url_https(cls, v: SecretStr | None, info: ValidationInfo) -> SecretStr | None:
"""Valide le schéma de l'URL CalDAV (HTTPS obligatoire par défaut).
HTTPS est toujours accepté. HTTP n'est accepté que pour un hôte de
boucle locale (``localhost``, ``127.0.0.1``, ``::1``) et uniquement
lorsque ``allow_insecure_http`` vaut ``True``. Les messages d'erreur
ne contiennent jamais l'URL brute (susceptible de contenir des
identifiants).
:param v: Valeur du champ ``url`` à valider.
:param info: Contexte de validation (accès aux autres champs).
:return: La valeur validée inchangée.
:rtype: SecretStr | None
:raises ValueError: Si le schéma n'est pas supporté ou si l'URL HTTP
n'est pas autorisée.
"""
if v is None:
return v
raw_url = v.get_secret_value()
parsed = urlparse(raw_url)
if parsed.scheme not in ("http", "https"):
raise ValueError("URL CalDAV invalide : schéma non supporté") from None
if parsed.scheme == "https":
return v
# HTTP — check allow_insecure_http flag and loopback
allow_insecure = info.data.get("allow_insecure_http", False)
if not allow_insecure:
raise ValueError(
"URL CalDAV non sécurisée : HTTPS requis (ou activer "
"CALDAV_ALLOW_INSECURE_HTTP pour localhost)"
) from None
hostname = parsed.hostname or ""
loopback_hosts = {"localhost", "127.0.0.1", "::1"}
if hostname not in loopback_hosts:
raise ValueError(
"URL CalDAV non sécurisée : HTTP autorisé uniquement pour localhost"
) from None
return v
_XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
class XmppSettings(BaseSettings):
"""Paramètres du canal de notifications XMPP (désactivé par défaut).
Tous les champs ont des valeurs par défaut afin que le canal XMPP reste
inactif tant qu'il n'est pas explicitement activé. Les variables
d'environnement correspondantes sont préfixées par ``XMPP_``.
Contraintes de champs : ``port`` est borné entre 1 et 65535 et ``timeout``
doit être strictement positif.
Politique TLS : la désactivation de TLS (``use_tls`` à ``False``) n'est
autorisée que sur un hôte de boucle locale (``localhost``, ``127.0.0.1``,
``::1``). Dans tout autre cas, une erreur de validation est levée,
indépendamment de l'état du champ ``enabled``.
"""
model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
env_prefix="XMPP_",
hide_input_in_errors=True,
)
enabled: bool = False
jid: str | None = None
password: SecretStr | None = None
host: str = ""
port: int = Field(default=5222, ge=1, le=65535)
to: str | None = None
resource: str = "pronote-sync"
use_tls: bool = True
timeout: int = Field(default=30, gt=0)
@field_validator("use_tls")
@classmethod
def _validate_tls_policy(cls, v: bool, info: ValidationInfo) -> bool:
"""Refuse la désactivation de TLS hors des hôtes de boucle locale.
La règle s'applique quel que soit l'état du champ ``enabled``. Le
message d'erreur ne contient aucune valeur sensible (``jid``,
``password``, ``to``).
:param v: Valeur du champ ``use_tls`` à valider.
:param info: Contexte de validation (accès aux autres champs).
:return: La valeur validée inchangée.
:rtype: bool
:raises ValueError: Si ``use_tls`` est ``False`` et que ``host``
n'est pas un hôte de boucle locale.
"""
if v is False:
host = info.data.get("host", "")
if host not in _XMPP_LOOPBACK_HOSTS:
raise ValueError(
"TLS désactivé n'est autorisé que sur les hôtes de loopback "
"(localhost, 127.0.0.1, ::1)."
) from None
return v
class AISettings(BaseSettings):
"""Paramètres de la synthèse par IA (désactivée par défaut).
Les variables d'environnement correspondantes sont préfixées par ``AI_``.
Le provider ``openai-compatible`` permet d'utiliser n'importe quelle API
compatible OpenAI via ``AI_BASE_URL`` ; les URLs en HTTP ne sont alors
acceptées que si ``AI_ALLOW_INSECURE_HTTP`` vaut ``true``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="AI_")
enabled: bool = False
provider: Literal["openai", "litellm", "openai-compatible"] = "openai"
base_url: str | None = None
api_key: SecretStr | None = None
allow_insecure_http: bool = False
model: str | None = None
class BlogSettings(BaseSettings):
"""Paramètres de la source RSS du blog du collège (désactivée par défaut).
Les variables d'environnement correspondantes sont préfixées par
``BLOG_``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="BLOG_")
enabled: bool = False
rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2"
class AppSettings(BaseSettings):
"""Paramètres généraux de l'application, sans préfixe d'environnement.
Contient notamment la fenêtre de synchronisation en jours
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``) et la configuration de
l'agenda théorique (``THEORETICAL_AGENDA_PATH``,
``THEORETICAL_WEEK_ANCHOR_DATE``, ``THEORETICAL_WEEK_ANCHOR_TYPE`` ainsi
que ``SCHOOL_HOLIDAYS_PATH`` pour les vacances scolaires).
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
dry_run: bool = False
log_level: str = "INFO"
theoretical_agenda_path: str | None = None
school_holidays_path: str | None = None
theoretical_week_anchor_date: date | None = None
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
sync_past_days: int = 7
sync_future_days: int = 30
class Settings(BaseSettings):
"""Configuration racine du pipeline ``pronote-sync``.
Agrège les sous-groupes de configuration : Pronote, CalDAV, XMPP, IA,
blog et paramètres généraux de l'application.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
pronote: PronoteSettings = Field(default_factory=PronoteSettings)
caldav: CalDAVSettings = Field(default_factory=CalDAVSettings)
xmpp: XmppSettings = Field(default_factory=XmppSettings)
ai: AISettings = Field(default_factory=AISettings)
blog: BlogSettings = Field(default_factory=BlogSettings)
app: AppSettings = Field(default_factory=AppSettings)
def redaction_secrets(self) -> tuple[SecretStr, ...]:
"""Énumère tous les secrets configurés pour la rédaction.
Collecte les valeurs :class:`pydantic.SecretStr` non vides présentes
dans les sous-configurations (URL iCal, mots de passe, code PIN QR et
clé API IA). Les valeurs vides ou ``None`` sont filtrées ; les
doublons sont supprimés.
:return: Tuple de secrets à masquer dans les messages d'erreur.
:rtype: tuple[SecretStr, ...]
"""
secrets = [
self.pronote.ical_url,
self.pronote.password,
self.pronote.qr_pin,
self.caldav.url,
self.caldav.password,
self.xmpp.password,
self.ai.api_key,
]
return tuple(dict.fromkeys(secret for secret in secrets if secret is not None))