""" MQTT authentication and ACL endpoints for mosquitto-go-auth HTTP backend. Mosquitto calls these on every CONNECT, SUBSCRIBE, and PUBLISH. - POST /mqtt/auth/user → validate device credentials - POST /mqtt/auth/acl → enforce per-device topic isolation Password strategy: HMAC-SHA256(MQTT_SECRET, username)[:32] - Deterministic: no storage needed, re-derive on every auth check - Rotating MQTT_SECRET invalidates all passwords at once if needed Transition support: during rollout, the legacy password "vesper" is also accepted so that devices still on old firmware stay connected. User types handled: - Device users (e.g. "PV25L22BP01R01", "PV-26A18-BC02R-X7KQA"): Authenticated via HMAC. ACL restricted to their own vesper/{sn}/... topics. - 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"} def _derive_password(username: str) -> str: """Derive the expected MQTT password for a given username.""" return hmac.new( settings.mqtt_secret.encode(), username.encode(), hashlib.sha256, ).hexdigest()[:32] def _is_valid_password(username: str, password: str) -> bool: """ Accept the password if it matches either: - The HMAC-derived password (new firmware) - The legacy hardcoded "vesper" password (old firmware, transition period) Remove the legacy check in Stage 7 once all devices are on new firmware. """ expected = _derive_password(username) hmac_ok = hmac.compare_digest(expected, password) legacy_ok = hmac.compare_digest(LEGACY_PASSWORD, password) return hmac_ok or legacy_ok def _base_sn(username: str) -> str: """ Strip the -kiosk suffix if present, returning the base serial number. e.g. "PV25L22BP01R01-kiosk" -> "PV25L22BP01R01" "PV25L22BP01R01" -> "PV25L22BP01R01" """ if username.endswith("-kiosk"): return username[: -len("-kiosk")] 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") def mqtt_auth_user( username: str = Form(...), password: str = Form(...), clientid: str = Form(default=""), ): """ Called by Mosquitto on every CONNECT. Returns 200 to allow, 403 to deny. 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) return Response(status_code=403) @router.post("/acl") def mqtt_auth_acl( username: str = Form(...), topic: str = Form(...), clientid: str = Form(default=""), acc: int = Form(...), # 1 = subscribe, 2 = publish, 3 = subscribe+publish ): """ Called by Mosquitto on every SUBSCRIBE and PUBLISH. Returns 200 to allow, 403 to deny. Topic pattern: vesper/{sn}/... - Device users: may only access their own SN segment - Kiosk users: stripped of -kiosk suffix, then same rule applies - Superusers (bonamin, NodeRED): full access """ # Superusers get full access (shouldn't reach here but handled defensively) if username in SUPERUSERS: return Response(status_code=200) # Derive the base SN (handles -kiosk suffix) base = _base_sn(username) # Topic must be vesper/{base_sn}/... parts = topic.split("/") if len(parts) >= 2 and parts[0] == "vesper" and parts[1] == base: return Response(status_code=200) return Response(status_code=403)