mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-20 23:02:44 +02:00
492 lines
21 KiB
Python
492 lines
21 KiB
Python
# streaming_providers/providers/eon/auth.py
|
|
"""
|
|
Eon authentication -- the auth class itself satisfies AuthProtocol
|
|
(get_access_token / build_headers / invalidate); managers get it directly.
|
|
|
|
Three identities are involved (see PVREon / HttpClient):
|
|
* client token -- OAuth client_credentials at the broker; only used for
|
|
device registration. Re-fetched on 401.
|
|
* device -- serial (client-generated), deviceId / deviceNumber
|
|
(server-minted). CLIENT IDENTITY: persisted, never
|
|
cleared with the session.
|
|
* user session -- access / refresh token + stream key + stream user. The
|
|
stream key is also the AES key of the CDN URL.
|
|
|
|
Login flows (platform-dependent):
|
|
web refresh_token grant, else password grant (username SHA-256 in
|
|
upper-case hex + password + deviceNumber, multipart form)
|
|
androidtv refresh_token grant, else OTP: the server hands out a code the
|
|
user confirms in the operator's portal (EonOtpRequiredError),
|
|
the next attempt exchanges it.
|
|
|
|
Recovery ladder on a rejected token (recover()), each rung once:
|
|
1. refresh_token grant 2. password / OTP login 3. re-register the
|
|
device (new deviceNumber) and log in again -> else AuthError.
|
|
|
|
Failure policy (same as the Variant B session adapter, README §9): a failed
|
|
login is recorded ONCE, after the whole ladder has run --
|
|
* EonLoginRejected (wrong credentials) -> PERMANENT: no network until
|
|
set_credentials() / reset();
|
|
* EonOtpRequiredError -> a pending human step, never
|
|
gated (the next call exchanges the confirmed code);
|
|
* anything else (network, 5xx, WAF 403) -> TRANSIENT: exponential backoff
|
|
BACKOFF_BASE * 2^(n-1) s, capped. A rejected grant INSIDE the ladder is a
|
|
signal (stale device), not a verdict, and is not recorded.
|
|
`last_error` keeps the original exception for the auth-status UI.
|
|
|
|
Rules: lazy (no network in __init__), one login for concurrent callers,
|
|
typed errors only, nothing secret in logs (lengths at most).
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import secrets
|
|
import string
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Dict, Optional, Protocol, Type
|
|
|
|
from requests.exceptions import HTTPError
|
|
|
|
from ...base.errors import AuthError, ProviderError, ServerError, error_from_exception
|
|
from ...base.utils.logger import logger
|
|
from ...base.utils.transport import transport_errors
|
|
from .constants import EonConfig, EonDefaults
|
|
from .bootstrap import EonEndpoints
|
|
from .errors import EonLoginRejected, EonOtpRequiredError
|
|
from .models import EonDeviceRegistration, EonTokenResponse
|
|
|
|
_PROVIDER = EonDefaults.PROVIDER_LABEL
|
|
|
|
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
|
|
BACKOFF_BASE_SECONDS = 30
|
|
BACKOFF_MAX_SECONDS = 900
|
|
|
|
|
|
class EonStore(Protocol):
|
|
"""Durable key/value state owned by the backend's session layer."""
|
|
|
|
def get(self, key: str) -> Optional[str]: ...
|
|
def set(self, key: str, value: str) -> None: ...
|
|
def delete(self, key: str) -> None: ...
|
|
|
|
|
|
class MemoryStore:
|
|
"""Non-durable default. With it every restart registers a NEW device."""
|
|
|
|
def __init__(self) -> None:
|
|
self._data: Dict[str, str] = {}
|
|
|
|
def get(self, key: str) -> Optional[str]:
|
|
return self._data.get(key)
|
|
|
|
def set(self, key: str, value: str) -> None:
|
|
self._data[key] = value
|
|
|
|
def delete(self, key: str) -> None:
|
|
self._data.pop(key, None)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StreamCredentials:
|
|
stream_key: str
|
|
stream_user: str
|
|
device_number: str
|
|
|
|
|
|
class EonAuth:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
http_manager: Any,
|
|
config: EonConfig,
|
|
endpoints: EonEndpoints,
|
|
store: Optional[EonStore] = None,
|
|
credentials: Any = None,
|
|
clock: Callable[[], float] = time.monotonic,
|
|
):
|
|
self._http = http_manager
|
|
self._config = config
|
|
self._endpoints = endpoints
|
|
self._store: EonStore = store if store is not None else MemoryStore()
|
|
self._credentials = credentials # object with .username / .password
|
|
self._access_token: Optional[str] = None
|
|
self._expires_at: Optional[float] = None
|
|
self._generic_token: Optional[str] = None
|
|
self._pending_otp: Optional[str] = None
|
|
self._lock = threading.RLock()
|
|
self._generic_lock = threading.RLock()
|
|
self._clock = clock
|
|
self.last_error: Optional[Exception] = None
|
|
self._permanent: Optional[Exception] = None
|
|
self._failures = 0
|
|
self._retry_at = 0.0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Credentials
|
|
# ------------------------------------------------------------------
|
|
@property
|
|
def has_credentials(self) -> bool:
|
|
c = self._credentials
|
|
return bool(c and getattr(c, "username", None) and getattr(c, "password", None))
|
|
|
|
def set_credentials(self, credentials: Any) -> None:
|
|
"""Replace the credentials, drop the session and forget any failure."""
|
|
self._credentials = credentials
|
|
self.invalidate()
|
|
self.reset()
|
|
|
|
def reset(self) -> None:
|
|
"""Forget recorded failures (permanent verdict and backoff)."""
|
|
with self._lock:
|
|
self.last_error = None
|
|
self._permanent = None
|
|
self._failures = 0
|
|
self._retry_at = 0.0
|
|
|
|
def details(self) -> Dict[str, Any]:
|
|
"""Free-form details for the auth-status UI."""
|
|
details: Dict[str, Any] = {}
|
|
if self.last_error is not None:
|
|
details["last_error"] = str(self.last_error)
|
|
details["last_error_type"] = type(self.last_error).__name__
|
|
if isinstance(self.last_error, EonOtpRequiredError):
|
|
details["otp"] = self.last_error.otp
|
|
details["otp_expires_minutes"] = self.last_error.expires_minutes
|
|
return details
|
|
|
|
# ------------------------------------------------------------------
|
|
# AuthProtocol
|
|
# ------------------------------------------------------------------
|
|
def get_access_token(self, force_refresh: bool = False) -> str:
|
|
if not force_refresh and self._token_valid():
|
|
return self._access_token # type: ignore[return-value]
|
|
with self._lock:
|
|
if not force_refresh and self._token_valid(): # another thread logged in
|
|
return self._access_token # type: ignore[return-value]
|
|
self._gated(self._login)
|
|
return self._access_token # type: ignore[return-value]
|
|
|
|
def build_headers(self) -> Dict[str, str]:
|
|
"""API headers incl. the bearer token (not for CDN requests)."""
|
|
return self._config.bearer_headers(self.get_access_token())
|
|
|
|
def invalidate(self) -> None:
|
|
"""Drop the SESSION (tokens, stream credentials). The device stays."""
|
|
with self._lock:
|
|
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)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Eon-specific
|
|
# ------------------------------------------------------------------
|
|
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):
|
|
raise ServerError(f"{_PROVIDER}: the session carries no stream credentials")
|
|
return StreamCredentials(stream_key=key, stream_user=user, device_number=number)
|
|
|
|
def recover(self, failed_token: Optional[str]) -> None:
|
|
"""Repair the session after a 401 with `failed_token`."""
|
|
with self._lock:
|
|
if self._access_token and self._access_token != failed_token:
|
|
return # another thread already repaired it
|
|
self._access_token = None
|
|
self._expires_at = None
|
|
self._gated(self._ladder)
|
|
|
|
def _ladder(self) -> None:
|
|
try:
|
|
self._login() # rungs 1 + 2
|
|
return
|
|
except EonOtpRequiredError:
|
|
raise
|
|
except AuthError:
|
|
logger.warning(f"{_PROVIDER}: login failed; re-registering the device")
|
|
self._reset_device() # rung 3
|
|
self._login() # AuthError propagates
|
|
|
|
def _gated(self, action: Callable[[], None]) -> None:
|
|
"""Run a login action behind the failure gate and record its outcome."""
|
|
if self._permanent is not None:
|
|
raise self._permanent
|
|
if self._clock() < self._retry_at and self.last_error is not None:
|
|
raise self.last_error
|
|
try:
|
|
action()
|
|
except EonOtpRequiredError as exc:
|
|
self.last_error = exc # a pending human step: not a failure
|
|
raise
|
|
except Exception as exc:
|
|
self._record_failure(exc)
|
|
raise
|
|
self.last_error = None
|
|
self._failures = 0
|
|
self._retry_at = 0.0
|
|
|
|
def _record_failure(self, exc: Exception) -> None:
|
|
self.last_error = exc
|
|
if isinstance(exc, EonLoginRejected):
|
|
self._permanent = exc
|
|
return
|
|
self._failures += 1
|
|
delay = min(BACKOFF_BASE_SECONDS * 2 ** (self._failures - 1), BACKOFF_MAX_SECONDS)
|
|
self._retry_at = self._clock() + delay
|
|
|
|
# ------------------------------------------------------------------
|
|
# Login
|
|
# ------------------------------------------------------------------
|
|
def _token_valid(self) -> bool:
|
|
if not self._access_token:
|
|
return False
|
|
if self._expires_at is None: # the server never told us: 401 decides
|
|
return True
|
|
return time.time() < self._expires_at - _TOKEN_REFRESH_BUFFER_SECONDS
|
|
|
|
def _login(self) -> None:
|
|
refresh = self._store.get(K_REFRESH_TOKEN)
|
|
if refresh:
|
|
try:
|
|
self._apply(self._grant({"grant_type": "refresh_token", "refresh_token": refresh}))
|
|
return
|
|
except AuthError:
|
|
logger.info(f"{_PROVIDER}: refresh token rejected")
|
|
self._store.delete(K_REFRESH_TOKEN)
|
|
|
|
if self._config.platform.is_android_tv:
|
|
self._login_otp()
|
|
return
|
|
|
|
if not self.has_credentials:
|
|
raise AuthError(
|
|
f"{_PROVIDER}: no credentials configured. "
|
|
f"Set username and password in the addon settings."
|
|
)
|
|
self._ensure_device()
|
|
self._apply(self._grant_password())
|
|
logger.info(f"{_PROVIDER}: authenticated")
|
|
|
|
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)
|
|
|
|
def _grant(self, params: Dict[str, str], *, data: Optional[bytes] = None,
|
|
headers: Optional[Dict[str, str]] = None,
|
|
rejected: Type[AuthError] = AuthError,
|
|
rejected_statuses: tuple = (400, 401, 403)) -> EonTokenResponse:
|
|
url = self._endpoints.url(EonDefaults.PATH_OAUTH_TOKEN)
|
|
hdrs = headers if headers is not None else self._config.basic_headers()
|
|
with transport_errors(f"token ({params['grant_type']})", _PROVIDER):
|
|
try:
|
|
kwargs: Dict[str, Any] = {"params": params, "headers": hdrs, "operation": "auth"}
|
|
if data is not None:
|
|
kwargs["data"] = data
|
|
else:
|
|
kwargs["json"] = {}
|
|
resp = self._http.post(url, **kwargs)
|
|
return EonTokenResponse.from_api_response(resp.json())
|
|
except HTTPError as exc:
|
|
status = exc.response.status_code if exc.response is not None else None
|
|
if status in (400, 401, 403):
|
|
kind = rejected if status in rejected_statuses else AuthError
|
|
raise kind(f"{_PROVIDER}: {params['grant_type']} grant rejected (HTTP {status})") from exc
|
|
raise error_from_exception(exc) from exc
|
|
|
|
def _grant_password(self) -> EonTokenResponse:
|
|
device_number = self._store.get(K_DEVICE_NUMBER)
|
|
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()
|
|
fields = (("username", username_hash),
|
|
("password", self._credentials.password),
|
|
("device_number", device_number))
|
|
body = "".join(
|
|
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
|
|
for name, value in fields
|
|
) + f"--{boundary}--"
|
|
headers = {
|
|
"User-Agent": self._config.user_agent,
|
|
"Accept": "application/json",
|
|
"Authorization": self._config.basic_auth_value(),
|
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
}
|
|
# 400/401 = wrong credentials (permanent); a 403 may be a WAF -> transient.
|
|
return self._grant({"grant_type": "password"}, data=body.encode("utf-8"), headers=headers,
|
|
rejected=EonLoginRejected, rejected_statuses=(400, 401))
|
|
|
|
def _login_otp(self) -> None:
|
|
self._ensure_device()
|
|
device_number = self._store.get(K_DEVICE_NUMBER) or ""
|
|
if self._pending_otp is None:
|
|
data = self._generic_get(EonDefaults.PATH_OTP, {"deviceNumber": device_number})
|
|
otp = data.get("otp") if isinstance(data, dict) else None
|
|
if not otp:
|
|
raise ServerError(f"{_PROVIDER}: OTP response has no code")
|
|
minutes = int(data.get("expiresInSeconds") or 0) // 60
|
|
self._pending_otp = str(otp)
|
|
raise EonOtpRequiredError(
|
|
f"{_PROVIDER}: confirm the one-time code {otp} in the operator's portal",
|
|
otp=str(otp), expires_minutes=minutes,
|
|
)
|
|
otp, self._pending_otp = self._pending_otp, None
|
|
try:
|
|
self._apply(self._grant({"grant_type": "otp", "otp": otp, "device_number": device_number}))
|
|
except AuthError as exc:
|
|
raise AuthError(f"{_PROVIDER}: the one-time code was not confirmed or has expired") from exc
|
|
|
|
# ------------------------------------------------------------------
|
|
# Client token + device registration
|
|
# ------------------------------------------------------------------
|
|
def _client_token(self, *, force: bool = False) -> str:
|
|
with self._generic_lock:
|
|
if self._generic_token and not force:
|
|
return self._generic_token
|
|
with transport_errors("client token", _PROVIDER):
|
|
try:
|
|
resp = self._http.post(
|
|
EonDefaults.BROKER_URL + EonDefaults.PATH_OAUTH_TOKEN,
|
|
params={"grant_type": "client_credentials"},
|
|
json={},
|
|
headers=self._config.basic_headers(),
|
|
operation="auth",
|
|
)
|
|
token = resp.json().get("access_token")
|
|
except HTTPError as exc:
|
|
raise error_from_exception(exc) from exc
|
|
if not token:
|
|
raise ServerError(f"{_PROVIDER}: client token response has no access_token")
|
|
self._generic_token = token
|
|
return token
|
|
|
|
def _generic_call(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
|
body: Optional[Dict[str, Any]] = None) -> Any:
|
|
"""Call with the client token; one retry after refreshing it on 401.
|
|
Returns the response; callers inspect `status_code` themselves."""
|
|
func = getattr(self._http, method, None)
|
|
if func is None:
|
|
raise NotImplementedError(f"http_manager has no {method}()")
|
|
url = self._endpoints.url(path)
|
|
for attempt in (1, 2):
|
|
headers = self._config.bearer_headers(self._client_token(force=attempt == 2))
|
|
kwargs: Dict[str, Any] = {"headers": headers, "operation": "api", "raise_for_status": False}
|
|
if params:
|
|
kwargs["params"] = params
|
|
if body is not None:
|
|
kwargs["json"] = body
|
|
resp = func(url, **kwargs)
|
|
if resp.status_code != 401 or attempt == 2:
|
|
return resp
|
|
raise ServerError(f"{_PROVIDER}: unreachable") # pragma: no cover
|
|
|
|
def _generic_get(self, path: str, params: Dict[str, Any]) -> Any:
|
|
with transport_errors(path, _PROVIDER):
|
|
resp = self._generic_call("get", path, params=params)
|
|
if resp.status_code != 200:
|
|
if resp.status_code in (400, 401, 403):
|
|
raise AuthError(f"{_PROVIDER}: {path} rejected (HTTP {resp.status_code})")
|
|
try:
|
|
resp.raise_for_status()
|
|
except HTTPError as exc:
|
|
raise error_from_exception(exc) from exc
|
|
raise ServerError(f"{_PROVIDER}: {path} answered HTTP {resp.status_code}")
|
|
return resp.json()
|
|
|
|
def _ensure_serial(self) -> str:
|
|
serial = self._store.get(K_DEVICE_SERIAL)
|
|
if not serial:
|
|
serial = "".join(secrets.choice(_SERIAL_ALPHABET) for _ in range(21))
|
|
self._store.set(K_DEVICE_SERIAL, serial)
|
|
return serial
|
|
|
|
def _ensure_device(self) -> None:
|
|
if self._store.get(K_DEVICE_ID) and self._store.get(K_DEVICE_NUMBER):
|
|
return
|
|
self._register_device()
|
|
|
|
def _reset_device(self) -> None:
|
|
self.invalidate()
|
|
self._store.delete(K_DEVICE_ID)
|
|
self._store.delete(K_DEVICE_NUMBER)
|
|
self._register_device()
|
|
|
|
def _register_device(self) -> None:
|
|
"""v2 (PATCH) when a deviceNumber is already known and the HTTP layer
|
|
can PATCH; v1 (POST) mints one otherwise. A v2 answer other than
|
|
404/405/501 is final: falling back would hide the rejection."""
|
|
serial = self._ensure_serial()
|
|
platform = self._config.platform
|
|
number = self._store.get(K_DEVICE_NUMBER)
|
|
|
|
with transport_errors("device registration", _PROVIDER):
|
|
if number and hasattr(self._http, "patch"):
|
|
resp = self._generic_call("patch", EonDefaults.PATH_DEVICES_V2, body={
|
|
"deviceNumber": number,
|
|
"deviceName": platform.device_name,
|
|
"modelName": platform.device_model,
|
|
"clientSwVersion": platform.client_sw_version,
|
|
**({"clientSwBuild": platform.client_sw_build} if platform.is_android_tv else {}),
|
|
"systemSwVersion": {"systemName": platform.system_sw, "version": platform.system_version},
|
|
})
|
|
if resp.status_code in (200, 206):
|
|
self._store_device(resp)
|
|
return
|
|
if resp.status_code not in (404, 405, 501):
|
|
raise AuthError(
|
|
f"{_PROVIDER}: device registration rejected (HTTP {resp.status_code})"
|
|
)
|
|
elif number:
|
|
logger.warning(f"{_PROVIDER}: http_manager cannot PATCH; registering via v1")
|
|
|
|
body: Dict[str, Any] = {
|
|
"deviceName": platform.device_name,
|
|
"deviceType": platform.device_type,
|
|
"modelName": platform.device_model,
|
|
"platform": platform.device_platform,
|
|
"serial": serial,
|
|
"clientSwVersion": platform.client_sw_version,
|
|
}
|
|
if platform.is_android_tv:
|
|
body["clientSwBuild"] = platform.client_sw_build
|
|
body["systemSwVersion"] = {"name": platform.system_sw, "version": platform.system_version}
|
|
if platform.is_android_tv:
|
|
body["fcmToken"] = ""
|
|
resp = self._generic_call("post", EonDefaults.PATH_DEVICES_V1, body=body)
|
|
if resp.status_code not in (200, 206):
|
|
if resp.status_code in (400, 401, 403):
|
|
raise AuthError(f"{_PROVIDER}: device registration rejected (HTTP {resp.status_code})")
|
|
raise ServerError(f"{_PROVIDER}: device registration failed (HTTP {resp.status_code})")
|
|
self._store_device(resp)
|
|
|
|
def _store_device(self, resp: Any) -> None:
|
|
try:
|
|
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)
|
|
logger.info(f"{_PROVIDER}: device registered") |