diff --git a/.env.example b/.env.example index 6b1ff75..085f0d8 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ PRONOTE_AUTH_MODE=password # PRONOTE_QR_CODE_FILE=/path/to/qr_code.json # PRONOTE_QR_PIN= # Valeur à définir localement dans .env ; ne jamais la committer. +# PIN de second facteur du compte Pronote, distinct du PIN de déchiffrement du QR code +# PRONOTE_ACCOUNT_PIN= # --- CalDAV --- CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ diff --git a/AGENTS.md b/AGENTS.md index 3b9ada2..d7496ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,7 +230,8 @@ def fetch_ical(url: str) -> str: - **Masquage** : Utiliser systématiquement `redact_url()`, `redact_secrets()`, et `redact_exception()` depuis `utils/redaction.py`. - **Chaînage d'exceptions** : Ne jamais conserver comme `__cause__` ou `__context__` une exception externe brute susceptible de contenir un secret. Journaliser la version expurgée puis utiliser - `raise ... from None`, ou chaîner une cause elle-même expurgée. + `raise ... from None` hors du bloc `except` (car `from None` seul laisse + l'exception externe dans `__context__`), ou chaîner une cause elle-même expurgée. - **Tests de non-fuite** : Vérifier les messages, les logs, `__cause__`, `__context__` et le traceback complet avec des sentinelles distinctes pour chaque secret. diff --git a/docs/exploitation.md b/docs/exploitation.md index f90094b..859f600 100644 --- a/docs/exploitation.md +++ b/docs/exploitation.md @@ -66,6 +66,11 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant. Le dry-run ne remplace pas une vérification des paramètres réellement chargés. +En mode `PRONOTE_AUTH_MODE=qr_token`, `PRONOTE_QR_PIN` déchiffre le QR code +exporté depuis le site web Pronote. Si le compte exige un second facteur, +configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est +transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par +token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs. En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier `.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index ce417c5..888af9a 100644 --- a/pronote_sync/config/settings.py +++ b/pronote_sync/config/settings.py @@ -47,6 +47,7 @@ class PronoteSettings(BaseSettings): auth_mode: Literal["password", "qr_token"] = "password" qr_code_file: str | None = None qr_pin: SecretStr | None = None + account_pin: SecretStr | None = None @field_serializer("ical_url") def _serialize_ical_url(self, value: SecretStr | None) -> str | None: @@ -72,6 +73,18 @@ class PronoteSettings(BaseSettings): return None return "**********" + @field_serializer("account_pin") + def _serialize_account_pin(self, value: SecretStr | None) -> str | None: + """Masque le PIN du compte lors de la sérialisation. + + :param value: Valeur du PIN de second facteur du compte. + :return: ``"**********"`` si la valeur est définie, ``None`` sinon. + :rtype: str | None + """ + if value is None: + return None + return "**********" + class CalDAVSettings(BaseSettings): """Paramètres d'accès au serveur CalDAV de destination. @@ -328,6 +341,7 @@ class Settings(BaseSettings): self.pronote.ical_url, self.pronote.password, self.pronote.qr_pin, + self.pronote.account_pin, self.caldav.url, self.caldav.password, self.xmpp.password, diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 6df44b6..c52c2ee 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -119,6 +119,9 @@ def _collect_auth_secrets(client: PronoteClient) -> list[str]: # PIN QR if settings.qr_pin is not None: secrets.append(settings.qr_pin.get_secret_value()) + # PIN de second facteur du compte + if settings.account_pin is not None: + secrets.append(settings.account_pin.get_secret_value()) # Contenu du fichier QR (jeton, login, url) if settings.qr_code_file is not None: try: @@ -336,8 +339,14 @@ class PronoteClient: if self._auth_state is not None: creds = self._auth_state.load() if creds is not None: + rotation_error: PronoteAuthRotationError | None = None try: - client = client_class.token_login(**creds) + account_pin = ( + self._settings.account_pin.get_secret_value() + if self._settings.account_pin is not None + else None + ) + client = client_class.token_login(**creds, account_pin=account_pin) if client.logged_in: self._client = client self._persist_credentials() @@ -356,12 +365,16 @@ class PronoteClient: redact_exception(exc, extra_secrets=_collect_auth_secrets(self)), ) # Token expiré/invalide — pas de repli vers l'enrôlement QR - raise PronoteAuthRotationError( + rotation_error = 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 + ) + if rotation_error is not None: + # Lever hors du bloc ``except`` évite de conserver l'erreur + # externe dans ``__context__``. + raise rotation_error from None # Enrôlement : premier login via QR code (aucun credential persisté) client = self._enroll_qr_code(client_class) @@ -396,6 +409,7 @@ class PronoteClient: ) from None # Read and validate QR code JSON + read_error: PronoteAuthRotationError | None = None try: qr_path = Path(qr_file) qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8")) @@ -405,10 +419,14 @@ class PronoteClient: redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self)), redact_exception(exc, extra_secrets=_collect_auth_secrets(self)), ) - raise PronoteAuthRotationError( + read_error = PronoteAuthRotationError( "Impossible de lire le fichier QR code : " f"{redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self))}" - ) from None + ) + if read_error is not None: + # Lever hors du bloc ``except`` évite de conserver l'erreur + # externe dans ``__context__``. + raise read_error from None # Validate required keys for key in ("login", "jeton", "url"): @@ -420,22 +438,33 @@ class PronoteClient: pin_value = qr_pin.get_secret_value() app_uuid = f"pronote-sync-{uuid4().hex}" + enrollment_error: PronoteAuthRotationError | None = None try: + account_pin = ( + self._settings.account_pin.get_secret_value() + if self._settings.account_pin is not None + else None + ) client = client_class.qrcode_login( qr_code=qr_data, pin=pin_value, uuid=app_uuid, + account_pin=account_pin, ) except Exception as exc: logger.error( "Échec de l'enrôlement QR : %s", redact_exception(exc, extra_secrets=_collect_auth_secrets(self)), ) - raise PronoteAuthRotationError( + enrollment_error = 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 + ) + if enrollment_error is not None: + # Lever hors du bloc ``except`` évite de conserver l'erreur + # externe dans ``__context__``. + raise enrollment_error from None return client diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 1cc098d..4efd2b6 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -207,6 +207,23 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None: assert "**********" in repr(settings.pronote.qr_pin) +def test_account_pin_loaded_as_secretstr_and_redacted(monkeypatch: MonkeyPatch) -> None: + """Vérifie que ``PRONOTE_ACCOUNT_PIN`` est secret et expurgé. + + :param monkeypatch: Fixture pytest pour modifier temporairement l'environnement. + :return: None + """ + monkeypatch.setenv("PRONOTE_ACCOUNT_PIN", "account-pin-42") + settings = load_settings() + + assert isinstance(settings.pronote.account_pin, SecretStr) + assert settings.pronote.account_pin.get_secret_value() == "account-pin-42" + assert settings.pronote.account_pin in settings.redaction_secrets() + assert "account-pin-42" not in str(settings) + assert "account-pin-42" not in settings.model_dump_json() + assert "**********" in settings.model_dump_json() + + def test_sync_past_days_negative_direct_instantiation() -> None: """Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation. diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index c9eb36a..c686eef 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import logging +import traceback from collections.abc import Generator from contextlib import contextmanager from datetime import date, datetime @@ -887,14 +888,17 @@ def test_get_informations_unchanged_in_password_mode( assert message.read is False +@pytest.mark.parametrize("account_pin", [None, "account-pin-42"]) def test_connect_password_mode_unchanged( mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings, + account_pin: str | None, ) -> None: """Vérifie que le mode password conserve le comportement historique. :param mocker: Fixture pytest-mock pour le mocking. :param pronote_settings: Paramètres Pronote valides en mode password. + :param account_pin: PIN de compte éventuellement configuré. :return: None """ from unittest.mock import Mock @@ -904,7 +908,10 @@ def test_connect_password_mode_unchanged( mocker.patch("pronotepy.ParentClient", new=mock_client_class) mocker.patch("pronotepy.Client") - client = PronoteClient(pronote_settings, auth_state=None) + settings = pronote_settings.model_copy( + update={"account_pin": SecretStr(account_pin) if account_pin is not None else None} + ) + client = PronoteClient(settings, auth_state=None) connected = client._connect() assert connected is mock_client @@ -919,8 +926,10 @@ def test_connect_password_mode_unchanged( assert mock_client_class.call_count == 1 +@pytest.mark.parametrize("account_pin", [None, "account-pin-42"]) def test_connect_qr_token_with_persisted_creds( mocker: pytest_mock.MockerFixture, + account_pin: str | None, ) -> None: """Vérifie le login par token persisté en mode qr_token. @@ -928,6 +937,7 @@ def test_connect_qr_token_with_persisted_creds( ``token_login`` et le token rotate est resauvegardé. :param mocker: Fixture pytest-mock pour le mocking. + :param account_pin: PIN de second facteur facultatif. :return: None """ creds = { @@ -940,10 +950,26 @@ def test_connect_qr_token_with_persisted_creds( auth_state = mocker.MagicMock(spec=PronoteAuthState) auth_state.load.return_value = creds - mock_client = mocker.MagicMock() + mock_client: MagicMock = mocker.MagicMock() mock_client.logged_in = True mock_client.export_credentials.return_value = rotated_creds - mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client) + + def token_login_fake( + pronote_url: str, + username: str, + password: str, + uuid: str, + account_pin: str | None = None, + client_identifier: str | None = None, + device_name: str | None = None, + ) -> MagicMock: + """Retourne le faux client avec la signature pronotepy 2.15.7.""" + del pronote_url, username, password, uuid, account_pin, client_identifier, device_name + return mock_client + + token_login = mocker.patch( + "pronotepy.ParentClient.token_login", autospec=True, side_effect=token_login_fake + ) settings = PronoteSettings( url="https://pronote.example.com", @@ -952,23 +978,27 @@ def test_connect_qr_token_with_persisted_creds( ent=None, account_type="parent", auth_mode="qr_token", + account_pin=SecretStr(account_pin) if account_pin is not None else None, ) client = PronoteClient(settings, auth_state=auth_state) connected = client._connect() assert connected is mock_client - pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined] + token_login.assert_called_once_with(**creds, account_pin=account_pin) auth_state.save.assert_called_once_with(rotated_creds) +@pytest.mark.parametrize("account_pin", [None, "account-pin-42"]) def test_connect_qr_token_no_creds_with_qr_code( mocker: pytest_mock.MockerFixture, tmp_path: Path, + account_pin: str | None, ) -> None: """Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté. :param mocker: Fixture pytest-mock pour le mocking. :param tmp_path: Répertoire temporaire de test. + :param account_pin: PIN de second facteur facultatif. :return: None """ qr_file = tmp_path / "qr_code.json" @@ -991,10 +1021,26 @@ def test_connect_qr_token_no_creds_with_qr_code( "password": "new-token", # pragma: allowlist secret "uuid": "new-uuid", } - mock_client = mocker.MagicMock() + mock_client: MagicMock = mocker.MagicMock() mock_client.logged_in = True mock_client.export_credentials.return_value = creds - mocker.patch("pronotepy.ParentClient.qrcode_login", return_value=mock_client) + + def qrcode_login_fake( + qr_code: dict[str, str], + pin: str, + uuid: str, + account_pin: str | None = None, + client_identifier: str | None = None, + device_name: str | None = None, + skip_2fa: bool = False, + ) -> MagicMock: + """Retourne le faux client avec la signature pronotepy 2.15.7.""" + del qr_code, pin, uuid, account_pin, client_identifier, device_name, skip_2fa + return mock_client + + qrcode_login = mocker.patch( + "pronotepy.ParentClient.qrcode_login", autospec=True, side_effect=qrcode_login_fake + ) settings = PronoteSettings( url="https://pronote.example.com", @@ -1005,15 +1051,16 @@ def test_connect_qr_token_no_creds_with_qr_code( auth_mode="qr_token", qr_code_file=str(qr_file), qr_pin=SecretStr("123456"), + account_pin=SecretStr(account_pin) if account_pin is not None else None, ) client = PronoteClient(settings, auth_state=auth_state) connected = client._connect() assert connected is mock_client - qrcode_login = pronotepy.ParentClient.qrcode_login - qrcode_login.assert_called_once() # type: ignore[attr-defined] - kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined] + qrcode_login.assert_called_once() + kwargs = qrcode_login.call_args.kwargs assert kwargs["pin"] == "123456" + assert kwargs["account_pin"] == account_pin assert kwargs["qr_code"] == { "login": "testuser", "jeton": "qr-jeton", @@ -1390,6 +1437,12 @@ def test_no_secrets_in_rotation_error_messages( assert sentinel_pin not in caplog.text assert sentinel_token not in caplog.text assert "sentinel-url" not in caplog.text + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + formatted_traceback = "".join(traceback.format_exception(exc_info.value)) + assert sentinel_pin not in formatted_traceback + assert sentinel_token not in formatted_traceback + assert "sentinel-url" not in formatted_traceback def test_no_raw_secrets_in_logs( @@ -1412,6 +1465,7 @@ def test_no_raw_secrets_in_logs( sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA" sentinel_pin = "SENTINEL_RAW_PIN_BRAVO" sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE" + sentinel_account_pin = "SENTINEL_RAW_ACCOUNT_PIN_DELTA" qr_file = tmp_path / "qr_code.json" qr_file.write_text( @@ -1435,7 +1489,8 @@ def test_no_raw_secrets_in_logs( mocker.patch( "pronotepy.ParentClient.token_login", side_effect=pronotepy.PronoteAPIError( - f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton}" + f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} " + f"puis {sentinel_account_pin}" ), ) @@ -1448,6 +1503,7 @@ def test_no_raw_secrets_in_logs( auth_mode="qr_token", qr_code_file=str(qr_file), qr_pin=SecretStr(sentinel_pin), + account_pin=SecretStr(sentinel_account_pin), ) client = PronoteClient(settings, auth_state=auth_state) @@ -1459,10 +1515,19 @@ def test_no_raw_secrets_in_logs( assert sentinel_token not in message assert sentinel_pin not in message assert sentinel_jeton not in message + assert sentinel_account_pin not in message assert caplog.text assert sentinel_token not in caplog.text assert sentinel_pin not in caplog.text assert sentinel_jeton not in caplog.text + assert sentinel_account_pin not in caplog.text + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + formatted_traceback = "".join(traceback.format_exception(exc_info.value)) + assert sentinel_token not in formatted_traceback + assert sentinel_pin not in formatted_traceback + assert sentinel_jeton not in formatted_traceback + assert sentinel_account_pin not in formatted_traceback # --- Persistence of credentials after data operations ---