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>
6.5 KiB
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(inWebConfig,musicmouse/config.py). It is required whenever thewebsection is present. Follow the repo's existing convention for opting out of something important (seeSIMULATEforserial_portandalsa_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>usinghashlib.scryptfrom 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 withhmac.compare_digest. - Add a CLI subcommand to set it. For example,
python -m musicmouse --config config.yml --set-passwordprompts twice withgetpass, hashes, and writes the key back intoconfig.ymlusing the existing round-trip helpers inmusicmouse/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/loginwith{"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. NotSecure: the device is served over plain HTTP on the LAN, and aSecurecookie 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 currentpassword_hash, so changing the password logs every browser out without extra bookkeeping. Keep the secret in a file undergeneral.library.cache(for examplesession-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/logoutclears the cookie.GET /api/authreturns 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 inbuild_router(or a small middleware), not route by route. The websocket checks the cookie beforeaccept()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 dedicatedUnauthorizederror.fetchOrNullOn404/fetchOrNullOnErrormust 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/stateor/api/librarycall, or the websocket, reports unauthorized. On success, reload the library, the state and the websocket. The simplest correct way islocation.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/apistate; check it.
Tests (backend)
- Every existing web test goes through the API. Give the test client fixture in
tests/test_web.py(andtest_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
/apiroute 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_hashinvalidates existing cookies. "disabled"lets everything through and logs the warning.- A missing key is a config error.
- The
--set-passwordcommand writes a hash that verifies and keeps the file's comments.
- Each
- 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.