|
@@ -416,6 +416,42 @@ class BambuCloudService:
|
|
|
logger.error("Email verification failed: %s", e)
|
|
logger.error("Email verification failed: %s", e)
|
|
|
raise BambuCloudAuthError(f"Verification failed: {e}")
|
|
raise BambuCloudAuthError(f"Verification failed: {e}")
|
|
|
|
|
|
|
|
|
|
+ async def _fetch_csrf_token(self, web_origin: str) -> str | None:
|
|
|
|
|
+ """Seed the ``bbl_csrf_token`` cookie and return its value (#2696).
|
|
|
|
|
+
|
|
|
|
|
+ Bambu added double-submit CSRF protection to the ``bambulab.com`` web
|
|
|
|
|
+ origin. A POST without the cookie is rejected ``403 {"error": "CSRF
|
|
|
|
|
+ error: missing_cookie"}`` before the request body is looked at; with the
|
|
|
|
|
+ cookie but no matching header it becomes ``missing_header``. Only
|
|
|
|
|
+ ``GET /api/csrf`` mints one — the sign-in *page* sets nothing but
|
|
|
|
|
+ Cloudflare's ``__cf_bm``, so landing there first does not help.
|
|
|
|
|
+
|
|
|
|
|
+ The token is re-fetched per verification rather than cached: the client
|
|
|
|
|
+ is process-wide and long-lived, so a stale cookie could otherwise
|
|
|
|
|
+ disagree with the header we send.
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ response = await self._client.get(
|
|
|
|
|
+ f"{web_origin}/api/csrf",
|
|
|
|
|
+ headers={"User-Agent": _USER_AGENT, "Accept": "application/json"},
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning("Failed to fetch Bambu Cloud CSRF token: %s", e)
|
|
|
|
|
+ return None
|
|
|
|
|
+ # httpx stores the Set-Cookie on the shared jar, which is also what makes
|
|
|
|
|
+ # the cookie ride along on the POST below — we only need the value here
|
|
|
|
|
+ # to echo it back in the header.
|
|
|
|
|
+ try:
|
|
|
|
|
+ token = self._client.cookies.get("bbl_csrf_token")
|
|
|
|
|
+ except Exception: # multiple cookies of the same name across domains
|
|
|
|
|
+ token = None
|
|
|
|
|
+ if not token:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "Bambu Cloud CSRF endpoint returned no bbl_csrf_token (status %s)",
|
|
|
|
|
+ response.status_code,
|
|
|
|
|
+ )
|
|
|
|
|
+ return token
|
|
|
|
|
+
|
|
|
async def verify_totp(self, tfa_key: str, code: str) -> dict:
|
|
async def verify_totp(self, tfa_key: str, code: str) -> dict:
|
|
|
"""
|
|
"""
|
|
|
Complete login with TOTP code from authenticator app.
|
|
Complete login with TOTP code from authenticator app.
|
|
@@ -433,9 +469,24 @@ class BambuCloudService:
|
|
|
# expected application-level "Login failed" JSON, no Cloudflare
|
|
# expected application-level "Login failed" JSON, no Cloudflare
|
|
|
# interstitial). Browser-impersonation removed to stay clearly on
|
|
# interstitial). Browser-impersonation removed to stay clearly on
|
|
|
# the right side of Bambu Lab's "no falsified client identity" line.
|
|
# the right side of Bambu Lab's "no falsified client identity" line.
|
|
|
- tfa_url = "https://bambulab.com/api/sign-in/tfa"
|
|
|
|
|
- if "bambulab.cn" in self.base_url:
|
|
|
|
|
- tfa_url = "https://bambulab.cn/api/sign-in/tfa"
|
|
|
|
|
|
|
+ web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
|
|
|
|
|
+ tfa_url = f"{web_origin}/api/sign-in/tfa"
|
|
|
|
|
+
|
|
|
|
|
+ # #2696: the web origin is CSRF-protected (double submit). Without
|
|
|
|
|
+ # both halves the endpoint 403s before it ever evaluates the code,
|
|
|
|
|
+ # which surfaced to users as a permanent, misleading "Invalid code".
|
|
|
|
|
+ # api.bambulab.com — where every other call in this service goes,
|
|
|
|
|
+ # including the email-code 2FA path — is not gated, which is why
|
|
|
|
|
+ # only TOTP sign-ins broke.
|
|
|
|
|
+ csrf_token = await self._fetch_csrf_token(web_origin)
|
|
|
|
|
+ if not csrf_token:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "success": False,
|
|
|
|
|
+ "message": (
|
|
|
|
|
+ "Could not obtain a security token from Bambu Cloud. "
|
|
|
|
|
+ "Check the server's internet access and try again."
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
response = await self._client.post(
|
|
response = await self._client.post(
|
|
|
tfa_url,
|
|
tfa_url,
|
|
@@ -443,6 +494,10 @@ class BambuCloudService:
|
|
|
"Content-Type": "application/json",
|
|
"Content-Type": "application/json",
|
|
|
"User-Agent": _USER_AGENT,
|
|
"User-Agent": _USER_AGENT,
|
|
|
"Accept": "application/json",
|
|
"Accept": "application/json",
|
|
|
|
|
+ # Echo of the bbl_csrf_token cookie httpx just stored. Both
|
|
|
|
|
+ # halves are required; the cookie alone yields
|
|
|
|
|
+ # "missing_header".
|
|
|
|
|
+ "x-bbl-csrf-token": csrf_token,
|
|
|
},
|
|
},
|
|
|
json={
|
|
json={
|
|
|
"tfaKey": tfa_key,
|
|
"tfaKey": tfa_key,
|
|
@@ -487,10 +542,26 @@ class BambuCloudService:
|
|
|
|
|
|
|
|
# Provide helpful error message
|
|
# Provide helpful error message
|
|
|
error_msg = data.get("message", "")
|
|
error_msg = data.get("message", "")
|
|
|
|
|
+
|
|
|
|
|
+ # A CSRF rejection means the code was never evaluated (#2696). It
|
|
|
|
|
+ # used to fall through to the generic path below and read as
|
|
|
|
|
+ # "Invalid code", which sent the reporter chasing clock drift and
|
|
|
|
|
+ # leading-zero parsing for a request Bambu had already refused.
|
|
|
|
|
+ csrf_error = data.get("error", "") if isinstance(data.get("error"), str) else ""
|
|
|
|
|
+ if "csrf" in csrf_error.lower() or data.get("reason") in ("missing_cookie", "missing_header"):
|
|
|
|
|
+ logger.error("Bambu Cloud rejected the TOTP request on CSRF grounds: %s", response.text[:200])
|
|
|
|
|
+ return {
|
|
|
|
|
+ "success": False,
|
|
|
|
|
+ "message": (
|
|
|
|
|
+ "Bambu Cloud rejected the sign-in request before checking your code "
|
|
|
|
|
+ "(security-token error). Your code is fine — please try again."
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if "expired" in error_msg.lower():
|
|
if "expired" in error_msg.lower():
|
|
|
return {"success": False, "message": "TOTP session expired. Please try logging in again."}
|
|
return {"success": False, "message": "TOTP session expired. Please try logging in again."}
|
|
|
if not error_msg:
|
|
if not error_msg:
|
|
|
- error_msg = f"TOTP verification failed (status {response.status_code})"
|
|
|
|
|
|
|
+ error_msg = data.get("error") or f"TOTP verification failed (status {response.status_code})"
|
|
|
|
|
|
|
|
return {"success": False, "message": error_msg}
|
|
return {"success": False, "message": error_msg}
|
|
|
|
|
|