Files
musicmouse/docs/agent-tasks/01-web-authentication.md
Martin Bauer 4cb2d085ca Add agent task files for the review's remaining fixes
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>
2026-10-04 10:51:27 +02:00

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 (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.