feat: auth: add auth module
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
@@ -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).
|
||||
"""
|
||||
...
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user