Add TOTP authenticator support for Bambu Cloud login (fixes #182)

TOTP (Two-Factor Authentication):
- Detect TOTP vs email verification from Bambu API loginType response
- Use dedicated TFA endpoint on bambulab.com (not api.bambulab.com)
- Include browser-like headers to bypass Cloudflare protection
- Extract token from JSON response or cookies
- Frontend shows appropriate messages for each verification type
- Added i18n translations for TOTP UI (en, de, ja)

Closes #182
This commit is contained in:
maziggy
2026-02-03 07:18:13 +01:00
parent cc0bfa764e
commit ea93535bfc
12 changed files with 467 additions and 69 deletions
+21 -6
View File
@@ -93,8 +93,12 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
"""
Initiate login to Bambu Cloud.
This will typically trigger a verification code to be sent to the user's email.
After receiving the code, call /cloud/verify to complete the login.
This will trigger either:
- Email verification: A code is sent to the user's email
- TOTP verification: User enters code from their authenticator app
After receiving/generating the code, call /cloud/verify to complete the login.
For TOTP, include the tfa_key from this response in the verify request.
"""
cloud = get_cloud_service()
@@ -112,6 +116,8 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
success=result.get("success", False),
needs_verification=result.get("needs_verification", False),
message=result.get("message", "Unknown error"),
verification_type=result.get("verification_type"),
tfa_key=result.get("tfa_key"),
)
except BambuCloudAuthError as e:
raise HTTPException(status_code=401, detail=str(e))
@@ -122,15 +128,24 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
@router.post("/verify", response_model=CloudLoginResponse)
async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
"""
Complete login with verification code.
Complete login with verification code (email or TOTP).
After calling /cloud/login, the user will receive an email with a 6-digit code.
Submit that code here to complete authentication.
For email verification:
- After calling /cloud/login, the user receives an email with a 6-digit code
- Submit the code with email address
For TOTP verification:
- The user enters the 6-digit code from their authenticator app
- Include the tfa_key from the /cloud/login response
"""
cloud = get_cloud_service()
try:
result = await cloud.verify_code(request.email, request.code)
# Use TOTP verification if tfa_key is provided
if request.tfa_key:
result = await cloud.verify_totp(request.tfa_key, request.code)
else:
result = await cloud.verify_code(request.email, request.code)
if result.get("success") and cloud.access_token:
await store_token(db, cloud.access_token, request.email)
+5 -2
View File
@@ -10,10 +10,11 @@ class CloudLoginRequest(BaseModel):
class CloudVerifyRequest(BaseModel):
"""Request to verify login with 2FA code."""
"""Request to verify login with 2FA code (email or TOTP)."""
email: str = Field(..., description="Bambu Lab account email")
code: str = Field(..., description="6-digit verification code from email")
code: str = Field(..., description="6-digit verification code")
tfa_key: str | None = Field(None, description="TFA key for TOTP verification (from login response)")
class CloudLoginResponse(BaseModel):
@@ -22,6 +23,8 @@ class CloudLoginResponse(BaseModel):
success: bool
needs_verification: bool = False
message: str
verification_type: str | None = None # "email" or "totp"
tfa_key: str | None = None # Key needed for TOTP verification
class CloudAuthStatus(BaseModel):
+110 -8
View File
@@ -56,9 +56,9 @@ class BambuCloudService:
async def login_request(self, email: str, password: str) -> dict:
"""
Initiate login - this will trigger a verification code email.
Initiate login - this will trigger either email verification or TOTP prompt.
Returns dict with login status and whether verification is needed.
Returns dict with login status, verification type, and tfaKey if needed.
"""
try:
response = await self._client.post(
@@ -71,12 +71,33 @@ class BambuCloudService:
)
data = response.json()
logger.debug(
f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
)
if response.status_code == 200:
# Check if we need verification code
# Bambu API returns loginType or may require tfaKey
if data.get("loginType") == "verifyCode" or "tfaKey" in data:
return {"success": False, "needs_verification": True, "message": "Verification code sent to email"}
login_type = data.get("loginType")
tfa_key = data.get("tfaKey")
# TOTP authentication required
if login_type == "tfa" or (tfa_key and login_type != "verifyCode"):
return {
"success": False,
"needs_verification": True,
"verification_type": "totp",
"tfa_key": tfa_key,
"message": "Enter the code from your authenticator app",
}
# Email verification required
if login_type == "verifyCode":
return {
"success": False,
"needs_verification": True,
"verification_type": "email",
"tfa_key": None,
"message": "Verification code sent to email",
}
# Direct login success (rare, usually needs 2FA)
if "accessToken" in data:
@@ -93,7 +114,7 @@ class BambuCloudService:
async def verify_code(self, email: str, code: str) -> dict:
"""
Complete login with verification code.
Complete login with email verification code.
"""
try:
response = await self._client.post(
@@ -106,6 +127,7 @@ class BambuCloudService:
)
data = response.json()
logger.debug(f"Email verify response: status={response.status_code}, hasToken={'accessToken' in data}")
if response.status_code == 200 and "accessToken" in data:
self._set_tokens(data)
@@ -114,9 +136,89 @@ class BambuCloudService:
return {"success": False, "message": data.get("message", "Verification failed")}
except Exception as e:
logger.error(f"Verification failed: {e}")
logger.error(f"Email verification failed: {e}")
raise BambuCloudAuthError(f"Verification failed: {e}")
async def verify_totp(self, tfa_key: str, code: str) -> dict:
"""
Complete login with TOTP code from authenticator app.
Args:
tfa_key: The tfaKey returned from initial login request
code: 6-digit TOTP code from authenticator app
"""
try:
# TFA endpoint is on bambulab.com, NOT api.bambulab.com
# Requires browser-like headers to bypass Cloudflare
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"
browser_headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://bambulab.com",
"Referer": "https://bambulab.com/",
}
response = await self._client.post(
tfa_url,
headers=browser_headers,
json={
"tfaKey": tfa_key,
"tfaCode": code,
},
)
logger.debug(
f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
)
# Handle empty response
if not response.text or not response.text.strip():
logger.warning(f"TOTP verification returned empty response (status {response.status_code})")
return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
try:
data = response.json()
except Exception as json_err:
logger.error(f"Failed to parse TOTP response: {json_err}, body: {response.text[:500]}")
return {"success": False, "message": "Invalid response from Bambu Cloud"}
# Token might be in accessToken, token field, or cookies
access_token = data.get("accessToken") or data.get("token")
# Also check cookies for token
if not access_token:
for cookie in response.cookies:
if "token" in cookie.lower():
access_token = response.cookies.get(cookie)
break
if response.status_code == 200 and access_token:
self.access_token = access_token
self.refresh_token = data.get("refreshToken")
from datetime import datetime, timedelta
self.token_expiry = datetime.now() + timedelta(days=30)
return {"success": True, "message": "Login successful"}
# Provide helpful error message
error_msg = data.get("message", "")
if "expired" in error_msg.lower():
return {"success": False, "message": "TOTP session expired. Please try logging in again."}
if not error_msg:
error_msg = f"TOTP verification failed (status {response.status_code})"
return {"success": False, "message": error_msg}
except Exception as e:
logger.error(f"TOTP verification failed: {e}")
# Return error instead of raising - don't trigger 401/500
return {"success": False, "message": f"TOTP verification error: {e}"}
def _set_tokens(self, data: dict):
"""Set tokens from login response."""
self.access_token = data.get("accessToken")