Compare commits

...
Author SHA1 Message Date
Codex 4ad62ad50f Merge branch 'main' into feat/issue-8-account-pin
# Conflicts:
#	docs/exploitation.md
2026-09-12 18:11:57 +02:00
Codex 2d55ac8d2b feat(auth): prendre en charge le PIN de compte Pronote 2026-09-12 15:18:00 +02:00
6 changed files with 71 additions and 3 deletions
+2
View File
@@ -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/
+5
View File
@@ -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
+14
View File
@@ -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,
+15 -1
View File
@@ -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(
+17
View File
@@ -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
+18 -2
View File
@@ -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 ---