diff --git a/backend/mqtt/auth.py b/backend/mqtt/auth.py index 7f9cde4..1097cf8 100644 --- a/backend/mqtt/auth.py +++ b/backend/mqtt/auth.py @@ -18,21 +18,38 @@ User types handled: - Kiosk users (e.g. "PV25L22BP01R01-kiosk"): Same HMAC auth derived from the full kiosk username. ACL: allowed to access topics of their base device (suffix stripped). +- App users (e.g. "app_"): + Remote phone app. Password = a Firebase ID token, verified with + firebase_admin (revocation checked). No per-user MQTT accounts exist. + Never accepted via HMAC or the legacy password. - admin, bonamin, NodeRED, and other non-device users: These connect via the passwd file backend (go-auth file backend). They never reach this HTTP backend — go-auth resolves them first. The ACL endpoint below handles them defensively anyway (superuser list). + +The handlers are plain `def` on purpose: they make blocking Firestore / +Firebase Auth calls, which FastAPI then runs in its threadpool instead of +stalling the event loop. """ import hmac import hashlib +import logging + from fastapi import APIRouter, Form, Response +from firebase_admin import auth as firebase_auth + from config import settings +from mqtt import app_users + +logger = logging.getLogger("mqtt.auth") router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"]) LEGACY_PASSWORD = "vesper" +APP_USER_PREFIX = "app_" + # Users authenticated via passwd file (go-auth file backend). # If they somehow reach the HTTP ACL endpoint, grant full access. SUPERUSERS = {"admin", "bonamin", "NodeRED"} @@ -72,8 +89,52 @@ def _base_sn(username: str) -> str: return username +def _deny_app(username: str, reason: str) -> Response: + # Never log the password — for app users it is a bearer token. + logger.warning("MQTT app auth denied for %s: %s", username, reason) + return Response(status_code=403) + + +def _auth_app_user(username: str, token: str) -> Response: + """Authenticate "app_" with a Firebase ID token as password.""" + uid = username[len(APP_USER_PREFIX):] + if not uid: + return _deny_app(username, "empty uid") + if not token: + return _deny_app(username, "empty token") + + try: + decoded = firebase_auth.verify_id_token(token, check_revoked=True) + except firebase_auth.RevokedIdTokenError: + return _deny_app(username, "token revoked") + except firebase_auth.ExpiredIdTokenError: + return _deny_app(username, "token expired") + except firebase_auth.UserDisabledError: + return _deny_app(username, "firebase account disabled") + except firebase_auth.InvalidIdTokenError as e: + return _deny_app(username, f"invalid token ({type(e).__name__})") + except Exception as e: + return _deny_app(username, f"token verification failed ({type(e).__name__})") + + if decoded.get("uid") != uid: + return _deny_app(username, "token uid does not match username") + + try: + # Fresh read on CONNECT (also refreshes the ACL cache). + user = app_users.get_app_user(uid, use_cache=False) + except Exception as e: + return _deny_app(username, f"user lookup failed ({type(e).__name__})") + + if user is None: + return _deny_app(username, "no user doc with this uid") + if user.blocked: + return _deny_app(username, "user is blocked") + + return Response(status_code=200) + + @router.post("/user") -async def mqtt_auth_user( +def mqtt_auth_user( username: str = Form(...), password: str = Form(...), clientid: str = Form(default=""), @@ -84,11 +145,16 @@ async def mqtt_auth_user( Username = device SN (new format: "PV-26A18-BC02R-X7KQA", old format: "PV25L22BP01R01") or kiosk variant: "PV25L22BP01R01-kiosk" + or app user: "app_" Password = HMAC-derived (new firmware) or "vesper" (legacy firmware) + or, for app users, a Firebase ID token Note: admin, bonamin and NodeRED authenticate via the go-auth passwd file backend and never reach this endpoint. """ + if username.startswith(APP_USER_PREFIX): + return _auth_app_user(username, password) + if _is_valid_password(username, password): return Response(status_code=200) @@ -96,7 +162,7 @@ async def mqtt_auth_user( @router.post("/acl") -async def mqtt_auth_acl( +def mqtt_auth_acl( username: str = Form(...), topic: str = Form(...), clientid: str = Form(default=""),