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

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.