From 2d55ac8d2bcac5cc1e761944451b6ef32fe301d2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 12 Sep 2026 15:18:00 +0200 Subject: [PATCH] feat(auth): prendre en charge le PIN de compte Pronote --- .env.example | 2 ++ docs/exploitation.md | 6 ++++++ pronote_sync/config/settings.py | 14 ++++++++++++++ pronote_sync/sources/pronote/client.py | 16 +++++++++++++++- tests/unit/test_config.py | 17 +++++++++++++++++ tests/unit/test_pronote_client.py | 20 ++++++++++++++++++-- 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index b268642..1247af5 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,8 @@ PRONOTE_AUTH_MODE=password # PRONOTE_AUTH_MODE=qr_token # PRONOTE_QR_CODE_FILE=/path/to/qr_code.json # PRONOTE_QR_PIN=1234 +# 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/docs/exploitation.md b/docs/exploitation.md index dca4ebc..f3d285c 100644 --- a/docs/exploitation.md +++ b/docs/exploitation.md @@ -48,6 +48,12 @@ 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. + ## Installation systemd Les fichiers versionnés sont : diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index 7964114..f803cd6 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 48670b1..657d828 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: @@ -337,7 +340,12 @@ class PronoteClient: creds = self._auth_state.load() if creds is not 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() @@ -421,10 +429,16 @@ class PronoteClient: app_uuid = f"pronote-sync-{uuid4().hex}" 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( diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 81efb2d..9d2dee6 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -207,4 +207,21 @@ 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() + + # Ensure trailing newline diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index cdfa312..e3e640a 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -674,8 +674,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. @@ -683,6 +685,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 = { @@ -707,23 +710,29 @@ 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] + pronotepy.ParentClient.token_login.assert_called_once_with( # type: ignore[attr-defined] + **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" @@ -760,6 +769,7 @@ 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() @@ -769,6 +779,7 @@ def test_connect_qr_token_no_creds_with_qr_code( qrcode_login.assert_called_once() # type: ignore[attr-defined] kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined] assert kwargs["pin"] == "123456" + assert kwargs["account_pin"] == account_pin assert kwargs["qr_code"] == { "login": "testuser", "jeton": "qr-jeton", @@ -1167,6 +1178,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( @@ -1190,7 +1202,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}" ), ) @@ -1203,6 +1216,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) @@ -1214,10 +1228,12 @@ 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 # --- Persistence of credentials after data operations --- -- 2.47.3