mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
feat(epg): Enhance Schedules Direct authentication with lockout handling and refactor token retrieval
This commit improves the Schedules Direct authentication process by implementing a lockout mechanism for specific error codes (4002/4003), preventing repeated token requests during lockout periods. The token retrieval logic has been centralized in the `sd_obtain_token` function, streamlining the authentication process across various components. Additionally, comprehensive tests have been added to ensure the correct handling of lockouts and authentication failures, enhancing overall reliability and user experience.
This commit is contained in:
parent
dc59469b21
commit
ddef24a8fc
5 changed files with 698 additions and 184 deletions
|
|
@ -20,6 +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.
|
||||
|
||||
## [0.28.1] - 2026-07-20
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
|
@ -130,56 +129,31 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
Authenticate with Schedules Direct using stored credentials.
|
||||
Returns (token, None) on success or (None, Response) on failure.
|
||||
"""
|
||||
import hashlib
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from apps.epg.sd_utils import sd_handle_2055, sd_headers_for_source
|
||||
from apps.epg.sd_utils import (
|
||||
SD_AUTH_CREDENTIAL_LOCKOUT_CODES,
|
||||
sd_obtain_token,
|
||||
)
|
||||
|
||||
username = (source.username or '').strip()
|
||||
password = (source.password or '').strip()
|
||||
if not username or not password:
|
||||
result = sd_obtain_token(source, timeout=15)
|
||||
if result.ok:
|
||||
return result.token, None
|
||||
|
||||
if result.debug_rejected:
|
||||
return None, Response(
|
||||
{"error": "Username and password are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
{"error": result.message},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
auth_response = http_requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': username, 'password': sha1_password},
|
||||
headers=sd_headers_for_source(source),
|
||||
timeout=15,
|
||||
)
|
||||
try:
|
||||
auth_data = auth_response.json()
|
||||
except ValueError:
|
||||
auth_data = {}
|
||||
if sd_handle_2055(source, auth_data):
|
||||
return None, Response(
|
||||
{
|
||||
"error": (
|
||||
"Schedules Direct rejected the debug routing header "
|
||||
"(code 2055). Extra Schedules Direct Debugging has "
|
||||
"been turned off. Retry without it unless SD support "
|
||||
"asked you to enable it."
|
||||
),
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
auth_response.raise_for_status()
|
||||
token = auth_data.get('token')
|
||||
if not token:
|
||||
return None, Response(
|
||||
{"error": "Authentication failed. Check your credentials."},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
return token, None
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return None, Response(
|
||||
{"error": f"Authentication failed: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
if result.message == 'Username and password are required.':
|
||||
http_status = status.HTTP_400_BAD_REQUEST
|
||||
elif result.code in SD_AUTH_CREDENTIAL_LOCKOUT_CODES:
|
||||
http_status = status.HTTP_401_UNAUTHORIZED
|
||||
elif result.lockout or result.soft or result.code:
|
||||
http_status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
else:
|
||||
http_status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
return None, Response({"error": result.message}, status=http_status)
|
||||
|
||||
def _get_sd_reset_at(self, source):
|
||||
"""Retrieve stored reset timestamp from EPGSource model field."""
|
||||
|
|
@ -584,16 +558,17 @@ class ProgramViewSet(viewsets.ModelViewSet):
|
|||
def poster(self, request, pk=None):
|
||||
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from apps.epg.sd_utils import (
|
||||
SD_CODE_IMAGE_NOT_FOUND,
|
||||
SD_IMAGE_LIMIT_CODES,
|
||||
sd_auth_lockout_active,
|
||||
sd_clear_cached_token,
|
||||
sd_get_cached_token,
|
||||
sd_handle_2055,
|
||||
sd_headers_for_source,
|
||||
sd_image_limit_active,
|
||||
sd_mark_icon_missing,
|
||||
sd_obtain_token,
|
||||
sd_parse_response_payload,
|
||||
sd_save_image_limit_lockout,
|
||||
sd_set_cached_token,
|
||||
|
|
@ -623,6 +598,18 @@ class ProgramViewSet(viewsets.ModelViewSet):
|
|||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
auth_lockout_active, auth_lockout_reason, _lockout_code = (
|
||||
sd_auth_lockout_active(source)
|
||||
)
|
||||
if auth_lockout_active:
|
||||
# Rely on the persisted lockout only. Do not seed the process-local
|
||||
# poster error cache here: that cache outlives credential changes and
|
||||
# cooldown expiry and would keep returning 503 afterward.
|
||||
return Response(
|
||||
{'error': auth_lockout_reason},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id)
|
||||
if error_cache and time.time() < error_cache['until']:
|
||||
return Response(
|
||||
|
|
@ -633,46 +620,27 @@ class ProgramViewSet(viewsets.ModelViewSet):
|
|||
token = sd_get_cached_token(source.id)
|
||||
|
||||
if not token:
|
||||
sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
auth_resp = http_requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': source.username, 'password': sha1_password},
|
||||
headers=sd_headers_for_source(source),
|
||||
timeout=10,
|
||||
auth = sd_obtain_token(source, timeout=10)
|
||||
if auth.debug_rejected:
|
||||
return Response(
|
||||
{'error': auth.message},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
auth_data = auth_resp.json()
|
||||
except ValueError:
|
||||
auth_data = {}
|
||||
if sd_handle_2055(source, auth_data):
|
||||
return Response(
|
||||
{
|
||||
'error': (
|
||||
'Schedules Direct rejected the debug routing header '
|
||||
'(code 2055). Extra Schedules Direct Debugging has '
|
||||
'been turned off.'
|
||||
),
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
token = auth_data.get('token')
|
||||
if not token:
|
||||
if not auth.ok:
|
||||
# Lockout codes are persisted by sd_obtain_token. Other failures
|
||||
# use a short process-local cache so workers do not hammer /token.
|
||||
if not auth.lockout:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': auth_data.get('message', 'Authentication failed'),
|
||||
'until': time.time() + (3600 if auth.code else 300),
|
||||
'reason': auth.message or 'Authentication failed',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
token_expires = auth_data.get(
|
||||
'tokenExpires', time.time() + 86400
|
||||
return Response(
|
||||
{'error': auth.message},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
sd_set_cached_token(source.id, token, token_expires)
|
||||
except http_requests.exceptions.RequestException:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 300,
|
||||
'reason': 'Network error reaching Schedules Direct',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
token = auth.token
|
||||
ProgramViewSet._sd_poster_error_cache.pop(source.id, None)
|
||||
sd_set_cached_token(source.id, token, auth.token_expires)
|
||||
|
||||
try:
|
||||
img_resp = http_requests.get(
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta, timezone as dt_timezone
|
||||
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
|
@ -19,8 +22,21 @@ from core.utils import dispatcharr_http_headers
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SD_BASE_URL = 'https://json.schedulesdirect.org/20141201'
|
||||
|
||||
# SD JSON error codes we must honor to avoid account blocks.
|
||||
SD_CODE_INVALID_DEBUG = 2055
|
||||
SD_CODE_SERVICE_OFFLINE = 3000
|
||||
SD_CODE_SERVICE_BUSY = 3001
|
||||
SD_CODE_ACCOUNT_EXPIRED = 4001
|
||||
SD_CODE_INVALID_PASSWORD_HASH = 4002
|
||||
SD_CODE_INVALID_USER_OR_PASSWORD = 4003
|
||||
SD_CODE_ACCOUNT_LOCKED = 4004
|
||||
SD_CODE_JSON_DISABLED = 4005
|
||||
SD_CODE_APP_NOT_AUTHORIZED = 4007
|
||||
SD_CODE_ACCOUNT_INACTIVE = 4008
|
||||
SD_CODE_TOO_MANY_LOGINS = 4009
|
||||
SD_CODE_TOO_MANY_UNIQUE_IPS = 4010
|
||||
SD_CODE_IMAGE_NOT_FOUND = 5000
|
||||
SD_CODE_MAX_IMAGE_DOWNLOADS = 5002
|
||||
SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL = 5003
|
||||
|
|
@ -30,6 +46,55 @@ SD_IMAGE_LIMIT_CODES = frozenset({
|
|||
SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL,
|
||||
})
|
||||
|
||||
# Soft /token failures: stop and retry later (idle), not a hard account error.
|
||||
SD_AUTH_SOFT_CODES = frozenset({
|
||||
SD_CODE_SERVICE_OFFLINE,
|
||||
SD_CODE_SERVICE_BUSY,
|
||||
})
|
||||
|
||||
# Wrong username/password (or hash): clear early when credentials change.
|
||||
SD_AUTH_CREDENTIAL_LOCKOUT_CODES = frozenset({
|
||||
SD_CODE_INVALID_PASSWORD_HASH,
|
||||
SD_CODE_INVALID_USER_OR_PASSWORD,
|
||||
})
|
||||
|
||||
# All /token codes we must not hammer. Includes soft codes (shorter cooldown).
|
||||
SD_AUTH_LOCKOUT_CODES = frozenset({
|
||||
SD_CODE_SERVICE_OFFLINE,
|
||||
SD_CODE_SERVICE_BUSY,
|
||||
SD_CODE_ACCOUNT_EXPIRED,
|
||||
SD_CODE_INVALID_PASSWORD_HASH,
|
||||
SD_CODE_INVALID_USER_OR_PASSWORD,
|
||||
SD_CODE_ACCOUNT_LOCKED,
|
||||
SD_CODE_JSON_DISABLED,
|
||||
SD_CODE_APP_NOT_AUTHORIZED,
|
||||
SD_CODE_ACCOUNT_INACTIVE,
|
||||
SD_CODE_TOO_MANY_LOGINS,
|
||||
SD_CODE_TOO_MANY_UNIQUE_IPS,
|
||||
})
|
||||
|
||||
SD_AUTH_LOCKOUT_SECONDS = 24 * 3600
|
||||
SD_AUTH_LOCKOUT_SECONDS_SOFT = 3600
|
||||
SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK = 15 * 60
|
||||
|
||||
|
||||
def sd_auth_lockout_seconds_for_code(code):
|
||||
"""Cooldown length for a /token error code."""
|
||||
if code == SD_CODE_ACCOUNT_LOCKED:
|
||||
return SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK
|
||||
if code in SD_AUTH_SOFT_CODES:
|
||||
return SD_AUTH_LOCKOUT_SECONDS_SOFT
|
||||
return SD_AUTH_LOCKOUT_SECONDS
|
||||
|
||||
|
||||
def sd_auth_lockout_retry_message():
|
||||
"""Suffix explaining how to clear an auth lockout."""
|
||||
return (
|
||||
"Not retrying Schedules Direct authentication until the username or "
|
||||
"password is updated, or the cooldown expires."
|
||||
)
|
||||
|
||||
|
||||
# Shared across uWSGI workers via Django's Redis cache.
|
||||
_SD_TOKEN_CACHE_PREFIX = 'sd:token:v1:'
|
||||
# Expire a bit early so we re-auth before SD rejects an almost-expired token.
|
||||
|
|
@ -119,6 +184,324 @@ 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:
|
||||
return (
|
||||
"Schedules Direct is offline for maintenance. "
|
||||
"Do not retry for at least 1 hour."
|
||||
)
|
||||
if code == SD_CODE_SERVICE_BUSY:
|
||||
return "Schedules Direct is busy. Stop requesting and retry later."
|
||||
if code == SD_CODE_ACCOUNT_EXPIRED:
|
||||
return (
|
||||
"Schedules Direct: account has expired. Please renew your "
|
||||
"subscription at schedulesdirect.org."
|
||||
)
|
||||
if code == SD_CODE_INVALID_PASSWORD_HASH:
|
||||
return (
|
||||
"Schedules Direct: invalid password hash. Check that credentials "
|
||||
"are stored correctly."
|
||||
)
|
||||
if code == SD_CODE_INVALID_USER_OR_PASSWORD:
|
||||
return "Schedules Direct: invalid username or password."
|
||||
if code == SD_CODE_ACCOUNT_LOCKED:
|
||||
return (
|
||||
"Schedules Direct: account locked due to too many failed login "
|
||||
"attempts. Try again in 15 minutes."
|
||||
)
|
||||
if code == SD_CODE_JSON_DISABLED:
|
||||
return (
|
||||
"Schedules Direct: JSON API access is disabled for this account. "
|
||||
"Contact Schedules Direct support."
|
||||
)
|
||||
if code == SD_CODE_APP_NOT_AUTHORIZED:
|
||||
return (
|
||||
"Schedules Direct: this application is not authorized. Please "
|
||||
"contact the Dispatcharr maintainers."
|
||||
)
|
||||
if code == SD_CODE_ACCOUNT_INACTIVE:
|
||||
return (
|
||||
"Schedules Direct: account is inactive. Please log in to "
|
||||
"schedulesdirect.org to reactivate."
|
||||
)
|
||||
if code == SD_CODE_TOO_MANY_LOGINS:
|
||||
return (
|
||||
"Schedules Direct: too many login attempts in 24 hours. Token is "
|
||||
"valid for 24 hours. Check for misconfiguration."
|
||||
)
|
||||
if code == SD_CODE_TOO_MANY_UNIQUE_IPS:
|
||||
return (
|
||||
"Schedules Direct: too many unique IP addresses in 24 hours. "
|
||||
"Avoid VPNs or multiple locations, or contact SD support to raise "
|
||||
"the limit."
|
||||
)
|
||||
if sd_message:
|
||||
return f"Schedules Direct authentication failed (code {code}): {sd_message}"
|
||||
return f"Schedules Direct authentication failed (code {code})."
|
||||
|
||||
|
||||
def sd_token_response_code(auth_data):
|
||||
"""Return integer SD ``code`` from a /token JSON body, or 0."""
|
||||
if not isinstance(auth_data, dict):
|
||||
return 0
|
||||
code = auth_data.get('code', 0)
|
||||
return code if isinstance(code, int) else 0
|
||||
|
||||
|
||||
def sd_auth_lockout_active(source, username=None, password=None):
|
||||
"""
|
||||
Return (active, reason, code) for a persisted auth lockout.
|
||||
|
||||
Cleared when username/password changed, or sd_auth_lockout_until has passed.
|
||||
"""
|
||||
if source is None:
|
||||
return False, None, None
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
if not cp.get('sd_auth_lockout'):
|
||||
return False, None, None
|
||||
|
||||
until_str = cp.get('sd_auth_lockout_until')
|
||||
until = parse_datetime(until_str) if until_str else None
|
||||
if until is None or timezone.now() >= until:
|
||||
sd_clear_auth_lockout(source)
|
||||
return False, None, None
|
||||
|
||||
if username is None:
|
||||
username = source.username
|
||||
if password is None:
|
||||
password = source.password
|
||||
|
||||
stored_fp = cp.get('sd_auth_lockout_credential_fp')
|
||||
current_fp = sd_credential_fingerprint(username, password)
|
||||
if not stored_fp or stored_fp != current_fp:
|
||||
sd_clear_auth_lockout(source)
|
||||
return False, None, None
|
||||
|
||||
code = cp.get('sd_auth_lockout_code')
|
||||
reason = cp.get('sd_auth_lockout_reason') or (
|
||||
'Schedules Direct authentication is temporarily blocked.'
|
||||
)
|
||||
return True, reason, code if isinstance(code, int) else None
|
||||
|
||||
|
||||
def sd_save_auth_lockout(source, code, username=None, password=None, reason=None):
|
||||
"""
|
||||
Persist an auth lockout so we stop calling /token for a cooldown period.
|
||||
|
||||
Duration depends on the code (15m for 4004, 1h for offline/busy, else 24h).
|
||||
Cleared early if username/password change.
|
||||
"""
|
||||
if source is None:
|
||||
return
|
||||
if code not in SD_AUTH_LOCKOUT_CODES:
|
||||
return
|
||||
|
||||
if username is None:
|
||||
username = source.username
|
||||
if password is None:
|
||||
password = source.password
|
||||
|
||||
if reason is None:
|
||||
reason = sd_auth_failure_message(code)
|
||||
|
||||
seconds = sd_auth_lockout_seconds_for_code(code)
|
||||
until = timezone.now() + timedelta(seconds=seconds)
|
||||
cp = dict(source.custom_properties or {})
|
||||
cp['sd_auth_lockout'] = True
|
||||
cp['sd_auth_lockout_code'] = code
|
||||
cp['sd_auth_lockout_reason'] = reason
|
||||
cp['sd_auth_lockout_until'] = until.isoformat()
|
||||
cp['sd_auth_lockout_credential_fp'] = sd_credential_fingerprint(
|
||||
username, password
|
||||
)
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
sd_clear_cached_token(source.id)
|
||||
logger.warning(
|
||||
"SD source %s: auth lockout (code %s). Not calling /token until "
|
||||
"username or password changes, or after %s.",
|
||||
source.id,
|
||||
code,
|
||||
until.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def sd_clear_auth_lockout(source):
|
||||
"""Clear a persisted auth lockout (success, credentials changed, or expired)."""
|
||||
if source is None:
|
||||
return
|
||||
cp = dict(source.custom_properties or {})
|
||||
changed = False
|
||||
for key in (
|
||||
'sd_auth_lockout',
|
||||
'sd_auth_lockout_code',
|
||||
'sd_auth_lockout_reason',
|
||||
'sd_auth_lockout_until',
|
||||
'sd_auth_lockout_credential_fp',
|
||||
):
|
||||
if key in cp:
|
||||
cp.pop(key, None)
|
||||
changed = True
|
||||
if changed:
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SDTokenAuthResult:
|
||||
"""Outcome of POST /token (or a short-circuit from a persisted lockout)."""
|
||||
|
||||
ok: bool
|
||||
token: str | None = None
|
||||
token_expires: float | None = None
|
||||
code: int | None = None
|
||||
message: str = ''
|
||||
soft: bool = False
|
||||
debug_rejected: bool = False
|
||||
lockout: bool = False
|
||||
|
||||
|
||||
_DEBUG_REJECTED_MESSAGE = (
|
||||
"Schedules Direct rejected the debug routing header (code 2055). "
|
||||
"Extra Schedules Direct Debugging has been turned off. Retry without it "
|
||||
"unless Schedules Direct support asked you to enable it."
|
||||
)
|
||||
|
||||
|
||||
def sd_obtain_token(source, username=None, password=None, *, timeout=30):
|
||||
"""
|
||||
Authenticate with Schedules Direct and return a session token.
|
||||
|
||||
Shared by refresh, lineup/form auth, and the poster proxy. Honors persisted
|
||||
lockouts, reads JSON ``code`` before HTTP status, and persists cooldowns for
|
||||
codes that must not be retried until the user or cooldown clears them.
|
||||
"""
|
||||
if source is None:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message='Schedules Direct source is required.',
|
||||
)
|
||||
|
||||
if username is None:
|
||||
username = (source.username or '').strip()
|
||||
else:
|
||||
username = (username or '').strip()
|
||||
if password is None:
|
||||
password = (source.password or '').strip()
|
||||
else:
|
||||
password = (password or '').strip()
|
||||
|
||||
if not username or not password:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message='Username and password are required.',
|
||||
)
|
||||
|
||||
active, reason, lockout_code = sd_auth_lockout_active(
|
||||
source, username, password
|
||||
)
|
||||
if active:
|
||||
msg = f"{reason} {sd_auth_lockout_retry_message()}"
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
code=lockout_code,
|
||||
message=msg,
|
||||
soft=lockout_code in SD_AUTH_SOFT_CODES if lockout_code else False,
|
||||
lockout=True,
|
||||
)
|
||||
|
||||
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': username, 'password': sha1_password},
|
||||
headers=sd_headers_for_source(source),
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message=f'Network error authenticating with Schedules Direct: {exc}',
|
||||
)
|
||||
|
||||
try:
|
||||
auth_data = response.json()
|
||||
except ValueError:
|
||||
auth_data = {}
|
||||
|
||||
if sd_handle_2055(source, auth_data):
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
code=SD_CODE_INVALID_DEBUG,
|
||||
message=_DEBUG_REJECTED_MESSAGE,
|
||||
debug_rejected=True,
|
||||
)
|
||||
|
||||
# Honor JSON ``code`` before raise_for_status. SD error semantics are in
|
||||
# the body; HTTP status alone is not reliable for auth failures.
|
||||
auth_code = sd_token_response_code(auth_data)
|
||||
if auth_code != 0:
|
||||
msg = sd_auth_failure_message(
|
||||
auth_code, auth_data.get('message', 'Unknown error')
|
||||
)
|
||||
soft = auth_code in SD_AUTH_SOFT_CODES
|
||||
locked = False
|
||||
if auth_code in SD_AUTH_LOCKOUT_CODES:
|
||||
sd_save_auth_lockout(
|
||||
source, auth_code, username, password, reason=msg
|
||||
)
|
||||
msg = f"{msg} {sd_auth_lockout_retry_message()}"
|
||||
locked = True
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
code=auth_code,
|
||||
message=msg,
|
||||
soft=soft,
|
||||
lockout=locked,
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message=f'Network error authenticating with Schedules Direct: {exc}',
|
||||
)
|
||||
|
||||
token = auth_data.get('token') if isinstance(auth_data, dict) else None
|
||||
if not token:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message='Schedules Direct returned no token.',
|
||||
)
|
||||
|
||||
sd_clear_auth_lockout(source)
|
||||
expires = None
|
||||
if isinstance(auth_data, dict):
|
||||
expires = auth_data.get('tokenExpires')
|
||||
if not isinstance(expires, (int, float)):
|
||||
expires = time.time() + _SD_TOKEN_DEFAULT_TTL_SECONDS
|
||||
return SDTokenAuthResult(
|
||||
ok=True,
|
||||
token=token,
|
||||
token_expires=float(expires),
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
def sd_next_midnight_utc():
|
||||
"""Return the next Schedules Direct counter reset (00:00Z)."""
|
||||
now = timezone.now()
|
||||
|
|
|
|||
|
|
@ -195,7 +195,8 @@ def _ensure_epg_refresh_terminal_status(source_id):
|
|||
)
|
||||
|
||||
|
||||
SD_BASE_URL = 'https://json.schedulesdirect.org/20141201'
|
||||
from apps.epg.sd_utils import SD_BASE_URL
|
||||
|
||||
SD_DAYS_TO_FETCH = 20
|
||||
SD_PROGRAM_BATCH_SIZE = 5000
|
||||
SD_BULK_GUIDE_FETCH_THRESHOLD = 3
|
||||
|
|
@ -3038,7 +3039,7 @@ def fetch_schedules_direct(
|
|||
# SD API spec requires the User-Agent to identify the application and version.
|
||||
# SergeantPanda confirmed Dispatcharr should identify itself properly.
|
||||
# -------------------------------------------------------------------------
|
||||
from apps.epg.sd_utils import sd_handle_2055, sd_headers_for_source
|
||||
from apps.epg.sd_utils import sd_headers_for_source, sd_obtain_token
|
||||
|
||||
def _sd_headers(token=None, content_type='application/json'):
|
||||
return sd_headers_for_source(source, token=token, content_type=content_type)
|
||||
|
|
@ -3215,108 +3216,39 @@ def fetch_schedules_direct(
|
|||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "parsing_programs", 2, message="Authenticating with Schedules Direct...")
|
||||
|
||||
try:
|
||||
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||
token_response = requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': username, 'password': sha1_password},
|
||||
headers=_sd_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
try:
|
||||
token_data = token_response.json()
|
||||
except ValueError:
|
||||
token_data = {}
|
||||
|
||||
if sd_handle_2055(source, token_data):
|
||||
msg = (
|
||||
"Schedules Direct rejected the debug routing header (code 2055). "
|
||||
"Extra Schedules Direct Debugging has been turned off. Retry the "
|
||||
"refresh unless Schedules Direct support asked you to enable it."
|
||||
)
|
||||
logger.error(msg)
|
||||
source.status = EPGSource.STATUS_ERROR
|
||||
source.last_message = msg
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "refresh", 100, status="error", error=msg)
|
||||
return
|
||||
|
||||
token_response.raise_for_status()
|
||||
|
||||
auth_code = token_data.get('code', 0)
|
||||
if auth_code != 0:
|
||||
# Soft offline/busy: stop without treating as a credential error.
|
||||
if auth_code in (3000, 3001):
|
||||
if auth_code == 3000:
|
||||
msg = (
|
||||
"Schedules Direct is offline for maintenance. "
|
||||
"Do not retry for at least 1 hour."
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
"Schedules Direct is busy. Stop requesting and retry later."
|
||||
)
|
||||
logger.warning(msg)
|
||||
source.status = EPGSource.STATUS_IDLE
|
||||
source.last_message = msg
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "refresh", 100, status="idle", message=msg)
|
||||
return
|
||||
|
||||
if auth_code == 4007:
|
||||
msg = "Schedules Direct: this application is not authorized. Please contact the Dispatcharr maintainers."
|
||||
elif auth_code == 4004:
|
||||
msg = "Schedules Direct: account locked due to too many failed login attempts. Try again in 15 minutes."
|
||||
elif auth_code == 4009:
|
||||
msg = "Schedules Direct: too many login attempts in 24 hours. Token is valid for 24 hours. Check for misconfiguration."
|
||||
elif auth_code == 4001:
|
||||
msg = "Schedules Direct: account has expired. Please renew your subscription at schedulesdirect.org."
|
||||
elif auth_code == 4008:
|
||||
msg = "Schedules Direct: account is inactive. Please log in to schedulesdirect.org to reactivate."
|
||||
elif auth_code == 4002:
|
||||
msg = "Schedules Direct: invalid password hash. Check that credentials are stored correctly."
|
||||
elif auth_code == 4003:
|
||||
msg = "Schedules Direct: invalid username or password."
|
||||
elif auth_code == 4005:
|
||||
msg = (
|
||||
"Schedules Direct: JSON API access is disabled for this account. "
|
||||
"Contact Schedules Direct support."
|
||||
)
|
||||
elif auth_code == 4010:
|
||||
msg = (
|
||||
"Schedules Direct: too many unique IP addresses in 24 hours. "
|
||||
"Avoid VPNs or multiple locations, or contact SD support to raise the limit."
|
||||
)
|
||||
else:
|
||||
msg = f"Schedules Direct authentication failed (code {auth_code}): {token_data.get('message', 'Unknown error')}"
|
||||
logger.error(msg)
|
||||
source.status = EPGSource.STATUS_ERROR
|
||||
source.last_message = msg
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "refresh", 100, status="error", error=msg)
|
||||
return
|
||||
|
||||
token = token_data.get('token')
|
||||
if not token:
|
||||
msg = "Schedules Direct returned no token."
|
||||
logger.error(msg)
|
||||
source.status = EPGSource.STATUS_ERROR
|
||||
source.last_message = msg
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "refresh", 100, status="error", error=msg)
|
||||
return
|
||||
|
||||
logger.info("Schedules Direct authentication successful.")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
msg = f"Network error authenticating with Schedules Direct: {e}"
|
||||
logger.error(msg, exc_info=True)
|
||||
auth = sd_obtain_token(source, username, password, timeout=30)
|
||||
if auth.debug_rejected:
|
||||
logger.error(auth.message)
|
||||
source.status = EPGSource.STATUS_ERROR
|
||||
source.last_message = msg
|
||||
source.last_message = auth.message
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "refresh", 100, status="error", error=msg)
|
||||
send_epg_update(
|
||||
source.id, "refresh", 100, status="error", error=auth.message
|
||||
)
|
||||
return
|
||||
|
||||
if not auth.ok:
|
||||
if auth.soft:
|
||||
logger.warning(auth.message)
|
||||
source.status = EPGSource.STATUS_IDLE
|
||||
source.last_message = auth.message
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(
|
||||
source.id, "refresh", 100, status="idle", message=auth.message
|
||||
)
|
||||
return
|
||||
logger.error(auth.message)
|
||||
source.status = EPGSource.STATUS_ERROR
|
||||
source.last_message = auth.message
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(
|
||||
source.id, "refresh", 100, status="error", error=auth.message
|
||||
)
|
||||
return
|
||||
|
||||
token = auth.token
|
||||
logger.info("Schedules Direct authentication successful.")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Step 2: Check account status (respect OFFLINE system status)
|
||||
# -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -226,6 +226,152 @@ class SDLineupChangesRemainingTests(TestCase):
|
|||
)
|
||||
|
||||
|
||||
class SDAuthCredentialLockoutTests(TestCase):
|
||||
"""4002/4003 lockouts must stop /token until credentials change or 24h."""
|
||||
|
||||
def test_lockout_active_until_password_changes(self):
|
||||
from apps.epg.sd_utils import (
|
||||
sd_auth_lockout_active,
|
||||
sd_save_auth_lockout,
|
||||
)
|
||||
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Cred Lockout Utils',
|
||||
source_type='schedules_direct',
|
||||
username='u',
|
||||
password='old',
|
||||
)
|
||||
sd_save_auth_lockout(source, 4003, 'u', 'old')
|
||||
active, reason, code = sd_auth_lockout_active(source, 'u', 'old')
|
||||
self.assertTrue(active)
|
||||
self.assertEqual(code, 4003)
|
||||
self.assertIn('invalid username or password', reason.lower())
|
||||
|
||||
active, reason, code = sd_auth_lockout_active(source, 'u', 'new')
|
||||
self.assertFalse(active)
|
||||
source.refresh_from_db()
|
||||
self.assertFalse((source.custom_properties or {}).get('sd_auth_lockout'))
|
||||
|
||||
def test_lockout_clears_when_username_changes(self):
|
||||
"""4003 is username or password; either field change must allow retry."""
|
||||
from apps.epg.sd_utils import (
|
||||
sd_auth_lockout_active,
|
||||
sd_save_auth_lockout,
|
||||
)
|
||||
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Cred Lockout Username',
|
||||
source_type='schedules_direct',
|
||||
username='olduser',
|
||||
password='samepass',
|
||||
)
|
||||
sd_save_auth_lockout(source, 4003, 'olduser', 'samepass')
|
||||
self.assertTrue(sd_auth_lockout_active(source, 'olduser', 'samepass')[0])
|
||||
|
||||
active, _, _ = sd_auth_lockout_active(source, 'newuser', 'samepass')
|
||||
self.assertFalse(active)
|
||||
source.refresh_from_db()
|
||||
self.assertFalse((source.custom_properties or {}).get('sd_auth_lockout'))
|
||||
|
||||
def test_lockout_expires_after_24_hours(self):
|
||||
"""Auto-clear after 24h so a transient SD-side failure can retry."""
|
||||
from apps.epg.sd_utils import (
|
||||
sd_auth_lockout_active,
|
||||
sd_save_auth_lockout,
|
||||
)
|
||||
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Cred Lockout Expiry',
|
||||
source_type='schedules_direct',
|
||||
username='u',
|
||||
password='p',
|
||||
)
|
||||
frozen_now = timezone.now()
|
||||
with patch('apps.epg.sd_utils.timezone.now', return_value=frozen_now):
|
||||
sd_save_auth_lockout(source, 4003, 'u', 'p')
|
||||
self.assertTrue(sd_auth_lockout_active(source, 'u', 'p')[0])
|
||||
source.refresh_from_db()
|
||||
self.assertTrue(
|
||||
(source.custom_properties or {}).get('sd_auth_lockout_until')
|
||||
)
|
||||
|
||||
later = frozen_now + timedelta(hours=24, seconds=1)
|
||||
with patch('apps.epg.sd_utils.timezone.now', return_value=later):
|
||||
active, _, _ = sd_auth_lockout_active(source, 'u', 'p')
|
||||
self.assertFalse(active)
|
||||
source.refresh_from_db()
|
||||
self.assertFalse((source.custom_properties or {}).get('sd_auth_lockout'))
|
||||
|
||||
def test_account_expired_4001_locks_for_24_hours(self):
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from apps.epg.sd_utils import (
|
||||
SD_AUTH_LOCKOUT_SECONDS,
|
||||
sd_auth_lockout_seconds_for_code,
|
||||
sd_save_auth_lockout,
|
||||
)
|
||||
|
||||
self.assertEqual(sd_auth_lockout_seconds_for_code(4001), SD_AUTH_LOCKOUT_SECONDS)
|
||||
self.assertEqual(sd_auth_lockout_seconds_for_code(4004), 15 * 60)
|
||||
self.assertEqual(sd_auth_lockout_seconds_for_code(3000), 3600)
|
||||
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Expired Lockout',
|
||||
source_type='schedules_direct',
|
||||
username='u',
|
||||
password='p',
|
||||
)
|
||||
frozen_now = timezone.now()
|
||||
with patch('apps.epg.sd_utils.timezone.now', return_value=frozen_now):
|
||||
sd_save_auth_lockout(source, 4001, 'u', 'p')
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.custom_properties.get('sd_auth_lockout_code'), 4001)
|
||||
until = parse_datetime(source.custom_properties['sd_auth_lockout_until'])
|
||||
self.assertAlmostEqual(
|
||||
(until - frozen_now).total_seconds(),
|
||||
24 * 3600,
|
||||
delta=2,
|
||||
)
|
||||
|
||||
@patch('requests.post')
|
||||
def test_sd_authenticate_http_400_with_4003_locks_out(self, mock_post):
|
||||
"""Lineup/form auth must persist lockout on HTTP 400 + code 4003."""
|
||||
import requests
|
||||
from apps.epg.api_views import EPGSourceViewSet
|
||||
|
||||
mock_resp = MagicMock(
|
||||
status_code=400,
|
||||
json=MagicMock(return_value={
|
||||
'code': 4003,
|
||||
'message': 'Invalid user or password',
|
||||
}),
|
||||
)
|
||||
mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
'400 Client Error', response=mock_resp
|
||||
)
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Form Auth Fail',
|
||||
source_type='schedules_direct',
|
||||
username='baduser',
|
||||
password='badpass',
|
||||
)
|
||||
view = EPGSourceViewSet()
|
||||
token, error = view._sd_authenticate(source)
|
||||
self.assertIsNone(token)
|
||||
self.assertEqual(error.status_code, 401)
|
||||
self.assertIn('invalid username or password', error.data['error'].lower())
|
||||
source.refresh_from_db()
|
||||
self.assertTrue((source.custom_properties or {}).get('sd_auth_lockout'))
|
||||
|
||||
# Second call must not hit /token.
|
||||
mock_post.reset_mock()
|
||||
token, error = view._sd_authenticate(source)
|
||||
self.assertIsNone(token)
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
class FetchSchedulesDirectAuthCodeTests(TestCase):
|
||||
"""Token response codes must map to idle vs error correctly."""
|
||||
|
||||
|
|
@ -280,6 +426,90 @@ class FetchSchedulesDirectAuthCodeTests(TestCase):
|
|||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
|
||||
self.assertIn('invalid username or password', source.last_message.lower())
|
||||
self.assertTrue((source.custom_properties or {}).get('sd_auth_lockout'))
|
||||
|
||||
@patch('apps.epg.tasks.requests.post')
|
||||
def test_auth_4003_http_400_persists_lockout_without_raise(self, mock_post):
|
||||
"""HTTP 400 + JSON code 4003 must hard-stop before raise_for_status."""
|
||||
import requests
|
||||
|
||||
mock_resp = MagicMock(
|
||||
status_code=400,
|
||||
json=MagicMock(return_value={
|
||||
'code': 4003,
|
||||
'message': 'Invalid user or password',
|
||||
}),
|
||||
)
|
||||
mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
'400 Client Error', response=mock_resp
|
||||
)
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Auth HTTP 400',
|
||||
source_type='schedules_direct',
|
||||
username='baduser',
|
||||
password='badpass',
|
||||
)
|
||||
|
||||
with patch('apps.epg.tasks.send_epg_update'):
|
||||
fetch_schedules_direct(source)
|
||||
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
|
||||
self.assertIn('invalid username or password', source.last_message.lower())
|
||||
self.assertTrue((source.custom_properties or {}).get('sd_auth_lockout'))
|
||||
mock_resp.raise_for_status.assert_not_called()
|
||||
|
||||
@patch('apps.epg.tasks.requests.post')
|
||||
def test_auth_lockout_skips_token_until_credentials_change(self, mock_post):
|
||||
"""Persisted 4003 lockout must not call /token again until credentials change."""
|
||||
from apps.epg.sd_utils import sd_save_auth_lockout
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Auth Lockout',
|
||||
source_type='schedules_direct',
|
||||
username='baduser',
|
||||
password='badpass',
|
||||
)
|
||||
sd_save_auth_lockout(source, 4003, 'baduser', 'badpass')
|
||||
|
||||
with patch('apps.epg.tasks.send_epg_update'):
|
||||
fetch_schedules_direct(source)
|
||||
|
||||
mock_post.assert_not_called()
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
|
||||
self.assertIn('not retrying', source.last_message.lower())
|
||||
|
||||
# Changing credentials clears the fingerprint lockout and allows /token.
|
||||
source.password = 'newpass'
|
||||
source.save(update_fields=['password'])
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={
|
||||
'code': 0,
|
||||
'token': 'new-token',
|
||||
'tokenExpires': time.time() + 86400,
|
||||
}),
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
with patch('apps.epg.tasks.send_epg_update'), \
|
||||
patch('apps.epg.tasks.requests.get') as mock_get:
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={
|
||||
'systemStatus': [{'status': 'Offline'}],
|
||||
}),
|
||||
raise_for_status=MagicMock(),
|
||||
)
|
||||
fetch_schedules_direct(source)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
source.refresh_from_db()
|
||||
self.assertFalse((source.custom_properties or {}).get('sd_auth_lockout'))
|
||||
|
||||
@patch('apps.epg.tasks.requests.post')
|
||||
def test_auth_too_many_ips_sets_error_status(self, mock_post):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue