feat: auth: add auth module

This commit is contained in:
2026-04-05 02:36:10 +02:00
parent 1ed51fdbdb
commit 449952c6c1
19 changed files with 711 additions and 375 deletions
+29
View File
@@ -0,0 +1,29 @@
"""
Author: Uyanide pywang0608@foxmail.com
Description: Credential authenticators for third-party provider APIs
"""
from lrx_cli.authenticators.qqmusic import QQMusicAuthenticator
from .base import BaseAuthenticator
from .spotify import SpotifyAuthenticator
from .musixmatch import MusixmatchAuthenticator
from .dummy import DummyAuthenticator
__all__ = [
"BaseAuthenticator",
"SpotifyAuthenticator",
"MusixmatchAuthenticator",
"QQMusicAuthenticator",
"DummyAuthenticator",
]
def create_authenticators(cache) -> dict[str, BaseAuthenticator]:
"""Factory function to create authenticators with cache access."""
return {
"dummy": DummyAuthenticator(),
"spotify": SpotifyAuthenticator(cache),
"musixmatch": MusixmatchAuthenticator(cache),
"qqmusic": QQMusicAuthenticator(),
}
+32
View File
@@ -0,0 +1,32 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-05 03:18:14
Description: Base class for credential authenticators
"""
from abc import ABC, abstractmethod
from typing import Optional
class BaseAuthenticator(ABC):
"""Manages obtaining, caching, and refreshing a credential for one provider."""
@property
@abstractmethod
def name(self) -> str: ...
def is_configured(self) -> bool:
"""True if the prerequisite config (e.g. env var) is present.
Default is True — authenticators that can obtain credentials anonymously
should not override this.
"""
return True
@abstractmethod
async def authenticate(self) -> Optional[str]:
"""Return current valid credential string, refreshing if needed.
Returns None if unavailable (misconfigured or network failure).
"""
...
+19
View File
@@ -0,0 +1,19 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-05 03:36:44
Description:
"""
from .base import BaseAuthenticator
class DummyAuthenticator(BaseAuthenticator):
@property
def name(self) -> str:
return "dummy"
def is_configured(self) -> bool:
return True
async def authenticate(self) -> None:
return None
+160
View File
@@ -0,0 +1,160 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-05 03:27:56
Description: Musixmatch authenticator — token management, 401 retry, and cooldown
"""
import time
from typing import Optional
from urllib.parse import urlencode
import httpx
from loguru import logger
from .base import BaseAuthenticator
from ..cache import CacheEngine
from ..config import (
HTTP_TIMEOUT,
MUSIXMATCH_TOKEN_URL,
MUSIXMATCH_USERTOKEN,
MUSIXMATCH_COOLDOWN_MS,
)
_MXM_HEADERS = {"Cookie": "x-mxm-token-guid="}
_MXM_BASE_PARAMS = {
"format": "json",
"app_id": "web-desktop-app-v1.0",
}
class MusixmatchAuthenticator(BaseAuthenticator):
def __init__(self, cache: CacheEngine) -> None:
self._cache = cache
self._cached_token: Optional[str] = None
self._cooldown_until_ms: int = 0
@property
def name(self) -> str:
return "musixmatch"
def is_configured(self) -> bool:
return True # anonymous token always available
def is_cooldown(self) -> bool:
"""Return True if Musixmatch requests are blocked due to repeated auth failure."""
now_ms = int(time.time() * 1000)
if self._cooldown_until_ms > now_ms:
return True
data = self._cache.get_credential("musixmatch_cooldown")
if data:
until = data.get("until_ms", 0)
if until > now_ms:
self._cooldown_until_ms = until
return True
return False
def _set_cooldown(self) -> None:
now_ms = int(time.time() * 1000)
until_ms = now_ms + MUSIXMATCH_COOLDOWN_MS
self._cooldown_until_ms = until_ms
self._cache.set_credential(
"musixmatch_cooldown",
{"until_ms": until_ms},
expires_at_ms=until_ms,
)
logger.warning("Musixmatch: token unavailable, entering cooldown for 1 hour")
def _invalidate_token(self) -> None:
"""Discard the current token from memory and DB."""
self._cached_token = None
# Store with an already-expired timestamp so get_credential returns None
self._cache.set_credential("musixmatch", {"token": ""}, expires_at_ms=1)
async def _fetch_new_token(self) -> Optional[str]:
"""Call token.get and persist the result. Returns token string or None."""
params = {
**_MXM_BASE_PARAMS,
"user_language": "en",
"t": str(int(time.time() * 1000)),
}
url = f"{MUSIXMATCH_TOKEN_URL}?{urlencode(params)}"
logger.debug("Musixmatch: fetching anonymous token")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
resp = await client.get(url, headers=_MXM_HEADERS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.warning(f"Musixmatch: token fetch failed: {e}")
return None
token = (
data.get("message", {}).get("body", {}).get("user_token")
if isinstance(data, dict)
else None
)
if not isinstance(token, str) or not token:
logger.warning("Musixmatch: unexpected token.get response structure")
return None
self._cached_token = token
# No expiry — token is valid until we get a 401
self._cache.set_credential("musixmatch", {"token": token}, expires_at_ms=None)
logger.debug("Musixmatch: obtained anonymous token")
return token
async def _get_token(self) -> Optional[str]:
"""Return a valid token: env var > memory > DB > fresh fetch."""
if MUSIXMATCH_USERTOKEN:
return MUSIXMATCH_USERTOKEN
if self._cached_token:
return self._cached_token
data = self._cache.get_credential("musixmatch")
if data and isinstance(data.get("token"), str) and data["token"]:
self._cached_token = data["token"]
return self._cached_token
return await self._fetch_new_token()
async def authenticate(self) -> Optional[str]:
if self.is_cooldown():
logger.debug("Musixmatch: authenticate called during cooldown")
return None
return await self._get_token()
async def get_json(self, url_base: str, params: dict) -> Optional[dict]:
"""Authenticated GET to a Musixmatch endpoint.
- Injects format, app_id, and usertoken automatically.
- On 401: invalidates token, fetches a fresh one, retries once.
- On failed token fetch (initial or retry): sets cooldown, returns None.
- On network / HTTP error: raises (callers map this to NETWORK_ERROR).
- Returns None if cooldown is active.
"""
if self.is_cooldown():
logger.debug("Musixmatch: request blocked by cooldown")
return None
token = await self._get_token()
if not token:
self._set_cooldown()
return None
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
url = f"{url_base}?{urlencode({**_MXM_BASE_PARAMS, **params, 'usertoken': token})}"
resp = await client.get(url, headers=_MXM_HEADERS)
if resp.status_code == 401:
logger.debug("Musixmatch: 401 received, refreshing token")
self._invalidate_token()
token = await self._fetch_new_token()
if not token:
self._set_cooldown()
return None
url = f"{url_base}?{urlencode({**_MXM_BASE_PARAMS, **params, 'usertoken': token})}"
resp = await client.get(url, headers=_MXM_HEADERS)
resp.raise_for_status()
return resp.json()
+25
View File
@@ -0,0 +1,25 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-05 03:47:30
Description: QQ Music API authenticator - currently only a proxy
"""
from typing import Optional
from .base import BaseAuthenticator
from ..config import QQ_MUSIC_API_URL
class QQMusicAuthenticator(BaseAuthenticator):
def __init__(self) -> None:
pass
@property
def name(self) -> str:
return "qqmusic"
def is_configured(self) -> bool:
return bool(QQ_MUSIC_API_URL)
async def authenticate(self) -> Optional[str]:
return QQ_MUSIC_API_URL
+203
View File
@@ -0,0 +1,203 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-05 03:18:14
Description: Spotify authenticator — TOTP-based access token via SP_DC cookie
"""
import hashlib
import hmac
import struct
import time
from typing import Optional, Tuple
import httpx
from loguru import logger
from .base import BaseAuthenticator
from ..cache import CacheEngine
from ..config import (
HTTP_TIMEOUT,
SPOTIFY_SERVER_TIME_URL,
SPOTIFY_SECRET_URL,
SPOTIFY_SP_DC,
SPOTIFY_TOKEN_URL,
UA_BROWSER,
)
_SPOTIFY_BASE_HEADERS = {
"Referer": "https://open.spotify.com/",
"Origin": "https://open.spotify.com",
"App-Platform": "WebPlayer",
"Spotify-App-Version": "1.2.88.21.g8e037c8f",
}
class SpotifyAuthenticator(BaseAuthenticator):
def __init__(self, cache: CacheEngine) -> None:
self._cache = cache
self._cached_secret: Optional[Tuple[str, int]] = None
self._cached_token: Optional[str] = None
self._token_expires_at: float = 0.0
@property
def name(self) -> str:
return "spotify"
def is_configured(self) -> bool:
return bool(SPOTIFY_SP_DC)
@staticmethod
def _generate_totp(server_time_s: int, secret: str) -> str:
counter = server_time_s // 30
counter_bytes = struct.pack(">Q", counter)
mac = hmac.new(secret.encode(), counter_bytes, hashlib.sha1).digest()
offset = mac[-1] & 0x0F
binary_code = (
(mac[offset] & 0x7F) << 24
| (mac[offset + 1] & 0xFF) << 16
| (mac[offset + 2] & 0xFF) << 8
| (mac[offset + 3] & 0xFF)
)
return str(binary_code % (10**6)).zfill(6)
def _load_cached_token(self) -> Optional[str]:
data = self._cache.get_credential("spotify")
if not data:
return None
expires_ms = data.get("accessTokenExpirationTimestampMs", 0)
if expires_ms <= int(time.time() * 1000):
logger.debug("Spotify: persisted token expired")
return None
token = data.get("accessToken", "")
if not token:
return None
self._cached_token = token
self._token_expires_at = expires_ms / 1000.0
logger.debug("Spotify: loaded token from DB cache")
return token
def _save_token(self, body: dict) -> None:
expires_ms = body.get("accessTokenExpirationTimestampMs")
self._cache.set_credential("spotify", body, expires_ms)
logger.debug("Spotify: token saved to DB cache")
async def _get_server_time(self, client: httpx.AsyncClient) -> Optional[int]:
try:
res = await client.get(SPOTIFY_SERVER_TIME_URL, timeout=HTTP_TIMEOUT)
res.raise_for_status()
data = res.json()
if not isinstance(data, dict) or "serverTime" not in data:
logger.error(f"Spotify: unexpected server-time response: {data}")
return None
server_time = data["serverTime"]
logger.debug(f"Spotify: server time = {server_time}")
return server_time
except Exception as e:
logger.error(f"Spotify: failed to fetch server time: {e}")
return None
async def _get_secret(self, client: httpx.AsyncClient) -> Optional[Tuple[str, int]]:
if self._cached_secret is not None:
logger.debug("Spotify: using cached TOTP secret")
return self._cached_secret
try:
res = await client.get(SPOTIFY_SECRET_URL, timeout=HTTP_TIMEOUT)
res.raise_for_status()
data = res.json()
if not isinstance(data, list) or len(data) == 0:
logger.error(
f"Spotify: unexpected secrets response (type={type(data).__name__})"
)
return None
last = data[-1]
if "secret" not in last or "version" not in last:
logger.error(f"Spotify: malformed secret entry: {list(last.keys())}")
return None
secret_raw = last["secret"]
version = last["version"]
secret = "".join(
str(ord(c) ^ ((i % 33) + 9)) for i, c in enumerate(secret_raw)
)
logger.debug(f"Spotify: decoded secret v{version} (len={len(secret)})")
self._cached_secret = (secret, version)
return self._cached_secret
except Exception as e:
logger.error(f"Spotify: failed to fetch secret: {e}")
return None
async def authenticate(self) -> Optional[str]:
if self._cached_token and time.time() < self._token_expires_at - 30:
logger.debug("Spotify: using in-memory cached token")
return self._cached_token
db_token = self._load_cached_token()
if db_token and time.time() < self._token_expires_at - 30:
return db_token
if not SPOTIFY_SP_DC:
logger.error("Spotify: SPOTIFY_SP_DC env var not set — cannot authenticate")
return None
headers = {
"User-Agent": UA_BROWSER,
"Accept": "*/*",
"Cookie": f"sp_dc={SPOTIFY_SP_DC}",
**_SPOTIFY_BASE_HEADERS,
}
async with httpx.AsyncClient(headers=headers) as client:
server_time = await self._get_server_time(client)
if server_time is None:
return None
secret_data = await self._get_secret(client)
if secret_data is None:
return None
secret, version = secret_data
totp = self._generate_totp(server_time, secret)
logger.debug(f"Spotify: generated TOTP v{version}: {totp}")
params = {
"reason": "init",
"productType": "web-player",
"totp": totp,
"totpVer": str(version),
"totpServer": totp,
}
try:
res = await client.get(
SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT
)
if res.status_code != 200:
logger.error(f"Spotify: token request returned {res.status_code}")
return None
body = res.json()
if not isinstance(body, dict) or "accessToken" not in body:
logger.error(
f"Spotify: unexpected token response keys: {list(body.keys()) if isinstance(body, dict) else type(body).__name__}"
)
return None
token = body["accessToken"]
if body.get("isAnonymous", False):
logger.warning(
"Spotify: received anonymous token — SP_DC may be invalid"
)
expires_ms = body.get("accessTokenExpirationTimestampMs", 0)
if expires_ms and expires_ms > int(time.time() * 1000):
self._token_expires_at = expires_ms / 1000.0
else:
logger.warning("Spotify: token expiry missing or invalid")
self._token_expires_at = time.time() + 3600
self._cached_token = token
self._save_token(body)
logger.debug("Spotify: obtained access token")
return token
except Exception as e:
logger.error(f"Spotify: token request failed: {e}")
return None