Files
bellsystems-cp/backend/mqtt/auth.py
T
bonaminandClaude Opus 5.5 8a668ca60f feat(mqtt-auth): authenticate phone-app users with Firebase ID tokens
The remote FlutterFlow app connects to Mosquitto as "app_<firebase_uid>"
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 <noreply@anthropic.com>
2026-09-30 00:02:21 +03:00

193 lines
6.7 KiB
Python

"""
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_<firebase_uid>"):
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_<firebase_uid>" 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_<firebase_uid>"
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)