Authentication (01), one input policy for skip/mute/volume steps (04), the firmware's interleaved serial frames (05), podcast as an album kind (06), and eight smaller cleanups (07). Each says why, what was decided, the design, the tests and when it is done. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
6.5 KiB
Markdown
122 lines
6.5 KiB
Markdown
# Task: password-protect the backend's HTTP API
|
|
|
|
## Why
|
|
|
|
The backend's HTTP API has no authentication and listens on `0.0.0.0`. The worst part
|
|
is the room page's Home Assistant proxy
|
|
(`python-backend/musicmouse/services/web/api.py`, `_proxy` and
|
|
`POST /api/ha/services/{domain}/{service}`): it forwards *any* service call on *any*
|
|
entity with the stored long-lived HA token. Anything on the LAN can unlock a lock,
|
|
disarm an alarm or restart Home Assistant through it. Parent mode (`/api/settings`,
|
|
`/api/remote/mapping`, `/api/timer`) is only hidden, not protected.
|
|
|
|
## What the owner decided
|
|
|
|
A browser authenticates **once** with a password, and then stays logged in for about
|
|
**a year**. The config stores only a **hash** of the password, never the password
|
|
itself. Use this design; do not substitute another one (no entity allow-list instead,
|
|
no per-endpoint tokens, no OAuth).
|
|
|
|
## Design
|
|
|
|
### Config
|
|
|
|
- Add `general.web.password_hash` (in `WebConfig`, `musicmouse/config.py`). It is
|
|
**required** whenever the `web` section is present. Follow the repo's existing
|
|
convention for opting out of something important (see `SIMULATE` for `serial_port`
|
|
and `alsa_device`): the literal value `"disabled"` turns authentication off, logs a
|
|
warning on **every** boot, and is the only way to run without it. A missing key is a
|
|
config error. Config errors already say which key is wrong.
|
|
- Hash format: `scrypt$<n>$<r>$<p>$<salt b64>$<hash b64>` using `hashlib.scrypt` from
|
|
the standard library. No new dependency. Choose parameters that verify in well under
|
|
a second on a Raspberry Pi 4 (for example n=2**14, r=8, p=1), and measure on the
|
|
desktop to sanity-check. Verify with `hmac.compare_digest`.
|
|
- Add a CLI subcommand to set it. For example,
|
|
`python -m musicmouse --config config.yml --set-password` prompts twice with
|
|
`getpass`, hashes, and writes the key back into `config.yml` using the existing
|
|
round-trip helpers in `musicmouse/services/web/settings.py` (`load_document`,
|
|
`atomic_write`), so comments survive. Exit afterwards and start nothing else.
|
|
- Document the key and the command in `python-backend/config.yml.example`.
|
|
|
|
### Sessions
|
|
|
|
- `POST /api/auth/login` with `{"password": "..."}`. On success it sets a cookie and
|
|
returns 204; on failure it returns 401 after a fixed delay of about 1 s (cheap brute-force
|
|
damping; no lockout state needed).
|
|
- The cookie (`mm_session`): `HttpOnly`, `SameSite=Strict`, `Path=/`,
|
|
`Max-Age` = 365 days. **Not** `Secure`: the device is served over plain HTTP on the
|
|
LAN, and a `Secure` cookie would never be sent back.
|
|
- The cookie value is a stateless signed token, `<expiry unix seconds>.<HMAC-SHA256>`.
|
|
The HMAC key is derived from a persistent random secret *and* the current
|
|
`password_hash`, so **changing the password logs every browser out** without extra
|
|
bookkeeping. Keep the secret in a file under `general.library.cache` (for example
|
|
`session-secret`, 32 random bytes, created on first start with mode 0600). Deleting
|
|
the cache logs everyone out, which is acceptable; say so in the docs.
|
|
- Optional, cheap, and nice: re-issue the cookie with a fresh year on any authenticated
|
|
request when fewer than 30 days remain, so a device in daily use never expires.
|
|
- `POST /api/auth/logout` clears the cookie. `GET /api/auth` returns 204 or 401, so the
|
|
front-end can check without side effects.
|
|
|
|
### What is protected
|
|
|
|
- **Every** route under `/api`, including the websocket `/api/ws`, except
|
|
`/api/auth/*`. Implement it once, as a FastAPI dependency on the router in
|
|
`build_router` (or a small middleware), not route by route. The websocket checks the
|
|
cookie *before* `accept()` and closes with code 4401 when it is missing or invalid.
|
|
- **Not** the static front-end files: the login screen has to load.
|
|
- **No localhost exemption.** The kiosk on the device logs in once like everyone else.
|
|
An exemption by peer address breaks silently the day a reverse proxy is put in front
|
|
of it (every request then looks local).
|
|
- MQTT, the IR remote and the serial link are not HTTP and are out of scope.
|
|
- Keep the room page's proxy as it is otherwise. Do not add an entity allow-list in
|
|
this task.
|
|
|
|
### Front-end (`web/`)
|
|
|
|
- `src/api/client.ts`: a 401 from any call raises a dedicated `Unauthorized` error.
|
|
`fetchOrNullOn404` / `fetchOrNullOnError` must not swallow a 401 as "not configured".
|
|
- A login screen: one password field, German copy ("Passwort", "Anmelden", "Falsches
|
|
Passwort"), styled like the existing sheets (`.overlay` / `.sheet`). It must work with
|
|
only a keyboard (the kiosk has no pointer). Show it when the first `/api/state` or
|
|
`/api/library` call, or the websocket, reports unauthorized. On success, reload the
|
|
library, the state and the websocket. The simplest correct way is `location.reload()`.
|
|
- `src/hooks/usePlayerState.ts`: on a 4401 close, stop the reconnect loop and show the
|
|
login screen instead of retrying every 1.5 s.
|
|
- The service worker (`public/sw.js`) must not cache `/api/auth/*`. It already avoids
|
|
`/api` state; check it.
|
|
|
|
## Tests (backend)
|
|
|
|
- Every existing web test goes through the API. Give the test client fixture in
|
|
`tests/test_web.py` (and `test_remote_api.py`, `test_tippen_api.py`) a logged-in
|
|
cookie, so the existing tests stay as they are. Add a separate unauthenticated client
|
|
fixture for the new tests.
|
|
- New tests:
|
|
- Each `/api` route returns 401 without the cookie. Generate the list from the router
|
|
so a new route cannot slip through unprotected.
|
|
- The websocket is refused without the cookie.
|
|
- Login succeeds with the right password and fails with a wrong one.
|
|
- A token past its expiry is rejected.
|
|
- Changing `password_hash` invalidates existing cookies.
|
|
- `"disabled"` lets everything through and logs the warning.
|
|
- A missing key is a config error.
|
|
- The `--set-password` command writes a hash that verifies and keeps the file's
|
|
comments.
|
|
- The fixed failure delay must not slow the suite down: inject it, as the codebase
|
|
injects `Clock`.
|
|
|
|
## Docs to update
|
|
|
|
- `python-backend/README.md`: the "Parent mode" paragraph that says there is no
|
|
authentication; the route table; a short "Authentication" section.
|
|
- `web/README.md`: the "Parent mode" paragraph.
|
|
- `docs/REPO_OVERVIEW.md`: the backend ↔ web paragraph and the config summary.
|
|
- The comment block above the room-control routes in `api.py`, which justifies the open
|
|
proxy with "trusted LAN device".
|
|
|
|
## Done when
|
|
|
|
`scripts/check.sh` passes, and a manual run against `--simulate` with a hash set shows
|
|
the login screen, logs in, survives a backend restart without asking again, and asks
|
|
again after the password is changed.
|