Files
bellsystems-cp/backend/mqtt/auth.py
T
bonaminandClaude Opus 5.5 81365eed89 feat(mqtt-auth): put legacy "vesper" password behind MQTT_ALLOW_LEGACY_PASSWORD
The shared legacy password is still needed for boards on pre-HMAC
firmware, but it was accepted for any username. Now:
- controlled by MQTT_ALLOW_LEGACY_PASSWORD (config.py, default true;
  documented in .env.example) so it can be switched off without a deploy,
- only accepted for device-shaped usernames (uppercase alphanumeric
  segments joined by "-", optional "-kiosk"), never for app_ users or
  any other shape,
- every successful legacy login is logged at WARNING with the username,
  rate-limited to once per username per hour, so the boards still
  depending on it are visible before the flag is turned off.

HMAC auth is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:03:23 +03:00

293 lines
10 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: while MQTT_ALLOW_LEGACY_PASSWORD is on (default), the
legacy password "vesper" is also accepted for device-shaped usernames so
boards still on old firmware stay connected. Each such login is logged
(once per username per hour) to show which boards still depend on it.
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
import re
import threading
import time
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_"
# Uppercase alphanumeric segments joined by "-", optional "-kiosk" suffix.
# Covers PV25L22BP01R01, PV-26A18-BC02R-X7KQA, BSVSPR-26C13X-STD01R-X7KQA.
DEVICE_USERNAME_RE = re.compile(r"[A-Z0-9]{2,}(?:-[A-Z0-9]+)*(?:-kiosk)?")
LEGACY_LOG_INTERVAL_SECONDS = 3600
_legacy_log_last: dict[str, float] = {}
_legacy_log_lock = threading.Lock()
# 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_device_username(username: str) -> bool:
"""Board serial (optionally with -kiosk), e.g. "PV25L22BP01R01",
"BSVSPR-26C13X-STD01R-X7KQA", "PV25L22BP01R01-kiosk"."""
return bool(DEVICE_USERNAME_RE.fullmatch(username))
def _log_legacy_auth(username: str) -> None:
"""Log a legacy-password login, at most once per username per interval."""
now = time.monotonic()
with _legacy_log_lock:
last = _legacy_log_last.get(username)
if last is not None and now - last < LEGACY_LOG_INTERVAL_SECONDS:
return
_legacy_log_last[username] = now
logger.warning("MQTT legacy password accepted for %s — board still on pre-HMAC firmware", username)
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),
only when MQTT_ALLOW_LEGACY_PASSWORD is on and the username is
device-shaped (never app_ users or unknown shapes).
"""
expected = _derive_password(username)
if hmac.compare_digest(expected, password):
return True
if (
settings.mqtt_allow_legacy_password
and _is_device_username(username)
and hmac.compare_digest(LEGACY_PASSWORD, password)
):
_log_legacy_auth(username)
return True
return False
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)
# Mosquitto access values as passed through by go-auth (`acc`).
ACC_READ = 1 # message delivery to the client
ACC_WRITE = 2 # publish
ACC_SUBSCRIBE = 4 # subscribe
# App-user topic allowlist, relative to vesper/{serial}/
APP_WRITE_TOPICS = frozenset({"control/command"})
APP_READ_TOPICS = frozenset({"control/ack", "status/heartbeat", "status/playback"})
def _app_acl_allowed(username: str, clientid: str, topic: str, acc: int) -> tuple[bool, str]:
"""ACL decision for "app_<uid>". Returns (allowed, reason)."""
uid = username[len(APP_USER_PREFIX):]
if not uid:
return False, "empty uid"
# Client id must be owned by this user, so one user can't take over
# (and disconnect) another user's session by reusing its client id.
if not clientid.startswith(f"{APP_USER_PREFIX}{uid}_"):
return False, f"clientid {clientid!r} not prefixed with app_<uid>_"
if "+" in topic or "#" in topic:
return False, "wildcards not allowed"
parts = topic.split("/")
if len(parts) != 4 or parts[0] != "vesper" or not parts[1]:
return False, "topic not vesper/{serial}/<a>/<b>"
serial, leaf = parts[1], f"{parts[2]}/{parts[3]}"
if acc == ACC_WRITE:
if leaf not in APP_WRITE_TOPICS:
return False, "publish not allowed on this topic"
elif acc in (ACC_READ, ACC_SUBSCRIBE):
if leaf not in APP_READ_TOPICS:
return False, "read/subscribe not allowed on this topic"
else:
return False, f"unsupported acc={acc}"
try:
user = app_users.get_app_user(uid)
except Exception as e:
return False, f"user lookup failed ({type(e).__name__})"
if user is None:
return False, "no user doc with this uid"
if user.blocked:
return False, "user is blocked"
if serial not in user.device_serials:
return False, "serial not assigned to user"
return True, ""
@router.post("/acl")
def mqtt_auth_acl(
username: str = Form(...),
topic: str = Form(...),
clientid: str = Form(default=""),
acc: int = Form(...), # 1 = read (delivery), 2 = write (publish), 4 = subscribe
):
"""
Called by Mosquitto on every SUBSCRIBE, PUBLISH and message delivery.
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
- App users (app_<uid>): only their assigned serials, only the
command/ack/heartbeat/playback topics — see _app_acl_allowed
- 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)
if username.startswith(APP_USER_PREFIX):
allowed, reason = _app_acl_allowed(username, clientid, topic, acc)
if allowed:
return Response(status_code=200)
logger.info("MQTT app ACL denied for %s (acc=%s, topic=%s): %s", username, acc, topic, reason)
return Response(status_code=403)
# 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)