From 91e153173dc6d10f5f63475d94d97f5dda0c7a09 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Tue, 20 Oct 2026 07:21:18 +0200 Subject: [PATCH] fix provider eon --- .../base/auth/session_manager.py | 9 +- .../base/network/http_manager.py | 7 ++ .../providers/_template_v2/README.md | 34 ++++-- lib/streaming_providers/providers/eon/auth.py | 66 ++++++++---- .../providers/eon/persistence.py | 102 ++++++++++++++++++ .../providers/eon/provider.py | 20 +++- 6 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 lib/streaming_providers/providers/eon/persistence.py diff --git a/lib/streaming_providers/base/auth/session_manager.py b/lib/streaming_providers/base/auth/session_manager.py index 5964145..27fa5e9 100644 --- a/lib/streaming_providers/base/auth/session_manager.py +++ b/lib/streaming_providers/base/auth/session_manager.py @@ -9,6 +9,11 @@ from ..utils.vfs import VFS from .base_auth import BaseAuthToken +# Any session key whose NAME contains one of these is masked in the INFO logs of +# save_session / load_session / save_scoped_token (unknown keys are logged in clear). +_SENSITIVE_KEY_PARTS = ("token", "secret", "password", "jwt", "serial", "device_number") + + class SessionManager: """ Manages persistent session data including tokens and device IDs @@ -667,7 +672,7 @@ class SessionManager: "password", "persona_token", "persona_jwt", - ]: # ← ADD THESE TWO FIELDS + ] or any(part in str(key).lower() for part in _SENSITIVE_KEY_PARTS): safe_data[key] = f"" if value else f"" elif isinstance(value, dict): # Recursively handle nested dicts (for scoped tokens) @@ -727,4 +732,4 @@ class SessionManager: logger.info(f"=== END SESSION FILE DEBUG ===") except Exception as e: - logger.error(f"Error during session file debug: {e}") + logger.error(f"Error during session file debug: {e}") \ No newline at end of file diff --git a/lib/streaming_providers/base/network/http_manager.py b/lib/streaming_providers/base/network/http_manager.py index 9bd5709..b2fa17a 100644 --- a/lib/streaming_providers/base/network/http_manager.py +++ b/lib/streaming_providers/base/network/http_manager.py @@ -275,6 +275,13 @@ class HTTPManager: """Perform DELETE request with proxy support""" return self._make_request("DELETE", url, operation, **kwargs) + def patch(self, url: str, operation: str = "api", **kwargs) -> requests.Response: + """Perform PATCH request with proxy support. + + Not retried on 429 (PATCH is not idempotent, see _IDEMPOTENT_METHODS). + Takes the same kwargs as put(), including `raise_for_status=False`.""" + return self._make_request("PATCH", url, operation, **kwargs) + # ------------------------------------------------------------------ # Core request logic # ------------------------------------------------------------------ diff --git a/lib/streaming_providers/providers/_template_v2/README.md b/lib/streaming_providers/providers/_template_v2/README.md index 62a7563..4a94078 100644 --- a/lib/streaming_providers/providers/_template_v2/README.md +++ b/lib/streaming_providers/providers/_template_v2/README.md @@ -988,9 +988,26 @@ apart in one auth class instead of one `token` field: **permanent** until `set_credentials()`; everything else is **transient** with exponential backoff (30 s … 900 s) and **no network while backing off**; a pending OTP is neither. Without this every failing call retried a full login against the SSO. -* Persisted state is behind a small `Store` protocol (`get/set/delete`) with a - non-durable in-memory default **that is loudly documented as such**: without a durable - store every restart registers a new device (§12.1b). +* **Persisted state is behind a small `Store` protocol** (`get / set / set_many / delete`) + with a non-durable `MemoryStore` default that is loudly documented as such: without a + durable store every restart registers a new device (§12.1b). The durable one is + `persistence.SessionStore` on top of `SessionManager` (one dict at + `session.json[][]`, country lower-case; `save_session` REPLACES that + dict, so the store keeps a write-through cache and writes the whole thing, batched with + `set_many`, skipped when nothing changed). `default_persistence()` finds the + `SessionManager` and the credentials loader the way `BaseAuthenticator` does (injected + settings manager, else its own) and degrades to `MemoryStore` with a warning, never to + an exception. Credentials are loaded **lazily at the first login** + (`settings_manager.get_provider_credentials(provider, country)`), not in `__init__`. +* **Persist only what cannot be re-derived.** Eon stores device serial / id / number and the + refresh token. The stream key and stream user (the AES key of the CDN URL) come back with + every token response, including the refresh grant, so they stay in memory. + `SessionManager` logs the **values of keys it does not know at INFO** on every + `save_session` / `load_session`; only its mask list (`access_token`, `refresh_token`, + `client_secret`, `password`, `persona_*`) is hidden. `session_manager_patch.diff` masks by + key-name fragment (`token`, `secret`, `password`, `jwt`, `serial`, `device_number`). + Test both: a restart reuses the device and the refresh token (no password grant, no new + device), and the stream key never appears in captured log output. * Never log the stream key, tokens, device serial or a signed URL — not even at DEBUG. ### Credentials API on the provider (OPTIONAL — only if v1 has it) @@ -1767,6 +1784,8 @@ Things that already cost time. Each is covered by a test or the contract unless 79. Recording a login failure inside the recovery ladder (a stale-device `400` then blocks the provider permanently) or treating every `AuthError` — including a WAF `403` — as "wrong credentials" (§9). +80. Persisting a re-derivable secret (stream key, anything the next token response + returns) through `SessionManager`: unknown keys are logged in clear at INFO (§9). --- @@ -1802,9 +1821,11 @@ Known gaps in the base, so you don't hunt for them: `_IDEMPOTENT_METHODS`) adds it. Raw/multipart bodies already work: `post(url, data=, headers={"Content-Type": "multipart/form-data; boundary=…"})`, and `json=` passes through `**kwargs`. -* **No shared persisted-state protocol for Variant A providers**: Eon ships its own - `EonStore` (`get/set/delete`) with an in-memory default; the session layer should offer - the same shape so identity survives restarts without per-provider glue. +* **No ready-made `Store` for Variant A providers**: `SessionManager` offers + `load_session` / `save_session` (a dict per provider + country) and scoped tokens (which + insist on `access_token` and expiry fields), not a key/value store, and it logs unknown + values in clear. Eon's `persistence.SessionStore` is the adapter; moving it (and the + masking patch) into `base/auth` would remove the per-provider glue. * **`CatchupManager` and `EpgManager` do not say who owns "the programme at a time"** for day-based endpoints; Eon's `get_epg` re-filters by overlap, a shared helper would remove that duplication (see the deliberately-not-added `get_entry_at`). @@ -1994,6 +2015,7 @@ meet one of these: | catalogues that need a session, TTL + lock | `bootstrap.py` (`EonCatalog`) | §5.1 | | client token + device + user session, recovery ladder, OTP | `auth.py`, `errors.py` | §9 | | one place for "401 → recover → retry once" | `api.py` | §8, §9 | +| durable device identity + credentials from the settings manager | `persistence.py` | §9 | | locally signed, short-lived manifest URL | `stream_url.py`, `channel_manager.py` | §11 | | day-based EPG with a boundary programme | `epg_manager.py` | §6.3 | | per-channel, server-supplied catchup window; catchup via the channel manager | `catchup_manager.py` | §6.6 | diff --git a/lib/streaming_providers/providers/eon/auth.py b/lib/streaming_providers/providers/eon/auth.py index 7acf6e9..2e3cffe 100644 --- a/lib/streaming_providers/providers/eon/auth.py +++ b/lib/streaming_providers/providers/eon/auth.py @@ -34,6 +34,12 @@ login is recorded ONCE, after the whole ladder has run -- signal (stale device), not a verdict, and is not recorded. `last_error` keeps the original exception for the auth-status UI. +Persistence: only what cannot be re-derived is stored -- device serial / id / +number (client identity) and the refresh token. The STREAM KEY and stream user +come back with every token response (also the refresh grant) and live in +memory only: they are the AES key of the CDN URL, and SessionManager logs the +values of unknown keys in clear at INFO. + Rules: lazy (no network in __init__), one login for concurrent callers, typed errors only, nothing secret in logs (lengths at most). """ @@ -63,8 +69,6 @@ K_DEVICE_SERIAL = "device_serial" K_DEVICE_ID = "device_id" K_DEVICE_NUMBER = "device_number" K_REFRESH_TOKEN = "refresh_token" -K_STREAM_KEY = "stream_key" -K_STREAM_USER = "stream_user" _SERIAL_ALPHABET = string.digits + string.ascii_letters + "-" _TOKEN_REFRESH_BUFFER_SECONDS = 60 @@ -77,6 +81,7 @@ class EonStore(Protocol): def get(self, key: str) -> Optional[str]: ... def set(self, key: str, value: str) -> None: ... + def set_many(self, values: Dict[str, str]) -> None: ... def delete(self, key: str) -> None: ... @@ -92,6 +97,9 @@ class MemoryStore: def set(self, key: str, value: str) -> None: self._data[key] = value + def set_many(self, values: Dict[str, str]) -> None: + self._data.update(values) + def delete(self, key: str) -> None: self._data.pop(key, None) @@ -112,6 +120,7 @@ class EonAuth: endpoints: EonEndpoints, store: Optional[EonStore] = None, credentials: Any = None, + credentials_loader: Optional[Callable[[], Any]] = None, clock: Callable[[], float] = time.monotonic, ): self._http = http_manager @@ -119,6 +128,9 @@ class EonAuth: self._endpoints = endpoints self._store: EonStore = store if store is not None else MemoryStore() self._credentials = credentials # object with .username / .password + self._credentials_loader = credentials_loader # lazy: no disk/network in __init__ + self._stream_key: Optional[str] = None # memory only (see docstring) + self._stream_user: Optional[str] = None self._access_token: Optional[str] = None self._expires_at: Optional[float] = None self._generic_token: Optional[str] = None @@ -134,9 +146,17 @@ class EonAuth: # ------------------------------------------------------------------ # Credentials # ------------------------------------------------------------------ + def _current_credentials(self) -> Any: + if self._credentials is None and self._credentials_loader is not None: + try: + self._credentials = self._credentials_loader() + except Exception as exc: # a broken settings backend is "no credentials" + logger.warning(f"{_PROVIDER}: could not load stored credentials: {exc!r}") + return self._credentials + @property def has_credentials(self) -> bool: - c = self._credentials + c = self._current_credentials() return bool(c and getattr(c, "username", None) and getattr(c, "password", None)) def set_credentials(self, credentials: Any) -> None: @@ -186,8 +206,9 @@ class EonAuth: self._access_token = None self._expires_at = None self._pending_otp = None - for key in (K_REFRESH_TOKEN, K_STREAM_KEY, K_STREAM_USER): - self._store.delete(key) + self._stream_key = None + self._stream_user = None + self._store.delete(K_REFRESH_TOKEN) # ------------------------------------------------------------------ # Eon-specific @@ -195,12 +216,12 @@ class EonAuth: def stream_credentials(self) -> StreamCredentials: """Stream key / user / device number, logging in first if needed.""" self.get_access_token() - key = self._store.get(K_STREAM_KEY) - user = self._store.get(K_STREAM_USER) number = self._store.get(K_DEVICE_NUMBER) - if not (key and user and number): + if not (self._stream_key and self._stream_user and number): raise ServerError(f"{_PROVIDER}: the session carries no stream credentials") - return StreamCredentials(stream_key=key, stream_user=user, device_number=number) + return StreamCredentials( + stream_key=self._stream_key, stream_user=self._stream_user, device_number=number + ) def recover(self, failed_token: Optional[str]) -> None: """Repair the session after a 401 with `failed_token`.""" @@ -285,14 +306,17 @@ class EonAuth: def _apply(self, tok: EonTokenResponse) -> None: self._access_token = tok.access_token self._expires_at = time.time() + tok.expires_in if tok.expires_in else None - for key, value in ( - (K_REFRESH_TOKEN, tok.refresh_token), - (K_STREAM_KEY, tok.stream_key), - (K_STREAM_USER, tok.stream_user), - (K_DEVICE_NUMBER, tok.device_number), - ): - if value: - self._store.set(key, value) + if tok.stream_key: + self._stream_key = tok.stream_key + if tok.stream_user: + self._stream_user = tok.stream_user + durable = {} + if tok.refresh_token: + durable[K_REFRESH_TOKEN] = tok.refresh_token + if tok.device_number: + durable[K_DEVICE_NUMBER] = tok.device_number + if durable: + self._store.set_many(durable) def _grant(self, params: Dict[str, str], *, data: Optional[bytes] = None, headers: Optional[Dict[str, str]] = None, @@ -321,9 +345,10 @@ class EonAuth: if not device_number: raise AuthError(f"{_PROVIDER}: no device number; device registration failed") boundary = "----EonBoundary" + secrets.token_hex(8) - username_hash = hashlib.sha256(self._credentials.username.encode("utf-8")).hexdigest().upper() + creds = self._current_credentials() + username_hash = hashlib.sha256(creds.username.encode("utf-8")).hexdigest().upper() fields = (("username", username_hash), - ("password", self._credentials.password), + ("password", creds.password), ("device_number", device_number)) body = "".join( f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n' @@ -487,6 +512,5 @@ class EonAuth: reg = EonDeviceRegistration.from_api_response(resp.json()) except ValueError as exc: raise ServerError(f"{_PROVIDER}: {exc}") from exc - self._store.set(K_DEVICE_ID, reg.device_id) - self._store.set(K_DEVICE_NUMBER, reg.device_number) + self._store.set_many({K_DEVICE_ID: reg.device_id, K_DEVICE_NUMBER: reg.device_number}) logger.info(f"{_PROVIDER}: device registered") \ No newline at end of file diff --git a/lib/streaming_providers/providers/eon/persistence.py b/lib/streaming_providers/providers/eon/persistence.py new file mode 100644 index 0000000..e156099 --- /dev/null +++ b/lib/streaming_providers/providers/eon/persistence.py @@ -0,0 +1,102 @@ +# streaming_providers/providers/eon/persistence.py +""" +Glue between EonAuth and the framework's storage managers. + +SessionStore -- EonStore on top of SessionManager: one dict at + session.json[][] (country LOWER-case, as + the managers key by it). Read once, written through. +default_persistence() -- finds a SessionManager and a credentials loader the + way BaseAuthenticator does (injected settings manager, else + a SettingsManager of its own); anything missing degrades to + the non-durable MemoryStore / no stored credentials, with a + warning, never to an exception. + +SessionManager logs every value of keys it does not know at INFO +(`Saving session data ...`): never hand this store a secret that can be +re-derived. EonAuth persists device identity and the refresh token only (the +refresh token is on SessionManager's mask list). +""" + +import threading +from typing import Any, Callable, Dict, Optional, Tuple + +from ...base.utils.logger import logger +from .auth import EonStore, MemoryStore + + +class SessionStore: + def __init__(self, session_manager: Any, provider: str, country: Optional[str]): + self._sm = session_manager + self._provider = provider + self._country = country or None + self._lock = threading.RLock() + self._cache: Optional[Dict[str, str]] = None + + def _data(self) -> Dict[str, str]: + if self._cache is None: + loaded = self._sm.load_session(self._provider, self._country) + self._cache = {k: v for k, v in (loaded or {}).items() if isinstance(v, str)} + return self._cache + + def get(self, key: str) -> Optional[str]: + with self._lock: + return self._data().get(key) + + def set(self, key: str, value: str) -> None: + self.set_many({key: value}) + + def set_many(self, values: Dict[str, str]) -> None: + with self._lock: + data = self._data() + if all(data.get(k) == v for k, v in values.items()): + return + data.update(values) + self._save() + + def delete(self, key: str) -> None: + with self._lock: + data = self._data() + if key in data: + del data[key] + self._save() + + def _save(self) -> None: + # save_session REPLACES the dict at that path: write the whole cache. + if not self._sm.save_session(self._provider, dict(self._data()), self._country): + logger.warning(f"{self._provider}: could not persist the session") + + +def default_persistence( + *, provider: str, country: str, settings_manager: Any = None, +) -> Tuple[EonStore, Optional[Callable[[], Any]]]: + """(store, credentials_loader); never raises.""" + manager = settings_manager + if manager is None: + try: + from ...base.settings.settings_manager import SettingsManager + manager = SettingsManager(config_dir=None, enable_kodi_integration=True) + except Exception as exc: + logger.warning(f"{provider}: no settings manager ({exc!r})") + if manager is not None and hasattr(manager, "register_provider"): + try: + manager.register_provider(provider) + except Exception as exc: + logger.warning(f"{provider}: could not register with the settings manager: {exc!r}") + + session_manager = getattr(manager, "session_manager", None) + if session_manager is None: + try: + from ...base.auth.session_manager import SessionManager + session_manager = SessionManager() + except Exception as exc: + logger.warning(f"{provider}: no SessionManager ({exc!r}); device identity is NOT persisted") + + store: EonStore = ( + SessionStore(session_manager, provider, country) if session_manager is not None + else MemoryStore() + ) + + loader: Optional[Callable[[], Any]] = None + if manager is not None and hasattr(manager, "get_provider_credentials"): + loader = lambda: manager.get_provider_credentials(provider, country) # noqa: E731 + return store, loader \ No newline at end of file diff --git a/lib/streaming_providers/providers/eon/provider.py b/lib/streaming_providers/providers/eon/provider.py index 2247235..47a52b5 100644 --- a/lib/streaming_providers/providers/eon/provider.py +++ b/lib/streaming_providers/providers/eon/provider.py @@ -24,6 +24,7 @@ from .catchup_manager import EonCatchupManager from .channel_manager import EonChannelManager from .constants import EonConfig, EonDefaults from .epg_manager import EonEpgManager +from .persistence import default_persistence __all__ = ["EonProvider"] @@ -54,6 +55,7 @@ class EonProvider(ManagedProvider): config: Optional[Dict] = None, proxy_config: Optional[ProxyConfig] = None, store: Optional[EonStore] = None, + settings_manager=None, ): super().__init__(country=country) self.country = self.country.lower() @@ -69,13 +71,23 @@ class EonProvider(ManagedProvider): timeout=self.provider_config.timeout, ) - # 3. Lazy collaborators: no network in __init__. + # 3. Persistence: device identity + refresh token via SessionManager, + # credentials via the settings manager (loaded lazily at first login). + # `store` overrides it (tests). + default_store, credentials_loader = default_persistence( + provider=EonDefaults.PROVIDER_NAME, country=self.country, + settings_manager=settings_manager, + ) + + # 4. Lazy collaborators: no network in __init__. self.endpoints = EonEndpoints( http_manager=self.http_manager, config=self.provider_config ) self.auth = EonAuth( http_manager=self.http_manager, config=self.provider_config, - endpoints=self.endpoints, store=store, + endpoints=self.endpoints, + store=store if store is not None else default_store, + credentials_loader=credentials_loader, ) self.api = EonApi( http_manager=self.http_manager, auth=self.auth, @@ -83,10 +95,10 @@ class EonProvider(ManagedProvider): ) self.catalog = EonCatalog(api=self.api, config=self.provider_config) - # 4. Provider-owned caches, borrowed by managers by reference. + # 5. Provider-owned caches, borrowed by managers by reference. self._url_cache: Dict = {} - # 5. Managers. NOTE: self.channels is now the ChannelManager. + # 6. Managers. NOTE: self.channels is now the ChannelManager. self._init_managers() def _build_channels(self):