diff --git a/CLAUDE.md b/CLAUDE.md index c1e64c2..768e359 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -365,6 +365,16 @@ used in a page must have a visible example there first. --- +## MQTT Auth (devices + phone-app users) + +Read `docs/mqtt-app-user-auth.md` before touching `backend/mqtt/auth.py`, +`backend/mqtt/app_users.py`, or any code that changes a device's `user_list` or a +user's `status`. Any code that edits `user_list` must also update the user's +`device_serials` in the same batch and invalidate the MQTT ACL cache, or app users +lose (or keep) access to the wrong devices. + +--- + ## API Client ```js diff --git a/docs/mqtt-app-user-auth.md b/docs/mqtt-app-user-auth.md new file mode 100644 index 0000000..6e8fbfd --- /dev/null +++ b/docs/mqtt-app-user-auth.md @@ -0,0 +1,144 @@ +# MQTT Authentication for Phone-App Users + +## Why this exists + +The remote phone app (FlutterFlow + Firebase Auth) talks to Vesper boards over MQTT. +We did not want to create an MQTT account per app user, so app users log into +Mosquitto with their **Firebase identity**, and the Console backend decides what +each one may do, based on which devices are assigned to them. + +Mosquitto uses **mosquitto-go-auth** with two backends: + +- **files** (passwd file on the VPS): `admin`, `bonamin`, `NodeRED`. +- **http** (this Console): `POST /mqtt/auth/user` (on CONNECT) and + `POST /mqtt/auth/acl` (on SUBSCRIBE, PUBLISH and every message delivery). + Code: `backend/mqtt/auth.py`, `backend/mqtt/app_users.py`. + +The Mosquitto / go-auth config lives on the VPS, **not in this repo**. + +## Connection contract for the app (read this if you build the app) + +| Setting | Value | +|------------|------------------------------------------------------------------------| +| Username | `app_` | +| Password | The user's current **Firebase ID token** (`getIdToken()`) | +| Client ID | Must start with `app__`, e.g. `app__` | +| Transport | **TLS only** (`mqtts://` 8883 or `wss://`): the password is a bearer token | +| Wildcards | Not allowed (`+` / `#` are denied) | + +Any other client ID is refused on every topic. The prefix rule stops one user from +reusing another user's client ID to kick them off. + +Allowed topics, only for serials assigned to that user: + +| Action | `acc` | Topics | +|---------------------------------|-------|------------------------------------------------------------------------| +| Publish | 2 | `vesper/{serial}/control/command` | +| Subscribe / receive messages | 4 / 1 | `vesper/{serial}/control/ack`, `vesper/{serial}/status/heartbeat`, `vesper/{serial}/status/playback` | + +Everything else is denied (403). + +Token lifetime: Mosquitto checks the password **only at CONNECT**. When the app +reconnects, it must fetch a fresh ID token (`getIdToken()` refreshes an expired one). +A connection stays open past token expiry. Blocking or unassigning still takes effect +on that connection, because every publish or delivery goes through the ACL (within +about 60 s of the go-auth ACL cache). + +## Which field is the device serial + +The serial in `vesper/{serial}/...` is the device doc's **`serial_number`**: it is +flashed into NVS and the firmware uses it as its MQTT id. Legacy docs only have it in +**`device_id`**. `users.service.device_serial_of()` holds that rule; always use it. + +## How access is decided + +- `users/{doc}.device_serials: [string]` lists the serials a user may reach. + It mirrors the devices' `user_list` and is written in the **same atomic Firestore + batch** by every Console path that edits `user_list`: + - `users.service.assign_device` / `unassign_device` + - `POST` / `DELETE /api/devices/{id}/user-list` (device Manage tab) + - `PUT /api/devices/{id}` when the body contains `user_list` + (`devices.service.update_device`) +- Users are resolved by the **`uid` field** (a query), **never by doc ID**. Console- + created users get random doc IDs (`.add()`); FlutterFlow uses the uid as the doc ID. +- A user is refused when `status == "blocked"`. +- Lookups are cached in-process for 60 s (`mqtt/app_users.py`). Assign/unassign, + update, block/unblock and delete call `invalidate(uid)`. If you add a new code path + that changes `user_list`, `device_serials` or `status`, it must do the same. + The cache is per process: if uvicorn ever runs multiple workers, invalidation only + reaches one of them (entries still expire after 60 s). +- `app_` usernames never go through the device HMAC or the legacy password. + +## Legacy "vesper" password + +Boards on pre-HMAC firmware still use the shared password `vesper`. +`MQTT_ALLOW_LEGACY_PASSWORD` (default `true`) controls it. It is only accepted for +device-shaped usernames. Each use is logged at WARNING, once per board per hour: + + MQTT legacy password accepted for — board still on pre-HMAC firmware + +When those lines stop appearing, set `MQTT_ALLOW_LEGACY_PASSWORD=false`. + +## Rollout checklist + +1. Deploy the backend. +2. **Backfill** `device_serials` for existing assignments (run it right after the deploy): + + cd backend + python scripts/backfill_user_device_serials.py # dry run, writes nothing + python scripts/backfill_user_device_serials.py --apply # writes + + It only writes the `device_serials` field on `users` docs. It is idempotent: a second + run finds nothing to change. Until it runs, the Console shows existing users with no + devices, and app users are denied everything. +3. **go-auth config on the VPS** (R = required, r = recommended): + + auth_opt_backends files, http # R files first, then http + auth_opt_http_host # R + auth_opt_http_port 8000 # R + auth_opt_http_getuser_uri /mqtt/auth/user # R + auth_opt_http_aclcheck_uri /mqtt/auth/acl # R + auth_opt_http_params_mode form # R endpoints read form fields; go-auth's default is json + auth_opt_http_response_mode status # r + auth_opt_http_method POST # r + auth_opt_http_timeout 5 # r + auth_opt_disable_superuser true # r no superuser endpoint exists or is needed + auth_opt_cache true # r + auth_opt_cache_type go-cache # r + auth_opt_auth_cache_seconds 60 # r + auth_opt_acl_cache_seconds 60 # r + auth_opt_auth_jitter_seconds 10 # r + auth_opt_acl_jitter_seconds 10 # r + allow_anonymous false # R + +4. **Check the files ACL file** (`auth_opt_files_acl_path`). go-auth allows a request if + *any* backend allows it, so general rules in that file apply to app users too and + bypass the per-device ACL. Only lines under a `user admin` / `user bonamin` / + `user NodeRED` block are safe. A `pattern ...` line, or a `topic ...` line placed + before the first `user` line, applies to every user. Such lines must not cover + `vesper/...`. +5. **Enable a TLS listener** in Mosquitto for the app (certificate on the VPS). + +## Known gaps / decisions + +- **FlutterFlow writes to `user_list`.** If the app changes `user_list` directly in + Firestore (e.g. a claim flow), it must also update `device_serials` the same way + (ArrayUnion / ArrayRemove of the serial on the user doc). Otherwise that user + cannot reach the device over MQTT. The app owner will handle this in the app. +- **The `device_users` subcollection** on devices (older way of recording device users, + read first by `devices.service.get_device_users`) is **intentionally ignored** by + the backfill and the MQTT ACL. Left as is by decision. +- The two Console assignment paths store `user_list` entries in different formats + (path strings vs DocumentReferences). This predates this work; all readers accept both. +- ACL denies are logged at INFO, which is not shown by default. Auth denies and + legacy-password logins are WARNING. + +## Tests + + cd backend + pip install pytest # not in requirements.txt + python -m pytest tests + +`tests/test_mqtt_auth.py` covers the auth and ACL endpoints. +`tests/test_device_serials_sync.py` covers the device PUT sync. +Firebase and Firestore are mocked.