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.
This commit is contained in:
SergeantPanda 2026-07-21 17:18:58 +00:00
parent 2a5ab5c268
commit 5b90ccff28
5 changed files with 124 additions and 26 deletions

View file

@ -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

View file

@ -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(

View file

@ -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,
)

View file

@ -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):

View file

@ -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):