From 8a668ca60fed4c1e4465962aa702a1d27b87e4f7 Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 30 Sep 2026 00:02:21 +0300 Subject: [PATCH] feat(mqtt-auth): authenticate phone-app users with Firebase ID tokens The remote FlutterFlow app connects to Mosquitto as "app_" with a Firebase ID token as the password, so no per-user MQTT accounts need to exist anywhere. For app_ usernames, POST /mqtt/auth/user now: - verifies the token with firebase_admin.auth.verify_id_token (check_revoked=True), - requires the decoded uid to equal the uid in the username, - requires a users doc with that `uid` field (queried, not by doc id) whose status is not "blocked" (same meaning as users.service.block_user). It returns 200/403 and logs the deny reason - never the token. app_ usernames never fall through to the HMAC / legacy "vesper" check. Device and kiosk auth are unchanged. App users are still denied every topic by the existing ACL until the app ACL lands in the next commit. Both handlers are now plain `def` so the blocking Firestore / Firebase calls run in FastAPI's threadpool instead of stalling the event loop. Co-Authored-By: Claude Opus 5.5 --- backend/mqtt/auth.py | 70 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) 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=""),