Files
script.service.ultimate/lib/streaming_providers/base/auth/credential_manager.py
T
2026-01-16 12:24:41 +01:00

697 lines
28 KiB
Python

# streaming_providers/base/auth/credential_manager.py
import time
from typing import Any, Dict, List, Optional
# Import centralized logger and VFS
from ..utils.logger import logger
from .credentials import BaseCredentials, ClientCredentials, UserPasswordCredentials
class CredentialManager:
"""
Manages loading and saving credentials from/to persistent storage
Now supports country-specific credentials
"""
def __init__(self, config_dir: Optional[str] = None):
# Initialize VFS with config directory support
from ..utils.vfs import VFS
self.vfs = VFS(config_dir=config_dir)
# Credentials file is always in the root of the VFS base path
self.credentials_file = "credentials.json"
# Ensure base directory exists
self.vfs.mkdirs("")
logger.debug(f"CredentialManager initialized with VFS base: {self.vfs.base_path}")
@staticmethod
def _get_credential_path(provider: str, country: Optional[str] = None) -> tuple:
"""
Determine the path to credential data based on country
Args:
provider: Provider name
country: Optional country code (e.g., 'de', 'at', 'ch')
Returns:
Tuple of (keys_path, is_nested) where keys_path is list of keys to navigate
"""
if country:
return [provider, country], True
else:
return [provider], False
def load_credentials(
self, provider: str, country: Optional[str] = None
) -> Optional[BaseCredentials]:
"""
Load credentials for a specific provider and optional country.
Now with fallback: tries with country first, then without country
for backward compatibility with credentials stored without country nesting.
Args:
provider: Provider name (e.g., 'rtlplus', 'joyn')
country: Optional country code (e.g., 'de', 'at', 'ch')
Returns:
BaseCredentials instance or None if not found
"""
country_str = f" (country: {country})" if country else ""
try:
logger.debug(f"CredentialManager: Loading credentials for '{provider}{country_str}'")
# Check if credentials file exists
if not self.vfs.exists(self.credentials_file):
logger.debug(
f"CredentialManager: Credentials file does not exist: {self.credentials_file}"
)
return None
logger.debug(f"CredentialManager: Reading credentials file: {self.credentials_file}")
data = self.vfs.read_json(self.credentials_file)
if not data:
logger.debug(f"CredentialManager: Credentials file is empty or invalid JSON")
return None
logger.debug(f"CredentialManager: Available providers in file: {list(data.keys())}")
# Try with country first (new format: {"provider": {"country": {...}}})
if country:
keys_path, _ = self._get_credential_path(provider, country)
provider_data = data
found = True
for key in keys_path:
if not isinstance(provider_data, dict) or key not in provider_data:
found = False
break
provider_data = provider_data[key]
if found and provider_data:
logger.debug(
f"CredentialManager: Found credentials with country nesting for '{provider}{country_str}'"
)
return self._create_credential_from_data(provider, country, provider_data)
# Fallback: try without country (legacy format: {"provider": {...}})
logger.debug(
f"CredentialManager: No country-nested credentials found, trying legacy format"
)
keys_path, _ = self._get_credential_path(provider, None)
provider_data = data
for key in keys_path:
if not isinstance(provider_data, dict) or key not in provider_data:
logger.debug(
f"CredentialManager: No credentials found at path: {' -> '.join(keys_path)}"
)
return None
provider_data = provider_data[key]
if provider_data:
logger.debug(
f"CredentialManager: Found credentials in legacy format for '{provider}{country_str}'"
)
return self._create_credential_from_data(provider, country, provider_data)
return None
# No country specified - direct lookup
keys_path, _ = self._get_credential_path(provider, None)
provider_data = data
for key in keys_path:
if not isinstance(provider_data, dict) or key not in provider_data:
logger.debug(
f"CredentialManager: No data found at path: {' -> '.join(keys_path)}"
)
return None
provider_data = provider_data[key]
if not provider_data:
logger.debug(f"CredentialManager: No data found for provider '{provider}'")
return None
return self._create_credential_from_data(provider, None, provider_data)
except Exception as e:
logger.error(
f"CredentialManager: Error loading credentials for '{provider}{country_str}': {e}"
)
return None
def _create_credential_from_data(
self, provider: str, country: Optional[str], provider_data: dict
) -> Optional[BaseCredentials]:
"""
Helper method to create credential object from loaded data.
Args:
provider: Provider name
country: Optional country code (for logging)
provider_data: Dictionary containing credential data
Returns:
BaseCredentials instance or None
"""
country_str = f" (country: {country})" if country else ""
logger.debug(
f"CredentialManager: Found data for '{provider}{country_str}': {list(provider_data.keys())}"
)
credential_type = provider_data.get("type")
logger.debug(
f"CredentialManager: Credential type for '{provider}{country_str}': {credential_type}"
)
if credential_type == "user_password":
username = provider_data.get("username", "")
password_present = "password" in provider_data
client_id = provider_data.get("client_id")
logger.debug(
f"CredentialManager: Creating UserPasswordCredentials for '{provider}{country_str}'"
)
logger.debug(
f"CredentialManager: Username: '{username}', Password present: {password_present}, Client ID: {client_id}"
)
creds = UserPasswordCredentials(
username=username,
password=self._decode_password(provider_data.get("password", "")),
client_id=client_id,
grant_type=provider_data.get("grant_type", "password"),
)
logger.debug(
f"CredentialManager: Successfully created UserPasswordCredentials for '{provider}{country_str}'"
)
return creds
elif credential_type == "client_credentials":
client_id = provider_data.get("client_id", "")
client_secret_present = "client_secret" in provider_data
logger.debug(
f"CredentialManager: Creating ClientCredentials for '{provider}{country_str}'"
)
logger.debug(
f"CredentialManager: Client ID: '{client_id}', Client secret present: {client_secret_present}"
)
creds = ClientCredentials(
client_id=client_id,
client_secret=self._decode_password(provider_data.get("client_secret", "")),
grant_type=provider_data.get("grant_type", "client_credentials"),
)
logger.debug(
f"CredentialManager: Successfully created ClientCredentials for '{provider}{country_str}'"
)
return creds
else:
logger.error(
f"CredentialManager: Unknown credential type for '{provider}{country_str}': {credential_type}"
)
return None
def save_credentials(
self, provider: str, credentials: BaseCredentials, country: Optional[str] = None
) -> bool:
"""
Save credentials for a specific provider and optional country
Args:
provider: Provider name
credentials: Credentials to save
country: Optional country code
Returns:
True if successful, False otherwise
"""
country_str = f" (country: {country})" if country else ""
try:
logger.debug(
f"Saving {credentials.credential_type} credentials for {provider}{country_str}"
)
# Load existing data
data = {}
if self.vfs.exists(self.credentials_file):
data = self.vfs.read_json(self.credentials_file) or {}
# Prepare provider data based on credential type
if isinstance(credentials, UserPasswordCredentials):
provider_data = {
"type": "user_password",
"username": credentials.username,
"password": self._encode_password(credentials.password),
"grant_type": credentials.grant_type,
}
if credentials.client_id:
provider_data["client_id"] = credentials.client_id
elif isinstance(credentials, ClientCredentials):
provider_data = {
"type": "client_credentials",
"client_id": credentials.client_id,
"client_secret": self._encode_password(credentials.client_secret),
"grant_type": credentials.grant_type,
}
else:
logger.error(f"Unsupported credential type: {type(credentials)}")
return False
# Navigate and create nested structure if needed
keys_path, is_nested = self._get_credential_path(provider, country)
# Build nested structure
current = data
for i, key in enumerate(keys_path[:-1]):
if key not in current:
current[key] = {}
elif not isinstance(current[key], dict):
logger.warning(f"Overwriting non-dict value at {key}")
current[key] = {}
current = current[key]
# Set the final value
current[keys_path[-1]] = provider_data
# Save to file using VFS
success = self.vfs.write_json(self.credentials_file, data)
if success:
logger.info(f"Successfully saved credentials for {provider}{country_str}")
# Verify by reading back
verify_data = self.vfs.read_json(self.credentials_file)
if verify_data:
verify_current = verify_data
found = True
for key in keys_path:
if not isinstance(verify_current, dict) or key not in verify_current:
found = False
break
verify_current = verify_current[key]
if found:
logger.debug(
f"Verification successful: {provider}{country_str} data found in saved file"
)
else:
logger.error(
f"Verification failed: {provider}{country_str} data NOT found in saved file"
)
return False
return True
else:
logger.error(f"Failed to save credentials for {provider}{country_str}")
return False
except Exception as e:
country_str = f" (country: {country})" if country else ""
logger.error(f"Error saving credentials for {provider}{country_str}: {e}")
return False
def delete_credentials(self, provider: str, country: Optional[str] = None) -> bool:
"""
Delete credentials for a specific provider and optional country
Args:
provider: Provider name
country: Optional country code (if None, deletes entire provider or all countries)
Returns:
True if successful, False otherwise
"""
country_str = f" (country: {country})" if country else ""
try:
if not self.vfs.exists(self.credentials_file):
logger.debug(
f"No credentials file exists, nothing to delete for {provider}{country_str}"
)
return True
data = self.vfs.read_json(self.credentials_file)
if not data:
logger.debug(
f"Credentials file is empty, nothing to delete for {provider}{country_str}"
)
return True
if country:
# Delete specific country data
if (
provider in data
and isinstance(data[provider], dict)
and country in data[provider]
):
del data[provider][country]
logger.info(f"Deleted stored credentials for {provider}{country_str}")
# If provider dict is now empty, remove it entirely
if not data[provider]:
del data[provider]
logger.debug(f"Provider {provider} had no more countries, removed entirely")
return self.vfs.write_json(self.credentials_file, data)
else:
logger.debug(
f"No stored credentials found to delete for {provider}{country_str}"
)
else:
# Delete entire provider data (all countries)
if provider in data:
del data[provider]
logger.info(f"Deleted all stored credentials for {provider}")
return self.vfs.write_json(self.credentials_file, data)
else:
logger.debug(f"No stored credentials found to delete for {provider}")
return True
except Exception as e:
country_str = f" (country: {country})" if country else ""
logger.error(f"Error deleting credentials for {provider}{country_str}: {e}")
return False
def list_providers(self) -> List[str]:
"""
List all providers with stored credentials
Returns:
List of provider names
"""
try:
if not self.vfs.exists(self.credentials_file):
logger.debug("No credentials file exists, returning empty provider list")
return []
data = self.vfs.read_json(self.credentials_file)
if not data:
logger.debug("Credentials file is empty, returning empty provider list")
return []
providers = list(data.keys())
logger.debug(f"Found stored credentials for providers: {providers}")
return providers
except Exception as e:
logger.error(f"Error listing providers: {e}")
return []
def get_all_countries(self, provider: str) -> List[str]:
"""
Get all countries that have credentials for a provider
Args:
provider: Provider name
Returns:
List of country codes
"""
try:
if not self.vfs.exists(self.credentials_file):
return []
data = self.vfs.read_json(self.credentials_file)
if not data or provider not in data:
return []
provider_data = data[provider]
# Check if this is a nested (country-aware) structure
if isinstance(provider_data, dict):
# Check if any keys look like country codes (2-3 char strings)
# and their values are dicts (credential data)
countries = []
for key, value in provider_data.items():
if isinstance(value, dict) and len(key) <= 3 and "type" in value:
countries.append(key)
return countries
return []
except Exception as e:
logger.error(f"Error getting countries for {provider}: {e}")
return []
def has_credentials(self, provider: str, country: Optional[str] = None) -> bool:
"""
Check if credentials exist for a provider and optional country
Args:
provider: Provider name
country: Optional country code
Returns:
True if credentials exist, False otherwise
"""
credentials = self.load_credentials(provider, country)
return credentials is not None and credentials.validate()
@staticmethod
def _encode_password(password: str) -> str:
"""No encoding - store as plaintext (TEMPORARY INSECURE SOLUTION)."""
logger.warning(
"Storing password in plaintext - this is insecure and should be replaced with proper encryption"
)
return password
@staticmethod
def _decode_password(encoded_password: str) -> str:
"""No decoding needed - passwords are stored in plaintext."""
return encoded_password
def export_config(
self, provider: Optional[str] = None, country: Optional[str] = None
) -> Dict[str, Any]:
"""
Export credentials to a portable format
Args:
provider: Optional provider name to export (if None, exports all)
country: Optional country code (only used if provider is specified)
Returns:
Dictionary with credentials data
"""
export_data = {
"version": "1.1", # Bumped for country support
"exported_at": time.time(),
"credentials": {},
}
if provider:
# Export specific provider
if country:
# Export specific country
credentials = self.load_credentials(provider, country)
if credentials:
export_data["credentials"][provider] = {
country: self._credential_to_dict(credentials)
}
else:
# Export all countries for provider or non-country data
countries = self.get_all_countries(provider)
if countries:
# Provider has country-specific credentials
provider_data = {}
for ctry in countries:
credentials = self.load_credentials(provider, ctry)
if credentials:
provider_data[ctry] = self._credential_to_dict(credentials)
if provider_data:
export_data["credentials"][provider] = provider_data
else:
# Non-country provider
credentials = self.load_credentials(provider)
if credentials:
export_data["credentials"][provider] = self._credential_to_dict(credentials)
else:
# Export all providers
for prov in self.list_providers():
countries = self.get_all_countries(prov)
if countries:
# Provider has country-specific credentials
provider_data = {}
for ctry in countries:
credentials = self.load_credentials(prov, ctry)
if credentials:
provider_data[ctry] = self._credential_to_dict(credentials)
if provider_data:
export_data["credentials"][prov] = provider_data
else:
# Non-country provider
credentials = self.load_credentials(prov)
if credentials:
export_data["credentials"][prov] = self._credential_to_dict(credentials)
return export_data
@staticmethod
def _credential_to_dict(credentials: BaseCredentials) -> Dict[str, Any]:
"""Convert credentials object to dictionary for export"""
if isinstance(credentials, UserPasswordCredentials):
return {
"type": "user_password",
"username": credentials.username,
"password": credentials.password,
"client_id": credentials.client_id,
}
elif isinstance(credentials, ClientCredentials):
return {
"type": "client_credentials",
"client_id": credentials.client_id,
"client_secret": credentials.client_secret,
}
else:
return {}
def import_config(self, config_data: Dict[str, Any]) -> Dict[str, bool]:
"""
Import credentials from exported configuration
Args:
config_data: Configuration data from export_config()
Returns:
Dictionary mapping provider names (or provider_country) to import success status
"""
results = {}
# Validate config data
if not isinstance(config_data, dict) or "credentials" not in config_data:
logger.error("Invalid credential config data format")
return results
credentials_data = config_data.get("credentials", {})
for provider, provider_data in credentials_data.items():
try:
# Check if this is country-aware structure
if isinstance(provider_data, dict) and not provider_data.get("type"):
# Nested structure with countries
for country, cred_data in provider_data.items():
if not isinstance(cred_data, dict):
continue
credentials = self._dict_to_credential(cred_data)
if credentials:
success = self.save_credentials(provider, credentials, country)
results[f"{provider}_{country}"] = success
if success:
logger.info(
f"Successfully imported credentials for {provider} ({country})"
)
else:
logger.error(
f"Failed to import credentials for {provider} ({country})"
)
else:
# Flat structure (no country)
credentials = self._dict_to_credential(provider_data)
if credentials:
success = self.save_credentials(provider, credentials)
results[provider] = success
if success:
logger.info(f"Successfully imported credentials for {provider}")
else:
logger.error(f"Failed to import credentials for {provider}")
except Exception as e:
logger.error(f"Error importing credentials for {provider}: {e}")
results[provider] = False
return results
@staticmethod
def _dict_to_credential(cred_data: Dict[str, Any]) -> Optional[BaseCredentials]:
"""Convert dictionary to credentials object"""
try:
if cred_data.get("type") == "user_password":
return UserPasswordCredentials(
username=cred_data.get("username", ""),
password=cred_data.get("password", ""),
client_id=cred_data.get("client_id"),
)
elif cred_data.get("type") == "client_credentials":
return ClientCredentials(
client_id=cred_data.get("client_id", ""),
client_secret=cred_data.get("client_secret", ""),
)
else:
logger.error(f"Unknown credential type: {cred_data.get('type')}")
return None
except Exception as e:
logger.error(f"Error creating credential from dict: {e}")
return None
def debug_credentials_file(self) -> None:
"""Debug method to log the current state of the credentials file"""
try:
logger.info(f"=== CREDENTIALS FILE DEBUG INFO ===")
credentials_file_path = self.vfs.join_path(self.credentials_file)
logger.info(f"Credentials file path: {credentials_file_path}")
logger.info(f"Credentials file exists: {self.vfs.exists(self.credentials_file)}")
if self.vfs.exists(self.credentials_file):
file_size = self.vfs.get_size(self.credentials_file)
logger.info(f"Credentials file size: {file_size} bytes")
if file_size and file_size > 0:
data = self.vfs.read_json(self.credentials_file)
if data:
logger.info(
f"Credentials file contains {len(data)} providers: {list(data.keys())}"
)
for provider, provider_data in data.items():
if isinstance(provider_data, dict):
# Check if nested (has countries)
has_countries = any(
isinstance(v, dict) and len(k) <= 3 and "type" in v
for k, v in provider_data.items()
)
if has_countries:
logger.info(f" {provider} (country-aware):")
for country, cred_data in provider_data.items():
if isinstance(cred_data, dict) and "type" in cred_data:
cred_type = cred_data.get("type")
username = cred_data.get("username", "N/A")
logger.info(
f" {country}: type={cred_type}, username={username}"
)
else:
# Flat structure (no country)
cred_type = provider_data.get("type", "unknown")
username = provider_data.get("username", "N/A")
logger.info(
f" {provider} (no country): type={cred_type}, username={username}"
)
else:
logger.error("Credentials file contains invalid JSON or is empty")
else:
logger.info("Credentials file is empty")
else:
logger.info("Credentials file does not exist yet")
logger.info(f"=== END CREDENTIALS FILE DEBUG ===")
except Exception as e:
logger.error(f"Error during credentials file debug: {e}")