Skip to content

Commit 5ef98c0

Browse files
feat: Encrypt auth keys in token store and enforce 401 for invalid tokens across endpoints.
1 parent 31bd758 commit 5ef98c0

File tree

7 files changed

+56
-1
lines changed

7 files changed

+56
-1
lines changed

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ TOKEN_TTL_SECONDS=0
77
ANNOUNCEMENT_HTML=
88
HOST_NAME=<your_addon_url>
99
RECOMMENDATION_SOURCE_ITEMS_LIMIT=10 # fetches recent watched/loved 10 movies and series to recommend based on those
10-
10+
TOKEN_SALT=change-me
11+
# generate some very long random string preferrably using cryptography libraries
1112
# UPDATER
1213
CATALOG_UPDATE_MODE=cron # Available options: cron, interval
1314
# cron updates catalogs at specified times

app/api/endpoints/catalogs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
4343
logger.info(f"[{token}] Fetching catalog for {type} with id {id}")
4444

4545
credentials = await token_store.get_user_data(token)
46+
if not credentials:
47+
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
4648
try:
4749
# Extract settings from credentials
4850
settings_dict = credentials.get("settings", {})

app/api/endpoints/manifest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def get_base_manifest(user_settings: UserSettings | None = None):
5959
@alru_cache(maxsize=1000, ttl=3600)
6060
async def fetch_catalogs(token: str):
6161
credentials = await token_store.get_user_data(token)
62+
if not credentials:
63+
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
6264

6365
if credentials.get("settings"):
6466
user_settings = UserSettings(**credentials["settings"])

app/api/endpoints/tokens.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
9393
# 2. Check if user already exists
9494
token = token_store.get_token_from_user_id(user_id)
9595
existing_data = await token_store.get_user_data(token)
96+
if not existing_data:
97+
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
9698

9799
# 3. Construct Settings
98100
default_settings = get_default_settings()

app/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
2121
ADDON_NAME: str = "Watchly"
2222
REDIS_URL: str = "redis://redis:6379/0"
2323
REDIS_TOKEN_KEY: str = "watchly:token:"
24+
TOKEN_SALT: str = "change-me"
2425
TOKEN_TTL_SECONDS: int = 0 # 0 = never expire
2526
ANNOUNCEMENT_HTML: str = ""
2627
AUTO_UPDATE_CATALOGS: bool = True

app/services/catalog_updater.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from apscheduler.schedulers.asyncio import AsyncIOScheduler
55
from apscheduler.triggers.cron import CronTrigger
66
from apscheduler.triggers.interval import IntervalTrigger
7+
from fastapi import HTTPException
78
from loguru import logger
89

910
from app.core.config import settings
@@ -17,6 +18,10 @@
1718

1819

1920
async def refresh_catalogs_for_credentials(token: str, credentials: dict[str, Any]) -> bool:
21+
if not credentials:
22+
logger.warning(f"[{token}] Attempted to refresh catalogs with no credentials.")
23+
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
24+
2025
auth_key = credentials.get("authKey")
2126
stremio_service = StremioService(auth_key=auth_key)
2227
# check if user has addon installed or not

app/services/token_store.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import base64
12
import json
23
from collections.abc import AsyncIterator
34
from typing import Any
45

56
import redis.asyncio as redis
67
from cachetools import TTLCache
8+
from cryptography.fernet import Fernet
9+
from cryptography.hazmat.primitives import hashes
10+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
711
from loguru import logger
812

913
from app.core.config import settings
@@ -23,6 +27,38 @@ def __init__(self) -> None:
2327
if not settings.REDIS_URL:
2428
logger.warning("REDIS_URL is not set. Token storage will fail until a Redis instance is configured.")
2529

30+
if not settings.TOKEN_SALT or settings.TOKEN_SALT == "change-me":
31+
logger.warning(
32+
"TOKEN_SALT is missing or using the default placeholder. Set a strong value to secure tokens."
33+
)
34+
35+
def _ensure_secure_salt(self) -> None:
36+
if not settings.TOKEN_SALT or settings.TOKEN_SALT == "change-me":
37+
logger.error("Refusing to store credentials because TOKEN_SALT is unset or using the insecure default.")
38+
raise RuntimeError(
39+
"Server misconfiguration: TOKEN_SALT must be set to a non-default value before storing credentials."
40+
)
41+
42+
def _get_cipher(self) -> Fernet:
43+
"""Get or create Fernet cipher instance based on TOKEN_SALT."""
44+
if self._cipher is None:
45+
kdf = PBKDF2HMAC(
46+
algorithm=hashes.SHA256(),
47+
length=32,
48+
salt=b"", # empty salt
49+
iterations=200_000,
50+
)
51+
52+
key = base64.urlsafe_b64encode(kdf.derive(settings.TOKEN_SALT.encode("utf-8")))
53+
self._cipher = Fernet(key)
54+
return self._cipher
55+
56+
def encrypt_token(self, token: str) -> str:
57+
return self._cipher.encrypt(token.encode("utf-8")).decode("utf-8")
58+
59+
def decrypt_token(self, enc: str) -> str:
60+
return self._cipher.decrypt(enc.encode("utf-8")).decode("utf-8")
61+
2662
async def _get_client(self) -> redis.Redis:
2763
if self._client is None:
2864
self._client = redis.from_url(settings.REDIS_URL, decode_responses=True, encoding="utf-8")
@@ -39,6 +75,7 @@ def get_user_id_from_token(self, token: str) -> str:
3975
return token.strip() if token else ""
4076

4177
async def store_user_data(self, user_id: str, payload: dict[str, Any]) -> str:
78+
self._ensure_secure_salt()
4279
token = self.get_token_from_user_id(user_id)
4380
key = self._format_key(token)
4481

@@ -48,6 +85,9 @@ async def store_user_data(self, user_id: str, payload: dict[str, Any]) -> str:
4885
# Store user_id in payload for convenience
4986
storage_data["user_id"] = user_id
5087

88+
if storage_data.get("authKey"):
89+
storage_data["authKey"] = self.encrypt_token(storage_data["authKey"])
90+
5191
client = await self._get_client()
5292
json_str = json.dumps(storage_data)
5393

@@ -74,6 +114,8 @@ async def get_user_data(self, token: str) -> dict[str, Any] | None:
74114

75115
try:
76116
data = json.loads(data_raw)
117+
if data.get("authKey"):
118+
data["authKey"] = self.decrypt_token(data["authKey"])
77119
self._payload_cache[token] = data
78120
return data
79121
except json.JSONDecodeError:

0 commit comments

Comments
 (0)