Compare commits
14 Commits
docs/agent
...
fix/qr-tok
| Author | SHA1 | Date | |
|---|---|---|---|
| 4228c1e636 | |||
| 8b924b55d1 | |||
| 22a662ab39 | |||
| 5188761209 | |||
|
999ed76ba7
|
|||
| 6a8685fc9d | |||
|
4df930bfe6
|
|||
|
d26cef8d3d
|
|||
| 7dc48f6f43 | |||
|
bc79ebf680
|
|||
|
0363898669
|
|||
| 4a6207f716 | |||
| 3b38253575 | |||
| bf4038814a |
15
.env.example
15
.env.example
@@ -1,6 +1,6 @@
|
|||||||
# --- Pronote ---
|
# --- Pronote ---
|
||||||
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
|
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
|
||||||
PRONOTE_URL=https://college.ent/pronote/eleve.html
|
PRONOTE_URL=https://college.ent/pronote/parent.html
|
||||||
PRONOTE_ACCOUNT_TYPE=parent
|
PRONOTE_ACCOUNT_TYPE=parent
|
||||||
PRONOTE_USERNAME=parent.dupont
|
PRONOTE_USERNAME=parent.dupont
|
||||||
PRONOTE_PASSWORD=your_secure_password
|
PRONOTE_PASSWORD=your_secure_password
|
||||||
@@ -11,6 +11,19 @@ PRONOTE_AGENDA_SOURCE=auto
|
|||||||
PRONOTE_HOMEWORK_SOURCE=auto
|
PRONOTE_HOMEWORK_SOURCE=auto
|
||||||
PRONOTE_MESSAGES_SOURCE=pronotepy
|
PRONOTE_MESSAGES_SOURCE=pronotepy
|
||||||
|
|
||||||
|
# --- Authentification Pronote ---
|
||||||
|
# Mode d'authentification : "password" (défaut) ou "qr_token"
|
||||||
|
# qr_token : pour les instances utilisant HubEduConnect/EduConnect où le mot de passe échoue
|
||||||
|
# Voir le wiki GuidePronote pour la procédure d'enrôlement QR code
|
||||||
|
PRONOTE_AUTH_MODE=password
|
||||||
|
|
||||||
|
# --- Mode qr_token (décommenter et renseigner pour l'authentification par QR code) ---
|
||||||
|
# Le QR code se génère sur le site web Pronote (espace parent > paramètres > QR code)
|
||||||
|
# Le QR code expire ~10 minutes après génération
|
||||||
|
# PRONOTE_AUTH_MODE=qr_token
|
||||||
|
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
||||||
|
# PRONOTE_QR_PIN=1234
|
||||||
|
|
||||||
# --- CalDAV ---
|
# --- CalDAV ---
|
||||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||||
CALDAV_USERNAME=user@example.com
|
CALDAV_USERNAME=user@example.com
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -48,6 +48,9 @@ Thumbs.db
|
|||||||
# --- Project-specific state files ---
|
# --- Project-specific state files ---
|
||||||
.blog_rss_state.json
|
.blog_rss_state.json
|
||||||
.caldav_sync_state.json
|
.caldav_sync_state.json
|
||||||
|
# État d'authentification pronotepy (QR code / token rotation)
|
||||||
|
.pronote_auth_state.json
|
||||||
|
.pronote_auth_state.json.lock
|
||||||
*.state.json
|
*.state.json
|
||||||
|
|
||||||
# --- Local scratch / WIP files ---
|
# --- Local scratch / WIP files ---
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": true,
|
"is_verified": true,
|
||||||
"line_number": 5064,
|
"line_number": 5084,
|
||||||
"is_secret": false
|
"is_secret": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -177,5 +177,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-08T10:45:46Z"
|
"generated_at": "2026-09-10T19:26:08Z"
|
||||||
}
|
}
|
||||||
|
|||||||
182
AGENTS.md
182
AGENTS.md
@@ -146,6 +146,36 @@ pronote-sync --dry-run
|
|||||||
- Réutiliser un téléchargement/parsing iCal pour l'agenda et les devoirs pendant un même run, sans
|
- Réutiliser un téléchargement/parsing iCal pour l'agenda et les devoirs pendant un même run, sans
|
||||||
cache global ni persistant.
|
cache global ni persistant.
|
||||||
|
|
||||||
|
### Contrat d'authentification QR code / token
|
||||||
|
|
||||||
|
- Le mode d'authentification est sélectionné par `PRONOTE_AUTH_MODE` :
|
||||||
|
- `password` (défaut) : authentification classique via URL, identifiant, mot de passe et ENT.
|
||||||
|
- `qr_token` : authentification par QR code puis token persistant (pour les instances Pronote
|
||||||
|
utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue).
|
||||||
|
- En mode `qr_token`, le premier login utilise `pronotepy.qrcode_login(qr_code, pin, uuid)` avec
|
||||||
|
les paramètres `PRONOTE_QR_CODE_FILE` (chemin du JSON QR) et `PRONOTE_QR_PIN` (PIN SecretStr). Le
|
||||||
|
QR code est obtenu depuis le site web Pronote (espace parent → paramètres → QR code), pas depuis
|
||||||
|
l'application mobile.
|
||||||
|
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
|
||||||
|
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
|
||||||
|
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
|
||||||
|
(refresh automatique pronotepy après une `PronoteAPIError`). Les credentials sont persistées après
|
||||||
|
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages,
|
||||||
|
informations) pour garantir la persistance du token valide.
|
||||||
|
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
|
||||||
|
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
|
||||||
|
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
|
||||||
|
`PipelineRunner.run()`, qui :
|
||||||
|
- journalise l'erreur (expurgée) ;
|
||||||
|
- envoie une notification XMPP actionnable si le canal est disponible et `dry_run` est inactif ;
|
||||||
|
- retourne un résultat dégradé `(None, errors)`.
|
||||||
|
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
|
||||||
|
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
|
||||||
|
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
|
||||||
|
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
|
||||||
|
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
|
||||||
|
les notifications XMPP.
|
||||||
|
|
||||||
### Contrat du provider `openai-compatible`
|
### Contrat du provider `openai-compatible`
|
||||||
- Le provider `openai-compatible` réutilise `OpenAISynthesisProvider` avec un `base_url` personnalisé ; aucun nouveau provider n'est créé.
|
- Le provider `openai-compatible` réutilise `OpenAISynthesisProvider` avec un `base_url` personnalisé ; aucun nouveau provider n'est créé.
|
||||||
- `AI_BASE_URL` et `AI_MODEL` sont requis ; `AI_API_KEY` est requis (MVP).
|
- `AI_BASE_URL` et `AI_MODEL` sont requis ; `AI_API_KEY` est requis (MVP).
|
||||||
@@ -218,155 +248,3 @@ Le projet suit un plan séquentiel en **15 jalons** (M1-M15) décrits dans [`TOD
|
|||||||
|----------|------|
|
|----------|------|
|
||||||
| [`GUIDE_DEV_PYTHON.md`](./GUIDE_DEV_PYTHON.md) | Spécification complète (architecture, modèles, parsing, déploiement) |
|
| [`GUIDE_DEV_PYTHON.md`](./GUIDE_DEV_PYTHON.md) | Spécification complète (architecture, modèles, parsing, déploiement) |
|
||||||
| [`TODO.md`](./TODO.md) | Plan de développement détaillé (15 jalons) |
|
| [`TODO.md`](./TODO.md) | Plan de développement détaillé (15 jalons) |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Agents OpenCode
|
|
||||||
|
|
||||||
Cette section s'applique uniquement lorsque le travail est exécuté avec le système d'agents d'OpenCode.
|
|
||||||
|
|
||||||
Les rôles d'agents disponibles pour ce projet sont les suivants :
|
|
||||||
|
|
||||||
- `@architect` : Arbitrages d'architecture et choix techniques structurants. **Ne produit pas de code.**
|
|
||||||
- `@coder` : Écrit et modifie du code, de la configuration et des scripts. **Ne valide pas** (ruff, mypy, pytest) — c'est le rôle de `@verifier`. **Ne diagnostique pas** — c'est le rôle de `@debugger`.
|
|
||||||
- `@debugger` : Reproduit un symptôme et établit sa cause profonde. **Ne modifie pas le code.**
|
|
||||||
- `@explorer` : Explore le dépôt en lecture seule. **Ne modifie rien, n'exécute pas de commandes.**
|
|
||||||
- `@orchestrator` : Compréhension globale, définition des jalons, coordination et garantie du résultat. **N'écrit pas de code.**
|
|
||||||
- `@planner` : Transforme une demande complexe en unités exécutables. **Ne dirige aucun technicien.**
|
|
||||||
- `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité. **Ne modifie pas le code.**
|
|
||||||
- `@security-auditor` : Audit indépendant d'une surface de sécurité. **Ne modifie pas le code.**
|
|
||||||
- `@tech-writer` : Rédige et maintient la documentation. **N'écrit pas de code applicatif.**
|
|
||||||
- `@test-engineer` : Conçoit, écrit et exécute des tests ciblés. **N'écrit pas de code de production.**
|
|
||||||
- `@ui-designer` : Conçoit et implémente les interfaces Web et terminal.
|
|
||||||
- `@verifier` : Vérifie indépendamment le comportement livré, les régressions et le respect des conventions (ruff, mypy, pytest, bandit, idempotence, mode dégradé). **Ne modifie pas le code.**
|
|
||||||
- `@web-explorer` : Recherche et extrait des sources Web vérifiables. **Ne modifie pas le dépôt.**
|
|
||||||
|
|
||||||
> **Note** : Ne pas utiliser `@coder` pour les tâches de documentation (`@tech-writer`) ni pour les tests (`@test-engineer`).
|
|
||||||
|
|
||||||
### Séparation des rôles
|
|
||||||
|
|
||||||
| Type de tâche | Agent responsable | Ne pas confier à |
|
|
||||||
|---|---|---|
|
|
||||||
| Écrire/modifier du code | `@coder` | `@verifier`, `@explorer` |
|
|
||||||
| Valider (ruff, mypy, pytest, bandit) | `@verifier` | `@coder` |
|
|
||||||
| Diagnostiquer un bug | `@debugger` | `@coder` |
|
|
||||||
| Écrire un test | `@test-engineer` | `@coder` |
|
|
||||||
| Rédiger de la documentation | `@tech-writer` | `@coder` |
|
|
||||||
| Explorer le dépôt (lecture) | `@explorer` | `@coder`, `@verifier` |
|
|
||||||
| Arbitrage technique structurant | `@architect` | `@coder`, `@planner` |
|
|
||||||
| Revue de code | `@reviewer` | `@coder`, `@verifier` |
|
|
||||||
| Audit de sécurité | `@security-auditor` | `@coder`, `@verifier` |
|
|
||||||
| Recherche web | `@web-explorer` | `@explorer` |
|
|
||||||
| Découpage de travail complexe | `@planner` | `@coder` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Workflow de modification
|
|
||||||
|
|
||||||
1. Lire la demande, [`TODO.md`](./TODO.md), les sections pertinentes de
|
|
||||||
[`GUIDE_DEV_PYTHON.md`](./GUIDE_DEV_PYTHON.md), `git status` et les fichiers concernés.
|
|
||||||
2. Préserver les changements existants de l'utilisateur.
|
|
||||||
3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable.
|
|
||||||
4. Faire une modification étroite et cohérente, en respectant les conventions du projet
|
|
||||||
(idempotence, modes explicites stricts et repli iCal → `pronotepy` uniquement en mode `auto`).
|
|
||||||
5. Faire vérifier le comportement par `@verifier` (ruff, mypy, pytest, bandit) et les cas d'erreur, notamment :
|
|
||||||
- Succès de la synchronisation Pronote → CalDAV/XMPP.
|
|
||||||
- Repli d'iCal vers `pronotepy` en mode `auto`, sans repli dans les modes explicites.
|
|
||||||
- Distinction entre résultat vide et échec des sources.
|
|
||||||
- Gestion des erreurs explicites sans fuite dans les causes ou tracebacks.
|
|
||||||
6. Mettre à jour la documentation et les exemples dans le même changement si leur comportement public évolue.
|
|
||||||
7. Cocher dans [`TODO.md`](./TODO.md) uniquement les éléments entièrement réalisés et validés.
|
|
||||||
8. Terminer avec un *handoff* concis : fichiers modifiés, validations exécutées, limites et prochaine étape.
|
|
||||||
|
|
||||||
> **Pour les changements larges ou risqués** : Produire d'abord un audit ou un aperçu.
|
|
||||||
> **Règle de commit** : Ne pas committer sans autorisation explicite. Une autorisation de commit ne vaut pas autorisation de push.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Branches et commits
|
|
||||||
|
|
||||||
- Une évolution cohérente se fait sur une **branche dédiée**.
|
|
||||||
- Nommer les branches selon le format `<type>/<sujet-en-kebab-case>`, où `<type>` est l'un des suivants :
|
|
||||||
`feature`, `fix`, `docs`, `chore` ou `refactor`.
|
|
||||||
- Partir de l'état validé de la branche principale (`main` ou `dev`), sauf demande explicite.
|
|
||||||
- Garder **un commit atomique par objectif vérifiable**. Utiliser les préfixes conventionnels pour les messages de commit :
|
|
||||||
- `feat:` pour une nouvelle fonctionnalité.
|
|
||||||
- `fix:` pour une correction de bug.
|
|
||||||
- `docs:` pour une mise à jour de documentation.
|
|
||||||
- `chore:` pour une tâche de maintenance.
|
|
||||||
- `refactor:` pour une refactorisation de code.
|
|
||||||
- Quand un agent a contribué au changement, ajouter un *trailer* Git standard au commit :
|
|
||||||
```
|
|
||||||
Co-authored-by: <harness>/<modèle> <adresse@agents.invalid>
|
|
||||||
```
|
|
||||||
- Avant un commit autorisé, vérifier :
|
|
||||||
- `git status` (fichiers modifiés attendus).
|
|
||||||
- Le diff complet (`git diff`).
|
|
||||||
- L'absence de secret ou de configuration locale dans le diff.
|
|
||||||
- Les validations pertinentes (`ruff`, `mypy`, `pytest`, `bandit`).
|
|
||||||
- `git diff --check` (pas de problèmes d'espaces blancs).
|
|
||||||
- Après le commit, rapporter :
|
|
||||||
- Le hash du commit.
|
|
||||||
- Le contenu du commit.
|
|
||||||
- Les validations exécutées.
|
|
||||||
|
|
||||||
> **Règle absolue** : Ne jamais pousser (`git push`) sans demande distincte et explicite.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Définition de terminé
|
|
||||||
|
|
||||||
Un changement est considéré comme **terminé** lorsque :
|
|
||||||
|
|
||||||
- Le cas nominal et les échecs pertinents sont testés (ex. : synchronisation réussie, repli
|
|
||||||
iCal → `pronotepy` en mode `auto`, modes explicites stricts, erreurs explicites).
|
|
||||||
- Les outils de validation (`ruff`, `mypy`, `pytest`, `bandit`) passent sans erreur.
|
|
||||||
- Aucun secret ni configuration locale n'apparaît dans le diff ou les fichiers suivis.
|
|
||||||
- La documentation reste cohérente avec le code (ex. : mise à jour des exemples, des contrats ou des décisions d'architecture).
|
|
||||||
- Le *handoff* distingue clairement :
|
|
||||||
- Ce qui a été vérifié localement (ex. : tests unitaires, linter).
|
|
||||||
- Ce qui nécessite encore une vérification manuelle (ex. : tests d'intégration avec un serveur CalDAV réel).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Versionnage et releases
|
|
||||||
|
|
||||||
### Politique de versionnage
|
|
||||||
|
|
||||||
Le projet suit **Semantic Versioning** (semver.org v2.0.0). Phase actuelle : `0.x` (pré-`1.0.0`).
|
|
||||||
|
|
||||||
| Changement | Incrément |
|
|
||||||
|------------|----------|
|
|
||||||
| Défaut constaté au déploiement | Patch (`0.1.Z`) — correction rétrocompatible |
|
|
||||||
| Ajout ou cassure en phase `0.x` | Minor (`0.Y.0`) |
|
|
||||||
| Déploiement réel validé | `1.0.0` |
|
|
||||||
|
|
||||||
### Règle absolue de validation
|
|
||||||
|
|
||||||
**Aucune montée de version (tag + release) ne peut être effectuée
|
|
||||||
sans validation préalable en environnement réel.** Les tests automatisés et la revue de code
|
|
||||||
ne suffisent pas ; le correctif ou la fonctionnalité doit avoir été testé avec succès
|
|
||||||
sur le serveur de production (ou un environnement équivalent) avant de tagger.
|
|
||||||
|
|
||||||
### Procédure de release
|
|
||||||
|
|
||||||
1. **Valider en environnement réel** : le correctif ou la fonctionnalité est testé
|
|
||||||
sur le serveur de production.
|
|
||||||
2. **Mettre à jour `pyproject.toml`** : incrémenter le champ `version` à la nouvelle version.
|
|
||||||
3. **Mettre à jour `CHANGELOG.md`** : ajouter une entrée sous le format
|
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) avec la nouvelle version et la date.
|
|
||||||
4. **Committer** : un commit `chore: monter en version x.y.z` regroupe
|
|
||||||
les mises à jour de `pyproject.toml` et `CHANGELOG.md`.
|
|
||||||
5. **Tagger** : créer un tag annoté `vx.y.z` sur le commit de version.
|
|
||||||
6. **Pousser le tag** : `git push origin vx.y.z`.
|
|
||||||
7. **Créer la release** sur Gitea avec le changelog correspondant.
|
|
||||||
|
|
||||||
### Cohérence des versions
|
|
||||||
|
|
||||||
Les trois sources de version doivent toujours être synchronisées au moment d'un tag :
|
|
||||||
- Le tag Git (`vx.y.z`)
|
|
||||||
- `pyproject.toml` (`version = "x.y.z"`)
|
|
||||||
- `CHANGELOG.md` (`## [x.y.z] - YYYY-MM-DD`)
|
|
||||||
|
|
||||||
> **Rappel** : Ne jamais créer un tag sans avoir d'abord mis à jour
|
|
||||||
> `pyproject.toml` et `CHANGELOG.md`.
|
|
||||||
|
|||||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.2] - 2026-09-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Authentification QR code / token** (`PRONOTE_AUTH_MODE=qr_token`) : alternative au mode `password` pour les instances Pronote utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML modifié).
|
||||||
|
- Enrôlement initial via QR code (`pronotepy.qrcode_login`)
|
||||||
|
- Persistance du token rotatif dans `.pronote_auth_state.json` (permissions `0600`, écriture atomique, symlink-safe)
|
||||||
|
- Login subsequent via `pronotepy.token_login` avec token persisté
|
||||||
|
- Notification XMPP actionnable en cas d'échec de rotation (`PronoteAuthRotationError`)
|
||||||
|
- Persistance du token après chaque opération de données réussie et dans le chemin d'erreur (refresh pronotepy)
|
||||||
|
- `_is_pronotepy_configured()` mode-aware : `qr_token` ne requiert que `PRONOTE_URL`
|
||||||
|
- Redaction des secrets explicites (token, PIN, jeton QR) dans tous les logs
|
||||||
|
- Nouvelles variables d'environnement : `PRONOTE_AUTH_MODE`, `PRONOTE_QR_CODE_FILE`, `PRONOTE_QR_PIN`
|
||||||
|
- Documentation : section "Contrat d'authentification QR code / token" dans `AGENTS.md`, section QR code dans le wiki `GuidePronote`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`)
|
||||||
|
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
|
||||||
|
- `.env.example` corrigé (`eleve.html` → `parent.html`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
|
||||||
|
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
- #20 — Triple authentification pronotepy (double INIT + refresh) lors d'un run
|
||||||
|
- #21 — Erreur pronotepy 20 « La page a expiré ! (11) » sur `get_informations`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 694 tests passés, couverture 94.93%
|
||||||
|
- 35 nouveaux tests QR/token : config, auth_state, client, propagation, intégration end-to-end
|
||||||
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-09-08
|
## [0.1.0] - 2026-09-08
|
||||||
|
|
||||||
Initial release covering milestones M1 through M15.
|
Initial release covering milestones M1 through M15.
|
||||||
|
|||||||
@@ -2259,7 +2259,27 @@ d'informations sont non critiques et peuvent retourner une liste vide avec un wa
|
|||||||
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
||||||
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
||||||
|
|
||||||
#### 5.1.8 Logique de repli (`sources/pronote/fallback.py`)
|
#### 5.1.8 Verrou du cycle d'authentification QR/token
|
||||||
|
|
||||||
|
En mode `qr_token`, le token Pronote est un état partagé et rotatif. Afin d'éviter que deux
|
||||||
|
exécutions ne réutilisent ou n'écrasent cet état simultanément, le client protège chaque cycle
|
||||||
|
d'authentification et de récupération par un verrou POSIX local non bloquant, situé dans
|
||||||
|
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
|
||||||
|
|
||||||
|
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
|
||||||
|
enrôlement QR initial, opération de données (agenda, devoirs, messages ou informations), puis
|
||||||
|
persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une
|
||||||
|
erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas
|
||||||
|
l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus
|
||||||
|
dans les logs ni dans ce message d'erreur.
|
||||||
|
|
||||||
|
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
|
||||||
|
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
|
||||||
|
Pronote doivent également partager le fichier d'état et son fichier de verrou. Le verrou ne fournit
|
||||||
|
aucune exclusion fiable entre plusieurs hôtes ou via NFS ; dans ces cas, l'opérateur doit prévoir
|
||||||
|
une exclusion externe ou utiliser un token distinct par instance.
|
||||||
|
|
||||||
|
#### 5.1.9 Logique de repli (`sources/pronote/fallback.py`)
|
||||||
|
|
||||||
Le `PronoteFetcher` dépend de `Settings` et d'un protocole de client injecté ; il ne construit pas
|
Le `PronoteFetcher` dépend de `Settings` et d'un protocole de client injecté ; il ne construit pas
|
||||||
de singleton et ne contient pas d'identifiants dupliqués.
|
de singleton et ne contient pas d'identifiants dupliqués.
|
||||||
|
|||||||
570
docs/pronote-auth.md
Normal file
570
docs/pronote-auth.md
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
> ⚠️ **AVERTISSEMENT**
|
||||||
|
>
|
||||||
|
> Ce document **n'est pas un guide officiel**. Il n'est ni approuvé, ni validé, ni autorisé par le Ministère de l'Éducation Nationale française ni par Docaposte (éditeur de Pronote / Index Éducation). Les informations présentées reposent sur des recherches publiques, des travaux de rétro-ingénierie menés par la communauté open-source et des analyses techniques. Les protocoles décrits ne sont pas officiellement publiés par Index Éducation et peuvent évoluer sans préavis.
|
||||||
|
>
|
||||||
|
> Ce document a été produit à l'aide de plusieurs agents basés sur des modèles de langage (LLM) :
|
||||||
|
> - **Mercury 2.5** (agent explorer) — exploration du code pour établir les faits techniques.
|
||||||
|
> - **Gemini 3.5 Flash** (agent web-explorer) — recherche des méthodes d'authentification externes.
|
||||||
|
> - **Hy3** (agent planner) — planification de la structure du document et découpage en sections.
|
||||||
|
> - **Mistral Medium** (agent tech-writer) — rédaction du document.
|
||||||
|
> - **GPT-5.6 Luna** (agent reviewer) — revue du contenu pour l'exactitude et la cohérence.
|
||||||
|
> - **GLM-5.2** (agent orchestrator) — coordination et intégration du travail.
|
||||||
|
> - **Gemini 3.7 Flash** (agent ui-designer) — conception de la version LaTeX/PDF.
|
||||||
|
# Authentification Pronote — Référence technique
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Ce manuel documente l’ensemble des méthodes d’authentification connues pour accéder aux données élèves/parents de Pronote (notes, emploi du temps, devoirs, absences, etc.). Il couvre à la fois les mécanismes officiellement supportés et les protocoles issus de l’analyse communautaire.
|
||||||
|
|
||||||
|
Le public visé inclut les développeurs, ingénieurs sécurité et intégrateurs système devant maîtriser l’authentification Pronote au niveau protocolaire. Les descriptions utilisent du pseudocode générique, des échanges HTTP et des schémas protocoles, sans présupposer de langage ou framework spécifique.
|
||||||
|
|
||||||
|
*Remarque méthodologique* : Les méthodes au-delà de l’export iCal s’appuient sur des recherches publiques et l’analyse de la communauté open source. Ces protocoles, non publiés officiellement par Index Éducation, peuvent évoluer sans préavis.
|
||||||
|
|
||||||
|
## Vue d'ensemble comparative
|
||||||
|
|
||||||
|
| Méthode | Périmètre de données | Identifiants requis | Expiration du jeton | Complexité | Statut |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| URL iCal sécurisée (`icalsecurise`) | Emploi du temps et, si inclus, cahier de textes/devoirs | Jeton dans l'URL | Longue durée | Très faible | Officiel |
|
||||||
|
| Connexion directe (identifiant/mot de passe) | Complet | Identifiant + mot de passe établissement | Session ~15–30 min | Élevée | Reverse-engineered |
|
||||||
|
| SSO ENT (CAS / SAML / Oze) | Complet | Identifiants ENT | Dépend de la session ENT | Très élevée | Reverse-engineered |
|
||||||
|
| SSO EduConnect | Complet | Identifiants nationaux EduConnect | Dépend de la session EduConnect | Très élevée | Reverse-engineered |
|
||||||
|
| CAS (Central Authentication Service) | Complet | Identifiants CAS | Dépend du ticket de service | Modérée | Officiel |
|
||||||
|
| QR Code + jeton mobile | Complet | Code PIN à 4 chiffres → `jetonConnexionAppliMobile` + UUID | QR valable 10 min ; jeton longue durée | Modérée | Reverse-engineered |
|
||||||
|
| API publique | N/A | N/A | N/A | N/A | Inexistante |
|
||||||
|
|
||||||
|
*Complet* désigne l’accès aux notes, emploi du temps, devoirs, absences, messagerie et paramètres, tandis que la méthode iCal se limite à l’emploi du temps et éventuellement aux devoirs.
|
||||||
|
|
||||||
|
## Méthode 1 : URL iCal sécurisée (icalsecurise)
|
||||||
|
|
||||||
|
### Principe général
|
||||||
|
Pronote expose un flux de calendrier en lecture seule conforme à la norme iCalendar (RFC 5545). L’accès est contrôlé par un jeton secret intégré dans l’URL sous forme de paramètre de requête `icalsecurise`. Ce jeton est unique par utilisateur et par établissement. **Le jeton constitue la seule crédentiale** : il doit être traité comme un mot de passe. Toute requête HTTP GET vers cette URL permet de récupérer les données du calendrier, sans nécessiter de cookies, de session ni d’en-têtes d’authentification.
|
||||||
|
|
||||||
|
|
||||||
|
### Obtention du jeton
|
||||||
|
Le jeton s’obtient manuellement depuis l’interface web de Pronote :
|
||||||
|
1. Se connecter à Pronote (Espace Parents ou Espace Élève) via n’importe quelle méthode d’authentification.
|
||||||
|
2. Accéder à la vue « Emploi du temps ».
|
||||||
|
3. Utiliser la fonction « Export iCal » ou « Exporter ».
|
||||||
|
4. Pronote génère une URL contenant le paramètre `icalsecurise`.
|
||||||
|
5. Copier cette URL : elle constitue la crédentiale.
|
||||||
|
|
||||||
|
Cette URL doit être stockée de manière sécurisée (ex. : gestionnaire de secrets, variables protégées). **Elle ne doit jamais être versionnée ou partagée en clair.**
|
||||||
|
|
||||||
|
|
||||||
|
### Format de l'URL
|
||||||
|
L’URL suit la structure suivante :
|
||||||
|
```
|
||||||
|
https://{etablissement}.index-education.net/pronote/ical/Edt_{prenom}.ics?icalsecurise={jeton}&version={version}¶m={param}
|
||||||
|
```
|
||||||
|
Exemple masqué :
|
||||||
|
`https://XXXXXXX.index-education.net/pronote/ical/Edt_Alice.ics?icalsecurise=••••••••&version=2023¶m=...`
|
||||||
|
|
||||||
|
Paramètres de requête :
|
||||||
|
- `icalsecurise` : jeton secret (crédentiale).
|
||||||
|
- `version` : version de Pronote.
|
||||||
|
- `param` : paramètres supplémentaires (optionnels).
|
||||||
|
|
||||||
|
|
||||||
|
### Flux d'authentification (protocole)
|
||||||
|
Le protocole d’authentification se résume ainsi :
|
||||||
|
|
||||||
|
1. **Préparation** :
|
||||||
|
Le client dispose de l’URL iCal sécurisée (obtenue comme décrit ci-dessus).
|
||||||
|
|
||||||
|
2. **Requête HTTP** :
|
||||||
|
Le client effectue une requête HTTP GET :
|
||||||
|
```
|
||||||
|
GET {ical-url}
|
||||||
|
Accept: text/calendar, */*;q=0.5
|
||||||
|
User-Agent: {identifiant-client}
|
||||||
|
```
|
||||||
|
- Délai d’attente : configurable (recommandé : 20 secondes).
|
||||||
|
|
||||||
|
3. **Validation de la réponse** :
|
||||||
|
- Si le code HTTP n’est pas 2xx → échec d’authentification ou erreur serveur.
|
||||||
|
- Si le corps de la réponse ne contient pas `BEGIN:VCALENDAR` → le jeton est probablement expiré ou l’URL est invalide. Le serveur peut retourner une page HTML d’erreur au lieu des données de calendrier.
|
||||||
|
|
||||||
|
4. **Traitement** :
|
||||||
|
Si la validation réussit, le corps de la réponse est une donnée iCalendar valide, prête à être analysée.
|
||||||
|
|
||||||
|
### Données échangées
|
||||||
|
|
||||||
|
- Le client envoie une seule requête HTTP GET vers l'URL iCal sécurisée.
|
||||||
|
- En-têtes de requête : `Accept: text/calendar, */*;q=0.5` et `User-Agent: <identifiant-client>`.
|
||||||
|
- Le serveur retourne une charge utile iCalendar (RFC 5545) si le jeton est valide.
|
||||||
|
- Si le jeton est invalide ou expiré, le serveur retourne une réponse non-calendrier (typiquement du HTML).
|
||||||
|
- Aucun cookie, jeton de session ou en-tête d'authentification n'est échangé — l'URL **est** la crédentiale.
|
||||||
|
|
||||||
|
### Identifiants et jetons
|
||||||
|
|
||||||
|
Le seul identifiant utilisé est le jeton `icalsecurise`, intégré directement dans l’URL. Ce jeton est :
|
||||||
|
- **Unique** par utilisateur et par établissement.
|
||||||
|
- **Sensible** : il équivaut à un mot de passe et doit être traité comme tel.
|
||||||
|
- **Transmis en clair** dans la chaîne de requête de l’URL, mais protégé en transit par HTTPS.
|
||||||
|
- **Autosuffisant** : aucune autre information (nom d’utilisateur, mot de passe, cookie de session ou jeton OAuth) n’est requise. L’URL **est** l’authentification.
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
|
||||||
|
Le jeton est **pérenne** : il reste valide jusqu’à sa révocation manuelle par l’utilisateur dans les paramètres Pronote, ou jusqu’à sa régénération par l’établissement (généralement à la rentrée scolaire).
|
||||||
|
Aucun mécanisme de rafraîchissement automatique ou de rotation n’existe. En cas d’expiration ou de rotation, le serveur retourne une réponse non-iCalendar (souvent une page HTML mentionnant *« Session expirée »*). Le client détecte cette situation en vérifiant l’absence de `BEGIN:VCALENDAR` dans le corps de la réponse.
|
||||||
|
|
||||||
|
La récupération nécessite une **réextraction manuelle** d’une nouvelle URL iCal depuis l’interface Pronote.
|
||||||
|
**Bonnes pratiques** : tester l’URL avant chaque rentrée et la régénérer proactivement si l’établissement est connu pour rotater les jetons à cette période.
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
1. **Surface d’attaque** : Le jeton est encodé dans l’URL. Il peut être exposé via les logs d’accès du serveur, les logs proxy, les en-têtes `Referer`, l’historique du navigateur ou une interception réseau (atténué par HTTPS).
|
||||||
|
2. **Exposition des identifiants** : En cas de fuite, le jeton accorde un accès en lecture à l’emploi du temps (et aux devoirs, si inclus) jusqu’à sa rotation par l’établissement.
|
||||||
|
3. **Résistance au rejeu** : Faible — absence de *nonce*, de validation temporelle ou de protection contre le *replay*. Toute entité disposant de l’URL peut récupérer les données à tout moment.
|
||||||
|
4. **Rotation** : Manuellement uniquement, via la régénération de l’URL dans Pronote. L’établissement contrôle les réinitialisations côté serveur.
|
||||||
|
5. **Recommandations** : Conserver l’URL comme un secret (jamais en contrôle de version). Utiliser HTTPS (par défaut). Limiter l’accès à l’URL en besoin d’en connaître. Rotater proactivement aux changements d’année scolaire. Masquer l’URL dans les messages d’erreur et les logs pour éviter les fuites accidentelles.
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
- **Périmètre des données** : limité à l’emploi du temps et, si inclus par l’établissement, aux devoirs (*cahier de textes*).
|
||||||
|
- **Accès en lecture seule** : aucune modification possible.
|
||||||
|
- **Exclusions** : notes, absences, messagerie, bulletins ou paramètres sont inaccessibles.
|
||||||
|
- **Latence** : l’export iCal peut présenter un délai de mise à jour (plusieurs heures) avant de refléter les modifications Pronote.
|
||||||
|
- **Rafraîchissement** : impossible par programmation — une intervention manuelle est toujours requise.
|
||||||
|
|
||||||
|
### Statut
|
||||||
|
|
||||||
|
**Officiel** — L’export iCal est une fonctionnalité supportée par Pronote, éditée par Index Éducation. Le mécanisme de jeton fait partie intégrante du produit, bien que son format interne et sa logique de génération ne soient pas documentés publiquement.
|
||||||
|
|
||||||
|
## Méthode 2 : Connexion directe (identifiant / mot de passe)
|
||||||
|
|
||||||
|
### Principe général
|
||||||
|
La connexion directe utilise un protocole propriétaire de type JSON sur HTTP(S), sécurisé par un chiffrement AES-256-CBC spécifique à la session et un mécanisme de défi-réponse. Il s’agit du protocole natif de Pronote, tel qu’utilisé par son interface web.
|
||||||
|
|
||||||
|
### Flux détaillé
|
||||||
|
|
||||||
|
**Étape 1 — Initialisation de la session (`GET /pronote/<espace>.html`)**
|
||||||
|
Le client effectue une requête GET vers l’espace cible (ex. `/pronote/eleve.html` pour un élève, `/pronote/parent.html` pour un parent). La réponse HTML contient un gestionnaire JavaScript `onload` avec les paramètres de session :
|
||||||
|
```
|
||||||
|
Session initialization parameters:
|
||||||
|
h = <session_id>
|
||||||
|
a = <espace_id> (3 = Élève, 7 = Parent)
|
||||||
|
sCrA = <encryption_flag>
|
||||||
|
sCoA = <compression_flag>
|
||||||
|
```
|
||||||
|
- `h` : identifiant de session (chaîne ou nombre unique).
|
||||||
|
- `a` : identifiant de l’espace (`3` pour Élève, `7` pour Parent).
|
||||||
|
- `sCrA` / `sCoA` : indicateurs de chiffrement et de compression.
|
||||||
|
|
||||||
|
**Étape 2 — Échange de clés (`POST /pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`)**
|
||||||
|
Le client envoie une charge utile `FonctionParametres` contenant `donneesSec.donnees.Uuid` :
|
||||||
|
- En HTTPS : un IV AES de 16 octets encodé en base64.
|
||||||
|
- En HTTP non sécurisé : un IV chiffré avec RSA-1024.
|
||||||
|
- `numeroOrdre` est un compteur incrémental (début à 1).
|
||||||
|
- Pour la première requête, `numeroOrdre` est chiffré avec AES-256-CBC, une clé vide MD5 (`d41d8cd98f00b204e9800998ecf8427e`) et un IV nul.
|
||||||
|
- Pour les requêtes suivantes, l’IV de session (issu de `Uuid`) est utilisé.
|
||||||
|
|
||||||
|
**Étape 3 — Identification (`POST ... / Identification`)**
|
||||||
|
Le client soumet une charge utile JSON :
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"nom": "Identification",
|
||||||
|
"session": "<session_id>",
|
||||||
|
"numeroOrdre": "<compteur_chiffré>",
|
||||||
|
"donneesSec": {
|
||||||
|
"donnees": {
|
||||||
|
"identifiant": "<nom_utilisateur>",
|
||||||
|
"genreConnexion": 0,
|
||||||
|
"genreEspace": <espace_id>,
|
||||||
|
"pourENT": false,
|
||||||
|
"enConnexionAuto": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Le serveur retourne : `alea` (sel aléatoire), `challenge` (chaîne hexadécimale), `modeCompLog` et `modeCompMdp` (indicateurs de normalisation de casse).
|
||||||
|
|
||||||
|
**Étape 4 — Résolution du défi**
|
||||||
|
Le client calcule le hachage du mot de passe :
|
||||||
|
```
|
||||||
|
mtp = MAJUSCULE(HEX(SHA256(alea + mot_de_passe_utilisateur)))
|
||||||
|
```
|
||||||
|
La clé de déchiffrement du défi est dérivée :
|
||||||
|
```
|
||||||
|
key_challenge = MD5(nom_utilisateur + mtp)
|
||||||
|
```
|
||||||
|
Le client déchiffre `challenge` avec AES-256-CBC, `key_challenge` et l’IV de session. Il supprime ensuite un caractère sur deux dans le texte en clair (ex. `abcdef` → `ace`), puis rechiffre la chaîne modifiée avec les mêmes paramètres AES et l’encode en hexadécimal.
|
||||||
|
|
||||||
|
**Étape 5 — Authentification (`POST ... / Authentification`)**
|
||||||
|
Le client envoie la réponse au défi via la fonction `Authentification`. Le serveur retourne les métadonnées utilisateur et une chaîne `cle` (entiers séparés par des virgules). Le client déchiffre `cle`, analyse les octets et calcule `MD5(octets)` pour obtenir la clé principale de chiffrement de session pour toutes les appels API ultérieurs.
|
||||||
|
|
||||||
|
### Données échangées
|
||||||
|
Identifiant de session, identifiant d’espace, IV AES (base64 ou chiffré RSA), compteur `numeroOrdre`, sel `alea`, chaîne de défi `challenge`, clé de session (`cle`). Toutes les données sensibles sont chiffrées en transit (AES-256-CBC).
|
||||||
|
|
||||||
|
### Identifiants et jetons
|
||||||
|
- **Identifiants** : nom d’utilisateur Pronote et mot de passe attribués par l’établissement.
|
||||||
|
- **Jetons de session** : identifiant de session (`h`), compteur de séquence (`numeroOrdre`), clé symétrique AES-256 (dérivée de `cle`).
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
- Les identifiants de session expirent après une courte période d’inactivité (généralement 15 à 30 minutes).
|
||||||
|
- La clé de session doit être utilisée pour tous les appels API ultérieurs dans la même session.
|
||||||
|
- Une réauthentification est nécessaire après expiration de la session.
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
1. **Surface d’attaque** : protocole propriétaire avec cryptographie personnalisée. Le point de terminaison de connexion est accessible depuis Internet. Les attaques MITM sont atténuées par HTTPS (mais RSA-1024 est utilisé pour l’échange d’IV en HTTP non sécurisé, ce qui est faible selon les normes modernes).
|
||||||
|
2. **Exposition des identifiants** : le nom d’utilisateur est transmis dans la requête d’identification (chiffré). Le mot de passe n’est jamais transmis en clair : seule la réponse au défi est envoyée.
|
||||||
|
3. **Résistance au rejeu** : modérée. Le mécanisme de défi-réponse utilise un sel aléatoire (`alea`) par session, rendant difficile le rejou d’une réponse de défi capturée. Cependant, le compteur `numeroOrdre` doit être géré avec soin pour éviter toute manipulation de séquence.
|
||||||
|
4. **Rotation** : aucune rotation automatique des jetons. La rotation des mots de passe dépend de la politique de l’établissement. Les clés de session expirent avec la session.
|
||||||
|
5. **Recommandations** : utiliser systématiquement HTTPS. Implémenter une gestion rigoureuse de `numeroOrdre`. Stocker les identifiants de manière sécurisée. Noter que la cryptographie personnalisée n’est pas équivalente à une authentification TLS standard : s’appuyer sur HTTPS pour la sécurité du transport.
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- La plupart des établissements secondaires français liés à un ENT ou à EduConnect bloquent les connexions directes par nom d’utilisateur/mot de passe et imposent le SSO.
|
||||||
|
- Le protocole propriétaire n’est pas officiellement documenté et peut changer sans préavis.
|
||||||
|
- La cryptographie personnalisée (AES-256-CBC avec des clés dérivées de MD5) est non standard et n’a pas fait l’objet d’un audit indépendant.
|
||||||
|
|
||||||
|
### Statut
|
||||||
|
**Rétro-conçu** — Index Éducation ne publie pas le protocole. Toutes les connaissances proviennent de l’analyse communautaire du client web Pronote en JavaScript.
|
||||||
|
|
||||||
|
## Méthode 3 : ENT (Espace Numérique de Travail)
|
||||||
|
|
||||||
|
### Principe général
|
||||||
|
La plupart des collèges et lycées français accèdent à Pronote via un ENT (Espace Numérique de Travail) régional ou départemental. L’ENT agit comme fournisseur d’identité (IdP) : l’utilisateur s’authentifie auprès de l’ENT, qui établit ensuite une session avec Pronote par SSO (Single Sign-On). Pronote reçoit des identifiants délégués sans gérer directement la connexion.
|
||||||
|
|
||||||
|
### Flux détaillé
|
||||||
|
1. **Connexion ENT** : L’utilisateur soumet ses identifiants à l’endpoint de connexion spécifique à l’ENT. Chaque ENT utilise son propre mécanisme (formulaire, CAS, SAML, Keycloak, etc.).
|
||||||
|
|
||||||
|
2. **Redirection SSO vers Pronote** : L’ENT redirige la session authentifiée vers Pronote via un lien connecteur ou une URL proxy (ex. `/cas/proxySSO/...` ou un lien SAML direct). L’ENT valide la session et redirige vers l’instance Pronote de l’établissement avec des cookies ou assertions SAML valides.
|
||||||
|
|
||||||
|
3. **Handshake Pronote** : La réponse HTML initiale de Pronote contient des identifiants temporaires dans l’attribut `onload` du `<body>` :
|
||||||
|
```
|
||||||
|
Session initialization parameters:
|
||||||
|
e = <temp_login>
|
||||||
|
f = <temp_auth_token>
|
||||||
|
...
|
||||||
|
```
|
||||||
|
- `e` : chaîne de connexion temporaire (unique par session SSO).
|
||||||
|
- `f` : jeton d’authentification temporaire.
|
||||||
|
|
||||||
|
4. **Challenge de session** : Le client exécute la requête standard `Identification` de Pronote (comme en Méthode 2) avec `pourENT: true`. Le challenge est résolu via une dérivation simplifiée :
|
||||||
|
```
|
||||||
|
key_ENT = MD5(UPPERCASE(HEX(SHA256(f))))
|
||||||
|
```
|
||||||
|
Cela remplace la dérivation `MD5(username + mtp)` utilisée en connexion directe. Aucun mot de passe utilisateur n’est nécessaire : le jeton `f` émis par l’ENT sert de justificatif.
|
||||||
|
|
||||||
|
|
||||||
|
### Architectures ENT prises en charge
|
||||||
|
|
||||||
|
| Architecture | Exemples | Mécanisme SSO |
|
||||||
|
|---|---|---|
|
||||||
|
| Open ENT NG / Open Digital Education | ent.iledefrance.fr, Paris Classe Numérique, Mon Collège Val d’Oise, L’Éduc de Normandie | SAML / redirection |
|
||||||
|
| Kosmos / Skolengo CAS | Mon Bureau Numérique, Mon-ENT-Occitanie, Cybercollèges42 | CAS |
|
||||||
|
| Oze ENT | (divers) | Keycloak avec endpoints proxy `/v1/ozapps` |
|
||||||
|
| WAYF / Shibboleth | e-lyco (Pays de la Loire) | SAML / Shibboleth |
|
||||||
|
| Portails personnalisés | Atrium Sud, LaClasse Lyon | Formulaires simples |
|
||||||
|
|
||||||
|
|
||||||
|
### Données échangées
|
||||||
|
Cookies/assertions de session ENT → identifiants temporaires Pronote (`e`, `f`) → session Pronote (via challenge-response avec clé dérivée de l’ENT).
|
||||||
|
|
||||||
|
|
||||||
|
### Identifiants et jetons
|
||||||
|
- **Identifiants principaux** : nom d’utilisateur et mot de passe ENT (spécifiques à chaque plateforme ENT).
|
||||||
|
- **Identifiants délégués** : `e` (connexion temporaire) et `f` (jeton) émis par Pronote après la redirection SSO.
|
||||||
|
- **Jeton de session** : clé AES-256 standard de Pronote (identique à la Méthode 2, dérivée après résolution du challenge).
|
||||||
|
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
- Les sessions ENT sont temporaires : leur durée dépend des politiques de chaque plateforme.
|
||||||
|
- La session Pronote établie via l’ENT suit le même cycle qu’une session en connexion directe (timeout d’inactivité ~15–30 min).
|
||||||
|
- Les tâches automatisées récurrentes doivent se réauthentifier régulièrement auprès de l’ENT.
|
||||||
|
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
1. **Surface d’attaque** : Les chaînes de redirection multiples (ENT → Pronote) augmentent la surface d’attaque. Chaque redirection est une opportunité d’interception de jetons.
|
||||||
|
2. **Exposition des identifiants** : Les identifiants utilisateur n’atteignent jamais Pronote directement. L’ENT agit comme intermédiaire de confiance. Le jeton `f` est éphémère (valide uniquement pour l’établissement initial de la session).
|
||||||
|
3. **Résistance au rejeu** : Le mécanisme challenge-response (identique à la Méthode 2) offre une résistance au rejeu pour la session Pronote. Les jetons de redirection SSO sont à usage unique.
|
||||||
|
4. **Rotation** : Le cycle de vie de la session ENT contrôle la rotation des identifiants. La clé de session Pronote est rotative par session.
|
||||||
|
5. **Recommandations** : Valider les certificats SSL à chaque étape de redirection. Ne pas journaliser les jetons intermédiaires. Les formulaires de connexion ENT évoluent fréquemment, ce qui peut rompre les clients automatisés.
|
||||||
|
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- Les modifications des formulaires web de connexion ENT, des endpoints SAML ou de l’authentification multifacteur (MFA) rompent souvent les clients automatisés sans interface.
|
||||||
|
- Chaque ENT possède un flux de connexion différent : aucune automatisation universelle n’est possible.
|
||||||
|
- Les sessions ENT sont temporaires ; les tâches automatisées récurrentes doivent se réauthentifier régulièrement.
|
||||||
|
- Certains ENT implémentent du MFA ou des CAPTCHA empêchant une automatisation complète.
|
||||||
|
|
||||||
|
|
||||||
|
### Statut
|
||||||
|
Implémentation SSO standardisée par Index Éducation et les éditeurs d’ENT, mais la consommation du protocole par des clients tiers est **reverse-engineered**.
|
||||||
|
|
||||||
|
## Méthode 4 : EduConnect
|
||||||
|
|
||||||
|
### Principe général
|
||||||
|
EduConnect est le service national d’authentification et de gestion des accès opéré par le Ministère de l’Éducation Nationale et de la Jeunesse (MENJ). Il agit comme fournisseur d’identité (IdP) pour les élèves et parents en France. L’authentification vers Pronote s’effectue selon deux modes, selon l’infrastructure de l’établissement.
|
||||||
|
|
||||||
|
### Flux détaillé
|
||||||
|
|
||||||
|
### Mode A : EduConnect via ENT régional
|
||||||
|
1. Le client initie une requête vers la page de connexion de l’ENT régional avec le paramètre `selection=EDU_parent_eleve`.
|
||||||
|
2. L’ENT redirige vers l’endpoint SAML2 d’EduConnect :
|
||||||
|
```
|
||||||
|
https://educonnect.education.gouv.fr/idp/profile/SAML2/Unsolicited/SSO
|
||||||
|
```
|
||||||
|
3. Le client soumet les identifiants à EduConnect :
|
||||||
|
- `j_username` : identifiant EduConnect,
|
||||||
|
- `j_password` : mot de passe EduConnect,
|
||||||
|
- `_eventId_proceed` : chaîne vide.
|
||||||
|
4. EduConnect retourne un formulaire `SAMLResponse` signé, posté vers le service de consommation d’assertions de l’ENT (ex. `/Shibboleth.sso/SAML2/POST`).
|
||||||
|
5. L’ENT établit des cookies de session et redirige vers Pronote (le flux ENT→Pronote suit alors la Méthode 3).
|
||||||
|
|
||||||
|
### Mode B : HubEduConnect direct (SSO Index Éducation Cloud)
|
||||||
|
Pour les établissements sans ENT régional :
|
||||||
|
1. Le client accède à la passerelle CAS centralisée d’Index Éducation :
|
||||||
|
```
|
||||||
|
https://hubeduconnect.index-education.net/EduConnect/cas/login?service=<URL_INSTANCE_PRONOTE>
|
||||||
|
```
|
||||||
|
2. La passerelle initie une requête SAML vers `educonnect.education.gouv.fr`.
|
||||||
|
3. L’utilisateur s’authentifie sur EduConnect (mêmes identifiants que le Mode A).
|
||||||
|
4. HubEduConnect reçoit l’assertion, la valide via une liste blanche, et redirige vers Pronote avec un ticket de service.
|
||||||
|
5. Pronote valide le ticket et établit la session (flux côté Pronote identique à la Méthode 2).
|
||||||
|
|
||||||
|
### Données échangées
|
||||||
|
- **Mode A** : Identifiants EduConnect → assertion SAML2 → cookies ENT → handshake SSO Pronote.
|
||||||
|
- **Mode B** : Identifiants EduConnect → assertion SAML2 → ticket CAS → session Pronote.
|
||||||
|
|
||||||
|
### Identifiants et jetons
|
||||||
|
- Identifiants principaux : identifiants nationaux EduConnect (gérés par le MENJ).
|
||||||
|
- Jetons intermédiaires : assertions SAML2 (Modes A/B), tickets de service CAS (Mode B).
|
||||||
|
- Jeton final : clé de session AES-256 Pronote (identique à la Méthode 2).
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
- Les sessions EduConnect sont temporaires (durée définie par le MENJ).
|
||||||
|
- Le ticket CAS (Mode B) est à usage unique et consommé lors de la validation par Pronote.
|
||||||
|
- La session Pronote suit un timeout d’inactivité standard (~15–30 min).
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
1. **Surface d’attaque** : Chaînes de redirections multiples (EduConnect → ENT/Hub → Pronote). Chaque redirection est un point d’interception. Les assertions SAML sont signées, réduisant les risques de contrefaçon.
|
||||||
|
2. **Exposition des identifiants** : Les identifiants EduConnect sont soumis directement à l’IdP EduConnect et ne transitent jamais vers Pronote ou l’ENT. Seule l’assertion SAML signée est transmise.
|
||||||
|
3. **Résistance au rejeu** : Les assertions SAML incluent des timestamps et sont à usage unique. Les tickets CAS le sont également.
|
||||||
|
4. **Rotation** : Gérée par le MENJ. Aucun mécanisme local.
|
||||||
|
5. **Recommandations** : Valider les signatures des assertions SAML. Ne pas mettre en cache les identifiants EduConnect. Les authentifications 2FA par SMS ou via FranceConnect ne sont pas gérables par des clients automatisés.
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- Les authentifications 2FA par SMS ou FranceConnect ne sont pas contournables par des clients programmatiques.
|
||||||
|
- Le flux repose sur des redirections HTTP multiples, fragiles et sensibles à la gestion des cookies.
|
||||||
|
- Les identifiants nationaux sont hautement sensibles : leur compromission affecte tous les services éducatifs.
|
||||||
|
|
||||||
|
### Statut
|
||||||
|
**Service officiel (MENJ / Index Éducation)** — EduConnect est un service gouvernemental officiel. Cependant, l’interaction programmatique par des clients tiers relève du **reverse engineering**.
|
||||||
|
|
||||||
|
## Méthode 5 : CAS (Central Authentication Service)
|
||||||
|
|
||||||
|
### Principe général
|
||||||
|
CAS (Central Authentication Service) est le protocole SSO sous-jacent utilisé dans les réseaux scolaires propriétaires et les ENT régionaux pour autoriser l’accès à Pronote. Protocole standardisé (RFC 4520), il est implémenté côté serveur. Pronote délègue l’authentification au serveur CAS, sans gérer directement les identifiants.
|
||||||
|
|
||||||
|
### Flux détaillé
|
||||||
|
1. **Demande de ticket de service** :
|
||||||
|
Pronote redirige l’utilisateur vers le serveur CAS :
|
||||||
|
```
|
||||||
|
https://<cas-server>/login?service=https://<pronote-host>/pronote/<espace>.html
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Authentification CAS** :
|
||||||
|
L’utilisateur soumet ses identifiants (ou effectue une authentification fédérée via EduConnect) sur le formulaire CAS.
|
||||||
|
|
||||||
|
3. **Génération du ticket** :
|
||||||
|
CAS renvoie une redirection HTTP 302 vers Pronote avec un paramètre `ticket` :
|
||||||
|
```
|
||||||
|
https://<pronote-host>/pronote/<espace>.html?ticket=ST-XXXXX-cas
|
||||||
|
```
|
||||||
|
Le ticket, préfixé par `ST-` (Service Ticket), est à usage unique.
|
||||||
|
|
||||||
|
4. **Validation du ticket de service** :
|
||||||
|
Le backend Pronote contacte directement l’URL de validation CAS pour vérifier le ticket et obtenir les attributs utilisateur :
|
||||||
|
```
|
||||||
|
https://<cas-server>/serviceValidate?service=https://<pronote-host>/pronote/<espace>.html&ticket=ST-XXXXX-cas
|
||||||
|
```
|
||||||
|
Le serveur CAS retourne les attributs (ex. : code UAI de l’établissement, identifiant élève). Pronote génère la session active et injecte le contexte d’initialisation dans le HTML client (mécanisme `onload` identique aux Méthodes 2 et 3).
|
||||||
|
|
||||||
|
### Données échangées
|
||||||
|
Identifiants CAS → Validation serveur CAS → Ticket de service (`ST-XXXXX-cas`) → Réponse de validation (attributs utilisateur : UAI, identifiant élève) → Initialisation de la session Pronote.
|
||||||
|
|
||||||
|
### Identifiants et jetons
|
||||||
|
- **Identifiants principaux** : Nom d’utilisateur et mot de passe CAS (ou identifiants fédérés EduConnect).
|
||||||
|
- **Jeton** : Ticket de service CAS (`ST-`, à usage unique).
|
||||||
|
- **Jeton de session** : Clé de session AES-256 Pronote (identique à la Méthode 2).
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
- Le ticket de service CAS est **à usage unique** : il est consommé lors de la validation et ne peut être réutilisé.
|
||||||
|
- La session Pronote résultante suit un timeout d’inactivité standard (~15–30 min).
|
||||||
|
- La durée de vie de la session CAS est régie par la politique de *Ticket-Granting Ticket* (TGT) du serveur CAS.
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
1. **Surface d’attaque** : Protocole standardisé et documenté. La surface d’attaque concerne principalement le formulaire de connexion CAS et la transmission du ticket (protégée par HTTPS).
|
||||||
|
2. **Exposition des identifiants** : Les identifiants sont soumis uniquement au serveur CAS — Pronote ne les voit jamais. Seul le ticket de service est transmis à Pronote.
|
||||||
|
3. **Résistance au rejeu** : Élevée — les tickets de service sont à usage unique et liés à une URL de service spécifique.
|
||||||
|
4. **Rotation** : La rotation des TGT est gérée par la politique du serveur CAS. Les tickets de service expirent rapidement (généralement en quelques secondes ou minutes).
|
||||||
|
5. **Recommandations** : Utiliser systématiquement HTTPS. Valider le paramètre `service` pour éviter le vol de tickets via des URL de service malveillantes. Appliquer une gestion rigoureuse du cycle de vie des TGT.
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- CAS est un protocole côté serveur : le serveur CAS doit être correctement configuré et accessible.
|
||||||
|
- L’appel `serviceValidate` s’effectue de serveur à serveur (backend Pronote → serveur CAS), nécessitant une connectivité réseau entre eux.
|
||||||
|
- Toutes les écoles n’utilisent pas CAS : certaines privilégient SAML ou des solutions SSO personnalisées.
|
||||||
|
|
||||||
|
### Statut
|
||||||
|
**Officiel** — CAS est un protocole standardisé (RFC 4520) officiellement implémenté côté backend Pronote et serveurs CAS.
|
||||||
|
|
||||||
|
## Méthode 6 : QR Code et jeton mobile
|
||||||
|
|
||||||
|
### Principe général
|
||||||
|
Pronote propose un mécanisme d’appairage par QR code pour connecter les appareils mobiles sans saisir les identifiants ENT complexes. L’utilisateur génère un QR code depuis l’interface web, définit un code PIN temporaire à 4 chiffres, puis le scanne avec l’application mobile. Le QR code contient des identifiants chiffrés qui, une fois déchiffrés, permettent un échange de jeton à longue durée de vie. Ce mécanisme contourne entièrement l’authentification ENT/EduConnect.
|
||||||
|
|
||||||
|
|
||||||
|
### Flux détaillé
|
||||||
|
|
||||||
|
**Phase 1 — Génération (interface web Pronote)**
|
||||||
|
1. Dans l’interface web, l’utilisateur accède à *Paramètres → Accès mobile / Application mobile*.
|
||||||
|
2. Il définit un code PIN temporaire à 4 chiffres.
|
||||||
|
3. Pronote affiche un QR code contenant un JSON chiffré :
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"login": "<AES_HEX_encrypted_username>",
|
||||||
|
"jeton": "<AES_HEX_encrypted_token>",
|
||||||
|
"url": "https://<host>/pronote/mobile.eleve.html"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase 2 — Déchiffrement**
|
||||||
|
- Algorithme : **AES-256-CBC**.
|
||||||
|
- IV : 16 octets nuls (`0x00...`).
|
||||||
|
- Clé : `MD5(PIN_code_string)` (ex. `MD5("1234")`).
|
||||||
|
- Résultat : `login` et `jeton` en clair.
|
||||||
|
|
||||||
|
**Phase 3 — Échange d’appairage initial**
|
||||||
|
1. Le client génère un UUID permanent (`uuidAppliMobile`).
|
||||||
|
2. Il envoie une requête à : `https://<host>/pronote/mobile.<espace>.html?login=true` (contourne la redirection ENT).
|
||||||
|
3. Requête `Identification` avec :
|
||||||
|
- `demandeConnexionAppliMobile: true`
|
||||||
|
- `demandeConnexionAppliMobileJeton: true`
|
||||||
|
- `uuidAppliMobile: "<DEVICE_UUID>"`
|
||||||
|
- `identifiant: "<decrypted_login>"`
|
||||||
|
4. Le défi est résolu avec `<decrypted_jeton>` comme mot de passe (mécanisme identique à la Méthode 2).
|
||||||
|
5. Le serveur retourne `jetonConnexionAppliMobile` (jeton à longue durée de vie).
|
||||||
|
|
||||||
|
**Phase 4 — Connexions ultérieures (auto-login)**
|
||||||
|
- `identifiant: <decrypted_login>`
|
||||||
|
- `uuidAppliMobile: <DEVICE_UUID>`
|
||||||
|
- `enConnexionAppliMobile: true`
|
||||||
|
- Mot de passe pour le défi : `jetonConnexionAppliMobile`
|
||||||
|
- À chaque connexion réussie, Pronote retourne un nouveau `jetonConnexionAppliMobile` à conserver.
|
||||||
|
|
||||||
|
|
||||||
|
### Données échangées
|
||||||
|
QR code JSON (login + jeton chiffrés + URL) → déchiffrement → `Identification` avec drapeaux mobiles → défi-réponse → `jetonConnexionAppliMobile`.
|
||||||
|
|
||||||
|
|
||||||
|
### Identifiants et jetons
|
||||||
|
- **Initial** : Code PIN à 4 chiffres (temporaire, utilisé uniquement pour le déchiffrement du QR code).
|
||||||
|
- **Déchiffrés** : `login` et `jeton` extraits du QR code (usage unique pour l’appairage initial).
|
||||||
|
- **Persistants** : `uuidAppliMobile` (UUID de l’appareil, permanent) + `jetonConnexionAppliMobile` (jeton à longue durée de vie, rafraîchi à chaque connexion).
|
||||||
|
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
- Le QR code est valide **10 minutes** après génération.
|
||||||
|
- Le `jetonConnexionAppliMobile` reste valide indéfiniment (souvent toute l’année scolaire), sauf :
|
||||||
|
- Révoqué par l’utilisateur dans les paramètres Pronote.
|
||||||
|
- Invalidé côté serveur.
|
||||||
|
- Le jeton est rafraîchi à chaque connexion réussie.
|
||||||
|
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
1. **Surface d’attaque** : Le QR code est affiché à l’écran (risque de *shoulder-surfing*). Le PIN à 4 chiffres offre 10 000 combinaisons. Le `jetonConnexionAppliMobile` est un identifiant à longue durée de vie stocké sur l’appareil.
|
||||||
|
2. **Exposition des identifiants** : Le QR code contient des identifiants chiffrés. Si intercepté avant déchiffrement, l’attaquant a besoin du PIN. Une fois le `jetonConnexionAppliMobile` obtenu, aucun PIN ni mot de passe n’est requis.
|
||||||
|
3. **Résistance au rejeu** : Le `jetonConnexionAppliMobile` est un *bearer token* : toute partie en possession du jeton peut s’authentifier. Le `uuidAppliMobile` offre un lien faible avec l’appareil, mais non vérifié cryptographiquement.
|
||||||
|
4. **Rotation** : Le `jetonConnexionAppliMobile` est rafraîchi à chaque connexion, mais l’ancien reste valide jusqu’à invalidation côté serveur. Aucune expiration automatique.
|
||||||
|
5. **Recommandations** : Utiliser un PIN robuste. Traiter le `jetonConnexionAppliMobile` comme un identifiant à longue durée de vie (stockage sécurisé). En cas de vol de l’appareil, révoquer l’accès mobile dans Pronote.
|
||||||
|
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- Le QR code expire après **10 minutes** : l’appairage doit être rapide.
|
||||||
|
- Le PIN à 4 chiffres est faible selon les normes modernes.
|
||||||
|
- Le `jetonConnexionAppliMobile` n’a pas d’expiration automatique : il persiste jusqu’à révocation manuelle.
|
||||||
|
- Le `uuidAppliMobile` n’est pas lié cryptographiquement à l’appareil : il peut être copié.
|
||||||
|
|
||||||
|
|
||||||
|
### Statut
|
||||||
|
Mécanisme **officiellement intégré** à l’application mobile Index Éducation. L’utilisation par des clients tiers repose sur de l’**ingénierie inverse**.
|
||||||
|
|
||||||
|
## Méthode 7 : API officielle et application mobile
|
||||||
|
|
||||||
|
### Principe général
|
||||||
|
Index Éducation ne propose pas d’API publique pour Pronote. L’accès tiers aux données repose sur l’ingénierie inverse des mêmes endpoints JSON-over-HTTP(S) utilisés par les clients web et mobile officiels. L’application mobile officielle s’authentifie via le mécanisme d’appairage par QR Code (Méthode 6) et communique via le même protocole propriétaire que le client web.
|
||||||
|
|
||||||
|
### Flux détaillé
|
||||||
|
|
||||||
|
L'application mobile s'authentifie via le flux d'appairage par QR Code (Méthode 6) puis communique via le même protocole JSON-over-HTTP(S) que le client web, en utilisant les endpoints `/pronote/mobile.<espace>.html` et `/pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`.
|
||||||
|
|
||||||
|
### Disponibilité d'une API développeur
|
||||||
|
- **API publique** : Inexistante. Index Éducation ne propose ni API REST ni GraphQL pour les élèves, parents ou développeurs tiers.
|
||||||
|
- **API institutionnelle/entreprise** : Des services d’intégration propriétaires sont proposés pour les systèmes partenaires (ex. connecteurs UDTS, HYPERPLANNING, ENT officiels). Ceux-ci nécessitent des accords de partenariat signés et des licences serveurs institutionnelles.
|
||||||
|
|
||||||
|
### Authentification de l'application mobile officielle
|
||||||
|
L’application mobile officielle (iOS/Android) se connecte via les mêmes endpoints JSON-over-HTTP(S) que l’interface web mobile :
|
||||||
|
- Chemins de base : `/pronote/mobile.<espace>.html`
|
||||||
|
- Dispatcher de fonctions : `/pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`
|
||||||
|
- Authentification : Utilise le flux d’appairage par QR Code (Méthode 6) et le jeton mobile persistant (`jetonConnexionAppliMobile` + `uuidAppliMobile`).
|
||||||
|
|
||||||
|
### Données échangées
|
||||||
|
Mêmes charges utiles JSON chiffrées en AES-256-CBC que le client web (Méthode 2). La variante mobile (`mobile.<espace>.html`) contourne les redirections SSO ENT/EduConnect.
|
||||||
|
|
||||||
|
### Identifiants et jetons
|
||||||
|
- `jetonConnexionAppliMobile` : Jeton bearer à longue durée de vie (voir Méthode 6).
|
||||||
|
- `uuidAppliMobile` : UUID de l’appareil.
|
||||||
|
- Clé de session : Clé AES-256 (même dérivation que la Méthode 2).
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
Le `jetonConnexionAppliMobile` est rafraîchi à chaque connexion. La durée de vie de la session suit le même délai d’inactivité (~15–30 min) que le client web.
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
1. **Surface d’attaque** : Les endpoints mobiles sont accessibles publiquement. Aucune clé API ni enregistrement développeur n’existe — néanmoins, l’accès exige un matériel de session Pronote valide et, pour l’auto-login mobile, le matériel d’authentification correspondant (`login`, `uuidAppliMobile`, `jetonConnexionAppliMobile`) issu du flux d’appairage par QR Code (Méthode 6).
|
||||||
|
2. **Exposition des identifiants** : Le `jetonConnexionAppliMobile` est un jeton bearer stocké sur l’appareil. S’il est extrait, il accorde un accès complet jusqu’à révocation.
|
||||||
|
3. **Résistance au rejeu** : Faible — le jeton est basé sur un bearer. Le `uuidAppliMobile` offre un lien faible avec l’appareil, non appliqué cryptographiquement.
|
||||||
|
4. **Rotation** : Le jeton est rafraîchi à chaque connexion, mais les anciens restent valides. Aucune expiration automatique.
|
||||||
|
5. **Recommandations** : Évitez l’ingénierie inverse du protocole pour un usage en production sans comprendre les implications légales. Stockez les jetons mobiles de manière sécurisée. Soyez conscient que Index Éducation peut modifier le protocole à tout moment.
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- Aucune documentation ou support officiel pour les développeurs tiers.
|
||||||
|
- Protocole propriétaire susceptible de changer sans préavis.
|
||||||
|
- Les implémentations basées sur l’ingénierie inverse peuvent cesser de fonctionner après les mises à jour de Pronote.
|
||||||
|
- Le statut légal de l’ingénierie inverse est incertain dans certaines juridictions.
|
||||||
|
|
||||||
|
### Statut
|
||||||
|
- API publique : **Inexistante**.
|
||||||
|
- Endpoints mobiles : **Ingénierie inverse** (même protocole que le client web, accessible via appairage QR Code).
|
||||||
|
|
||||||
|
## Annexe A : Bibliothèques open source tierces
|
||||||
|
|
||||||
|
Les bibliothèques open source ci-dessous implémentent les protocoles d'authentification Pronote décrits dans ce manuel. Ces projets sont maintenus par la communauté et ne sont pas affiliés à Index Éducation. Les fonctionnalités décrites reposent sur les informations publiques disponibles dans leurs dépôts et peuvent avoir évolué depuis la rédaction de ce document.
|
||||||
|
|
||||||
|
| Bibliothèque | Langage | Dépôt | Méthodes d'authentification prises en charge |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **pronotepy** | Python | `bain3/pronotepy` | Connexion directe, jeton mobile / QR code, CAS SSO, EduConnect (HubEduConnect direct et via 30+ ENT régionaux : Open ENT NG, Skolengo, Oze, Shibboleth/WAYF). |
|
||||||
|
| **pronote-api** | TypeScript / JS | `Litarvan/pronote-api` | Connexion directe, CAS SSO, redirections ENT, authentification par jeton. |
|
||||||
|
| **pronote-qrcode-api** | JavaScript | `Androz2091/pronote-qrcode-api` | Implémentation de référence pour le déchiffrement des QR codes Pronote et l'échange initial de jeton mobile. |
|
||||||
|
| **pawnote / Blocksnote** | TypeScript / JS | `BlocksHub/Blocksnote` | Clients TypeScript modernes implémentant le protocole Pronote complet (direct, ENT, QR code, jetons). |
|
||||||
|
|
||||||
|
Ces bibliothèques illustrent la faisabilité des méthodes d'authentification présentées. Leur utilisation en environnement de production comporte des risques : les modifications de protocole par Index Éducation peuvent rendre les implémentations obsolètes sans préavis, et le statut juridique de l'ingénierie inverse des protocoles propriétaires varie selon les juridictions.
|
||||||
|
|
||||||
|
## Annexe B : Tableau comparatif synthétique
|
||||||
|
|
||||||
|
Cette annexe consolide les caractéristiques clés des 7 méthodes d'authentification Pronote en un tableau de référence unique, incluant les dimensions d'analyse de sécurité.
|
||||||
|
|
||||||
|
| Méthode | Périmètre | Identifiants | Expiration | Complexité | Surface d'attaque | Exposition | Rejeu | Rotation | Recommandation | Statut |
|
||||||
|
|---------|-----------|--------------|------------|-----------|-------------------|-------------|-------|----------|----------------|--------|
|
||||||
|
| **URL iCal sécurisée** | EDT + devoirs (si inclus) | Jeton URL | Longue durée | Très faible | Jeton dans URL (logs, referers) | Credential longue durée | Faible (pas de nonce) | Manuelle | Stocker en secret, HTTPS, rotate à la rentrée | Officiel |
|
||||||
|
| **Connexion directe** | Complet | Identifiant + mot de passe | Session ~15–30 min | Élevée | Endpoint public, crypto custom | Mot de passe non transmis (challenge) | Modérée (alea aléatoire) | Session seulement | HTTPS obligatoire, gérer `numeroOrdre` | Reverse-engineered |
|
||||||
|
| **SSO ENT** | Complet | Identifiants ENT | Session ENT | Très élevée | Redirections multiples | Credentials via ENT (intermédiaire) | Jetons SSO single-use | Session ENT | Valider SSL à chaque redirection | Reverse-engineered |
|
||||||
|
| **SSO EduConnect** | Complet | Identifiants nationaux | Session EduConnect | Très élevée | Redirections EduConnect→ENT/Hub | Credentials MENJ (très sensibles) | SAML single-use + timestamps | MENJ | Ne pas cacher les credentials, 2FA bloque automation | Reverse-engineered |
|
||||||
|
| **CAS** | Complet | Identifiants CAS | Ticket single-use | Modérée | Login form CAS | Credentials via CAS uniquement | Ticket single-use | TGT (politique CAS) | Valider paramètre `service`, HTTPS | Officiel |
|
||||||
|
| **QR Code + jeton mobile** | Complet | PIN 4 chiffres → jeton + UUID | QR 10 min ; jeton longue durée | Modérée | QR écran, PIN faible, jeton bearer | Jeton longue durée stocké sur appareil | Faible (bearer) | À chaque login (ancien reste valide) | PIN fort, stockage sécurisé, révoquer si perte | Reverse-engineered |
|
||||||
|
| **API publique** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | Inexistante |
|
||||||
|
|
||||||
|
**Légende** :
|
||||||
|
- *Complet* : accès aux notes, emploi du temps, devoirs, absences, messagerie et paramètres.
|
||||||
|
- *Reverse-engineered* : protocole non publié officiellement par Index Éducation, basé sur l'analyse communautaire.
|
||||||
|
- *Officiel* : mécanisme fourni et pris en charge par Index Éducation.
|
||||||
BIN
docs/pronote-auth.pdf
Normal file
BIN
docs/pronote-auth.pdf
Normal file
Binary file not shown.
56
docs/pronote-auth.tex
Normal file
56
docs/pronote-auth.tex
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
%!TEX TS-program = lualatex
|
||||||
|
%!TEX encoding = UTF-8 Unicode
|
||||||
|
%% =============================================================================
|
||||||
|
%% FICHIER : pronote-auth.tex
|
||||||
|
%% DESCRIPTION : Manuel technique & académique — Authentification Pronote
|
||||||
|
%% TITRE : Authentification Pronote — Référence technique
|
||||||
|
%% AUTEUR : Équipe Architecture & Sécurité
|
||||||
|
%% DATE : Septembre 2026
|
||||||
|
%%
|
||||||
|
%% INSTRUCTIONS DE COMPILATION :
|
||||||
|
%% Ce document est conçu pour être compilé avec LuaLaTeX :
|
||||||
|
%% lualatex -interaction=nonstopmode docs/pronote-auth.tex
|
||||||
|
%% lualatex -interaction=nonstopmode docs/pronote-auth.tex
|
||||||
|
%% (Deux passes nécessaires pour la génération de la table des matières,
|
||||||
|
%% des références croisées et des hyperliens).
|
||||||
|
%%
|
||||||
|
%% CHOIX TYPOGRAPHIQUES & DESIGN :
|
||||||
|
%% 1. Police Principale (Sérif) : EB Garamond
|
||||||
|
%% Justification : Référence historique et académique de la typographie
|
||||||
|
%% française, apportant élégance, chaleur et excellente lisibilité sur
|
||||||
|
%% papier ou écran haute densité.
|
||||||
|
%% 2. Police Sans-Sérif : Carlito (compatible métriquement Calibri)
|
||||||
|
%% Justification : Modernité sobre, sans empattement, idéale pour les
|
||||||
|
%% titres techniques, étiquettes de tableaux et métadonnées.
|
||||||
|
%% 3. Police Chasse Fixe (Monospace) : DejaVu Sans Mono
|
||||||
|
%% Justification : Rendu précis des glyphes informatiques, alignement
|
||||||
|
%% parfait pour les algorithmes, JSON et requêtes HTTP.
|
||||||
|
%% 4. Palette Chromatique : Palette "Bleu Institutionnel & Ardoise"
|
||||||
|
%% - primaryNavy (#142D55) : Titres, identité principale, reliure visuelle
|
||||||
|
%% - secondarySlate (#465F7D) : Sous-titres, filets, structure
|
||||||
|
%% - accentSteel (#007396) : Liens hypertexte, repères visuels
|
||||||
|
%% - Teintes fonctionnelles douces pour encadrés et callouts.
|
||||||
|
%% =============================================================================
|
||||||
|
|
||||||
|
\input{preamble}
|
||||||
|
|
||||||
|
\begin{document}
|
||||||
|
|
||||||
|
\input{frontmatter}
|
||||||
|
|
||||||
|
\include{ch-introduction}
|
||||||
|
\include{ch-overview}
|
||||||
|
\include{ch-method1}
|
||||||
|
\include{ch-method2}
|
||||||
|
\include{ch-method3}
|
||||||
|
\include{ch-method4}
|
||||||
|
\include{ch-method5}
|
||||||
|
\include{ch-method6}
|
||||||
|
\include{ch-method7}
|
||||||
|
|
||||||
|
\appendix
|
||||||
|
|
||||||
|
\include{annex-libraries}
|
||||||
|
\include{annex-comparison}
|
||||||
|
|
||||||
|
\end{document}
|
||||||
@@ -37,11 +37,14 @@ class PronoteSettings(BaseSettings):
|
|||||||
username: str | None = None
|
username: str | None = None
|
||||||
password: SecretStr | None = None
|
password: SecretStr | None = None
|
||||||
ent: str | None = None
|
ent: str | None = None
|
||||||
pronote_url: str | None = None
|
url: str | None = None
|
||||||
account_type: Literal["student", "parent"] = "parent"
|
account_type: Literal["student", "parent"] = "parent"
|
||||||
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||||
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||||
messages_source: Literal["pronotepy"] = "pronotepy"
|
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")
|
@field_serializer("ical_url")
|
||||||
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
||||||
@@ -55,6 +58,18 @@ class PronoteSettings(BaseSettings):
|
|||||||
return None
|
return None
|
||||||
return "**********"
|
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):
|
class CalDAVSettings(BaseSettings):
|
||||||
"""Paramètres d'accès au serveur CalDAV de destination.
|
"""Paramètres d'accès au serveur CalDAV de destination.
|
||||||
@@ -266,8 +281,9 @@ class Settings(BaseSettings):
|
|||||||
"""Énumère tous les secrets configurés pour la rédaction.
|
"""Énumère tous les secrets configurés pour la rédaction.
|
||||||
|
|
||||||
Collecte les valeurs :class:`pydantic.SecretStr` non vides présentes
|
Collecte les valeurs :class:`pydantic.SecretStr` non vides présentes
|
||||||
dans les sous-configurations (Pronote, CalDAV, XMPP, IA). Les valeurs
|
dans les sous-configurations (URL iCal, mots de passe, code PIN QR et
|
||||||
vides ou ``None`` sont filtrées ; les doublons sont supprimés.
|
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.
|
:return: Tuple de secrets à masquer dans les messages d'erreur.
|
||||||
:rtype: tuple[SecretStr, ...]
|
:rtype: tuple[SecretStr, ...]
|
||||||
@@ -275,6 +291,7 @@ class Settings(BaseSettings):
|
|||||||
secrets = [
|
secrets = [
|
||||||
self.pronote.ical_url,
|
self.pronote.ical_url,
|
||||||
self.pronote.password,
|
self.pronote.password,
|
||||||
|
self.pronote.qr_pin,
|
||||||
self.caldav.url,
|
self.caldav.url,
|
||||||
self.caldav.password,
|
self.caldav.password,
|
||||||
self.xmpp.password,
|
self.xmpp.password,
|
||||||
|
|||||||
@@ -21,6 +21,38 @@ class PronoteSyncError(Exception):
|
|||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class PronoteAuthRotationError(PronoteSyncError):
|
||||||
|
"""Erreur de rotation du token d'authentification pronotepy (QR code / token).
|
||||||
|
|
||||||
|
Levée quand le token persisté est invalide ou expiré et qu'un ré-enrôlement
|
||||||
|
manuel (suppression du fichier d'état + nouveau QR code) est nécessaire.
|
||||||
|
|
||||||
|
:ivar message: Message décrivant l'action à effectuer, sans secret.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
"""Initialise l'erreur de rotation.
|
||||||
|
|
||||||
|
:param message: Message actionnable sans secret (PIN, token, URL).
|
||||||
|
"""
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class PronoteAuthStateLockError(PronoteSyncError):
|
||||||
|
"""Erreur levée lorsqu'un autre processus détient l'état d'authentification.
|
||||||
|
|
||||||
|
Cette erreur indique qu'une opération QR code / token concurrente est en
|
||||||
|
cours. Son message ne contient ni chemin local sensible ni credential.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
"""Initialise l'erreur de contention du verrou d'état.
|
||||||
|
|
||||||
|
:param message: Message actionnable expurgé décrivant la contention.
|
||||||
|
"""
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
class ErrorSeverity(StrEnum):
|
class ErrorSeverity(StrEnum):
|
||||||
"""Niveau de gravité d'une erreur produite par le pipeline."""
|
"""Niveau de gravité d'une erreur produite par le pipeline."""
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ from typing import Protocol, runtime_checkable
|
|||||||
from pronote_sync.channels import get_channel
|
from pronote_sync.channels import get_channel
|
||||||
from pronote_sync.channels.protocol import Channel
|
from pronote_sync.channels.protocol import Channel
|
||||||
from pronote_sync.config.settings import Settings
|
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.blog import ExternalInfo
|
||||||
from pronote_sync.models.pronote import PronoteData
|
from pronote_sync.models.pronote import PronoteData
|
||||||
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
|
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.pipeline.steps.synthesis import synthesis_step
|
||||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
from pronote_sync.sources.blog.rss import BlogRSSClient
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
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.client import PronoteClient
|
||||||
from pronote_sync.sources.pronote.fallback import PronoteFetcher, PronoteFetcherProtocol
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher, PronoteFetcherProtocol
|
||||||
from pronote_sync.sources.theoretical import get_theoretical_provider
|
from pronote_sync.sources.theoretical import get_theoretical_provider
|
||||||
@@ -133,7 +139,15 @@ class PipelineRunner:
|
|||||||
blog_state = BlogRSSState() if settings.blog.enabled else None
|
blog_state = BlogRSSState() if settings.blog.enabled else None
|
||||||
return cls(
|
return cls(
|
||||||
settings=settings,
|
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,
|
agenda_comparator=comparator,
|
||||||
synthesis_provider=get_synthesis_provider(settings.ai),
|
synthesis_provider=get_synthesis_provider(settings.ai),
|
||||||
channel=get_channel(settings.xmpp, dry_run=effective_dry_run),
|
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
|
des étapes facultatives sont converties en :class:`PipelineWarning` afin
|
||||||
que les étapes suivantes, notamment XMPP, restent exécutées.
|
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.
|
:return: Données Pronote normalisées ou ``None``, puis erreurs et avertissements.
|
||||||
:rtype: tuple[PronoteData | None, list[PipelineError]]
|
:rtype: tuple[PronoteData | None, list[PipelineError]]
|
||||||
"""
|
"""
|
||||||
@@ -271,6 +290,28 @@ class PipelineRunner:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("send", self._redact(exc))
|
self._warn("send", self._redact(exc))
|
||||||
return data, [*self._errors, *self._warnings]
|
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:
|
except PipelineCriticalError as exc:
|
||||||
logger.error("Erreur critique du pipeline : %s", exc.message)
|
logger.error("Erreur critique du pipeline : %s", exc.message)
|
||||||
self._errors.append(exc)
|
self._errors.append(exc)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
|
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
|
||||||
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
||||||
from pronote_sync.models.homework import Homework
|
from pronote_sync.models.homework import Homework
|
||||||
from pronote_sync.models.message import Message
|
from pronote_sync.models.message import Message
|
||||||
@@ -101,6 +101,8 @@ def fetch_step(
|
|||||||
:return: Données récupérées et avertissements non critiques.
|
:return: Données récupérées et avertissements non critiques.
|
||||||
:rtype: tuple[FetchedPronoteData, list[PipelineWarning]]
|
:rtype: tuple[FetchedPronoteData, list[PipelineWarning]]
|
||||||
:raises PipelineCriticalError: Si l'agenda ou les devoirs ne sont pas disponibles.
|
:raises PipelineCriticalError: Si l'agenda ou les devoirs ne sont pas disponibles.
|
||||||
|
:raises PronoteAuthRotationError: Si une rotation du token d'authentification
|
||||||
|
pronotepy est nécessaire : propagée telle quelle jusqu'au pipeline.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
lessons, school_events = fetcher.fetch_agenda()
|
lessons, school_events = fetcher.fetch_agenda()
|
||||||
@@ -108,6 +110,8 @@ def fetch_step(
|
|||||||
homeworks = fetcher.fetch_homework(target_date)
|
homeworks = fetcher.fetch_homework(target_date)
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
|
except PronoteAuthRotationError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise PipelineCriticalError(
|
raise PipelineCriticalError(
|
||||||
f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch"
|
f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch"
|
||||||
|
|||||||
261
pronote_sync/sources/pronote/auth_state.py
Normal file
261
pronote_sync/sources/pronote/auth_state.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""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 collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteAuthStateLockError, 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
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def lock(self) -> Generator[None]:
|
||||||
|
"""Protège une opération d'état par un verrou POSIX non bloquant.
|
||||||
|
|
||||||
|
Le verrou est conservé dans le fichier frère ``<state_file>.lock`` afin
|
||||||
|
de survivre à l'écriture atomique du fichier d'état. Le fichier de
|
||||||
|
verrou reste présent après libération et est créé en ``0600`` pour ne
|
||||||
|
pas élargir l'accès aux métadonnées de l'état sensible.
|
||||||
|
|
||||||
|
:return: Un gestionnaire de contexte qui tient le verrou exclusif.
|
||||||
|
:rtype: collections.abc.Generator[None, None, None]
|
||||||
|
:raises PronoteAuthStateLockError: Si un autre processus détient déjà
|
||||||
|
le verrou ou si son acquisition échoue.
|
||||||
|
"""
|
||||||
|
lock_file = self._state_file.with_name(f"{self._state_file.name}.lock")
|
||||||
|
descriptor: int | None = None
|
||||||
|
try:
|
||||||
|
descriptor = os.open(
|
||||||
|
str(lock_file),
|
||||||
|
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
except OSError:
|
||||||
|
logger.error("Impossible d'ouvrir le verrou d'état d'authentification Pronote.")
|
||||||
|
if descriptor is not None:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
if descriptor is None:
|
||||||
|
raise PronoteAuthStateLockError(
|
||||||
|
"Impossible d'acquérir le verrou d'état d'authentification Pronote."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
is_contended = False
|
||||||
|
lock_acquisition_failed = False
|
||||||
|
try:
|
||||||
|
flock(descriptor, LOCK_EX | LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
is_contended = True
|
||||||
|
except OSError:
|
||||||
|
logger.error("Impossible d'acquérir le verrou d'état d'authentification Pronote.")
|
||||||
|
os.close(descriptor)
|
||||||
|
lock_acquisition_failed = True
|
||||||
|
|
||||||
|
if lock_acquisition_failed:
|
||||||
|
raise PronoteAuthStateLockError(
|
||||||
|
"Impossible d'acquérir le verrou d'état d'authentification Pronote."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
if is_contended:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise PronoteAuthStateLockError(
|
||||||
|
"Une autre opération d'authentification Pronote est déjà en cours."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
flock(descriptor, LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -10,19 +10,26 @@ des cours et des devoirs se propagent pour déclencher le repli iCal.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pronotepy
|
import pronotepy
|
||||||
import pronotepy.ent as pronotepy_ent
|
import pronotepy.ent as pronotepy_ent
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from pronote_sync.config.settings import PronoteSettings
|
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.agenda import Lesson, LessonStatus
|
||||||
from pronote_sync.models.homework import Homework
|
from pronote_sync.models.homework import Homework
|
||||||
from pronote_sync.models.message import Message, MessageType
|
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
|
from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -91,6 +98,51 @@ def _resolve_ent(ent_name: str) -> Any:
|
|||||||
return resolver
|
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):
|
class PronoteClientProtocol(Protocol):
|
||||||
"""Interface du client Pronote consommée par la logique de repli."""
|
"""Interface du client Pronote consommée par la logique de repli."""
|
||||||
|
|
||||||
@@ -143,52 +195,249 @@ class PronoteClient:
|
|||||||
exceptions se propager pour déclencher le repli iCal.
|
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.
|
"""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._settings: PronoteSettings = settings
|
||||||
|
self._auth_state: PronoteAuthState | None = auth_state
|
||||||
self._client: pronotepy.Client | None = None
|
self._client: pronotepy.Client | None = None
|
||||||
|
|
||||||
def _connect(self) -> pronotepy.Client:
|
def _connect(self) -> pronotepy.Client:
|
||||||
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
|
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
|
||||||
|
|
||||||
Le client est créé une seule fois puis réutilisé pour les appels
|
En mode ``password``, utilise l'authentification classique (URL,
|
||||||
suivants. Le nom d'ENT, s'il est configuré, est résolu via
|
username, password, ENT). En mode ``qr_token``, utilise le token
|
||||||
:func:`_resolve_ent` ; en l'absence d'ENT, ``ent=None`` est transmis
|
persisté via :class:`PronoteAuthState`, ou procède à l'enrôlement
|
||||||
à ``pronotepy`` pour une connexion directe. Le type de compte
|
initial par QR code si aucun token n'est présent.
|
||||||
(``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é.
|
:return: Le client ``pronotepy`` connecté.
|
||||||
:rtype: pronotepy.Client
|
:rtype: pronotepy.Client
|
||||||
:raises ValueError: Si ``pronote_url``, ``username`` ou ``password``
|
: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 _persist_credentials(self) -> None:
|
||||||
|
"""Persiste les credentials d'authentification après une opération réussie.
|
||||||
|
|
||||||
|
Le token pronotepy peut être rafraîchi (rotaté) par le serveur lors d'un
|
||||||
|
appel de données (agenda, devoirs, messages). Cette méthode persiste
|
||||||
|
systématiquement les credentials courantes pour garantir la disponibilité
|
||||||
|
du token valide au prochain run.
|
||||||
|
|
||||||
|
Ne fait rien si aucun :class:`PronoteAuthState` n'est configuré (mode
|
||||||
|
``password``) ou si le client n'est pas connecté.
|
||||||
|
"""
|
||||||
|
if self._auth_state is None or self._client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._auth_state.save(self._client.export_credentials())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Échec de la persistance des credentials : %s", redact_exception(exc))
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _qr_token_operation_lock(self) -> Generator[None]:
|
||||||
|
"""Verrouille un cycle d'authentification et de récupération QR/token.
|
||||||
|
|
||||||
|
Le verrou englobe le chargement du token, le login, l'opération de
|
||||||
|
données et la persistance qui suit. Il est volontairement absent du
|
||||||
|
mode ``password``, qui ne partage pas de fichier d'état de token.
|
||||||
|
|
||||||
|
:return: Un gestionnaire de contexte protégeant le cycle QR/token.
|
||||||
|
:rtype: collections.abc.Generator[None, None, None]
|
||||||
|
:raises PronoteAuthStateLockError: Si l'état QR/token est déjà utilisé
|
||||||
|
par une autre opération.
|
||||||
|
"""
|
||||||
|
if self._settings.auth_mode != "qr_token" or self._auth_state is None:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._auth_state.lock():
|
||||||
|
yield
|
||||||
|
|
||||||
|
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
|
||||||
|
:raises ValueError: Si ``url``, ``username`` ou ``password``
|
||||||
est manquant, ou si l'ENT fourni est inconnu.
|
est manquant, ou si l'ENT fourni est inconnu.
|
||||||
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
|
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
|
||||||
"""
|
"""
|
||||||
if self._client is None:
|
url = self._settings.url
|
||||||
pronote_url = self._settings.pronote_url
|
|
||||||
username = self._settings.username
|
username = self._settings.username
|
||||||
password = self._settings.password
|
password = self._settings.password
|
||||||
ent = self._settings.ent
|
ent = self._settings.ent
|
||||||
if pronote_url is None or username is None or password is None:
|
if url is None or username is None or password is None:
|
||||||
raise ValueError("pronote_url, username et password sont requis pour pronotepy")
|
raise ValueError("url, username et password sont requis pour pronotepy")
|
||||||
resolver = _resolve_ent(ent) if ent is not None else None
|
resolver = _resolve_ent(ent) if ent is not None else None
|
||||||
client_class: type[pronotepy.Client] = (
|
client_class: type[pronotepy.Client] = (
|
||||||
pronotepy.ParentClient
|
pronotepy.ParentClient if self._settings.account_type == "parent" else pronotepy.Client
|
||||||
if self._settings.account_type == "parent"
|
|
||||||
else pronotepy.Client
|
|
||||||
)
|
)
|
||||||
self._client = client_class(
|
self._client = client_class(
|
||||||
pronote_url=pronote_url,
|
pronote_url=url,
|
||||||
username=username,
|
username=username,
|
||||||
password=password.get_secret_value(),
|
password=password.get_secret_value(),
|
||||||
ent=resolver,
|
ent=resolver,
|
||||||
)
|
)
|
||||||
return self._client
|
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._client = client
|
||||||
|
self._persist_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
|
||||||
|
self._client = client
|
||||||
|
self._persist_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]:
|
def get_messages(self) -> list[Message]:
|
||||||
"""Récupère les messages des discussions Pronote.
|
"""Récupère les messages des discussions Pronote.
|
||||||
|
|
||||||
@@ -199,6 +448,7 @@ class PronoteClient:
|
|||||||
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
@@ -215,6 +465,7 @@ class PronoteClient:
|
|||||||
read=message.seen,
|
read=message.seen,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._persist_credentials()
|
||||||
return messages
|
return messages
|
||||||
except (
|
except (
|
||||||
pronotepy.PronoteAPIError,
|
pronotepy.PronoteAPIError,
|
||||||
@@ -227,6 +478,7 @@ class PronoteClient:
|
|||||||
"Échec de la récupération des messages Pronote : %s",
|
"Échec de la récupération des messages Pronote : %s",
|
||||||
redact_exception(exc),
|
redact_exception(exc),
|
||||||
)
|
)
|
||||||
|
self._persist_credentials()
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_informations(self) -> list[Message]:
|
def get_informations(self) -> list[Message]:
|
||||||
@@ -238,6 +490,7 @@ class PronoteClient:
|
|||||||
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
@@ -253,6 +506,7 @@ class PronoteClient:
|
|||||||
read=info.read,
|
read=info.read,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._persist_credentials()
|
||||||
return messages
|
return messages
|
||||||
except (
|
except (
|
||||||
pronotepy.PronoteAPIError,
|
pronotepy.PronoteAPIError,
|
||||||
@@ -265,6 +519,7 @@ class PronoteClient:
|
|||||||
"Échec de la récupération des informations Pronote : %s",
|
"Échec de la récupération des informations Pronote : %s",
|
||||||
redact_exception(exc),
|
redact_exception(exc),
|
||||||
)
|
)
|
||||||
|
self._persist_credentials()
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_lessons(self, start: date, end: date) -> list[Lesson]:
|
def get_lessons(self, start: date, end: date) -> list[Lesson]:
|
||||||
@@ -284,12 +539,15 @@ class PronoteClient:
|
|||||||
:param end: Date de fin de la fenêtre (incluse).
|
:param end: Date de fin de la fenêtre (incluse).
|
||||||
:return: Liste des cours.
|
:return: Liste des cours.
|
||||||
:rtype: list[Lesson]
|
: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 pronotepy.PronoteAPIError: Si l'API Pronote échoue.
|
||||||
:raises ValueError: Si la configuration ou l'ENT est invalide.
|
:raises ValueError: Si la configuration ou l'ENT est invalide.
|
||||||
:raises requests.RequestException: Si une requête réseau échoue.
|
:raises requests.RequestException: Si une requête réseau échoue.
|
||||||
:raises ConnectionError: Si la connexion réseau échoue.
|
:raises ConnectionError: Si la connexion réseau échoue.
|
||||||
:raises TimeoutError: Si la requête réseau expire.
|
:raises TimeoutError: Si la requête réseau expire.
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
lessons: list[Lesson] = []
|
lessons: list[Lesson] = []
|
||||||
for lesson in client.lessons(start, end):
|
for lesson in client.lessons(start, end):
|
||||||
@@ -319,6 +577,7 @@ class PronoteClient:
|
|||||||
content=content.description if content is not None else None,
|
content=content.description if content is not None else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._persist_credentials()
|
||||||
return lessons
|
return lessons
|
||||||
|
|
||||||
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
||||||
@@ -334,12 +593,15 @@ class PronoteClient:
|
|||||||
:param end: Date de fin de la fenêtre (incluse).
|
:param end: Date de fin de la fenêtre (incluse).
|
||||||
:return: Liste des devoirs.
|
:return: Liste des devoirs.
|
||||||
:rtype: list[Homework]
|
: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 pronotepy.PronoteAPIError: Si l'API Pronote échoue.
|
||||||
:raises ValueError: Si la configuration ou l'ENT est invalide.
|
:raises ValueError: Si la configuration ou l'ENT est invalide.
|
||||||
:raises requests.RequestException: Si une requête réseau échoue.
|
:raises requests.RequestException: Si une requête réseau échoue.
|
||||||
:raises ConnectionError: Si la connexion réseau échoue.
|
:raises ConnectionError: Si la connexion réseau échoue.
|
||||||
:raises TimeoutError: Si la requête réseau expire.
|
:raises TimeoutError: Si la requête réseau expire.
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
homeworks: list[Homework] = []
|
homeworks: list[Homework] = []
|
||||||
for hw in client.homework(start, end):
|
for hw in client.homework(start, end):
|
||||||
@@ -354,4 +616,5 @@ class PronoteClient:
|
|||||||
html=hw.description,
|
html=hw.description,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._persist_credentials()
|
||||||
return homeworks
|
return homeworks
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from enum import StrEnum
|
|||||||
from typing import Literal, Protocol
|
from typing import Literal, Protocol
|
||||||
|
|
||||||
from pronote_sync.config.settings import Settings
|
from pronote_sync.config.settings import Settings
|
||||||
from pronote_sync.errors import PipelineCriticalError
|
from pronote_sync.errors import PipelineCriticalError, PronoteAuthRotationError
|
||||||
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
||||||
from pronote_sync.models.homework import Homework
|
from pronote_sync.models.homework import Homework
|
||||||
from pronote_sync.models.message import Message
|
from pronote_sync.models.message import Message
|
||||||
@@ -147,15 +147,21 @@ class PronoteFetcher:
|
|||||||
return self._settings.pronote.ical_url is not None
|
return self._settings.pronote.ical_url is not None
|
||||||
|
|
||||||
def _is_pronotepy_configured(self) -> bool:
|
def _is_pronotepy_configured(self) -> bool:
|
||||||
"""Vérifie que la source pronotepy est entièrement configurée.
|
"""Vérifie si la source pronotepy est utilisable selon le mode d'authentification.
|
||||||
|
|
||||||
:return: ``True`` si ``pronote_url``, ``username`` et ``password``
|
:return: ``True`` si pronotepy est configuré pour le mode
|
||||||
sont tous définis, ``False`` sinon.
|
d'authentification actif, ``False`` sinon.
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
pronote = self._settings.pronote
|
pronote = self._settings.pronote
|
||||||
|
if pronote.auth_mode == "qr_token":
|
||||||
|
# En mode qr_token, seul PRONOTE_URL est requis.
|
||||||
|
# Le QR code et le PIN ne sont nécessaires que pour l'enrôlement initial.
|
||||||
|
# Les exécutions suivantes utilisent le token persisté.
|
||||||
|
return pronote.url is not None
|
||||||
|
# En mode password, URL + identifiant + mot de passe sont requis.
|
||||||
return (
|
return (
|
||||||
pronote.pronote_url is not None
|
pronote.url is not None
|
||||||
and pronote.username is not None
|
and pronote.username is not None
|
||||||
and pronote.password is not None
|
and pronote.password is not None
|
||||||
)
|
)
|
||||||
@@ -249,10 +255,14 @@ class PronoteFetcher:
|
|||||||
:return: Tuple ``(cours, événements scolaires)``.
|
:return: Tuple ``(cours, événements scolaires)``.
|
||||||
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
||||||
:raises PipelineCriticalError: Si toutes les sources tentées échouent.
|
:raises PipelineCriticalError: Si toutes les sources tentées échouent.
|
||||||
|
:raises PronoteAuthRotationError: Si une rotation du token d'authentification
|
||||||
|
pronotepy est nécessaire : propagée telle quelle, sans repli.
|
||||||
"""
|
"""
|
||||||
primary, fallback = self._agenda_sources()
|
primary, fallback = self._agenda_sources()
|
||||||
try:
|
try:
|
||||||
return self._fetch_agenda_source(primary)
|
return self._fetch_agenda_source(primary)
|
||||||
|
except PronoteAuthRotationError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Échec de la récupération %s pour l'agenda : %s",
|
"Échec de la récupération %s pour l'agenda : %s",
|
||||||
@@ -266,6 +276,8 @@ class PronoteFetcher:
|
|||||||
logger.info("Repli sur %s pour l'agenda.", fallback)
|
logger.info("Repli sur %s pour l'agenda.", fallback)
|
||||||
try:
|
try:
|
||||||
lessons, school_events = self._fetch_agenda_source(fallback)
|
lessons, school_events = self._fetch_agenda_source(fallback)
|
||||||
|
except PronoteAuthRotationError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Échec de la récupération %s pour l'agenda : %s",
|
"Échec de la récupération %s pour l'agenda : %s",
|
||||||
@@ -370,10 +382,14 @@ class PronoteFetcher:
|
|||||||
:return: Liste des devoirs.
|
:return: Liste des devoirs.
|
||||||
:rtype: list[Homework]
|
:rtype: list[Homework]
|
||||||
:raises PipelineCriticalError: Si toutes les sources tentées échouent.
|
:raises PipelineCriticalError: Si toutes les sources tentées échouent.
|
||||||
|
:raises PronoteAuthRotationError: Si une rotation du token d'authentification
|
||||||
|
pronotepy est nécessaire : propagée telle quelle, sans repli.
|
||||||
"""
|
"""
|
||||||
primary, fallback = self._homework_sources()
|
primary, fallback = self._homework_sources()
|
||||||
try:
|
try:
|
||||||
return self._fetch_homework_source(primary, target_date)
|
return self._fetch_homework_source(primary, target_date)
|
||||||
|
except PronoteAuthRotationError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Échec de la récupération %s pour les devoirs : %s",
|
"Échec de la récupération %s pour les devoirs : %s",
|
||||||
@@ -387,6 +403,8 @@ class PronoteFetcher:
|
|||||||
logger.info("Repli sur %s pour les devoirs.", fallback)
|
logger.info("Repli sur %s pour les devoirs.", fallback)
|
||||||
try:
|
try:
|
||||||
homeworks = self._fetch_homework_source(fallback, target_date)
|
homeworks = self._fetch_homework_source(fallback, target_date)
|
||||||
|
except PronoteAuthRotationError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Échec de la récupération %s pour les devoirs : %s",
|
"Échec de la récupération %s pour les devoirs : %s",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pronote-sync"
|
name = "pronote-sync"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
description = "Synchronisation Pronote → CalDAV + XMPP"
|
description = "Synchronisation Pronote → CalDAV + XMPP"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
requires-python = ">=3.13.5"
|
requires-python = ">=3.13.5"
|
||||||
|
|||||||
@@ -9,17 +9,19 @@ import pytest
|
|||||||
from pydantic import SecretStr
|
from pydantic import SecretStr
|
||||||
|
|
||||||
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
|
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
|
||||||
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
|
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
|
||||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
|
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
|
||||||
from pronote_sync.models.blog import BlogArticle
|
from pronote_sync.models.blog import BlogArticle
|
||||||
from pronote_sync.models.diff import AgendaDiff
|
from pronote_sync.models.diff import AgendaDiff
|
||||||
from pronote_sync.models.homework import Homework
|
from pronote_sync.models.homework import Homework
|
||||||
from pronote_sync.models.message import Message
|
from pronote_sync.models.message import Message
|
||||||
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
|
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
|
||||||
|
from pronote_sync.models.xmpp import XmppMessage
|
||||||
from pronote_sync.pipeline.run import PipelineRunner
|
from pronote_sync.pipeline.run import PipelineRunner
|
||||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
from pronote_sync.sources.blog.rss import BlogRSSClient
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
|
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
||||||
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
from pronote_sync.sync.diff import AgendaComparator
|
from pronote_sync.sync.diff import AgendaComparator
|
||||||
|
|
||||||
@@ -393,6 +395,60 @@ def test_from_settings_with_theoretical_agenda_instantiates_comparator(
|
|||||||
assert isinstance(runner._agenda_comparator, RecordingComparator)
|
assert isinstance(runner._agenda_comparator, RecordingComparator)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_settings_password_mode_passes_auth_state_none(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Password mode (default) constructs PronoteClient with auth_state=None."""
|
||||||
|
import pronote_sync.pipeline.run as run_module
|
||||||
|
|
||||||
|
constructed: list[tuple[object, object]] = []
|
||||||
|
|
||||||
|
class RecordingClient:
|
||||||
|
"""PronoteClient constructor recording the supplied auth_state."""
|
||||||
|
|
||||||
|
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
|
||||||
|
"""Record the constructor arguments used by the composition root.
|
||||||
|
|
||||||
|
:param settings: Pronote settings supplied by the composition root.
|
||||||
|
:param auth_state: Auth state handler supplied by the composition root.
|
||||||
|
"""
|
||||||
|
constructed.append((settings, auth_state))
|
||||||
|
|
||||||
|
monkeypatch.setattr(run_module, "PronoteClient", RecordingClient)
|
||||||
|
|
||||||
|
PipelineRunner.from_settings(Settings())
|
||||||
|
|
||||||
|
assert len(constructed) == 1
|
||||||
|
assert constructed[0][1] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_settings_qr_token_mode_passes_auth_state_instance(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""QR-token mode constructs PronoteClient with a PronoteAuthState instance."""
|
||||||
|
import pronote_sync.pipeline.run as run_module
|
||||||
|
|
||||||
|
constructed: list[tuple[object, object]] = []
|
||||||
|
|
||||||
|
class RecordingClient:
|
||||||
|
"""PronoteClient constructor recording the supplied auth_state."""
|
||||||
|
|
||||||
|
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
|
||||||
|
"""Record the constructor arguments used by the composition root.
|
||||||
|
|
||||||
|
:param settings: Pronote settings supplied by the composition root.
|
||||||
|
:param auth_state: Auth state handler supplied by the composition root.
|
||||||
|
"""
|
||||||
|
constructed.append((settings, auth_state))
|
||||||
|
|
||||||
|
monkeypatch.setattr(run_module, "PronoteClient", RecordingClient)
|
||||||
|
|
||||||
|
PipelineRunner.from_settings(Settings(pronote=PronoteSettings(auth_mode="qr_token")))
|
||||||
|
|
||||||
|
assert len(constructed) == 1
|
||||||
|
assert isinstance(constructed[0][1], PronoteAuthState)
|
||||||
|
|
||||||
|
|
||||||
def test_runner_reuses_ical_download_and_parse_within_one_run(
|
def test_runner_reuses_ical_download_and_parse_within_one_run(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
pipeline_inputs: tuple[Lesson, Homework],
|
pipeline_inputs: tuple[Lesson, Homework],
|
||||||
@@ -1063,3 +1119,338 @@ def test_runner_ical_cache_cleanup_on_second_run(
|
|||||||
"https://pronote.example.test/calendar.ics",
|
"https://pronote.example.test/calendar.ics",
|
||||||
"https://pronote.example.test/calendar.ics",
|
"https://pronote.example.test/calendar.ics",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_error_sends_xmpp_notification() -> None:
|
||||||
|
"""Une PronoteAuthRotationError envoie une notification XMPP puis retourne un résultat dégradé.
|
||||||
|
|
||||||
|
Ce test vérifie que l'erreur de rotation se propage à travers le pipeline réel
|
||||||
|
(PronoteFetcher → fetch_step → PipelineRunner.run) et déclenche une notification XMPP
|
||||||
|
avec un message actionnable.
|
||||||
|
"""
|
||||||
|
calls: list[str] = []
|
||||||
|
channel = StubChannel(calls)
|
||||||
|
|
||||||
|
# Créer un client Pronote qui lève PronoteAuthRotationError
|
||||||
|
class RotatingPronoteClient:
|
||||||
|
"""Client Pronote qui simule une erreur de rotation de token."""
|
||||||
|
|
||||||
|
def get_lessons(self, start: date, end: date) -> list[Lesson]:
|
||||||
|
"""Lève l'erreur de rotation lors de la récupération des cours.
|
||||||
|
|
||||||
|
:param start: Début de la fenêtre (ignoré).
|
||||||
|
:param end: Fin de la fenêtre (ignoré).
|
||||||
|
:return: Ne retourne jamais.
|
||||||
|
:raises PronoteAuthRotationError: Toujours.
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
|
||||||
|
|
||||||
|
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
||||||
|
"""Ne devrait pas être appelé si fetch_agenda échoue.
|
||||||
|
|
||||||
|
:param start: Début de la fenêtre (ignoré).
|
||||||
|
:param end: Fin de la fenêtre (ignoré).
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Homework]
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_messages(self) -> list[Message]:
|
||||||
|
"""Ne devrait pas être appelé si fetch_agenda échoue.
|
||||||
|
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_informations(self) -> list[Message]:
|
||||||
|
"""Ne devrait pas être appelé si fetch_agenda échoue.
|
||||||
|
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="test",
|
||||||
|
password=SecretStr("test_password"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = PipelineRunner(
|
||||||
|
settings=settings,
|
||||||
|
pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()),
|
||||||
|
channel=channel,
|
||||||
|
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
data, errors = runner.run()
|
||||||
|
|
||||||
|
assert data is None
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert isinstance(errors[0], PipelineCriticalError)
|
||||||
|
assert len(channel.messages) == 1
|
||||||
|
message = channel.messages[0]
|
||||||
|
assert isinstance(message, XmppMessage)
|
||||||
|
assert message.target_date == date(2026, 9, 8)
|
||||||
|
assert message.synthesis is not None
|
||||||
|
assert "Rotation" in message.synthesis
|
||||||
|
assert "token" in message.synthesis
|
||||||
|
assert "QR code" in message.synthesis
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_error_no_channel_no_xmpp_send() -> None:
|
||||||
|
"""Sans canal XMPP, l'erreur de rotation ne tente aucun envoi.
|
||||||
|
|
||||||
|
Ce test vérifie que même sans canal XMPP configuré, l'erreur de rotation
|
||||||
|
est correctement capturée et retournée dans la liste des erreurs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class RotatingPronoteClient:
|
||||||
|
"""Client Pronote qui simule une erreur de rotation de token."""
|
||||||
|
|
||||||
|
def get_lessons(self, start: date, end: date) -> list[Lesson]:
|
||||||
|
"""Lève l'erreur de rotation lors de la récupération des cours.
|
||||||
|
|
||||||
|
:param start: Début de la fenêtre (ignoré).
|
||||||
|
:param end: Fin de la fenêtre (ignoré).
|
||||||
|
:return: Ne retourne jamais.
|
||||||
|
:raises PronoteAuthRotationError: Toujours.
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
|
||||||
|
|
||||||
|
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:param start: Début de la fenêtre (ignoré).
|
||||||
|
:param end: Fin de la fenêtre (ignoré).
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Homework]
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_messages(self) -> list[Message]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_informations(self) -> list[Message]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="test",
|
||||||
|
password=SecretStr("test_password"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = PipelineRunner(
|
||||||
|
settings=settings,
|
||||||
|
pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()),
|
||||||
|
channel=None,
|
||||||
|
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
data, errors = runner.run()
|
||||||
|
|
||||||
|
assert data is None
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert isinstance(errors[0], PipelineCriticalError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_error_dry_run_no_xmpp_send() -> None:
|
||||||
|
"""En dry-run, l'erreur de rotation n'envoie aucune notification XMPP.
|
||||||
|
|
||||||
|
Ce test vérifie que même en mode dry-run, l'erreur de rotation est correctement
|
||||||
|
capturée et retournée, mais aucune notification XMPP n'est envoyée.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class RotatingPronoteClient:
|
||||||
|
"""Client Pronote qui simule une erreur de rotation de token."""
|
||||||
|
|
||||||
|
def get_lessons(self, start: date, end: date) -> list[Lesson]:
|
||||||
|
"""Lève l'erreur de rotation lors de la récupération des cours.
|
||||||
|
|
||||||
|
:param start: Début de la fenêtre (ignoré).
|
||||||
|
:param end: Fin de la fenêtre (ignoré).
|
||||||
|
:return: Ne retourne jamais.
|
||||||
|
:raises PronoteAuthRotationError: Toujours.
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
|
||||||
|
|
||||||
|
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:param start: Début de la fenêtre (ignoré).
|
||||||
|
:param end: Fin de la fenêtre (ignoré).
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Homework]
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_messages(self) -> list[Message]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_informations(self) -> list[Message]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
channel = StubChannel(calls)
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="test",
|
||||||
|
password=SecretStr("test_password"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
messages_source="pronotepy",
|
||||||
|
auth_mode="password",
|
||||||
|
qr_code_file=None,
|
||||||
|
qr_pin=None,
|
||||||
|
ical_url=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = PipelineRunner(
|
||||||
|
settings=settings,
|
||||||
|
pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()),
|
||||||
|
channel=channel,
|
||||||
|
dry_run=True,
|
||||||
|
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
data, errors = runner.run()
|
||||||
|
|
||||||
|
assert data is None
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert isinstance(errors[0], PipelineCriticalError)
|
||||||
|
assert channel.messages == []
|
||||||
|
assert "send" not in calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_secrets_in_xmpp_message() -> None:
|
||||||
|
"""La synthèse XMPP de rotation ne contient aucun secret (token, PIN, URL).
|
||||||
|
|
||||||
|
Ce test vérifie que le message XMPP généré pour une erreur de rotation
|
||||||
|
ne contient aucun secret sensible, même si l'erreur originale en contenait.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class RotatingPronoteClient:
|
||||||
|
"""Client Pronote qui simule une erreur de rotation avec secrets dans message."""
|
||||||
|
|
||||||
|
def get_lessons(self, start: date, end: date) -> list[Lesson]:
|
||||||
|
"""Lève l'erreur de rotation avec message contenant des secrets.
|
||||||
|
|
||||||
|
:param start: Début de la fenêtre (ignoré).
|
||||||
|
:param end: Fin de la fenêtre (ignoré).
|
||||||
|
:return: Ne retourne jamais.
|
||||||
|
:raises PronoteAuthRotationError: Toujours, avec des secrets dans le message.
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
raise PronoteAuthRotationError(
|
||||||
|
"Token sk-sentinel-token-987654 invalide et PIN 000000 pour "
|
||||||
|
"https://pronote.sentinel.example/icalsecurise"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:param start: Début de la fenêtre (ignoré).
|
||||||
|
:param end: Fin de la fenêtre (ignoré).
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Homework]
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_messages(self) -> list[Message]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_informations(self) -> list[Message]:
|
||||||
|
"""Ne devrait pas être appelé.
|
||||||
|
|
||||||
|
:return: Liste vide.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
channel = StubChannel([])
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="test",
|
||||||
|
password=SecretStr("test_password"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = PipelineRunner(
|
||||||
|
settings=settings,
|
||||||
|
pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()),
|
||||||
|
channel=channel,
|
||||||
|
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
data, errors = runner.run()
|
||||||
|
|
||||||
|
assert data is None
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert len(channel.messages) == 1
|
||||||
|
message = channel.messages[0]
|
||||||
|
assert isinstance(message, XmppMessage)
|
||||||
|
assert message.synthesis is not None
|
||||||
|
# Vérifier que les secrets ne sont pas dans le message final
|
||||||
|
assert "sk-sentinel-token-987654" not in message.synthesis
|
||||||
|
assert "000000" not in message.synthesis
|
||||||
|
assert "pronote.sentinel.example" not in message.synthesis
|
||||||
|
# Vérifier que le message contient les instructions actionnables
|
||||||
|
assert ".pronote_auth_state.json" in message.synthesis
|
||||||
|
assert "PRONOTE_QR_CODE_FILE" in message.synthesis
|
||||||
|
assert "PRONOTE_QR_PIN" in message.synthesis
|
||||||
|
|||||||
@@ -116,4 +116,95 @@ def test_no_singleton_import() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que PRONOTE_URL mappe au champ url via le préfixe PRONOTE_.
|
||||||
|
|
||||||
|
Ce test couvre la régression où PRONOTE_URL n'était pas mappé vers le
|
||||||
|
champ du modèle à cause du double préfixe PRONOTE_.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
test_url = "https://example.index-education.net/pronote/parent.html"
|
||||||
|
monkeypatch.setenv("PRONOTE_URL", test_url)
|
||||||
|
settings = load_settings()
|
||||||
|
assert settings.pronote.url == test_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_mode_default_password() -> None:
|
||||||
|
"""Vérifie que ``auth_mode`` vaut ``"password"`` par défaut.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = PronoteSettings()
|
||||||
|
assert settings.auth_mode == "password"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_mode_env_qr_token(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``PRONOTE_AUTH_MODE=qr_token`` est chargé correctement.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("PRONOTE_AUTH_MODE", "qr_token")
|
||||||
|
settings = load_settings()
|
||||||
|
assert settings.pronote.auth_mode == "qr_token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_pin_loaded_as_secretstr_and_masked(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``PRONOTE_QR_PIN`` est chargé en ``SecretStr`` et masqué.
|
||||||
|
|
||||||
|
Le PIN ne doit apparaître nulle part dans les représentations textuelles
|
||||||
|
(str, repr, JSON) : seul le masque ``**********`` est visible.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("PRONOTE_QR_PIN", "123456")
|
||||||
|
settings = load_settings()
|
||||||
|
assert isinstance(settings.pronote.qr_pin, SecretStr)
|
||||||
|
assert settings.pronote.qr_pin.get_secret_value() == "123456"
|
||||||
|
|
||||||
|
str_repr = str(settings)
|
||||||
|
assert "123456" not in str_repr
|
||||||
|
assert "**********" in str_repr
|
||||||
|
|
||||||
|
repr_repr = repr(settings)
|
||||||
|
assert "123456" not in repr_repr
|
||||||
|
assert "**********" in repr_repr
|
||||||
|
|
||||||
|
json_str = settings.model_dump_json()
|
||||||
|
assert "123456" not in json_str
|
||||||
|
assert "**********" in json_str
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_code_file_loaded_as_plain_string(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``PRONOTE_QR_CODE_FILE`` est chargé comme chaîne simple.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("PRONOTE_QR_CODE_FILE", "/data/qr_code.png")
|
||||||
|
settings = load_settings()
|
||||||
|
assert isinstance(settings.pronote.qr_code_file, str)
|
||||||
|
assert settings.pronote.qr_code_file == "/data/qr_code.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que le PIN QR est collecté pour la rédaction des secrets.
|
||||||
|
|
||||||
|
Le ``SecretStr`` du PIN doit figurer dans ``redaction_secrets()`` et sa
|
||||||
|
représentation textuelle doit rester masquée.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("PRONOTE_QR_PIN", "654321")
|
||||||
|
settings = load_settings()
|
||||||
|
secrets = settings.redaction_secrets()
|
||||||
|
assert settings.pronote.qr_pin in secrets
|
||||||
|
assert "654321" not in repr(settings.pronote.qr_pin)
|
||||||
|
assert "**********" in repr(settings.pronote.qr_pin)
|
||||||
|
|
||||||
|
|
||||||
# Ensure trailing newline
|
# Ensure trailing newline
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def fixture_mock_settings() -> Settings:
|
|||||||
"""
|
"""
|
||||||
return Settings(
|
return Settings(
|
||||||
pronote=PronoteSettings(
|
pronote=PronoteSettings(
|
||||||
pronote_url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
ical_url=SecretStr("file:///fake/ical.ics"),
|
ical_url=SecretStr("file:///fake/ical.ics"),
|
||||||
agenda_source="auto",
|
agenda_source="auto",
|
||||||
homework_source="auto",
|
homework_source="auto",
|
||||||
@@ -251,7 +251,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
# Disable pronotepy so fallback is None
|
# Disable pronotepy so fallback is None
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
@@ -280,7 +280,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
|
|||||||
"""
|
"""
|
||||||
# Override settings to use ical mode explicitly and disable fallback
|
# Override settings to use ical mode explicitly and disable fallback
|
||||||
mock_fetcher._settings.pronote.agenda_source = "ical"
|
mock_fetcher._settings.pronote.agenda_source = "ical"
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
@@ -438,7 +438,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
|||||||
target_date = date(2025, 9, 10)
|
target_date = date(2025, 9, 10)
|
||||||
|
|
||||||
# Disable pronotepy so fallback is None
|
# Disable pronotepy so fallback is None
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
@@ -531,7 +531,7 @@ def test_no_secrets_in_error_messages(
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
|
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
@@ -629,7 +629,7 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
|
|||||||
"""
|
"""
|
||||||
# Disable both sources
|
# Disable both sources
|
||||||
mock_fetcher._settings.pronote.ical_url = None
|
mock_fetcher._settings.pronote.ical_url = None
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with pytest.raises(PipelineCriticalError) as exc_info:
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
||||||
mock_fetcher.fetch_agenda()
|
mock_fetcher.fetch_agenda()
|
||||||
@@ -1020,7 +1020,7 @@ def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher
|
|||||||
assert fallback is None
|
assert fallback is None
|
||||||
|
|
||||||
# Without pronotepy configured
|
# Without pronotepy configured
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
primary, fallback = mock_fetcher._homework_sources()
|
primary, fallback = mock_fetcher._homework_sources()
|
||||||
assert primary == "ical"
|
assert primary == "ical"
|
||||||
assert fallback is None
|
assert fallback is None
|
||||||
@@ -1080,7 +1080,7 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
|
|||||||
"""
|
"""
|
||||||
mock_fetcher._settings.pronote.homework_source = "auto"
|
mock_fetcher._settings.pronote.homework_source = "auto"
|
||||||
mock_fetcher._settings.pronote.ical_url = None
|
mock_fetcher._settings.pronote.ical_url = None
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with pytest.raises(PipelineCriticalError) as exc_info:
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
||||||
mock_fetcher._homework_sources()
|
mock_fetcher._homework_sources()
|
||||||
@@ -1298,7 +1298,7 @@ def test_is_pronotepy_configured_without_ent_returns_true() -> None:
|
|||||||
# Créer des settings avec pronotepy configuré mais sans ent
|
# Créer des settings avec pronotepy configuré mais sans ent
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
pronote=PronoteSettings(
|
pronote=PronoteSettings(
|
||||||
pronote_url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
username="testuser",
|
username="testuser",
|
||||||
password=SecretStr("testpass"),
|
password=SecretStr("testpass"),
|
||||||
ent=None, # Explicitement None
|
ent=None, # Explicitement None
|
||||||
@@ -1315,6 +1315,110 @@ def test_is_pronotepy_configured_without_ent_returns_true() -> None:
|
|||||||
assert fetcher._is_pronotepy_configured() is True
|
assert fetcher._is_pronotepy_configured() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pronotepy_configured_password_mode_all_set() -> None:
|
||||||
|
"""Test _is_pronotepy_configured() en mode password avec tous les champs définis.
|
||||||
|
|
||||||
|
URL, identifiant et mot de passe sont présents : la fonction retourne True.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
assert fetcher._is_pronotepy_configured() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pronotepy_configured_password_mode_missing_password() -> None:
|
||||||
|
"""Test _is_pronotepy_configured() en mode password sans mot de passe.
|
||||||
|
|
||||||
|
Le mot de passe est None : la fonction retourne False.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=None,
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
assert fetcher._is_pronotepy_configured() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pronotepy_configured_qr_token_mode_url_only() -> None:
|
||||||
|
"""Test _is_pronotepy_configured() en mode qr_token avec URL uniquement.
|
||||||
|
|
||||||
|
En mode qr_token, seul l'URL est requis : l'identifiant et le mot de
|
||||||
|
passe peuvent être absents, la fonction retourne True.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username=None,
|
||||||
|
password=None,
|
||||||
|
auth_mode="qr_token",
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
assert fetcher._is_pronotepy_configured() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pronotepy_configured_qr_token_mode_no_url() -> None:
|
||||||
|
"""Test _is_pronotepy_configured() en mode qr_token sans URL.
|
||||||
|
|
||||||
|
L'URL est None : la fonction retourne False, même si le mode qr_token
|
||||||
|
ne requiert que PRONOTE_URL.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url=None,
|
||||||
|
username=None,
|
||||||
|
password=None,
|
||||||
|
auth_mode="qr_token",
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
assert fetcher._is_pronotepy_configured() is False
|
||||||
|
|
||||||
|
|
||||||
def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
|
def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
|
||||||
"""Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
|
"""Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
|
||||||
|
|
||||||
@@ -1327,7 +1431,7 @@ def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() ->
|
|||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
pronote=PronoteSettings(
|
pronote=PronoteSettings(
|
||||||
pronote_url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
username="testuser",
|
username="testuser",
|
||||||
password=SecretStr("testpass"),
|
password=SecretStr("testpass"),
|
||||||
ent=None, # Explicitement None
|
ent=None, # Explicitement None
|
||||||
@@ -1358,7 +1462,7 @@ def test_homework_sources_auto_without_ical_and_without_ent_returns_pronotepy()
|
|||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
pronote=PronoteSettings(
|
pronote=PronoteSettings(
|
||||||
pronote_url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
username="testuser",
|
username="testuser",
|
||||||
password=SecretStr("testpass"),
|
password=SecretStr("testpass"),
|
||||||
ent=None, # Explicitement None
|
ent=None, # Explicitement None
|
||||||
|
|||||||
283
tests/unit/test_pronote_auth_state.py
Normal file
283
tests/unit/test_pronote_auth_state.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
"""Tests unitaires pour le gestionnaire d'état d'authentification Pronote.
|
||||||
|
|
||||||
|
Ce module valide le comportement de :class:`PronoteAuthState` dans
|
||||||
|
:mod:`pronote_sync.sources.pronote.auth_state`. Les tests couvrent :
|
||||||
|
|
||||||
|
- Le chargement des credentials (absent, corrompu, version invalide),
|
||||||
|
- La persistance et le rechargement des credentials,
|
||||||
|
- Les permissions ``0600`` du fichier d'état,
|
||||||
|
- La suppression via :meth:`clear`,
|
||||||
|
- L'absence de fuite des credentials dans les journaux.
|
||||||
|
|
||||||
|
Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteAuthStateLockError
|
||||||
|
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_no_file_returns_none(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie qu'un fichier d'état absent renvoie ``None``.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state = PronoteAuthState(tmp_path / "missing.json")
|
||||||
|
|
||||||
|
assert state.load() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_then_load_roundtrip(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que des credentials sauvegardés sont rechargés à l'identique.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "auth.json"
|
||||||
|
credentials = {
|
||||||
|
"pronote_url": "https://example.com/pronote",
|
||||||
|
"username": "parent-1",
|
||||||
|
"password": "token-123", # pragma: allowlist secret
|
||||||
|
"uuid": "uuid-456",
|
||||||
|
}
|
||||||
|
|
||||||
|
state = PronoteAuthState(state_file)
|
||||||
|
state.save(credentials)
|
||||||
|
loaded = PronoteAuthState(state_file).load()
|
||||||
|
|
||||||
|
assert loaded == credentials
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_corrupted_json_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Vérifie qu'un fichier JSON corrompu renvoie ``None`` et journalise un avertissement.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "corrupt.json"
|
||||||
|
state_file.write_text("not json{", encoding="utf-8")
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
result = PronoteAuthState(state_file).load()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert "Impossible de charger le fichier d'état d'authentification Pronote" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_wrong_version_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Vérifie qu'une version non supportée renvoie ``None`` et journalise un avertissement.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "wrong_version.json"
|
||||||
|
state_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"credentials": {
|
||||||
|
"pronote_url": "https://example.com",
|
||||||
|
"username": "u",
|
||||||
|
"password": "t",
|
||||||
|
"uuid": "i",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
result = PronoteAuthState(state_file).load()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert "version absente ou non supportée" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_missing_version_returns_none(
|
||||||
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un fichier sans champ version renvoie ``None``.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "missing_version.json"
|
||||||
|
state_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"credentials": {
|
||||||
|
"pronote_url": "https://example.com",
|
||||||
|
"username": "u",
|
||||||
|
"password": "t",
|
||||||
|
"uuid": "i",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
result = PronoteAuthState(state_file).load()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_removes_file(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que clear supprime le fichier d'état existant.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "auth.json"
|
||||||
|
state = PronoteAuthState(state_file)
|
||||||
|
state.save({"pronote_url": "u", "username": "u", "password": "t", "uuid": "i"})
|
||||||
|
|
||||||
|
assert state_file.exists()
|
||||||
|
state.clear()
|
||||||
|
|
||||||
|
assert not state_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_no_file_noop(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que clear ne fait rien quand le fichier n'existe pas.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state = PronoteAuthState(tmp_path / "missing.json")
|
||||||
|
|
||||||
|
state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_creates_file_with_0600_permissions(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que le fichier d'état est créé avec les permissions ``0600``.
|
||||||
|
|
||||||
|
Le fichier contient un token vivant : il doit être lisible uniquement
|
||||||
|
par le propriétaire.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "auth.json"
|
||||||
|
state = PronoteAuthState(state_file)
|
||||||
|
state.save({"pronote_url": "u", "username": "u", "password": "t", "uuid": "i"})
|
||||||
|
|
||||||
|
assert os.stat(state_file).st_mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Vérifie qu'aucun contenu des credentials n'apparaît dans les journaux.
|
||||||
|
|
||||||
|
Des sentinelles distinctes sont utilisées pour ``pronote_url``,
|
||||||
|
``username``, ``password`` et ``uuid`` ; aucun de ces marqueurs ne doit
|
||||||
|
apparaître dans les messages journalisés lors d'une sauvegarde, d'un
|
||||||
|
chargement et d'une suppression.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "auth.json"
|
||||||
|
credentials = {
|
||||||
|
"pronote_url": "https://SENTINEL_URL_ZZZ.example/pronote",
|
||||||
|
"username": "SENTINEL_USER_ZZZ",
|
||||||
|
"password": "SENTINEL_PASSWORD_ZZZ", # pragma: allowlist secret
|
||||||
|
"uuid": "SENTINEL_UUID_ZZZ",
|
||||||
|
}
|
||||||
|
|
||||||
|
state = PronoteAuthState(state_file)
|
||||||
|
with caplog.at_level(logging.DEBUG):
|
||||||
|
state.save(credentials)
|
||||||
|
state.load()
|
||||||
|
state.clear()
|
||||||
|
|
||||||
|
assert "SENTINEL_URL_ZZZ" not in caplog.text
|
||||||
|
assert "SENTINEL_USER_ZZZ" not in caplog.text
|
||||||
|
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
|
||||||
|
assert "SENTINEL_UUID_ZZZ" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_rejects_concurrent_access_with_a_redacted_dedicated_error(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie qu'un verrou concurrent échoue immédiatement sans fuite interne.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / ".pronote_auth_state.json"
|
||||||
|
state = PronoteAuthState(state_file)
|
||||||
|
competing_state = PronoteAuthState(state_file)
|
||||||
|
|
||||||
|
with state.lock():
|
||||||
|
assert state_file.with_name(f"{state_file.name}.lock").exists()
|
||||||
|
with pytest.raises(PronoteAuthStateLockError) as exc_info:
|
||||||
|
with competing_state.lock():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert "BlockingIOError" not in str(exc_info.value)
|
||||||
|
assert exc_info.value.__cause__ is None
|
||||||
|
assert exc_info.value.__context__ is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_open_failure_does_not_log_sensitive_lock_path(
|
||||||
|
tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un échec d'ouverture du verrou ne divulgue pas son chemin.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
:param monkeypatch: Fixture pytest pour remplacer l'ouverture du verrou.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel_path = "/SENTINEL_LOCK_PATH_ZZZ/.pronote_auth_state.json.lock"
|
||||||
|
|
||||||
|
def raise_lock_open_error(*args: object, **kwargs: object) -> int:
|
||||||
|
"""Simule un refus d'ouverture portant un chemin sensible."""
|
||||||
|
del args, kwargs
|
||||||
|
raise OSError(13, "Permission denied", sentinel_path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "open", raise_lock_open_error)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
with pytest.raises(PronoteAuthStateLockError) as exc_info:
|
||||||
|
with PronoteAuthState(tmp_path / ".pronote_auth_state.json").lock():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert "Impossible d'ouvrir le verrou d'état d'authentification Pronote" in caplog.text
|
||||||
|
assert "SENTINEL_LOCK_PATH_ZZZ" not in caplog.text
|
||||||
|
assert exc_info.value.__cause__ is None
|
||||||
|
assert exc_info.value.__context__ is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_is_released_when_the_protected_operation_raises(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que le verrou est libéré même si le bloc protégé échoue.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / ".pronote_auth_state.json"
|
||||||
|
lock_file = state_file.with_name(f"{state_file.name}.lock")
|
||||||
|
state = PronoteAuthState(state_file)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="échec simulé"):
|
||||||
|
with state.lock():
|
||||||
|
raise RuntimeError("échec simulé")
|
||||||
|
|
||||||
|
descriptor = os.open(lock_file, os.O_RDWR)
|
||||||
|
try:
|
||||||
|
flock(descriptor, LOCK_EX | LOCK_NB)
|
||||||
|
flock(descriptor, LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
File diff suppressed because it is too large
Load Diff
180
tests/unit/test_rotation_propagation.py
Normal file
180
tests/unit/test_rotation_propagation.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""Unit tests for PronoteAuthRotationError propagation through each layer.
|
||||||
|
|
||||||
|
These tests verify that the rotation error propagates correctly through the
|
||||||
|
real call chain without being wrapped in PipelineCriticalError at any layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
from pronote_sync.errors import PipelineCriticalError, PronoteAuthRotationError
|
||||||
|
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
||||||
|
from pronote_sync.models.homework import Homework
|
||||||
|
from pronote_sync.models.message import Message
|
||||||
|
from pronote_sync.pipeline.steps.fetch import fetch_step
|
||||||
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
|
|
||||||
|
|
||||||
|
class StubPronoteClientWithRotationError:
|
||||||
|
"""Stub PronoteClient that raises PronoteAuthRotationError from its methods."""
|
||||||
|
|
||||||
|
def get_lessons(self, start: date, end: date) -> list[Lesson]:
|
||||||
|
"""Raise rotation error when fetching lessons.
|
||||||
|
|
||||||
|
:param start: Start date (unused).
|
||||||
|
:param end: End date (unused).
|
||||||
|
:return: Never returns.
|
||||||
|
:raises PronoteAuthRotationError: Always.
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
|
||||||
|
|
||||||
|
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
||||||
|
"""Raise rotation error when fetching homeworks.
|
||||||
|
|
||||||
|
:param start: Start date (unused).
|
||||||
|
:param end: End date (unused).
|
||||||
|
:return: Never returns.
|
||||||
|
:raises PronoteAuthRotationError: Always.
|
||||||
|
"""
|
||||||
|
del start, end
|
||||||
|
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
|
||||||
|
|
||||||
|
def get_messages(self) -> list[Message]:
|
||||||
|
"""Return empty messages list.
|
||||||
|
|
||||||
|
:return: Empty list.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_informations(self) -> list[Message]:
|
||||||
|
"""Return empty information messages list.
|
||||||
|
|
||||||
|
:return: Empty list.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class StubSettings:
|
||||||
|
"""Minimal settings stub for PronoteFetcher."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize with minimal configuration."""
|
||||||
|
self.pronote = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="test",
|
||||||
|
password=SecretStr("test_password"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
messages_source="pronotepy",
|
||||||
|
auth_mode="password",
|
||||||
|
qr_code_file=None,
|
||||||
|
qr_pin=None,
|
||||||
|
ical_url=None,
|
||||||
|
)
|
||||||
|
self.app = type("AppSettings", (), {"sync_past_days": 7, "sync_future_days": 7})()
|
||||||
|
|
||||||
|
|
||||||
|
class StubFetcherWithRotationError:
|
||||||
|
"""Stub PronoteFetcher that raises PronoteAuthRotationError from its methods."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the stub fetcher."""
|
||||||
|
self._settings = StubSettings()
|
||||||
|
self._client = StubPronoteClientWithRotationError()
|
||||||
|
|
||||||
|
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||||
|
"""Raise rotation error when fetching agenda.
|
||||||
|
|
||||||
|
:return: Never returns.
|
||||||
|
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
||||||
|
:raises PronoteAuthRotationError: Always.
|
||||||
|
"""
|
||||||
|
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
|
||||||
|
|
||||||
|
def fetch_homework(self, target_date: date) -> list[Homework]:
|
||||||
|
"""Raise rotation error when fetching homework.
|
||||||
|
|
||||||
|
:param target_date: Target date (unused).
|
||||||
|
:return: Never returns.
|
||||||
|
:rtype: list[Homework]
|
||||||
|
:raises PronoteAuthRotationError: Always.
|
||||||
|
"""
|
||||||
|
del target_date
|
||||||
|
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
|
||||||
|
|
||||||
|
def fetch_messages(self) -> list[Message]:
|
||||||
|
"""Return empty messages list.
|
||||||
|
|
||||||
|
:return: Empty list.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_informations(self) -> list[Message]:
|
||||||
|
"""Return empty information messages list.
|
||||||
|
|
||||||
|
:return: Empty list.
|
||||||
|
:rtype: list[Message]
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pronote_fetcher_fetch_agenda_propagates_rotation_error() -> None:
|
||||||
|
"""PronoteFetcher.fetch_agenda() propagates PronoteAuthRotationError without wrapping.
|
||||||
|
|
||||||
|
This test verifies that when the underlying PronoteClient raises
|
||||||
|
PronoteAuthRotationError, the fetcher propagates it directly without
|
||||||
|
converting it to PipelineCriticalError.
|
||||||
|
"""
|
||||||
|
settings = Settings(pronote=StubSettings().pronote)
|
||||||
|
fetcher = PronoteFetcher(settings, StubPronoteClientWithRotationError())
|
||||||
|
|
||||||
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||||
|
fetcher.fetch_agenda()
|
||||||
|
|
||||||
|
assert "Token persisté expiré" in str(exc_info.value)
|
||||||
|
assert not isinstance(exc_info.value, PipelineCriticalError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pronote_fetcher_fetch_homework_propagates_rotation_error() -> None:
|
||||||
|
"""PronoteFetcher.fetch_homework() propagates PronoteAuthRotationError without wrapping.
|
||||||
|
|
||||||
|
This test verifies that when the underlying PronoteClient raises
|
||||||
|
PronoteAuthRotationError, the fetcher propagates it directly without
|
||||||
|
converting it to PipelineCriticalError.
|
||||||
|
"""
|
||||||
|
settings = Settings(pronote=StubSettings().pronote)
|
||||||
|
fetcher = PronoteFetcher(settings, StubPronoteClientWithRotationError())
|
||||||
|
target_date = date(2026, 9, 9)
|
||||||
|
|
||||||
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||||
|
fetcher.fetch_homework(target_date)
|
||||||
|
|
||||||
|
assert "Token persisté expiré" in str(exc_info.value)
|
||||||
|
assert not isinstance(exc_info.value, PipelineCriticalError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_step_propagates_rotation_error() -> None:
|
||||||
|
"""fetch_step() propagates PronoteAuthRotationError without wrapping.
|
||||||
|
|
||||||
|
This test verifies that the pipeline step fetch_step() propagates
|
||||||
|
PronoteAuthRotationError directly from the fetcher without converting
|
||||||
|
it to PipelineCriticalError.
|
||||||
|
"""
|
||||||
|
fetcher = StubFetcherWithRotationError()
|
||||||
|
|
||||||
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||||
|
fetch_step(fetcher, today=date(2026, 9, 8))
|
||||||
|
|
||||||
|
assert "Token persisté expiré" in str(exc_info.value)
|
||||||
|
assert not isinstance(exc_info.value, PipelineCriticalError)
|
||||||
Reference in New Issue
Block a user