""" 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). - 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). """ import hmac import hashlib from fastapi import APIRouter, Form, Response from config import settings router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"]) LEGACY_PASSWORD = "vesper" # Users authenticated via passwd file (go-auth file backend). # If they somehow reach the HTTP ACL endpoint, grant full access. SUPERUSERS = {"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 @router.post("/user") async 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" Password = HMAC-derived (new firmware) or "vesper" (legacy firmware) Note: bonamin and NodeRED authenticate via the go-auth passwd file backend and never reach this endpoint. """ if _is_valid_password(username, password): return Response(status_code=200) return Response(status_code=403) @router.post("/acl") async 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)