feat(auth) : prendre en charge le PIN de compte Pronote #37

Merged
OpenCode merged 4 commits from feat/issue-8-account-pin into main 2026-09-12 23:57:49 +02:00
7 changed files with 152 additions and 18 deletions
+2
View File
@@ -24,6 +24,8 @@ PRONOTE_AUTH_MODE=password
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json # PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
# PRONOTE_QR_PIN= # PRONOTE_QR_PIN=
# Valeur à définir localement dans .env ; ne jamais la committer. # 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 ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
+2 -1
View File
@@ -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`. - **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 - **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 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 - **Tests de non-fuite** : Vérifier les messages, les logs, `__cause__`, `__context__` et le
traceback complet avec des sentinelles distinctes pour chaque secret. traceback complet avec des sentinelles distinctes pour chaque secret.
+6
View File
@@ -66,6 +66,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 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. 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.
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run` l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
+14
View File
@@ -47,6 +47,7 @@ class PronoteSettings(BaseSettings):
auth_mode: Literal["password", "qr_token"] = "password" auth_mode: Literal["password", "qr_token"] = "password"
qr_code_file: str | None = None qr_code_file: str | None = None
qr_pin: SecretStr | None = None qr_pin: SecretStr | None = None
account_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:
@@ -72,6 +73,18 @@ class PronoteSettings(BaseSettings):
return None return None
return "**********" 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): class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination. """Paramètres d'accès au serveur CalDAV de destination.
@@ -328,6 +341,7 @@ class Settings(BaseSettings):
self.pronote.ical_url, self.pronote.ical_url,
self.pronote.password, self.pronote.password,
self.pronote.qr_pin, self.pronote.qr_pin,
self.pronote.account_pin,
self.caldav.url, self.caldav.url,
self.caldav.password, self.caldav.password,
self.xmpp.password, self.xmpp.password,
+36 -7
View File
@@ -119,6 +119,9 @@ def _collect_auth_secrets(client: PronoteClient) -> list[str]:
# PIN QR # PIN QR
if settings.qr_pin is not None: if settings.qr_pin is not None:
secrets.append(settings.qr_pin.get_secret_value()) 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) # Contenu du fichier QR (jeton, login, url)
if settings.qr_code_file is not None: if settings.qr_code_file is not None:
try: try:
@@ -336,8 +339,14 @@ class PronoteClient:
if self._auth_state is not None: if self._auth_state is not None:
creds = self._auth_state.load() creds = self._auth_state.load()
if creds is not None: if creds is not None:
rotation_error: PronoteAuthRotationError | None = None
try: 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: if client.logged_in:
self._client = client self._client = client
self._persist_credentials() self._persist_credentials()
@@ -356,12 +365,16 @@ class PronoteClient:
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)), redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
) )
# Token expiré/invalide — pas de repli vers l'enrôlement QR # 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. " "Le token d'authentification Pronote est expiré ou invalide. "
"Action requise : supprimez le fichier .pronote_auth_state.json " "Action requise : supprimez le fichier .pronote_auth_state.json "
"et relancez avec un nouveau QR code (PRONOTE_QR_CODE_FILE + " "et relancez avec un nouveau QR code (PRONOTE_QR_CODE_FILE + "
"PRONOTE_QR_PIN)." "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é) # Enrôlement : premier login via QR code (aucun credential persisté)
client = self._enroll_qr_code(client_class) client = self._enroll_qr_code(client_class)
@@ -396,6 +409,7 @@ class PronoteClient:
) from None ) from None
# Read and validate QR code JSON # Read and validate QR code JSON
read_error: PronoteAuthRotationError | None = None
try: try:
qr_path = Path(qr_file) qr_path = Path(qr_file)
qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8")) 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_secrets(qr_file, extra_secrets=_collect_auth_secrets(self)),
redact_exception(exc, 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 : " "Impossible de lire le fichier QR code : "
f"{redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self))}" 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 # Validate required keys
for key in ("login", "jeton", "url"): for key in ("login", "jeton", "url"):
@@ -420,22 +438,33 @@ class PronoteClient:
pin_value = qr_pin.get_secret_value() pin_value = qr_pin.get_secret_value()
app_uuid = f"pronote-sync-{uuid4().hex}" app_uuid = f"pronote-sync-{uuid4().hex}"
enrollment_error: PronoteAuthRotationError | None = None
try: 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( client = client_class.qrcode_login(
qr_code=qr_data, qr_code=qr_data,
pin=pin_value, pin=pin_value,
uuid=app_uuid, uuid=app_uuid,
account_pin=account_pin,
) )
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Échec de l'enrôlement QR : %s", "Échec de l'enrôlement QR : %s",
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)), 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é. " "É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 à " "Générez un nouveau QR code dans l'application Pronote et mettez à "
"jour PRONOTE_QR_CODE_FILE." "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 return client
+17
View File
@@ -207,6 +207,23 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
assert "**********" in repr(settings.pronote.qr_pin) 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: def test_sync_past_days_negative_direct_instantiation() -> None:
"""Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation. """Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation.
+75 -10
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json import json
import logging import logging
import traceback
from collections.abc import Generator from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date, datetime from datetime import date, datetime
@@ -887,14 +888,17 @@ def test_get_informations_unchanged_in_password_mode(
assert message.read is False assert message.read is False
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_password_mode_unchanged( def test_connect_password_mode_unchanged(
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings, pronote_settings: PronoteSettings,
account_pin: str | None,
) -> None: ) -> None:
"""Vérifie que le mode password conserve le comportement historique. """Vérifie que le mode password conserve le comportement historique.
:param mocker: Fixture pytest-mock pour le mocking. :param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides en mode password. :param pronote_settings: Paramètres Pronote valides en mode password.
:param account_pin: PIN de compte éventuellement configuré.
:return: None :return: None
""" """
from unittest.mock import Mock 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.ParentClient", new=mock_client_class)
mocker.patch("pronotepy.Client") 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() connected = client._connect()
assert connected is mock_client assert connected is mock_client
@@ -919,8 +926,10 @@ def test_connect_password_mode_unchanged(
assert mock_client_class.call_count == 1 assert mock_client_class.call_count == 1
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_qr_token_with_persisted_creds( def test_connect_qr_token_with_persisted_creds(
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
account_pin: str | None,
) -> None: ) -> None:
"""Vérifie le login par token persisté en mode qr_token. """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é. ``token_login`` et le token rotate est resauvegardé.
:param mocker: Fixture pytest-mock pour le mocking. :param mocker: Fixture pytest-mock pour le mocking.
:param account_pin: PIN de second facteur facultatif.
:return: None :return: None
""" """
creds = { creds = {
@@ -940,10 +950,26 @@ def test_connect_qr_token_with_persisted_creds(
auth_state = mocker.MagicMock(spec=PronoteAuthState) auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = creds auth_state.load.return_value = creds
mock_client = mocker.MagicMock() mock_client: MagicMock = mocker.MagicMock()
mock_client.logged_in = True mock_client.logged_in = True
mock_client.export_credentials.return_value = rotated_creds 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( settings = PronoteSettings(
url="https://pronote.example.com", url="https://pronote.example.com",
@@ -952,23 +978,27 @@ def test_connect_qr_token_with_persisted_creds(
ent=None, ent=None,
account_type="parent", account_type="parent",
auth_mode="qr_token", auth_mode="qr_token",
account_pin=SecretStr(account_pin) if account_pin is not None else None,
) )
client = PronoteClient(settings, auth_state=auth_state) client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect() connected = client._connect()
assert connected is mock_client 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) 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( def test_connect_qr_token_no_creds_with_qr_code(
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
tmp_path: Path, tmp_path: Path,
account_pin: str | None,
) -> None: ) -> None:
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté. """Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
:param mocker: Fixture pytest-mock pour le mocking. :param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test. :param tmp_path: Répertoire temporaire de test.
:param account_pin: PIN de second facteur facultatif.
:return: None :return: None
""" """
qr_file = tmp_path / "qr_code.json" 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 "password": "new-token", # pragma: allowlist secret
"uuid": "new-uuid", "uuid": "new-uuid",
} }
mock_client = mocker.MagicMock() mock_client: MagicMock = mocker.MagicMock()
mock_client.logged_in = True mock_client.logged_in = True
mock_client.export_credentials.return_value = creds 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( settings = PronoteSettings(
url="https://pronote.example.com", url="https://pronote.example.com",
@@ -1005,15 +1051,16 @@ def test_connect_qr_token_no_creds_with_qr_code(
auth_mode="qr_token", auth_mode="qr_token",
qr_code_file=str(qr_file), qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"), qr_pin=SecretStr("123456"),
account_pin=SecretStr(account_pin) if account_pin is not None else None,
) )
client = PronoteClient(settings, auth_state=auth_state) client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect() connected = client._connect()
assert connected is mock_client assert connected is mock_client
qrcode_login = pronotepy.ParentClient.qrcode_login qrcode_login.assert_called_once()
qrcode_login.assert_called_once() # type: ignore[attr-defined] kwargs = qrcode_login.call_args.kwargs
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
assert kwargs["pin"] == "123456" assert kwargs["pin"] == "123456"
assert kwargs["account_pin"] == account_pin
assert kwargs["qr_code"] == { assert kwargs["qr_code"] == {
"login": "testuser", "login": "testuser",
"jeton": "qr-jeton", "jeton": "qr-jeton",
@@ -1390,6 +1437,12 @@ def test_no_secrets_in_rotation_error_messages(
assert sentinel_pin not in caplog.text assert sentinel_pin not in caplog.text
assert sentinel_token not in caplog.text assert sentinel_token not in caplog.text
assert "sentinel-url" 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( 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_token = "SENTINEL_RAW_TOKEN_ALPHA"
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO" sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE" sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
sentinel_account_pin = "SENTINEL_RAW_ACCOUNT_PIN_DELTA"
qr_file = tmp_path / "qr_code.json" qr_file = tmp_path / "qr_code.json"
qr_file.write_text( qr_file.write_text(
@@ -1435,7 +1489,8 @@ def test_no_raw_secrets_in_logs(
mocker.patch( mocker.patch(
"pronotepy.ParentClient.token_login", "pronotepy.ParentClient.token_login",
side_effect=pronotepy.PronoteAPIError( 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", auth_mode="qr_token",
qr_code_file=str(qr_file), qr_code_file=str(qr_file),
qr_pin=SecretStr(sentinel_pin), qr_pin=SecretStr(sentinel_pin),
account_pin=SecretStr(sentinel_account_pin),
) )
client = PronoteClient(settings, auth_state=auth_state) 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_token not in message
assert sentinel_pin not in message assert sentinel_pin not in message
assert sentinel_jeton not in message assert sentinel_jeton not in message
assert sentinel_account_pin not in message
assert caplog.text assert caplog.text
assert sentinel_token not in caplog.text assert sentinel_token not in caplog.text
assert sentinel_pin not in caplog.text assert sentinel_pin not in caplog.text
assert sentinel_jeton 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 --- # --- Persistence of credentials after data operations ---