fix(auth): corriger les retours de revue du PIN Pronote
This commit is contained in:
@@ -306,8 +306,8 @@ class AppSettings(BaseSettings):
|
||||
school_holidays_path: str | None = None
|
||||
theoretical_week_anchor_date: date | None = None
|
||||
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
|
||||
sync_past_days: int = 7
|
||||
sync_future_days: int = 30
|
||||
sync_past_days: int = Field(default=7, ge=0)
|
||||
sync_future_days: int = Field(default=30, ge=0)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -339,6 +339,7 @@ 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:
|
||||
account_pin = (
|
||||
self._settings.account_pin.get_secret_value()
|
||||
@@ -364,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)
|
||||
@@ -404,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"))
|
||||
@@ -413,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"):
|
||||
@@ -428,6 +438,7 @@ 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()
|
||||
@@ -445,11 +456,15 @@ class PronoteClient:
|
||||
"É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
|
||||
|
||||
@@ -502,9 +517,23 @@ class PronoteClient:
|
||||
Chaque entrée est mappée sur un modèle :class:`Message` de type
|
||||
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
|
||||
|
||||
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
||||
En mode ``qr_token``, la récupération est ignorée sans connexion ni
|
||||
appel réseau : l'endpoint ``PageActualites`` renvoie une erreur
|
||||
pronotepy 20 sur les instances HubEduConnect testées, provoquant un
|
||||
refresh redondant du token. La méthode retourne alors immédiatement
|
||||
une liste vide et journalise un message INFO unique ; ce comportement
|
||||
n'est pas configurable.
|
||||
|
||||
:return: Liste des informations et sondages ; vide en cas d'erreur ou
|
||||
en mode ``qr_token``.
|
||||
:rtype: list[Message]
|
||||
"""
|
||||
if self._settings.auth_mode == "qr_token":
|
||||
logger.info(
|
||||
"Récupération des informations Pronote ignorée : endpoint "
|
||||
"PageActualites indisponible en mode d'authentification qr_token."
|
||||
)
|
||||
return []
|
||||
with self._qr_token_operation_lock():
|
||||
try:
|
||||
client = self._connect()
|
||||
|
||||
@@ -15,20 +15,83 @@ logger = logging.getLogger(__name__)
|
||||
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
|
||||
|
||||
|
||||
_SENSITIVE_QUERY_PARAMS = {"token", "key", "api_key", "secret", "password", "auth"}
|
||||
|
||||
|
||||
def _validate_base_url(provider: str, url: str, allow_insecure_http: bool) -> str | None:
|
||||
"""Valide structurellement une URL de base IA, partagée entre providers.
|
||||
|
||||
Applique les règles structurelles identiques aux trois providers
|
||||
(``openai``, ``litellm`` et ``openai-compatible``) : URL parsable par
|
||||
``urlparse`` (``ValueError`` rejeté), hostname non vide, schéma limité
|
||||
à ``http``/``https`` (HTTP refusé sauf si ``allow_insecure_http`` vaut
|
||||
``True``), absence d'identifiants dans le netloc et de paramètres
|
||||
sensibles dans la requête (y compris les paramètres sans valeur). L'URL
|
||||
est retournée strictement inchangée : aucune manipulation automatique
|
||||
du suffixe ``/v1`` n'est effectuée. En cas de violation, un
|
||||
avertissement est journalisé (l'URL est toujours masquée via
|
||||
:func:`redact_url`) et ``None`` est retourné ; la fonction ne lève
|
||||
jamais d'exception et n'effectue aucun appel réseau.
|
||||
|
||||
:param provider: Nom du provider (utilisé pour le message d'avertissement).
|
||||
:param url: URL de base à valider (non vide).
|
||||
:param allow_insecure_http: Autorise ou non les URLs en HTTP.
|
||||
:return: L'URL validée, strictement inchangée, ou ``None`` si invalide.
|
||||
:rtype: str | None
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme:
|
||||
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
||||
return None
|
||||
if not parsed.hostname:
|
||||
logger.warning("URL sans hostname pour le provider %s : %s", provider, redact_url(url))
|
||||
return None
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
logger.warning(
|
||||
"Schéma d'URL non supporté pour le provider %s : %s", provider, redact_url(url)
|
||||
)
|
||||
return None
|
||||
if parsed.scheme == "http" and not allow_insecure_http:
|
||||
logger.warning(
|
||||
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true pour le provider %s : %s",
|
||||
provider,
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
logger.warning(
|
||||
"Credentials dans l'URL refusés pour le provider %s : %s",
|
||||
provider,
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
param_names = [name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)]
|
||||
if any(name in _SENSITIVE_QUERY_PARAMS for name in param_names):
|
||||
logger.warning(
|
||||
"Paramètres sensibles dans l'URL refusés pour le provider %s : %s",
|
||||
provider,
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
# Accéder à parsed.port peut lever ValueError (port invalide/hors bornes).
|
||||
parsed.port # noqa: B018
|
||||
except ValueError:
|
||||
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
||||
return None
|
||||
return url
|
||||
|
||||
|
||||
def _validate_openai_compatible_config(
|
||||
url: str | None, model: str | None, allow_insecure_http: bool
|
||||
) -> str | None:
|
||||
"""Valide la configuration du provider ``openai-compatible``.
|
||||
|
||||
Vérifie la présence de l'URL de base et du modèle, le schéma de l'URL
|
||||
(HTTPS obligatoire, HTTP accepté uniquement si ``allow_insecure_http``
|
||||
vaut ``True``), la présence d'un hostname non vide, l'absence
|
||||
d'identifiants dans le netloc et de paramètres sensibles dans la
|
||||
requête (y compris les paramètres sans valeur). Une URL malformée
|
||||
(``ValueError`` levé par ``urlparse``) est également rejetée. En cas
|
||||
d'échec, un avertissement est journalisé (l'URL est toujours masquée
|
||||
via :func:`redact_url`) et ``None`` est retourné : la synthèse IA se
|
||||
dégrade silencieusement, sans jamais lever d'exception.
|
||||
Vérifie d'abord la présence de l'URL de base et du modèle (spécifique
|
||||
à ``openai-compatible``), puis délègue les règles structurelles
|
||||
partagées à :func:`_validate_base_url`. En cas d'échec, un
|
||||
avertissement est journalisé et ``None`` est retourné : la synthèse IA
|
||||
se dégrade silencieusement, sans jamais lever d'exception.
|
||||
|
||||
:param url: URL de base de l'API compatible OpenAI.
|
||||
:param model: Identifiant du modèle à utiliser.
|
||||
@@ -43,42 +106,7 @@ def _validate_openai_compatible_config(
|
||||
if not model:
|
||||
logger.warning("Modèle requis pour le provider openai-compatible")
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"URL invalide pour le provider openai-compatible : %s",
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
if not parsed.hostname:
|
||||
logger.warning(
|
||||
"URL sans hostname pour le provider openai-compatible : %s",
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
logger.warning(
|
||||
"Schéma d'URL non supporté pour le provider openai-compatible : %s",
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
if parsed.scheme == "http" and not allow_insecure_http:
|
||||
logger.warning(
|
||||
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true : %s",
|
||||
redact_url(url),
|
||||
)
|
||||
return None
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
logger.warning("Credentials dans l'URL refusés : %s", redact_url(url))
|
||||
return None
|
||||
sensitive_names = {"token", "key", "api_key", "secret", "password", "auth"}
|
||||
param_names = [name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)]
|
||||
if any(name in sensitive_names for name in param_names):
|
||||
logger.warning("Paramètres sensibles dans l'URL refusés : %s", redact_url(url))
|
||||
return None
|
||||
return url
|
||||
return _validate_base_url("openai-compatible", url, allow_insecure_http)
|
||||
|
||||
|
||||
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||
@@ -87,14 +115,15 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
||||
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
||||
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
||||
journalisé et ``None`` est retourné. Pour le provider
|
||||
``openai-compatible``, la configuration (URL de base et modèle) est
|
||||
validée par :func:`_validate_openai_compatible_config` ; en cas de
|
||||
rejet, ``None`` est retourné avec un avertissement.
|
||||
journalisé et ``None`` est retourné. Pour ``openai`` et ``litellm``,
|
||||
une ``base_url`` éventuelle est validée par :func:`_validate_base_url` ;
|
||||
pour le provider ``openai-compatible``, la configuration (URL de base
|
||||
et modèle) est validée par :func:`_validate_openai_compatible_config` ;
|
||||
en cas de rejet, ``None`` est retourné avec un avertissement.
|
||||
|
||||
:param settings: Paramètres IA.
|
||||
:return: Le fournisseur configuré, ou ``None`` si désactivé, sans clé API
|
||||
ou avec une configuration ``openai-compatible`` invalide.
|
||||
ou avec une configuration invalide.
|
||||
:rtype: SynthesisProvider | None
|
||||
"""
|
||||
if not settings.enabled:
|
||||
@@ -111,6 +140,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||
except ImportError:
|
||||
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
||||
return None
|
||||
if base_url is not None and (
|
||||
_validate_base_url("litellm", base_url, settings.allow_insecure_http) is None
|
||||
):
|
||||
return None
|
||||
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||
|
||||
if settings.provider == "openai-compatible":
|
||||
@@ -121,4 +154,8 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||
return None
|
||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
||||
|
||||
if base_url is not None and (
|
||||
_validate_base_url("openai", base_url, settings.allow_insecure_http) is None
|
||||
):
|
||||
return None
|
||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||
|
||||
Reference in New Issue
Block a user