# 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$$$

$$` 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, `.`. 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.