docs: MQTT app-user auth reference + CLAUDE.md pointer
docs/mqtt-app-user-auth.md records what future sessions (and whoever builds the phone app) need and can't get from the code alone: - app connection contract: username app_<uid>, Firebase ID token as password, client id prefix app_<uid>_, TLS only, allowed topics per acc, - serial field (serial_number, legacy device_id) and the device_serials mirror + every code path that must keep it in sync, - the uid-field lookup rule and ACL cache invalidation, - legacy "vesper" password flag and its log line, - rollout checklist: backfill, go-auth VPS config (required/recommended), files-ACL check, TLS listener, - decisions/gaps: FlutterFlow must sync device_serials itself; the device_users subcollection is intentionally ignored. CLAUDE.md gets a short section pointing agents at it before they touch MQTT auth or anything that edits user_list/status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
## API Client
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|||||||
@@ -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_<firebase_uid>` |
|
||||||
|
| Password | The user's current **Firebase ID token** (`getIdToken()`) |
|
||||||
|
| Client ID | Must start with `app_<firebase_uid>_`, e.g. `app_<uid>_<installId>` |
|
||||||
|
| 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 <serial> — 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 <host reaching the backend> # 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.
|
||||||
Reference in New Issue
Block a user