From 5b90ccff28fcb047bda8c742ac8d00dc48b852be Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 21 Jul 2026 17:18:58 +0000 Subject: [PATCH] feat(epg): Enhance Schedules Direct token management with credential fingerprinting This commit improves the Schedules Direct token management by introducing a credential fingerprinting mechanism. The fingerprint is used to ensure that cached tokens are only reused when the username and password match, preventing token misuse across different accounts. Additionally, the token caching functions have been updated to store and validate the fingerprint, and tests have been added to verify the correct behavior when changing credentials. This enhancement increases security and reliability in handling Schedules Direct authentication. --- CHANGELOG.md | 2 +- apps/epg/sd_api.py | 6 +- apps/epg/sd_utils.py | 47 +++++++++----- apps/epg/serializers.py | 9 +++ apps/epg/tests/test_schedules_direct.py | 86 +++++++++++++++++++++++-- 5 files changed, 124 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ab49b8..b6101b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Schedules Direct poster proxy no longer ignores daily image download limits or missing-image errors.** SD returns codes 5002/5003 as HTTP 200 with a JSON body; the proxy previously only inspected HTTP 400 and kept requesting images after the limit, which can get accounts blocked. It now detects 5002/5003 (subscriber and trial), stops further image fetches for that source until the next midnight UTC (persisted on the EPG source so all workers honor it), and on explicit IMAGE_NOT_FOUND (code 5000) clears that program's `sd_icon` so the same dead URI is not retried. Transient bare HTTP 404s do not blacklist the URI. - **Schedules Direct auth and lineup change handling better match SD guidelines.** Token codes 3000/3001 (offline/busy) stop as idle with clear "do not retry" messaging instead of looking like credential failures; 4010 (too many unique IPs) and related account codes get explicit messages. Lineup add/delete respects a known daily change lockout before calling SD, handles 4100 on deletes, and the UI blocks remove when no changes remain (adds and deletes both count toward the 6/day limit). -- **Schedules Direct stops retrying `/token` on auth failures that will not clear by themselves.** Codes 4002/4003 (bad credentials), 4001/4005/4007/4008 (account state), 4009/4010 (login/IP limits), and 4004 (15-minute account lock) persist a cooldown on the EPG source. Soft codes 3000/3001 use a 1-hour idle cooldown. Lockouts clear when username/password change or the cooldown expires. Token auth is centralized in `sd_obtain_token` for refresh, lineup/form, and poster paths. +- **Schedules Direct stops retrying `/token` on auth failures that will not clear by themselves.** Codes 4002/4003 (bad credentials), 4001/4005/4007/4008 (account state), 4009/4010 (login/IP limits), and 4004 (15-minute account lock) persist a cooldown on the EPG source. Soft codes 3000/3001 use a 1-hour idle cooldown. Lockouts clear when username/password change or the cooldown expires. Token auth is centralized in `sd_obtain_token` for refresh, lineup/form, and poster paths. Redis-cached SD tokens are bound to a credential fingerprint (and cleared when the EPG source username/password change) so switching accounts cannot reuse another session. - **Schedules Direct code split out of general EPG modules.** Refresh pipeline and Celery tasks live in `apps/epg/sd_tasks.py`; lineup/poster API mixins in `apps/epg/sd_api.py`; shared protocol helpers remain in `sd_utils.py`. Progress WebSocket updates (`send_epg_update`) live in `apps/epg/utils.py` so XMLTV and SD tasks can import cleanly. `tasks.py` / `api_views.py` re-export or inherit so existing imports and Celery task names are unchanged. ## [0.28.1] - 2026-07-20 diff --git a/apps/epg/sd_api.py b/apps/epg/sd_api.py index b80285a5..577d72bd 100644 --- a/apps/epg/sd_api.py +++ b/apps/epg/sd_api.py @@ -35,7 +35,6 @@ from apps.epg.sd_utils import ( sd_obtain_token, sd_parse_response_payload, sd_save_image_limit_lockout, - sd_set_cached_token, ) from core.utils import dispatcharr_http_headers @@ -472,7 +471,9 @@ class SchedulesDirectPosterMixin: status=status.HTTP_503_SERVICE_UNAVAILABLE, ) - token = sd_get_cached_token(source.id) + token = sd_get_cached_token( + source.id, username=source.username, password=source.password + ) if not token: auth = sd_obtain_token(source, timeout=10) @@ -495,7 +496,6 @@ class SchedulesDirectPosterMixin: ) token = auth.token self._sd_poster_error_cache.pop(source.id, None) - sd_set_cached_token(source.id, token, auth.token_expires) try: img_resp = http_requests.get( diff --git a/apps/epg/sd_utils.py b/apps/epg/sd_utils.py index d8244d5f..3198bc5f 100644 --- a/apps/epg/sd_utils.py +++ b/apps/epg/sd_utils.py @@ -96,7 +96,7 @@ def sd_auth_lockout_retry_message(): # Shared across uWSGI workers via Django's Redis cache. -_SD_TOKEN_CACHE_PREFIX = 'sd:token:v1:' +_SD_TOKEN_CACHE_PREFIX = 'sd:token:' # Expire a bit early so we re-auth before SD rejects an almost-expired token. _SD_TOKEN_CACHE_SKEW_SECONDS = 60 _SD_TOKEN_DEFAULT_TTL_SECONDS = 86400 @@ -106,14 +106,28 @@ def sd_token_cache_key(source_id): return f'{_SD_TOKEN_CACHE_PREFIX}{source_id}' -def sd_get_cached_token(source_id): +def sd_credential_fingerprint(username, password): + """ + Stable fingerprint of SD credentials for lockout and token-cache matching. + + Used so a persisted auth lockout or cached token clears automatically when + the user changes username or password. + """ + raw = f'{(username or "").strip()}\0{(password or "").strip()}' + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def sd_get_cached_token(source_id, username=None, password=None): """ Return a cached SD token string for this source, or None. + Requires ``username`` / ``password`` so a cached session is only reused when + credentials still match (avoids using account A's token after switching to B). On Redis failure, returns None so the caller re-authenticates. """ if source_id is None: return None + current_fp = sd_credential_fingerprint(username, password) try: payload = cache.get(sd_token_cache_key(source_id)) except Exception as exc: @@ -129,16 +143,20 @@ def sd_get_cached_token(source_id): expires = payload.get('expires') if not token or not isinstance(expires, (int, float)): return None + if payload.get('fp') != current_fp: + sd_clear_cached_token(source_id) + return None if time.time() >= float(expires) - _SD_TOKEN_CACHE_SKEW_SECONDS: return None return token -def sd_set_cached_token(source_id, token, expires=None): +def sd_set_cached_token(source_id, token, expires=None, username=None, password=None): """ Cache an SD token for this source until near tokenExpires. ``expires`` is a UNIX epoch seconds value from SD's tokenExpires field. + Stores a credential fingerprint so callers must still match to reuse it. """ if source_id is None or not token: return False @@ -155,7 +173,11 @@ def sd_set_cached_token(source_id, token, expires=None): try: cache.set( sd_token_cache_key(source_id), - {'token': token, 'expires': expires}, + { + 'token': token, + 'expires': expires, + 'fp': sd_credential_fingerprint(username, password), + }, timeout=ttl, ) return True @@ -184,17 +206,6 @@ def sd_clear_cached_token(source_id): return False -def sd_credential_fingerprint(username, password): - """ - Stable fingerprint of SD credentials for lockout matching. - - Used so a persisted auth lockout clears automatically when the user - changes username or password, without requiring a separate clear hook. - """ - raw = f'{(username or "").strip()}\0{(password or "").strip()}' - return hashlib.sha256(raw.encode('utf-8')).hexdigest() - - def sd_auth_failure_message(code, sd_message=None): """Human-readable message for a Schedules Direct /token error code.""" if code == SD_CODE_SERVICE_OFFLINE: @@ -494,10 +505,14 @@ def sd_obtain_token(source, username=None, password=None, *, timeout=30): expires = auth_data.get('tokenExpires') if not isinstance(expires, (int, float)): expires = time.time() + _SD_TOKEN_DEFAULT_TTL_SECONDS + expires = float(expires) + sd_set_cached_token( + source.id, token, expires, username=username, password=password + ) return SDTokenAuthResult( ok=True, token=token, - token_expires=float(expires), + token_expires=expires, code=0, ) diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index ed99efb4..9d8c05b2 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -2,6 +2,10 @@ from core.utils import validate_flexible_url, build_absolute_uri_with_port from rest_framework import serializers from .models import EPGSource, EPGData, ProgramData from apps.channels.models import Channel, Stream +from apps.epg.sd_utils import ( + sd_clear_cached_token, + sd_credential_fingerprint, +) class EPGSourceSerializer(serializers.ModelSerializer): epg_data_count = serializers.SerializerMethodField() @@ -67,11 +71,16 @@ class EPGSourceSerializer(serializers.ModelSerializer): ct = instance.refresh_task.crontab cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' instance._cron_expression = cron_expr + prior_fp = sd_credential_fingerprint(instance.username, instance.password) for attr, value in validated_data.items(): if attr == 'password' and not value: continue setattr(instance, attr, value) instance.save() + # Drop any Redis SD session tied to the previous username/password so + # poster traffic cannot keep using another account's token. + if prior_fp != sd_credential_fingerprint(instance.username, instance.password): + sd_clear_cached_token(instance.id) return instance def create(self, validated_data): diff --git a/apps/epg/tests/test_schedules_direct.py b/apps/epg/tests/test_schedules_direct.py index fe336967..8510ccd3 100644 --- a/apps/epg/tests/test_schedules_direct.py +++ b/apps/epg/tests/test_schedules_direct.py @@ -1789,12 +1789,18 @@ class SDUtilsTests(TestCase): ) source_id = 4242 + user, password = 'sduser', 'sdpass' sd_clear_cached_token(source_id) - self.assertIsNone(sd_get_cached_token(source_id)) - self.assertTrue(sd_set_cached_token(source_id, 'tok-abc', time.time() + 3600)) - self.assertEqual(sd_get_cached_token(source_id), 'tok-abc') + self.assertIsNone(sd_get_cached_token(source_id, user, password)) + self.assertTrue( + sd_set_cached_token( + source_id, 'tok-abc', time.time() + 3600, + username=user, password=password, + ) + ) + self.assertEqual(sd_get_cached_token(source_id, user, password), 'tok-abc') sd_clear_cached_token(source_id) - self.assertIsNone(sd_get_cached_token(source_id)) + self.assertIsNone(sd_get_cached_token(source_id, user, password)) def test_token_cache_ignores_near_expiry(self): from apps.epg.sd_utils import ( @@ -1804,10 +1810,78 @@ class SDUtilsTests(TestCase): ) source_id = 4243 + user, password = 'sduser', 'sdpass' sd_clear_cached_token(source_id) # Within skew window: set should refuse or get should miss. - self.assertFalse(sd_set_cached_token(source_id, 'tok-soon', time.time() + 30)) - self.assertIsNone(sd_get_cached_token(source_id)) + self.assertFalse( + sd_set_cached_token( + source_id, 'tok-soon', time.time() + 30, + username=user, password=password, + ) + ) + self.assertIsNone(sd_get_cached_token(source_id, user, password)) + + def test_token_cache_misses_after_username_change(self): + """Cached token must not be reused after switching SD accounts.""" + from apps.epg.sd_utils import ( + sd_clear_cached_token, + sd_get_cached_token, + sd_set_cached_token, + ) + + source_id = 4244 + sd_clear_cached_token(source_id) + self.assertTrue( + sd_set_cached_token( + source_id, 'tok-account-a', time.time() + 3600, + username='account-a', password='pass-a', + ) + ) + self.assertEqual( + sd_get_cached_token(source_id, 'account-a', 'pass-a'), + 'tok-account-a', + ) + self.assertIsNone( + sd_get_cached_token(source_id, 'account-b', 'pass-a') + ) + # Mismatch clears the stale entry so account B cannot resurrect it. + self.assertIsNone( + sd_get_cached_token(source_id, 'account-a', 'pass-a') + ) + + def test_serializer_username_change_clears_token_cache(self): + """Updating an EPG source username must drop the Redis SD token.""" + from apps.epg.serializers import EPGSourceSerializer + from apps.epg.sd_utils import ( + sd_clear_cached_token, + sd_get_cached_token, + sd_set_cached_token, + ) + + source = EPGSource.objects.create( + name='SD Token Clear', + source_type='schedules_direct', + username='account-a', + password='pass-a', + ) + sd_clear_cached_token(source.id) + self.assertTrue( + sd_set_cached_token( + source.id, 'tok-a', time.time() + 3600, + username='account-a', password='pass-a', + ) + ) + serializer = EPGSourceSerializer( + source, data={'username': 'account-b'}, partial=True + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + serializer.save() + self.assertIsNone( + sd_get_cached_token(source.id, 'account-a', 'pass-a') + ) + self.assertIsNone( + sd_get_cached_token(source.id, 'account-b', 'pass-a') + ) class SDPosterProxyErrorHandlingTests(TestCase):