diff --git a/CHANGELOG.md b/CHANGELOG.md index c045e049..b073bb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,41 @@ accepts both, but the store flags the old spelling as deprecated TLS-terminating proxy needs nothing more: a portless `Host` matches an `https://` page. +### Optional web login + +- The web interface can require a password, **off by default**: a device that + does not set one behaves exactly as before. Set it under **General > + Security**; from then on every page and API route needs a login (a session + cookie, 30 days, kept across restarts) or an API token. Unauthenticated page + loads go to the new `/login` page, HTMX requests get `HX-Redirect` to it, + and API calls get `401` JSON (`AUTH_REQUIRED` / `INVALID_TOKEN`). Wrong + passwords are rate-limited per address (5 a minute, 30 an hour, through the + existing flask-limiter). Log out from the header. Changing the password + signs every other browser out. (`web_interface/auth.py`) +- **API tokens** for Home Assistant, scripts and the MQTT bridge: create, + list and revoke them in the same section, send them as + `Authorization: Bearer `. A token is shown once; only its SHA-256 is + stored. Tokens cannot change login settings. The MQTT bridge takes one as + `ledmatrix_api_token` (or `LEDMATRIX_MQTT_LEDMATRIX_API_TOKEN`, or the + Tools tab); it needs one only when it runs on another machine. +- Always open, login or not: requests from the Pi itself (loopback, without + proxy headers), the Wi-Fi setup flow (`/setup` and the Wi-Fi status, scan + and connect routes) while the Pi is in access-point mode, static files, the + captive-portal probe URLs, and `/api/v3/health`, which then answers only + `{"status": "healthy" | "degraded"}` to a caller that is not logged in. +- The password hash (werkzeug), the token hashes and the cookie-signing key + live in the `web_auth` section of `config/config_secrets.json`. No API + returns them: `GET /api/v3/config/main`, `GET /api/v3/config/secrets` and + the raw JSON editor leave the section out, the raw secrets save keeps the + stored one, a `/config/main` save drops a `web_auth` key, and orphaned-plugin + cleanup no longer treats it as a plugin (`CORE_SECRETS_KEYS`). +- **Lost password:** `sudo python3 scripts/reset_web_password.py` on the Pi + turns login off (`--revoke-tokens` also deletes the tokens), or open the + interface from the Pi itself. +- New routes: `/login`, `/logout`, `GET /api/v3/auth/status`, + `POST /api/v3/auth/password`, `POST /api/v3/auth/disable`, + `GET|POST /api/v3/auth/tokens`, `DELETE /api/v3/auth/tokens/`. + ### Fixes - On-demand no longer restarts a running display. `POST diff --git a/SECURITY.md b/SECURITY.md index 69def687..40a6819c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -61,8 +61,23 @@ Out of scope (please report upstream): LEDMatrix is designed for trusted local networks. Several limitations are intentional rather than vulnerabilities: -- **No web UI authentication.** The web interface assumes the network - it's running on is trusted. Don't expose port 5000 to the internet. +- **Web UI authentication is optional and off by default.** Out of the + box the web interface assumes the network it's running on is trusted. + Setting a password under **General > Security** makes every page and + API route require a login or an API token (`Authorization: Bearer`), + with wrong passwords rate-limited per address + (`web_interface/auth.py`). Deliberately left open even then: requests + from the Pi itself (loopback without proxy headers; a reverse proxy on + the Pi must add `X-Forwarded-For`, or every request it relays counts as + local), the Wi-Fi setup flow while the Pi is in access-point mode, + static files, and a status-only `/api/v3/health`. The password is a + werkzeug hash and tokens are stored as SHA-256, in + `config/config_secrets.json`, which no API returns. There is no TLS: + over plain HTTP the password and tokens cross the LAN in the clear, so + still don't expose port 5000 to the internet; put a TLS reverse proxy + or a VPN in front for remote access. Anyone with shell access to the Pi + can turn login off (`scripts/reset_web_password.py`), which is the + documented recovery path. "Trusted network" does not mean "trusted websites", though: any page a LAN user opens could make their browser POST to the Pi. So the interface refuses a `POST`/`PUT`/`PATCH`/`DELETE` whose `Origin` (or diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 6f08fb65..0fbc2411 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -29,6 +29,28 @@ API; call it server-side instead. Behind a reverse proxy, pass the original `Host` through, port included (nginx: `proxy_set_header Host $http_host;`; `$host` drops the port) -- `X-Forwarded-Host` is not read. +**Authentication (optional, off by default).** With no web password set, +nothing below needs credentials. Once one is set (General > Security, or +[`POST /auth/password`](#web-login-and-api-tokens)), every route needs a login +session or an API token: + +```bash +curl -H "Authorization: Bearer lmx_..." http://your-pi-ip:5000/api/v3/display/current +``` + +Without either, an API route answers `401` with +`{"status": "error", "error_code": "AUTH_REQUIRED", "message": ...}`, a +`WWW-Authenticate: Bearer realm="LEDMatrix"` header and the login page's URL +in `X-LEDMatrix-Login`; an unknown or revoked token gets `"error_code": +"INVALID_TOKEN"`. Browser page loads are redirected to `/login` instead, and +HTMX requests get `401` with `HX-Redirect: /login?...`. Never asked for +credentials: requests from the Pi itself (loopback, with no `X-Forwarded-For`, +`X-Real-IP`, `Forwarded` or `X-Forwarded-Host` header), `/static/*`, the +captive-portal probe URLs, `/login`, `/api/v3/health` (status only, see +[Health Check](#health-check)), and -- only while the Pi is in access-point +mode -- `/setup`, `GET /wifi/status`, `GET /wifi/scan` and +`POST /wifi/connect`. + ## Table of Contents - [Configuration](#configuration) @@ -46,6 +68,7 @@ API; call it server-side instead. Behind a reverse proxy, pass the original - [Health and Status](#health-and-status) - [Schedule (dim/power)](#schedule-dimpower) - [Integrations](#integrations) +- [Web login and API tokens](#web-login-and-api-tokens) - [Plugin-specific endpoints](#plugin-specific-endpoints) - [Starlark Apps](#starlark-apps) @@ -224,7 +247,10 @@ times `07:00`-`23:00`. At least one day must be enabled. Retrieve `config/config_secrets.json` with every set value replaced by eight bullet characters (`"••••••••"`). Empty values and `YOUR_*` placeholders -are returned as-is, so a client can tell "set" from "not set". +are returned as-is, so a client can tell "set" from "not set". The +`web_auth` section (the web login's password hash, API-token hashes and +cookie key) is left out entirely; [Get Main Configuration](#get-main-configuration) +leaves it out too. **Response**: ```json @@ -249,7 +275,8 @@ Replace `config/config.json` with the JSON body (advanced use only). Save the secrets file (advanced use only). Masked values (`"••••••••"`) and blank strings in the body are dropped, and the rest is merged onto the stored secrets, so posting back the GET response unchanged changes nothing. -A secret cannot be cleared by blanking it here. +A secret cannot be cleared by blanking it here. A `web_auth` key in the body +is ignored; the stored login settings are kept. --- @@ -1963,6 +1990,10 @@ Health of the web interface, display service, config file, plugin system and display snapshot. `data.status` is `healthy` or `degraded`, with `data.services` and `data.checks`. +Open even when the web login is on, for uptime monitors; a caller that is not +logged in (and has no token) then gets only `{"status": "success", "data": +{"status": "healthy" | "degraded"}}`. + ### Hardware Status **GET** `/api/v3/hardware/status` @@ -2028,20 +2059,100 @@ enabled. Home Assistant MQTT bridge service state and settings: `data.service`, `data.config_exists`, `data.config_path`, `data.config` (password -omitted), `data.password_set`, `data.env_override_prefix`. +omitted), `data.password_set`, `data.api_token_set`, `data.env_override_prefix`. **PUT** `/api/v3/integrations/mqtt-bridge/config` Write `integrations/mqtt_bridge/bridge_config.json`. Only the keys you send change. The password is write-only: omit `mqtt_password` to keep it, send a -value to replace it, or send `"clear_password": true`. A password with +value to replace it, or send `"clear_password": true`. The web-login API token +the bridge sends (`ledmatrix_api_token`, needed only when login is on and the +bridge runs on another machine) is write-only the same way, cleared with +`"clear_api_token": true`. A password with `mqtt_tls` off is refused unless `allow_insecure_mqtt` is true. Returns -`data.password_set` and `data.restart_required` (the bridge must be -restarted to pick up changes). See +`data.password_set`, `data.api_token_set` and `data.restart_required` (the +bridge must be restarted to pick up changes). See [integrations/mqtt_bridge/README.md](../integrations/mqtt_bridge/README.md). --- +## Web login and API tokens + +The optional password and API tokens (`web_auth` in +`config/config_secrets.json`, `web_interface/auth.py`). No route here returns +the password hash, a token hash or the cookie key. A request authenticated by +an API token gets `403` `TOKEN_NOT_ALLOWED` from every route in this section: +tokens are for integrations, not for changing who can log in. Wrong current +passwords (`403` `WRONG_PASSWORD`) count against the same per-address limit as +the login page: 5 a minute, 30 an hour, then `429`. + +Lost password: run `sudo python3 scripts/reset_web_password.py` on the Pi. + +### Login status + +**GET** `/api/v3/auth/status` + +```json +{ + "status": "success", + "data": { + "enabled": true, + "signed_in": true, + "access": "session", + "min_password_length": 8, + "tokens": [ + {"id": "3f9c1a2b4d5e6f70", "name": "Home Assistant", "prefix": "lmx_Ab3d", + "created_at": "2026-09-29T20:14:03+00:00"} + ] + } +} +``` + +`access` is how this request got in: `open` (login off), `session`, +`localhost`, `ap-setup` or `token`. + +### Set or change the password + +**POST** `/api/v3/auth/password` + +Body: `{"new_password": "...", "current_password": "..."}`. +`current_password` is required once login is on. At least 8 characters, no +leading or trailing space (`400` `WEAK_PASSWORD`). Setting the first password +turns login on. Every existing login session ends; the caller's own browser +is signed in again with the answer. + +### Turn login off + +**POST** `/api/v3/auth/disable` + +Body: `{"current_password": "..."}`. Removes the password; API tokens are +kept (and are needed again if login is turned back on). + +### API tokens + +**GET** `/api/v3/auth/tokens` — `data.tokens`, as in the status answer. + +**POST** `/api/v3/auth/tokens` — body `{"name": "Home Assistant"}` (1-60 +characters). Answers `201` with `data.token`, the token itself (`lmx_` plus 43 +characters), and `data.record`. **The token is never shown again**; only its +SHA-256 is stored. At most 50 tokens. + +**DELETE** `/api/v3/auth/tokens/` — revoke; it stops working on the next +request. `404` for an unknown id. + +Send a token as `Authorization: Bearer `. + +### Login page + +`GET /login` shows the login form (and redirects home when login is off or +this browser is already signed in); `POST /login` with a form field `password` +(and optional `next`, a path on this server) signs in and redirects to `next`, +or answers `401` with the form again. `POST /logout` ends the session. Both +are outside `/api/v3` and go through the cross-site check like every other +`POST`. + +--- + ## Plugin-specific endpoints A handful of endpoints belong to individual plugins. The music plugin's diff --git a/docs/WEB_INTERFACE_GUIDE.md b/docs/WEB_INTERFACE_GUIDE.md index f42cc432..23a249e5 100644 --- a/docs/WEB_INTERFACE_GUIDE.md +++ b/docs/WEB_INTERFACE_GUIDE.md @@ -130,6 +130,34 @@ Configure basic system settings: Click **Save** to write changes to `config/config.json`. Most changes require a display service restart from **Overview**. +Below the settings, the **Security** section (its own buttons, not the Save +button) controls the optional login: + +- **Web interface password** — off by default. Setting one turns login on: + browsers on your network then see a login page, and stay logged in for 30 + days (across restarts). The browser you set it from stays logged in. + Changing the password logs every other browser out. **Turn login off** + needs the current password. A **Log out** button appears in the header + while you are logged in. Five wrong passwords in a minute (or 30 in an + hour) from one address make it wait. +- **API tokens** — for Home Assistant, scripts, or the MQTT bridge on another + machine. Give it a name, click **Create token**, and copy the token right + away: it is shown once. Revoke it here when it is no longer needed. +- Never asked for a password: a browser on the Pi itself, and the Wi-Fi setup + page while the Pi is in access-point mode (so you can always get it back on + a network). + +**Forgot the password?** SSH into the Pi and run: + +```bash +sudo python3 ~/LEDMatrix/scripts/reset_web_password.py +``` + +(use the folder LEDMatrix is installed in). Login is off again right away, +no restart needed, and you can set a new password. API tokens are kept; add +`--revoke-tokens` to delete them too. Alternatively, open +`http://localhost:5000` in a browser on the Pi itself. + ### Display Tab Configure your LED matrix hardware: @@ -346,6 +374,14 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at - `POST /api/v3/plugins/install` — Install a plugin from the store - `POST /api/v3/plugins/install-from-url` — Install a plugin from a GitHub URL +If the optional login is on, send an API token (General > Security): + +```bash +curl -H "Authorization: Bearer lmx_..." http://your-pi-ip:5000/api/v3/display/current +``` + +Scripts running on the Pi itself need no token. + **Note:** See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete API documentation. --- @@ -408,9 +444,17 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at ## Security Considerations **Network Access:** -- The interface is accessible to anyone on your local network -- No authentication is currently implemented -- Recommended for trusted networks only +- By default the interface is accessible to anyone on your local network +- An optional password (General > Security) makes every page and API call + need a login or an API token; see [General Tab](#general-tab). Requests + from the Pi itself and the Wi-Fi setup flow in access-point mode stay open, + and `/api/v3/health` answers only its overall status without a login +- The interface speaks plain HTTP, so the password and tokens cross your + network unencrypted: still recommended for trusted networks only +- Behind a reverse proxy **on the Pi**, make it send `X-Forwarded-For` + (nginx: `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`). + Without it every proxied request looks like it comes from the Pi itself, + which is never asked to log in **Other websites:** - A web page you open elsewhere could otherwise make your browser send diff --git a/integrations/mqtt_bridge/README.md b/integrations/mqtt_bridge/README.md index 555c6364..14ca9ae4 100644 --- a/integrations/mqtt_bridge/README.md +++ b/integrations/mqtt_bridge/README.md @@ -96,6 +96,13 @@ environment as `LEDMATRIX_MQTT_` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say), which keeps a broker password out of a file on disk — put it in a systemd drop-in with `Environment=` or `EnvironmentFile=` instead. +**Web login.** If the web interface's optional login is on (General > +Security), a bridge running on the Pi itself still needs nothing: requests from +the Pi are never asked to log in. A bridge on another machine needs an API +token: create one under General > Security and set `"ledmatrix_api_token"` +(or `LEDMATRIX_MQTT_LEDMATRIX_API_TOKEN`, or the token field in the Tools tab's +bridge settings). It is sent as `Authorization: Bearer `. + Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips certificate verification and exists only for a self-signed broker on a trusted LAN; it logs a warning when used. diff --git a/integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py b/integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py index a2199a63..895dffa1 100644 --- a/integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py +++ b/integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py @@ -66,6 +66,10 @@ DEFAULTS = { "mqtt_tls": False, "mqtt_tls_insecure": False, "ledmatrix_api_base": "http://localhost:5000", + # Only needed when the web interface's optional login is on AND the bridge + # reaches it from another machine: requests from the Pi itself never need + # one. Create it under General > Security; sent as a Bearer token. + "ledmatrix_api_token": None, "request_timeout": 15, "on_demand_duration": None, "log_level": "INFO", @@ -125,10 +129,13 @@ class LEDMatrixClient: """ def __init__(self, api_base: str, timeout: int = 15, - session: Optional[requests.Session] = None): + session: Optional[requests.Session] = None, + api_token: Optional[str] = None): self.api_base = api_base.rstrip("/") self.timeout = timeout self.session = session or requests.Session() + if api_token: + self.session.headers["Authorization"] = f"Bearer {api_token}" def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]: url = f"{self.api_base}/api/v3{path}" @@ -403,7 +410,8 @@ class Bridge: self.status_topic = f"{self.command_topic}/status" self.state_topic = f"{self.command_topic}/state" self.availability_topic = f"{self.command_topic}/availability" - self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"]) + self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"], + api_token=config.get("ledmatrix_api_token") or None) self.handler = CommandHandler(self.client, config.get("on_demand_duration")) self._stop = threading.Event() self._mqtt = None diff --git a/scripts/README.md b/scripts/README.md index 216e6282..3c4710f1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -37,6 +37,7 @@ display; **diagnostic** — run by hand on a Pi when something is wrong. | `prove_security.py` | keep | Security property checks run by pre-commit | | `render_bench.py` | diagnostic | Benchmarks the render loop against the panel's real refresh rate on a synthetic strip | | `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG | +| `reset_web_password.py` | keep | Turns the optional web login off when the password is lost (`sudo python3 scripts/reset_web_password.py`; docs/WEB_INTERFACE_GUIDE.md) | | `run_plugin_tests.py` | dev-only | Discovers and runs plugin test suites | | `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly | | `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in | diff --git a/scripts/reset_web_password.py b/scripts/reset_web_password.py new file mode 100644 index 00000000..d122f401 --- /dev/null +++ b/scripts/reset_web_password.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Turn the web interface's optional login off, for when the password is lost. + +Removes the password (and the key that signs login cookies) from the +``web_auth`` section of ``config/config_secrets.json``. The interface is then +open again, as it is before a password is ever set, and a new password can be +set under General > Security. API tokens are kept unless ``--revoke-tokens`` +is given. Nothing else in the secrets file is touched, and the web service +does not need a restart: it notices the change on the next request. + +Run it on the Pi, from any directory: + + sudo python3 ~/LEDMatrix/scripts/reset_web_password.py + +``sudo`` because the secrets file is not readable by every user. The file +keeps its owner and permissions. + +Another way in without the password: open the interface from the Pi itself +(http://localhost:5000). Requests from the Pi are never asked to log in. +""" +import argparse +import json +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.config_manager_atomic import atomic_write_json # noqa: E402 + +SECTION = 'web_auth' # web_interface/auth.py; not imported to keep Flask out +LOGIN_KEYS = ('password_hash', 'session_secret', 'password_set_at') + + +def reset(settings_file: Path, revoke_tokens: bool = False) -> str: + """Clear the login from ``settings_file`` (config_secrets.json). + + Returns what was done. The message names the file and counts tokens; it + never includes anything read from the file. + """ + if not settings_file.exists(): + return f'{settings_file} does not exist, so no password is set. Nothing to do.' + with open(settings_file, 'r', encoding='utf-8') as fh: + data = json.load(fh) + if not isinstance(data, dict): + raise ValueError(f'{settings_file} does not hold a JSON object') + + section = data.get(SECTION) + if not isinstance(section, dict): + return 'No web login password is set. Nothing to do.' + + had_password = bool(section.get('password_hash')) + token_count = len(section.get('tokens') or []) + for key in LOGIN_KEYS: + section.pop(key, None) + if revoke_tokens: + section.pop('tokens', None) + if section: + data[SECTION] = section + else: + data.pop(SECTION, None) + + if not had_password and not (revoke_tokens and token_count): + return 'No web login password is set. Nothing to do.' + atomic_write_json(settings_file, data) + + done = [] + if had_password: + done.append('Web login is off: the interface opens without a password. ' + 'Set a new one under General > Security.') + if revoke_tokens and token_count: + done.append(f'Revoked {token_count} API token(s).') + elif token_count: + done.append(f'{token_count} API token(s) kept (use --revoke-tokens to remove them).') + return ' '.join(done) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description='Turn the LEDMatrix web login off (lost password recovery).') + parser.add_argument('--secrets', dest='settings_file', type=Path, + default=PROJECT_ROOT / 'config' / 'config_secrets.json', + help='secrets file (default: config/config_secrets.json ' + 'in this LEDMatrix checkout)') + parser.add_argument('--revoke-tokens', action='store_true', + help='also delete every API token') + args = parser.parse_args(argv) + settings_file = args.settings_file + try: + outcome = reset(settings_file, revoke_tokens=args.revoke_tokens) + except PermissionError: + print(f'Permission denied reading or writing {settings_file}. Run it with sudo.', + file=sys.stderr) + return 1 + except (OSError, ValueError) as err: + print(f'Could not reset the web login: {err}', file=sys.stderr) + return 1 + print(outcome) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/core_config_keys.py b/src/core_config_keys.py index 8d8a07fa..30eba5f5 100644 --- a/src/core_config_keys.py +++ b/src/core_config_keys.py @@ -43,11 +43,14 @@ CORE_CONFIG_KEYS = frozenset({ }) #: Top-level keys of ``config_secrets.json`` that belong to the core rather than -#: to a plugin: the GitHub token the Plugin Store reads, and the historical -#: ``youtube`` section. Plugin secrets are namespaced by plugin id, so anything -#: deciding whether a secrets section is a plugin's needs this as well as -#: ``CORE_CONFIG_KEYS``. +#: to a plugin: the GitHub token the Plugin Store reads, the historical +#: ``youtube`` section, and ``web_auth`` (the optional web login's password +#: hash and API-token hashes, web_interface/auth.py) -- which orphan-plugin +#: cleanup would otherwise delete, logging everyone out. Plugin secrets are +#: namespaced by plugin id, so anything deciding whether a secrets section is a +#: plugin's needs this as well as ``CORE_CONFIG_KEYS``. CORE_SECRETS_KEYS = frozenset({ 'github', 'youtube', + 'web_auth', }) diff --git a/src/plugin_system/state_reconciliation.py b/src/plugin_system/state_reconciliation.py index 13a23123..6f63c362 100644 --- a/src/plugin_system/state_reconciliation.py +++ b/src/plugin_system/state_reconciliation.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path -from src.core_config_keys import CORE_CONFIG_KEYS +from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS from src.plugin_system.plugin_dirs import PluginDirectoryIndex from src.plugin_system.state_manager import PluginStateManager from src.logging_config import get_logger @@ -293,11 +293,11 @@ class StateReconciliation: # Top-level config keys that are NOT plugins. The core keys come from the # shared list in src/core_config_keys.py -- a private copy here missed # #581's 'auto_update' and reported it as a plugin missing from disk. - # 'github'/'youtube' are the historical secrets-file keys. The secrets file + # CORE_SECRETS_KEYS are the core's own secrets-file keys. The secrets file # itself is read at run time too (ignored_config_keys): load_config() merges # it in, and naming its keys one by one let a 'data' key become a phantom # plugin permanently reported as "in config but not on disk". - _SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'}) + _SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | CORE_SECRETS_KEYS def _get_config_state(self) -> Dict[str, Dict[str, Any]]: """Get plugin state from config file.""" diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index cb2a37f6..83a5d66d 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -1,4 +1,54 @@ [ + [ + "/api/v3/auth/disable", + "api_v3.disable_web_login", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/auth/password", + "api_v3.set_web_password", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/auth/status", + "api_v3.get_web_auth_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/auth/tokens", + "api_v3.create_api_token", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/auth/tokens", + "api_v3.list_api_tokens", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/auth/tokens/", + "api_v3.revoke_api_token", + [ + "DELETE", + "OPTIONS" + ] + ], [ "/api/v3/backup/", "api_v3.backup_delete", diff --git a/test/test_web_auth.py b/test/test_web_auth.py new file mode 100644 index 00000000..c318c016 --- /dev/null +++ b/test/test_web_auth.py @@ -0,0 +1,661 @@ +"""The optional web login: off by default, and complete when it is on. + +web_interface/auth.py adds a password (a login session) and API tokens +(``Authorization: Bearer``) on top of the always-on Origin guard. With no +password stored nothing may change for anyone. With one, every page and API +route needs a session or a token, except requests from the Pi itself, the +Wi-Fi setup flow in access-point mode, static files and a minimal health +answer. The password hash, token hashes and cookie key must never leave the +process through any API. + +Flask's test client reports REMOTE_ADDR 127.0.0.1, which the login exempts, +so every "someone on the LAN" client here sets a LAN address explicitly. +""" +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Blueprint, Flask, jsonify + +from src.config_manager import ConfigManager +from web_interface import auth as web_auth +from web_interface import origin_guard +from test._api_v3_test_helpers import api_v3_module # noqa: F401 - fixture + +REPO = Path(__file__).resolve().parent.parent +LAN = '192.168.1.50' +PASSWORD = 'correct horse battery' + + +def _flask_limiter(): + try: + from flask_limiter import Limiter + from flask_limiter.util import get_remote_address + except ImportError: + return None + return Limiter, get_remote_address + + +@pytest.fixture +def config_manager(tmp_path): + (tmp_path / 'config.json').write_text(json.dumps({'display': {'hardware': {'brightness': 50}}})) + (tmp_path / 'secrets.json').write_text(json.dumps({'github': {'api_token': 'ghp_realtoken'}})) + return ConfigManager(config_path=str(tmp_path / 'config.json'), + secrets_path=str(tmp_path / 'secrets.json')) + + +def build(config_manager, api_v3_module, ap_mode=False, limiter=True): + """An app shaped like the real one: api_v3, a page, the setup page, the + Origin guard and the login hook, in the real app's order.""" + app = Flask(__name__, + template_folder=str(REPO / 'web_interface' / 'templates'), + static_folder=str(REPO / 'web_interface' / 'static')) + app.config['TESTING'] = True + app.secret_key = 'per-process-random-in-the-real-app' + api_v3_module.api_v3.config_manager = config_manager + lim = None + if limiter and _flask_limiter(): + Limiter, get_remote_address = _flask_limiter() + lim = Limiter(app=app, key_func=get_remote_address, storage_uri='memory://') + app.register_blueprint(api_v3_module.api_v3, url_prefix='/api/v3') + # Stand-ins for routes whose real bodies need hardware or nmcli. + app.view_functions['api_v3.get_wifi_status'] = lambda: jsonify({'status': 'success'}) + app.view_functions['api_v3.scan_wifi_networks'] = lambda: jsonify({'status': 'success'}) + + pages = Blueprint('pages_v3', __name__) + + @pages.route('/') + def index(): + return 'the interface' + + @pages.route('/partials/') + def load_partial(name): + return 'a partial' + + @pages.route('/setup') + def captive_setup(): + return 'wifi setup' + + app.register_blueprint(pages) + origin_guard.init_app(app) + ap = {'active': ap_mode} + web_auth.init_app(app, config_manager, limiter=lim, + is_ap_mode_active=lambda: ap['active']) + app.ap = ap + app.limiter = lim + return app + + +def lan_client(app, address=LAN): + client = app.test_client() + client.environ_base['REMOTE_ADDR'] = address + return client + + +def secrets_on_disk(config_manager): + with open(config_manager.get_secrets_path()) as fh: + return json.load(fh) + + +def enable(app, password=PASSWORD): + """Turn login on the way the Security section does, from the LAN.""" + client = lan_client(app) + r = client.post('/api/v3/auth/password', json={'new_password': password}) + assert r.status_code == 200, r.get_json() + return client + + +# --- Off by default ----------------------------------------------------------- + +class TestOffByDefault: + def test_nothing_is_stored_and_nothing_is_asked(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + assert web_auth.get_store(app).is_enabled() is False + c = lan_client(app) + page = c.get('/', headers={'Accept': 'text/html'}) + assert page.status_code == 200 and page.get_data(as_text=True) == 'the interface' + assert c.get('/partials/general', headers={'HX-Request': 'true'}).status_code == 200 + api = c.get('/api/v3/auth/status') + assert api.status_code == 200 + assert api.get_json()['data']['enabled'] is False + # No login cookie is handed out when there is no login. + assert 'Set-Cookie' not in page.headers + assert 'web_auth' not in secrets_on_disk(config_manager) + + def test_the_login_page_just_goes_home(self, config_manager, api_v3_module): + c = lan_client(build(config_manager, api_v3_module)) + r = c.get('/login') + assert r.status_code == 302 and r.headers['Location'] == '/' + + def test_the_health_answer_is_the_full_one(self, config_manager, api_v3_module): + c = lan_client(build(config_manager, api_v3_module)) + data = c.get('/api/v3/health').get_json()['data'] + assert 'services' in data and 'checks' in data + + def test_the_real_app_registers_it(self): + from web_interface.app import app as real_app + assert isinstance(web_auth.get_store(real_app), web_auth.AuthStore) + assert 'ledmatrix_auth.login' in real_app.view_functions + hooks = [f.__name__ for f in real_app.before_request_funcs[None]] + assert '_require_login' in hooks + # After the captive-portal redirect, so AP mode still lands on /setup. + assert hooks.index('_require_login') > hooks.index('captive_portal_redirect') + + +# --- Turned on ------------------------------------------------------------------ + +class TestTurnedOn: + def test_setting_a_password_turns_it_on_and_keeps_that_browser_in( + self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + setter = enable(app) + assert web_auth.get_store(app).is_enabled() + assert setter.get('/', headers={'Accept': 'text/html'}).status_code == 200 + stored = secrets_on_disk(config_manager) + assert stored['github'] == {'api_token': 'ghp_realtoken'} # untouched + assert stored['web_auth']['password_hash'].startswith(('scrypt:', 'pbkdf2:')) + assert PASSWORD not in json.dumps(stored) + + def test_a_page_load_goes_to_the_login_page(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + r = lan_client(app).get('/partials/general?x=1', + headers={'Accept': 'text/html', 'Sec-Fetch-Mode': 'navigate'}) + assert r.status_code == 302 + assert r.headers['Location'] == '/login?next=/partials/general?x%3D1' + + def test_an_htmx_request_gets_hx_redirect(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + r = lan_client(app).get('/partials/general', headers={ + 'HX-Request': 'true', 'HX-Current-URL': 'http://ledpi.local:5000/?tab=general'}) + assert r.status_code == 401 + assert r.headers['HX-Redirect'] == '/login?next=/?tab%3Dgeneral' + + def test_an_api_request_gets_401_json(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + r = lan_client(app).get('/api/v3/auth/tokens') + assert r.status_code == 401 + assert r.get_json()['error_code'] == 'AUTH_REQUIRED' + assert r.headers['WWW-Authenticate'].startswith('Bearer') + assert r.headers['X-LEDMatrix-Login'] == '/login' + + def test_a_post_without_login_is_refused_not_run(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + r = lan_client(app).post('/api/v3/auth/tokens', json={'name': 'sneaky'}) + assert r.status_code == 401 + assert 'tokens' not in secrets_on_disk(config_manager)['web_auth'] + + def test_unknown_paths_do_not_leak_a_404_first(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + assert lan_client(app).get('/api/v3/no-such-thing').status_code == 401 + + +#: next= values that must never send the browser anywhere but '/'. +OFFSITE_NEXT_TARGETS = [ + '//evil.example/x', + '/\\evil.example', + '/\\/evil.example', + '\\\\evil.example', + 'http://evil.example/', + 'https:evil.example', + 'HTTP://evil.example', + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + '/logout', + '/login?next=//evil.example', + # Percent-encoded: a later decode must not turn them into the above. + '/%2F%2Fevil.example', + '/%2fevil.example', + '/%5Cevil.example', + '/%5cevil.example', + '%2F%2Fevil.example', + '%252F%252Fevil.example', + # Browsers drop tabs and newlines inside URLs, so '/\t/' would be '//'. + '/\t/evil.example', + '/\n/evil.example', + '/%09/evil.example', + '/%0D%0A/evil.example', + '/\x7f/evil.example', + ' //evil.example', +] + + +class TestLogin: + def test_the_right_password_logs_in_and_returns_to_next(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + c = lan_client(app) + page = c.get('/login?next=/partials/plugins') + assert page.status_code == 200 + assert 'name="password"' in page.get_data(as_text=True) + r = c.post('/login', data={'password': PASSWORD, 'next': '/partials/plugins'}) + assert r.status_code == 302 and r.headers['Location'] == '/partials/plugins' + assert c.get('/api/v3/auth/tokens').status_code == 200 + assert c.get('/api/v3/auth/status').get_json()['data']['signed_in'] is True + + def test_a_wrong_password_is_refused(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + c = lan_client(app) + r = c.post('/login', data={'password': 'not it'}) + assert r.status_code == 401 + assert 'not right' in r.get_data(as_text=True) + assert c.get('/api/v3/auth/tokens').status_code == 401 + + @pytest.mark.parametrize('target', OFFSITE_NEXT_TARGETS) + def test_next_never_leaves_the_interface(self, config_manager, api_v3_module, target): + app = build(config_manager, api_v3_module) + enable(app) + r = lan_client(app).post('/login', data={'password': PASSWORD, 'next': target}) + assert r.status_code == 302 and r.headers['Location'] == '/' + + @pytest.mark.parametrize('target', OFFSITE_NEXT_TARGETS) + def test_a_get_with_an_offsite_next_redirects_home(self, config_manager, api_v3_module, + target): + # Already signed in: GET /login redirects straight to next. + app = build(config_manager, api_v3_module) + c = enable(app) + c.post('/login', data={'password': PASSWORD}) + r = c.get('/login', query_string={'next': target}) + assert r.status_code == 302 and r.headers['Location'] == '/' + + +@pytest.mark.parametrize('target', OFFSITE_NEXT_TARGETS) +def test_safe_next_refuses_anything_off_this_server(target): + assert web_auth.safe_next(target) == '/' + + +@pytest.mark.parametrize('target', [ + '/', '/partials/plugins', '/?tab=general', '/v3?tab=plugins&x=1', + '/partials/general?x%3D1', '/a%20b', +]) +def test_safe_next_keeps_a_local_path(target): + assert web_auth.safe_next(target) == target + + +@pytest.mark.parametrize('target', [None, 42, '', 'partials/plugins']) +def test_safe_next_refuses_a_non_path(target): + assert web_auth.safe_next(target) == '/' + + def test_logout_ends_the_session(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + c = enable(app) + r = c.post('/logout') + assert r.status_code == 302 and r.headers['Location'] == '/login' + assert c.get('/api/v3/auth/tokens').status_code == 401 + + def test_the_session_survives_a_restart(self, config_manager, api_v3_module): + c = enable(build(config_manager, api_v3_module)) + cookie = c.get_cookie('session') + assert cookie is not None + # A new process: a new random app.secret_key, the same stored key. + restarted = lan_client(build(config_manager, api_v3_module)) + restarted.set_cookie('session', cookie.value) + assert restarted.get('/api/v3/auth/tokens').status_code == 200 + + def test_changing_the_password_signs_other_browsers_out(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + changer = enable(app) + other = lan_client(app) + other.post('/login', data={'password': PASSWORD}) + assert other.get('/api/v3/auth/tokens').status_code == 200 + r = changer.post('/api/v3/auth/password', json={ + 'current_password': PASSWORD, 'new_password': 'a brand new one'}) + assert r.status_code == 200 + assert changer.get('/api/v3/auth/tokens').status_code == 200 + assert other.get('/api/v3/auth/tokens').status_code == 401 + + def test_changing_or_disabling_needs_the_current_password(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + c = enable(app) + r = c.post('/api/v3/auth/password', json={'current_password': 'wrong', + 'new_password': 'another one'}) + assert r.status_code == 403 and r.get_json()['error_code'] == 'WRONG_PASSWORD' + assert c.post('/api/v3/auth/disable', json={'current_password': 'wrong'}).status_code == 403 + assert web_auth.get_store(app).is_enabled() + r = c.post('/api/v3/auth/disable', json={'current_password': PASSWORD}) + assert r.status_code == 200 + assert not web_auth.get_store(app).is_enabled() + assert lan_client(app).get('/api/v3/auth/tokens').status_code == 200 + + def test_a_short_password_is_refused(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + r = lan_client(app).post('/api/v3/auth/password', json={'new_password': 'short'}) + assert r.status_code == 400 and r.get_json()['error_code'] == 'WEAK_PASSWORD' + assert not web_auth.get_store(app).is_enabled() + + +class TestRateLimit: + @pytest.fixture(autouse=True) + def _needs_limiter(self): + if _flask_limiter() is None: + pytest.skip('flask-limiter is not installed (web_interface/requirements.txt)') + + def test_wrong_passwords_are_rate_limited(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + c = lan_client(app) + for _ in range(5): + assert c.post('/login', data={'password': 'guess'}).status_code == 401 + blocked = c.post('/login', data={'password': 'guess'}) + assert blocked.status_code == 429 + assert 'Too many' in blocked.get_data(as_text=True) + # Even the right password waits: that is what makes guessing slow. + assert c.post('/login', data={'password': PASSWORD}).status_code == 429 + # Per address: someone else on the LAN can still log in. + assert lan_client(app, '192.168.1.51').post( + '/login', data={'password': PASSWORD}).status_code == 302 + + def test_successful_logins_do_not_count(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + for _ in range(8): + c = lan_client(app) + assert c.post('/login', data={'password': PASSWORD}).status_code == 302 + + def test_guessing_through_the_settings_routes_is_limited_too( + self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + c = enable(app) + for _ in range(5): + assert c.post('/api/v3/auth/disable', + json={'current_password': 'guess'}).status_code == 403 + assert c.post('/api/v3/auth/disable', + json={'current_password': 'guess'}).status_code == 429 + + +# --- Tokens --------------------------------------------------------------------- + +class TestTokens: + def test_a_token_grants_api_access_until_revoked(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + admin = enable(app) + created = admin.post('/api/v3/auth/tokens', json={'name': 'Home Assistant'}) + assert created.status_code == 201 + body = created.get_json()['data'] + token, record = body['token'], body['record'] + assert token.startswith('lmx_') and record['prefix'] == token[:8] + assert 'hash' not in record + + integration = lan_client(app, '192.168.1.77') + auth = {'Authorization': f'Bearer {token}'} + assert integration.get('/api/v3/health', headers=auth).get_json()['data'].get('checks') is not None + assert integration.get('/', headers=auth).status_code == 200 + + listed = admin.get('/api/v3/auth/tokens').get_json()['data']['tokens'] + assert [t['name'] for t in listed] == ['Home Assistant'] + assert token not in json.dumps(listed) + + assert admin.delete(f"/api/v3/auth/tokens/{record['id']}").status_code == 200 + r = integration.get('/api/v3/auth/status', headers=auth) + assert r.status_code == 401 and r.get_json()['error_code'] == 'INVALID_TOKEN' + + def test_only_a_hash_is_stored(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + admin = enable(app) + token = admin.post('/api/v3/auth/tokens', json={'name': 'script'}).get_json()['data']['token'] + stored = json.dumps(secrets_on_disk(config_manager)) + assert token not in stored + assert web_auth._token_digest(token) in stored + + def test_a_made_up_token_is_refused(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + r = lan_client(app).get('/api/v3/auth/status', + headers={'Authorization': 'Bearer lmx_madeup'}) + assert r.status_code == 401 and r.get_json()['error_code'] == 'INVALID_TOKEN' + + def test_a_token_cannot_change_login_settings(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + admin = enable(app) + token = admin.post('/api/v3/auth/tokens', json={'name': 'ha'}).get_json()['data']['token'] + auth = {'Authorization': f'Bearer {token}'} + integration = lan_client(app, '192.168.1.77') + assert integration.post('/api/v3/auth/tokens', json={'name': 'more'}, + headers=auth).status_code == 403 + assert integration.post('/api/v3/auth/disable', json={'current_password': PASSWORD}, + headers=auth).status_code == 403 + assert web_auth.get_store(app).is_enabled() + + def test_revoking_an_unknown_token_is_a_404(self, config_manager, api_v3_module): + admin = enable(build(config_manager, api_v3_module)) + assert admin.delete('/api/v3/auth/tokens/nope').status_code == 404 + + @pytest.mark.parametrize('name, expected', [ + ('', 'Give the token a name'), + ('x' * (web_auth.MAX_TOKEN_NAME_LENGTH + 1), 'at most'), + ]) + def test_a_bad_token_name_gets_the_fixed_message(self, config_manager, api_v3_module, + name, expected): + admin = enable(build(config_manager, api_v3_module)) + r = admin.post('/api/v3/auth/tokens', json={'name': name}) + assert r.status_code == 400 + assert expected in r.get_json()['message'] + + def test_the_log_never_holds_a_password_token_or_hash( + self, config_manager, api_v3_module, caplog): + app = build(config_manager, api_v3_module) + with caplog.at_level('DEBUG'): + admin = enable(app) + admin.post('/api/v3/auth/password', json={ + 'current_password': PASSWORD, 'new_password': 'another fine phrase'}) + created = admin.post('/api/v3/auth/tokens', json={'name': 'Home Assistant'}) + token = created.get_json()['data']['token'] + record = created.get_json()['data']['record'] + admin.delete(f"/api/v3/auth/tokens/{record['id']}") + logged = caplog.text + assert 'Home Assistant' in logged and record['id'] in logged + for secret in (PASSWORD, 'another fine phrase', token, + web_auth._token_digest(token)): + assert secret not in logged + + +# --- Exemptions ----------------------------------------------------------------- + +class TestExemptions: + def test_the_wifi_setup_flow_is_open_in_ap_mode(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module, ap_mode=True) + enable(app) + phone = lan_client(app, '192.168.4.20') + assert phone.get('/setup').status_code == 200 + assert phone.get('/api/v3/wifi/status').status_code == 200 + assert phone.get('/api/v3/wifi/scan').status_code == 200 + # Only the setup flow: the rest of the interface still needs a login. + assert phone.get('/api/v3/auth/tokens').status_code == 401 + assert phone.get('/', headers={'Accept': 'text/html'}).status_code == 302 + + def test_the_setup_flow_is_not_open_outside_ap_mode(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module, ap_mode=False) + enable(app) + assert lan_client(app).get('/api/v3/wifi/status').status_code == 401 + + def test_requests_from_the_pi_itself_are_open(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + for address in ('127.0.0.1', '::1'): + local = lan_client(app, address) + assert local.get('/api/v3/auth/tokens').status_code == 200 + assert local.get('/', headers={'Accept': 'text/html'}).status_code == 200 + + def test_a_proxy_on_the_pi_does_not_make_everyone_local(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + proxied = lan_client(app, '127.0.0.1') + r = proxied.get('/api/v3/auth/tokens', headers={'X-Forwarded-For': '203.0.113.9'}) + assert r.status_code == 401 + + def test_static_files_are_open(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + enable(app) + assert lan_client(app).get('/static/v3/app.css').status_code == 200 + + def test_health_is_open_but_minimal(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + admin = enable(app) + r = lan_client(app).get('/api/v3/health') + assert r.status_code == 200 + assert set(r.get_json()['data']) == {'status'} + assert 'checks' in admin.get('/api/v3/health').get_json()['data'] + + +# --- The hash never leaves ------------------------------------------------------ + +class TestSecretsNeverLeave: + @pytest.fixture + def enabled(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + admin = enable(app) + token = admin.post('/api/v3/auth/tokens', json={'name': 'ha'}).get_json()['data']['token'] + section = secrets_on_disk(config_manager)['web_auth'] + needles = [section['password_hash'], section['session_secret'], + section['tokens'][0]['hash'], token] + return app, admin, needles + + def test_no_config_or_auth_endpoint_returns_them(self, enabled): + app, admin, needles = enabled + for url in ('/api/v3/config/main', '/api/v3/config/secrets', + '/api/v3/auth/status', '/api/v3/auth/tokens'): + r = admin.get(url) + assert r.status_code == 200, url + text = r.get_data(as_text=True) + for needle in needles: + assert needle not in text, url + assert 'web_auth' not in text, url + # The other secrets are still there (masked), so the strip was targeted. + assert 'github' in admin.get('/api/v3/config/secrets').get_json()['data'] + + def test_the_raw_json_editor_does_not_show_them(self, enabled, config_manager, monkeypatch): + app, _, needles = enabled + from web_interface.blueprints import pages_v3 as pages_module + monkeypatch.setattr(pages_module.pages_v3, 'config_manager', config_manager, raising=False) + with app.test_request_context('/partials/raw-json'): + html = pages_module._load_raw_json_partial() + for needle in needles: + assert needle not in html + assert 'web_auth' not in html + + def test_the_raw_secrets_save_cannot_overwrite_the_login(self, enabled, config_manager): + app, admin, _ = enabled + before = secrets_on_disk(config_manager)['web_auth'] + r = admin.post('/api/v3/config/raw/secrets', json={ + 'github': {'api_token': 'ghp_new'}, + 'web_auth': {'password_hash': 'plaintext-that-matches-nothing'}}) + assert r.status_code == 200 + after = secrets_on_disk(config_manager) + assert after['web_auth'] == before + assert after['github']['api_token'] == 'ghp_new' + + def test_a_main_config_save_cannot_plant_one(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + r = lan_client(app).post('/api/v3/config/main', json={ + 'web_auth': {'password_hash': 'x'}, 'timezone': 'America/Chicago'}) + assert r.status_code == 200, r.get_json() + with open(config_manager.get_config_path()) as fh: + assert 'web_auth' not in json.load(fh) + assert not web_auth.get_store(app).is_enabled() + + def test_orphan_cleanup_keeps_the_section(self, enabled, config_manager): + config_manager.cleanup_orphaned_plugin_configs([]) + assert 'password_hash' in secrets_on_disk(config_manager)['web_auth'] + + +# --- Recovery ------------------------------------------------------------------- + +class TestRecovery: + def _script(self): + sys.path.insert(0, str(REPO / 'scripts')) + try: + import reset_web_password + finally: + sys.path.remove(str(REPO / 'scripts')) + return reset_web_password + + def test_the_reset_script_turns_login_off_and_keeps_the_rest( + self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + admin = enable(app) + admin.post('/api/v3/auth/tokens', json={'name': 'ha'}) + message = self._script().reset(Path(config_manager.get_secrets_path())) + assert 'off' in message + stored = secrets_on_disk(config_manager) + assert 'password_hash' not in stored['web_auth'] + assert 'session_secret' not in stored['web_auth'] + assert len(stored['web_auth']['tokens']) == 1 + assert stored['github'] == {'api_token': 'ghp_realtoken'} + # The running app sees it without a restart. + assert not web_auth.get_store(app).is_enabled() + assert lan_client(app).get('/api/v3/auth/tokens').status_code == 200 + + def test_revoke_tokens_removes_the_section(self, config_manager, api_v3_module): + app = build(config_manager, api_v3_module) + admin = enable(app) + admin.post('/api/v3/auth/tokens', json={'name': 'ha'}) + self._script().reset(Path(config_manager.get_secrets_path()), revoke_tokens=True) + assert 'web_auth' not in secrets_on_disk(config_manager) + + def test_the_script_prints_nothing_from_the_file(self, config_manager, api_v3_module, + capsys): + app = build(config_manager, api_v3_module) + admin = enable(app) + token = admin.post('/api/v3/auth/tokens', json={'name': 'ha'}).get_json()['data']['token'] + secrets_before = json.dumps(secrets_on_disk(config_manager)) + assert self._script().main(['--secrets', config_manager.get_secrets_path()]) == 0 + out = capsys.readouterr() + printed = out.out + out.err + assert 'off' in printed + section = json.loads(secrets_before)['web_auth'] + for secret in (PASSWORD, token, section['password_hash'], section['session_secret'], + 'ghp_realtoken'): + assert secret not in printed + + def test_nothing_to_do_writes_nothing(self, config_manager): + path = Path(config_manager.get_secrets_path()) + before = path.stat().st_mtime_ns + assert 'Nothing to do' in self._script().reset(path) + assert path.stat().st_mtime_ns == before + + +# --- The MQTT bridge ------------------------------------------------------------ + +def test_the_mqtt_bridge_sends_its_token(): + bridge_path = REPO / 'integrations' / 'mqtt_bridge' / 'ledmatrix_mqtt_bridge.py' + import importlib.util + spec = importlib.util.spec_from_file_location('ledmatrix_mqtt_bridge_auth_test', bridge_path) + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except ImportError as e: + pytest.skip(f'bridge dependencies are not installed: {e}') + assert 'ledmatrix_api_token' in module.DEFAULTS + with_token = module.LEDMatrixClient('http://pi:5000', session=MagicMock(headers={}), + api_token='lmx_abc') + assert with_token.session.headers['Authorization'] == 'Bearer lmx_abc' + without = module.LEDMatrixClient('http://pi:5000', session=MagicMock(headers={})) + assert 'Authorization' not in without.session.headers + + +def test_the_bridge_settings_keep_the_token_write_only(tmp_path, monkeypatch, api_v3_module): + import web_interface.blueprints.api_v3.misc as misc + cfg = tmp_path / 'bridge_config.json' + for mod in (misc, api_v3_module): + monkeypatch.setattr(mod, '_MQTT_BRIDGE_CONFIG', cfg, raising=False) + monkeypatch.setattr(mod, '_MQTT_BRIDGE_DIR', tmp_path, raising=False) + app = Flask(__name__) + app.register_blueprint(api_v3_module.api_v3, url_prefix='/api/v3') + c = app.test_client() + url = '/api/v3/integrations/mqtt-bridge' + assert c.put(url + '/config', json={'ledmatrix_api_token': 'lmx_secret'}).status_code == 200 + got = c.get(url).get_json()['data'] + assert got['api_token_set'] is True + assert 'lmx_secret' not in json.dumps(got) + # Saving other fields leaves it alone; clear_api_token removes it. + c.put(url + '/config', json={'mqtt_topic': 'x/y'}) + assert json.loads(cfg.read_text())['ledmatrix_api_token'] == 'lmx_secret' + c.put(url + '/config', json={'clear_api_token': True}) + assert json.loads(cfg.read_text())['ledmatrix_api_token'] is None diff --git a/web_interface/app.py b/web_interface/app.py index 456aa804..09598765 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -64,8 +64,11 @@ config_manager = ConfigManager() # server's own host; requests with neither header (curl, Home Assistant, the # MQTT bridge) are not from a browser and pass. There are no CSRF tokens: # neither the HTMX forms nor the fetch() calls carry one. Anyone who can reach -# the port directly can still use the API, so exposing the UI beyond a trusted -# network still needs real authentication. +# the port directly can still use the API unless the optional login is on: +# web_interface/auth.py (registered below the captive-portal redirect) adds a +# password and API tokens. It is off until a password is set in General > +# Security, and even then leaves requests from the Pi itself and the Wi-Fi +# setup flow in access-point mode open. # Initialize rate limiting (prevent accidental abuse, not security) try: @@ -504,6 +507,8 @@ def captive_portal_redirect(): '/connecttest.txt', # Windows detection '/success.txt', # Firefox detection '/favicon.ico', # Favicon + '/login', # Optional web login (web_interface/auth.py) + '/logout', ] for allowed_path in allowed_paths: @@ -513,6 +518,13 @@ def captive_portal_redirect(): # Redirect to lightweight captive portal setup page (not the full UI) return redirect(url_for('pages_v3.captive_setup'), code=302) +# Optional login (off until a password is set in General > Security). After +# the captive-portal redirect, so in AP mode an unknown path still lands on +# /setup rather than on the login page; the setup flow itself stays open. +from web_interface import auth as web_auth +web_auth.init_app(app, config_manager, limiter=limiter, + is_ap_mode_active=is_ap_mode_active) + # Append a content-version query param (file mtime) to every static URL so the # long-lived `immutable` cache (see add_security_headers below) is actually safe: # when a static file changes its URL changes, so browsers refetch it. Without diff --git a/web_interface/auth.py b/web_interface/auth.py new file mode 100644 index 00000000..f06c28ba --- /dev/null +++ b/web_interface/auth.py @@ -0,0 +1,632 @@ +""" +Optional login for the web interface, off by default. + +Nothing here changes a device that has not set a password: with no password +stored, the hook below returns immediately and every page and API route +answers exactly as before. The cross-site Origin guard +(``web_interface/origin_guard.py``) is separate and always on. + +Turning it on is setting a password (General tab -> Security). From then on +every page and API route needs one of: + +* a login session (the ``/login`` page; a signed cookie, 30 days); +* an API token, sent as ``Authorization: Bearer `` (for Home Assistant, + scripts and the MQTT bridge when it runs on another machine). + +and these stay open without either: + +* requests from the Pi itself (loopback, and no proxy headers -- a reverse + proxy on the same Pi would otherwise make every request look local); +* the Wi-Fi setup flow (``/setup`` and the status/scan/connect routes it + calls) while the Pi is in access-point mode, so a Pi that lost its network + can still be put back on one; +* static assets, the captive-portal probe URLs, the login page itself, and + ``/api/v3/health``, which then answers only its overall status. + +Where it is stored: the ``web_auth`` section of ``config/config_secrets.json``, +the file every other credential lives in. The password is a werkzeug hash +(``generate_password_hash``); each API token is stored as a SHA-256 of the +token, which is safe for a 256-bit random value and cheap enough to check on +every request, and is shown once, when it is created. The section also holds +the key that signs login cookies, so they survive a restart and die with a +password change. This module reads that file directly (cached on its mtime) +rather than through the merged config, so a ``web_auth`` key smuggled into +``config.json`` means nothing. The config API and the raw-JSON editor leave +the section out entirely. + +Lost password: ``sudo python3 scripts/reset_web_password.py`` on the Pi (or +open the interface from the Pi itself, which is always allowed). See +docs/WEB_INTERFACE_GUIDE.md. +""" +import hashlib +import hmac +import json +import logging +import os +import secrets +import threading +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import unquote, urlsplit + +from flask import (Blueprint, Flask, current_app, g, jsonify, redirect, + render_template, request, session, url_for) +from flask.sessions import SecureCookieSessionInterface +from itsdangerous import URLSafeTimedSerializer +from werkzeug.security import check_password_hash, generate_password_hash + +logger = logging.getLogger('web_interface.auth') + +#: The config_secrets.json section this module owns. +SECTION = 'web_auth' +#: Key under Flask's app.extensions. +EXTENSION_KEY = 'ledmatrix_auth' + +MIN_PASSWORD_LENGTH = 8 +MAX_PASSWORD_LENGTH = 256 +MAX_TOKENS = 50 +MAX_TOKEN_NAME_LENGTH = 60 +TOKEN_PREFIX = 'lmx_' # nosec B105 - token prefix, not a credential +SESSION_KEY = 'ledmatrix_auth' +SESSION_LIFETIME = timedelta(days=30) + +#: Failed attempts only (see _counts_as_a_failure). Per client address. +LOGIN_RATE_LIMIT = '5 per minute;30 per hour' + +_LOOPBACK_ADDRESSES = frozenset({'127.0.0.1', '::1', '::ffff:127.0.0.1'}) +#: A request carrying any of these came through a proxy, so its loopback +#: address says nothing about where the user is. +_PROXY_HEADERS = ('X-Forwarded-For', 'X-Real-IP', 'Forwarded', 'X-Forwarded-Host') + +#: Open whether or not auth is on: the login page, core static files, the +#: captive-portal probes (they answer fixed text, or redirect to /setup in AP +#: mode) and the favicon. +_ALWAYS_OPEN_ENDPOINTS = frozenset({ + 'static', + 'ledmatrix_auth.login', + 'ledmatrix_auth.logout', + 'hotspot_detect', 'generate_204', 'connecttest_txt', 'success_txt', + 'favicon', +}) +#: Open without a session, but answering only a minimal body (see +#: request_is_authenticated). +_HEALTH_ENDPOINTS = frozenset({'api_v3.get_health'}) +#: The Wi-Fi setup flow captive_setup.html drives. Open only in AP mode. +_AP_SETUP_ENDPOINTS = frozenset({ + 'pages_v3.captive_setup', 'pages_v3_legacy.captive_setup', + 'api_v3.get_wifi_status', 'api_v3.scan_wifi_networks', 'api_v3.connect_wifi', +}) + + +class AuthError(Exception): + """A request to change auth settings that cannot be applied as asked. + + ``user_message`` is a fixed sentence written in this module for the + person on the settings page; it never carries data from anywhere else, + so it is what the routes return (never ``str()`` of an exception). + """ + + def __init__(self, user_message: str): + super().__init__(user_message) + self.user_message = user_message + + +def _now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _token_digest(token: str) -> str: + return hashlib.sha256(token.encode('utf-8')).hexdigest() + + +class AuthStore: + """The ``web_auth`` section of config_secrets.json. + + Reads are cached against the file's (mtime, size), so the per-request + check costs one ``stat``, and a change made by another process (the reset + script, a backup restore) is seen on the next request. Writes go through + ``ConfigManager.save_raw_file_content`` like every other secrets save: + atomic, with the permissions secrets get. + """ + + def __init__(self, config_manager): + self._config_manager = config_manager + self._lock = threading.RLock() + self._signature: Any = object() # never equal to a real signature + self._failed_signature: Any = None + self._section: Dict[str, Any] = {} + + # -- reading --------------------------------------------------------- + + def _path(self) -> str: + return self._config_manager.get_secrets_path() + + def _file_signature(self): + try: + st = os.stat(self._path()) + except FileNotFoundError: + return None + except OSError: + return 'unreadable' + return (st.st_mtime_ns, st.st_size) + + def _read_secrets_strict(self) -> Dict[str, Any]: + """The whole secrets file; {} when absent. Raises on anything else. + + Strict on purpose, unlike ConfigManager.get_raw_file_content (which + answers {} for an unreadable file): a write built on that {} would + replace every other credential in the file. + """ + try: + with open(self._path(), 'r', encoding='utf-8') as fh: + data = json.load(fh) + except FileNotFoundError: + return {} + if not isinstance(data, dict): + raise ValueError(f'{self._path()} does not hold a JSON object') + return data + + def section(self) -> Dict[str, Any]: + """The current ``web_auth`` section (a cached dict; do not mutate).""" + signature = self._file_signature() + with self._lock: + if signature == self._signature: + return self._section + try: + data = self._read_secrets_strict() + except (OSError, ValueError) as err: + # Keep the last good answer rather than guess. A file that was + # never readable leaves auth off, as the rest of the app treats + # an unreadable secrets file as "no secrets". Logged once per + # version of the file, not once per request. + if signature != self._failed_signature: + self._failed_signature = signature + logger.error("Could not read %s for web login settings: %s", + self._path(), err) + return self._section + raw = data.get(SECTION) + self._section = raw if isinstance(raw, dict) else {} + self._signature = signature + self._failed_signature = None + return self._section + + def is_enabled(self) -> bool: + return bool(self.section().get('password_hash')) + + def session_secret(self) -> Optional[str]: + secret = self.section().get('session_secret') + return secret if isinstance(secret, str) and secret else None + + def check_password(self, password: Any) -> bool: + stored = self.section().get('password_hash') + if not stored or not isinstance(stored, str) or not isinstance(password, str): + return False + if len(password) > MAX_PASSWORD_LENGTH: + return False + try: + return check_password_hash(stored, password) + except (ValueError, TypeError) as err: + # The type only: the message could quote part of the stored value. + logger.error("The stored web login is not usable (%s); reset it " + "with scripts/reset_web_password.py", type(err).__name__) + return False + + def _raw_tokens(self) -> List[Dict[str, Any]]: + tokens = self.section().get('tokens') + if not isinstance(tokens, list): + return [] + return [t for t in tokens if isinstance(t, dict)] + + def list_tokens(self) -> List[Dict[str, Any]]: + """Token records without their hashes, oldest first.""" + return [_public_token(t) for t in self._raw_tokens()] + + def verify_token(self, presented: Any) -> Optional[Dict[str, Any]]: + """The public record of the token ``presented`` is, or None.""" + if not isinstance(presented, str) or not presented or len(presented) > 200: + return None + digest = _token_digest(presented) + match = None + for record in self._raw_tokens(): + stored = record.get('hash') + # Every record is compared, so the time taken does not depend on + # which one matched. + if isinstance(stored, str) and hmac.compare_digest(stored, digest): + match = record + return _public_token(match) if match else None + + # -- writing --------------------------------------------------------- + + def _update(self, mutate: Callable[[Dict[str, Any]], Any]) -> Any: + with self._lock: + data = self._read_secrets_strict() + current = data.get(SECTION) + section = dict(current) if isinstance(current, dict) else {} + result = mutate(section) + if section: + data[SECTION] = section + else: + data.pop(SECTION, None) + self._config_manager.save_raw_file_content('secrets', data) + self._signature = object() # re-read on next access + return result + + def set_password(self, password: str) -> None: + """Store a new password (turning login on) and sign everyone out. + + A new cookie-signing key is generated with it, so every session made + under the old password stops working. + """ + problem = password_problem(password) + if problem: + raise AuthError(problem) + hashed = generate_password_hash(password) + + def mutate(section): + section['password_hash'] = hashed + section['session_secret'] = secrets.token_hex(32) + section['password_set_at'] = _now_iso() + self._update(mutate) + + def disable(self) -> None: + """Remove the password (login off). API tokens are kept.""" + def mutate(section): + for key in ('password_hash', 'session_secret', 'password_set_at'): + section.pop(key, None) + self._update(mutate) + + def create_token(self, name: str) -> Tuple[Dict[str, Any], str]: + """A new token: (public record, the token itself -- shown once).""" + name = (name or '').strip() + if not name: + raise AuthError('Give the token a name, such as "Home Assistant".') + if len(name) > MAX_TOKEN_NAME_LENGTH: + raise AuthError(f'Token names are at most {MAX_TOKEN_NAME_LENGTH} characters.') + token = TOKEN_PREFIX + secrets.token_urlsafe(32) + record = { + 'id': secrets.token_hex(8), + 'name': name, + 'hash': _token_digest(token), + 'prefix': token[:len(TOKEN_PREFIX) + 4], + 'created_at': _now_iso(), + } + + def mutate(section): + tokens = [t for t in (section.get('tokens') or []) if isinstance(t, dict)] + if len(tokens) >= MAX_TOKENS: + raise AuthError(f'There are already {MAX_TOKENS} tokens; revoke one first.') + tokens.append(record) + section['tokens'] = tokens + self._update(mutate) + return _public_token(record), token + + def revoke_token(self, token_id: str) -> bool: + """Delete a token by id. False when there is no such token.""" + def mutate(section): + tokens = [t for t in (section.get('tokens') or []) if isinstance(t, dict)] + kept = [t for t in tokens if t.get('id') != token_id] + if len(kept) == len(tokens): + return False + if kept: + section['tokens'] = kept + else: + section.pop('tokens', None) + return True + # Check first, so revoking an unknown id writes nothing. + if not any(t.get('id') == token_id for t in self._raw_tokens()): + return False + return self._update(mutate) + + +def _public_token(record: Dict[str, Any]) -> Dict[str, Any]: + return {key: record.get(key) for key in ('id', 'name', 'prefix', 'created_at')} + + +def password_problem(password: Any) -> Optional[str]: + """Why ``password`` cannot be used, or None.""" + if not isinstance(password, str) or len(password) < MIN_PASSWORD_LENGTH: + return f'Use at least {MIN_PASSWORD_LENGTH} characters.' + if len(password) > MAX_PASSWORD_LENGTH: + return f'Use at most {MAX_PASSWORD_LENGTH} characters.' + if password.strip() != password: + return 'The password cannot start or end with a space.' + return None + + +def strip_auth_section(data: Any) -> Any: + """A shallow copy of ``data`` without the ``web_auth`` section. + + For every response that dumps a whole config or secrets dict. The section + holds the password hash, the token hashes and the cookie-signing key; no + client needs any of them, and the dedicated /api/v3/auth routes manage it. + """ + if isinstance(data, dict) and SECTION in data: + data = {k: v for k, v in data.items() if k != SECTION} + return data + + +# -- request-time helpers -------------------------------------------------- + +def get_store(app: Optional[Flask] = None) -> Optional[AuthStore]: + app = app or current_app + return app.extensions.get(EXTENSION_KEY) + + +def is_local_request() -> bool: + """From this machine, and not relayed by a proxy on it.""" + address = request.remote_addr or '' + if address not in _LOOPBACK_ADDRESSES and not address.startswith('127.'): + return False + return not any(header in request.headers for header in _PROXY_HEADERS) + + +def bearer_token() -> Optional[str]: + header = request.headers.get('Authorization', '') + scheme, _, value = header.partition(' ') + if scheme.lower() != 'bearer': + return None + return value.strip() or None + + +def request_is_authenticated() -> bool: + """True unless this request got in only through an open endpoint. + + Always True when login is off. The health route uses it to decide how + much to say. + """ + return getattr(g, 'ledmatrix_auth_via', 'open') != 'unauthenticated' + + +def auth_via() -> str: + """How this request was let in: open, localhost, session, token, + ap-setup, or unauthenticated (an always-open endpoint).""" + return getattr(g, 'ledmatrix_auth_via', 'open') + + +def sign_in_this_session() -> None: + session.clear() + session[SESSION_KEY] = True + session.permanent = True + + +def _leaves_this_server(path: str) -> bool: + """Whether a browser could read ``path`` as another site, or it is not + plainly printable. + + ``//host`` and ``/\\host`` are protocol-relative (browsers treat ``\\`` + as ``/``), so no backslash is accepted anywhere; control characters are + stripped or mangled by browsers and can smuggle either form past a + prefix check. + """ + if path.startswith('//') or '\\' in path: + return True + return any(ord(c) < 32 or ord(c) == 127 for c in path) + + +def safe_next(target: Any) -> str: + """``target`` if it is a local path to return to after login, else ``/``. + + Only a path on this server is accepted: it starts with a single ``/``, + has no scheme or host, and is not ``//host`` or ``/\\host`` (which + browsers read as another site) either as sent or once percent-decoded. + """ + if not isinstance(target, str) or not target.startswith('/'): + return '/' + if _leaves_this_server(target) or _leaves_this_server(unquote(target)): + return '/' + parts = urlsplit(target) + if parts.scheme or parts.netloc: + return '/' + if parts.path.rstrip('/') in ('/login', '/logout', '/v3/login', '/v3/logout'): + return '/' + # Rebuilt behind a constant '/': with the checks above, what follows it + # cannot start another authority, so the result is a path on this server. + return '/' + target[1:] + + +def _return_path() -> str: + """Where the login page should send the user back to.""" + if request.headers.get('HX-Request') == 'true': + current = request.headers.get('HX-Current-URL', '') + parts = urlsplit(current) + path = parts.path or '/' + return safe_next(path + (f'?{parts.query}' if parts.query else '')) + if _is_page_navigation(): + path = request.full_path if request.query_string else request.path + return safe_next(path) + return '/' + + +def _is_page_navigation() -> bool: + if request.method not in ('GET', 'HEAD') or request.path.startswith('/api/'): + return False + mode = request.headers.get('Sec-Fetch-Mode') + if mode is not None: + return mode == 'navigate' + return 'text/html' in request.headers.get('Accept', '') + + +def _login_url(next_path: str) -> str: + if next_path and next_path != '/': + return url_for('ledmatrix_auth.login', next=next_path) + return url_for('ledmatrix_auth.login') + + +def _refuse(error_code: str, message: str): + """401 in the form the caller can act on. + + A page load is redirected to the login page. An HTMX request gets + ``HX-Redirect``, which htmx follows whatever the status. Anything else + (fetch, scripts, EventSource) gets JSON, with the login URL in + ``X-LEDMatrix-Login`` for the interface's own fetch() wrapper. + """ + login_url = _login_url(_return_path()) + if _is_page_navigation() and request.headers.get('HX-Request') != 'true': + return redirect(login_url) + response = jsonify({'status': 'error', 'error_code': error_code, 'message': message}) + response.status_code = 401 + response.headers['WWW-Authenticate'] = 'Bearer realm="LEDMatrix"' + response.headers['X-LEDMatrix-Login'] = login_url + if request.headers.get('HX-Request') == 'true': + response.headers['HX-Redirect'] = login_url + return response + + +class _AuthSessionInterface(SecureCookieSessionInterface): + """Signs the session cookie with the stored key once login is on. + + ``app.secret_key`` is random per process, which would sign everybody out + on every restart. The stored key lives next to the password and is + replaced whenever the password is, which is what signs every other + browser out after a password change. + """ + + def __init__(self, store: AuthStore): + self._store = store + + def get_signing_serializer(self, app): + secret = self._store.session_secret() + if not secret: + return super().get_signing_serializer(app) + return URLSafeTimedSerializer( + secret, + salt=self.salt, + serializer=self.serializer, + signer_kwargs={ + 'key_derivation': self.key_derivation, + 'digest_method': self.digest_method, + }, + ) + + +# -- login / logout pages ---------------------------------------------------- + +auth_pages = Blueprint('ledmatrix_auth', __name__) + + +@auth_pages.route('/login', methods=['GET', 'POST']) +def login(): + store = get_store() + next_path = safe_next(request.values.get('next', '/')) + if store is None or not store.is_enabled(): + return redirect(next_path) + if request.method == 'GET': + if session.get(SESSION_KEY): + return redirect(next_path) + return render_template('v3/login.html', error=None, next_path=next_path) + + if store.check_password(request.form.get('password', '')): + sign_in_this_session() + logger.info("Web login from %s", request.remote_addr) + return redirect(next_path) + logger.warning("Failed web login from %s", request.remote_addr) + # 401 is what the rate limit counts (see _counts_as_a_failure). + return render_template('v3/login.html', next_path=next_path, + error='That password is not right. Try again.'), 401 + + +@auth_pages.route('/logout', methods=['POST']) +def logout(): + session.clear() + store = get_store() + if store is not None and store.is_enabled(): + return redirect(url_for('ledmatrix_auth.login')) + return redirect('/') + + +@auth_pages.errorhandler(429) +def _login_rate_limited(_error): + """The login page's own answer to too many wrong passwords. + + Scoped to this blueprint, so the API keeps its JSON 429s. + """ + return render_template( + 'v3/login.html', + next_path=safe_next(request.values.get('next', '/')), + error='Too many wrong passwords. Wait a minute and try again.'), 429 + + +def _counts_as_a_failure(response) -> bool: + """Only wrong passwords use up the login rate limit. + + 401 from the login form; 403 from the settings routes that ask for the + current password. + """ + return response.status_code in (401, 403) + + +def init_app(app: Flask, config_manager, *, limiter=None, + is_ap_mode_active: Optional[Callable[[], bool]] = None, + store: Optional[AuthStore] = None) -> AuthStore: + """Register the login pages, the session signer and the access hook. + + Register it after the captive-portal redirect, so in AP mode an unknown + path still goes to /setup rather than to the login page. + """ + store = store or AuthStore(config_manager) + app.extensions[EXTENSION_KEY] = store + app.session_interface = _AuthSessionInterface(store) + app.config['PERMANENT_SESSION_LIFETIME'] = SESSION_LIFETIME + app.config.setdefault('SESSION_COOKIE_SAMESITE', 'Lax') + app.config.setdefault('SESSION_COOKIE_HTTPONLY', True) + app.register_blueprint(auth_pages) + ap_mode_active = is_ap_mode_active or (lambda: False) + + if limiter is not None: + # flask-limiter enforces a decorated limit inside the wrapper it + # returns, not in its before_request hook, so the wrapper has to be + # what the URL map calls: decorating an already-registered function + # and discarding the result limits nothing. + limits = {'ledmatrix_auth.login': {'methods': ['POST']}, + 'api_v3.set_web_password': {}, + 'api_v3.disable_web_login': {}} + for endpoint, options in limits.items(): + view = app.view_functions.get(endpoint) + if view is not None: + app.view_functions[endpoint] = limiter.limit( + LOGIN_RATE_LIMIT, deduct_when=_counts_as_a_failure, **options)(view) + else: + logger.warning("flask-limiter is not installed: failed web logins are " + "not rate-limited. Install web_interface/requirements.txt.") + + @app.before_request + def _require_login(): + if not store.is_enabled(): + return None # login off: nothing changes + if request.method == 'OPTIONS': + return None + endpoint = request.endpoint or '' + if endpoint in _ALWAYS_OPEN_ENDPOINTS: + return None + if session.get(SESSION_KEY): + g.ledmatrix_auth_via = 'session' + return None + token = bearer_token() + if token is not None: + if store.verify_token(token): + g.ledmatrix_auth_via = 'token' + return None + return _refuse('INVALID_TOKEN', 'That API token is not valid. It may ' + 'have been revoked; create a new one in the web ' + 'interface under General > Security.') + if is_local_request(): + g.ledmatrix_auth_via = 'localhost' + return None + if endpoint in _HEALTH_ENDPOINTS: + g.ledmatrix_auth_via = 'unauthenticated' + return None + if endpoint in _AP_SETUP_ENDPOINTS and ap_mode_active(): + g.ledmatrix_auth_via = 'ap-setup' + return None + return _refuse('AUTH_REQUIRED', 'Log in to the LEDMatrix interface, or ' + 'send an API token as "Authorization: Bearer ".') + + @app.context_processor + def _auth_template_state(): + enabled = store.is_enabled() + return {'web_auth_state': { + 'enabled': enabled, + 'signed_in': bool(enabled and session.get(SESSION_KEY)), + }} + + return store diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index 6009ecba..c951bb39 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -2238,7 +2238,10 @@ def _read_mqtt_bridge_config() -> Dict[str, Any]: name shadows it. """ settings = dict(_MQTT_BRIDGE_DEFAULTS) + # Write-only credentials: never in _MQTT_BRIDGE_DEFAULTS, which is what + # the GET route echoes back. settings['mqtt_password'] = None + settings['ledmatrix_api_token'] = None try: if _MQTT_BRIDGE_CONFIG.is_file(): with open(_MQTT_BRIDGE_CONFIG, encoding='utf-8') as handle: @@ -2320,6 +2323,7 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402,F401 plugins, starlark, system, + web_login, wifi, ) diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index f14d13f3..809503d9 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -15,6 +15,7 @@ from src.display_geometry import ORIENTATION_ROTATE_DEGREES from src.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message from src.pi5_matrix_support import is_raspberry_pi_5 from web_interface.cache import invalidate_cache +from web_interface.auth import SECTION as _WEB_AUTH_SECTION, strip_auth_section import web_interface.blueprints.api_v3 as _pkg # Read through the module rather than bound by value: tests patch these @@ -78,7 +79,11 @@ def get_main_config(): return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 config = api_v3.config_manager.load_config() - return jsonify({'status': 'success', 'data': _redact_credentials(config)}) + # load_config() merges config_secrets.json in, web_auth (the login + # password hash, token hashes and cookie key) included. No client needs + # any of it; /api/v3/auth/* manages it. + return jsonify({'status': 'success', + 'data': _redact_credentials(strip_auth_section(config))}) @api_v3.route('/config/schedule', methods=['GET']) def get_schedule_config(): """Get current schedule configuration""" @@ -470,6 +475,11 @@ def save_main_config(): if key in data: data[key] = data[key] == 'on' + # The login settings are secrets with their own routes + # (/api/v3/auth/*); a web_auth key here would land in config.json. + if isinstance(data, dict): + data.pop(_WEB_AUTH_SECTION, None) + if not data: return jsonify({'status': 'error', 'message': 'No data provided'}), 400 @@ -1148,8 +1158,10 @@ def get_secrets_config(): # credentials. It was handing all of them to anyone who could reach # the port. Values are masked; empty and YOUR_* placeholders are left # alone so a client can still tell "set" from "not set". + # web_auth is left out altogether, not masked: it is managed by + # /api/v3/auth/*, and the raw save below keeps whatever is stored. return jsonify({'status': 'success', - 'data': mask_all_secret_values(config)}) + 'data': mask_all_secret_values(strip_auth_section(config))}) def _raw_config_save_error(e): """The 500 both raw-config save routes answer a failed save with. @@ -1245,6 +1257,10 @@ def save_raw_secrets_config(): # The cost is that a secret can no longer be cleared by blanking it. # That needs its own affordance; a control that erases credentials as # a side effect of saving an unrelated one is not it. + # The login section never reaches this editor (see the GET above) and + # is not written from it: a hand-typed password_hash would be a + # plaintext that no password matches. The stored one is kept. + data.pop(_WEB_AUTH_SECTION, None) current = api_v3.config_manager.get_raw_file_content('secrets') or {} merged = deep_merge(current, strip_masked_values(data)) api_v3.config_manager.save_raw_file_content('secrets', merged) diff --git a/web_interface/blueprints/api_v3/misc.py b/web_interface/blueprints/api_v3/misc.py index e1fd7af4..c708d7c0 100644 --- a/web_interface/blueprints/api_v3/misc.py +++ b/web_interface/blueprints/api_v3/misc.py @@ -17,6 +17,7 @@ from src.common.path_safety import safe_path_component from src.common import sync_manager as _sync from src import error_aggregator as _errors from web_interface import display_preview +from web_interface.auth import request_is_authenticated import web_interface.blueprints.api_v3 as _pkg # Read through the module rather than bound by value: tests patch these # as module attributes, and a value binding would not see the patch. @@ -124,6 +125,11 @@ def get_health(): if not all_healthy: health_status['status'] = 'degraded' + if not request_is_authenticated(): + # Web login is on and this caller has not logged in: the route + # stays open for uptime monitors, but says only up or degraded. + return jsonify({'status': 'success', + 'data': {'status': health_status['status']}}) return jsonify({'status': 'success', 'data': health_status}) except Exception as e: logger.error("%s failed", request.path, exc_info=True) @@ -444,6 +450,8 @@ def get_mqtt_bridge(): 'config': safe, # Enough to render "a password is set" without disclosing it. 'password_set': bool(password), + # Likewise the web-login API token (only needed off-Pi). + 'api_token_set': bool(config.get('ledmatrix_api_token')), 'env_override_prefix': 'LEDMATRIX_MQTT_', } }) @@ -497,6 +505,15 @@ def update_mqtt_bridge_config(): else: config['mqtt_password'] = existing_password + # The web-login API token is write-only the same way. + if _coerce_to_bool(data.get('clear_api_token')): + config['ledmatrix_api_token'] = None + elif 'ledmatrix_api_token' in data and str(data['ledmatrix_api_token']).strip() != '': + new_token = str(data['ledmatrix_api_token']).strip() + if len(new_token) > 200: + return jsonify({'status': 'error', 'message': 'API token is too long'}), 400 + config['ledmatrix_api_token'] = new_token + # CWE-319: a password with TLS off is sent in the clear. On a trusted # LAN that is a normal, deliberate setup, so this is refused rather # than forbidden -- allow_insecure_mqtt is the explicit acknowledgement. @@ -534,6 +551,7 @@ def update_mqtt_bridge_config(): message += ' Restart the bridge for them to take effect.' return jsonify({'status': 'success', 'message': message, 'data': {'password_set': bool(config.get('mqtt_password')), + 'api_token_set': bool(config.get('ledmatrix_api_token')), 'restart_required': service['active']}}) except Exception as e: logger.exception('Error saving MQTT bridge settings') diff --git a/web_interface/blueprints/api_v3/web_login.py b/web_interface/blueprints/api_v3/web_login.py new file mode 100644 index 00000000..e34fccb7 --- /dev/null +++ b/web_interface/blueprints/api_v3/web_login.py @@ -0,0 +1,176 @@ +"""Optional web login: password and API-token management. + +The login itself, the access hook and the storage live in +web_interface/auth.py; these routes are the settings the General tab's +Security section drives. None of them ever returns the password hash, a token +hash, or the cookie-signing key. + +Routes decorate the shared `api_v3` Blueprint from the package `__init__`, +so their endpoint names are `api_v3.` like every other route. +""" +from web_interface import auth as web_auth +from web_interface.blueprints.api_v3 import ( + api_v3, describe_exception, jsonify, logger, request, +) + + +def _store_or_error(): + store = web_auth.get_store() + if store is None: + return None, (jsonify({'status': 'error', 'error_code': 'AUTH_UNAVAILABLE', + 'message': 'Login settings are not available in this process.'}), 503) + if web_auth.auth_via() == 'token': + # An integration's token is for driving the display, not for + # changing who can log in or minting more tokens. + return None, (jsonify({'status': 'error', 'error_code': 'TOKEN_NOT_ALLOWED', + 'message': 'API tokens cannot change login settings. ' + 'Log in to the web interface instead.'}), 403) + return store, None + + +def _json_body(): + data = request.get_json(silent=True) + return data if isinstance(data, dict) else None + + +def _status_payload(store): + return { + 'enabled': store.is_enabled(), + 'signed_in': bool(store.is_enabled() and web_auth.auth_via() == 'session'), + 'access': web_auth.auth_via(), + 'min_password_length': web_auth.MIN_PASSWORD_LENGTH, + 'tokens': store.list_tokens(), + } + + +def _save_failed(e, what): + logger.error("Could not save web login settings (%s)", what, exc_info=True) + return jsonify({'status': 'error', 'error_code': 'AUTH_SAVE_FAILED', + 'message': f'Could not save the {what}; see logs for details', + 'details': describe_exception(e)}), 500 + + +@api_v3.route('/auth/status', methods=['GET']) +def get_web_auth_status(): + """Whether login is on, how this request got in, and the token list.""" + store, error = _store_or_error() + if error: + return error + return jsonify({'status': 'success', 'data': _status_payload(store)}) + + +@api_v3.route('/auth/password', methods=['POST']) +def set_web_password(): + """Set the password (turns login on) or change it. + + Body: ``{"new_password": "...", "current_password": "..."}``; the current + one is required once login is on. Signs every other browser out, and + keeps this one signed in. + """ + store, error = _store_or_error() + if error: + return error + data = _json_body() + if data is None: + return jsonify({'status': 'error', 'message': 'Body must be a JSON object'}), 400 + if store.is_enabled() and not store.check_password(data.get('current_password')): + # 403, not 401: the caller is signed in; the password is what's wrong. + # This is what the rate limit counts. + return jsonify({'status': 'error', 'error_code': 'WRONG_PASSWORD', + 'message': 'The current password is not right.'}), 403 + new_password = data.get('new_password') + problem = web_auth.password_problem(new_password) + if problem: + return jsonify({'status': 'error', 'error_code': 'WEAK_PASSWORD', + 'message': problem}), 400 + was_enabled = store.is_enabled() + try: + store.set_password(new_password) + except web_auth.AuthError as e: + return jsonify({'status': 'error', 'message': e.user_message}), 400 + except Exception as e: + return _save_failed(e, 'password') + web_auth.sign_in_this_session() + # Names the event only; the new value is never logged. + logger.info("Web login %s from %s", + 'password changed' if was_enabled else 'turned on (password set)', request.remote_addr) + return jsonify({'status': 'success', + 'message': 'Password changed.' if was_enabled else + 'Login is on. Other browsers now need the password.', + 'data': _status_payload(store)}) + + +@api_v3.route('/auth/disable', methods=['POST']) +def disable_web_login(): + """Turn login off. Body: ``{"current_password": "..."}``. Tokens are kept.""" + store, error = _store_or_error() + if error: + return error + if not store.is_enabled(): + return jsonify({'status': 'success', 'message': 'Login is already off.', + 'data': _status_payload(store)}) + data = _json_body() or {} + if not store.check_password(data.get('current_password')): + return jsonify({'status': 'error', 'error_code': 'WRONG_PASSWORD', + 'message': 'The current password is not right.'}), 403 + try: + store.disable() + except Exception as e: + return _save_failed(e, 'login setting') + logger.info("Web login turned off from %s", request.remote_addr) + return jsonify({'status': 'success', + 'message': 'Login is off. Anyone on your network can open the interface.', + 'data': _status_payload(store)}) + + +@api_v3.route('/auth/tokens', methods=['GET']) +def list_api_tokens(): + """Token names, ids, first characters and creation times. Never the token.""" + store, error = _store_or_error() + if error: + return error + return jsonify({'status': 'success', 'data': {'tokens': store.list_tokens()}}) + + +@api_v3.route('/auth/tokens', methods=['POST']) +def create_api_token(): + """Create a token. Body: ``{"name": "Home Assistant"}``. + + The answer's ``data.token`` is the only time the token is ever shown. + """ + store, error = _store_or_error() + if error: + return error + data = _json_body() + if data is None: + return jsonify({'status': 'error', 'message': 'Body must be a JSON object'}), 400 + try: + record, token = store.create_token(str(data.get('name') or '')) + except web_auth.AuthError as e: + return jsonify({'status': 'error', 'message': e.user_message}), 400 + except Exception as e: + return _save_failed(e, 'token') + # The name and id only, never the token or its hash. + logger.info("API access %r (id %s) created from %s", record['name'], record['id'], + request.remote_addr) + return jsonify({'status': 'success', + 'message': 'Token created. Copy it now: it is not shown again.', + 'data': {'token': token, 'record': record}}), 201 + + +@api_v3.route('/auth/tokens/', methods=['DELETE']) +def revoke_api_token(token_id): + """Revoke a token by id. It stops working on the next request.""" + store, error = _store_or_error() + if error: + return error + try: + revoked = store.revoke_token(token_id) + except Exception as e: + return _save_failed(e, 'token list') + if not revoked: + return jsonify({'status': 'error', 'error_code': 'NOT_FOUND', + 'message': 'No token with that id.'}), 404 + logger.info("API access id %s revoked from %s", token_id, request.remote_addr) + return jsonify({'status': 'success', 'message': 'Token revoked.', + 'data': {'tokens': store.list_tokens()}}) diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index e12c40ed..54013c45 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -475,7 +475,25 @@ def _load_general_partial(): auto_update_status = None return render_template('v3/partials/general.html', main_config=main_config, - auto_update_status=auto_update_status) + auto_update_status=auto_update_status, + web_login=_web_login_state()) + + +def _web_login_state(): + """What the General tab's Security section shows; None hides it. + + None when the app has no login store (a bare test app), so the section + only appears where it can work. Never includes a hash. + """ + from web_interface import auth as web_auth + store = web_auth.get_store() + if store is None: + return None + return { + 'enabled': store.is_enabled(), + 'tokens': store.list_tokens(), + 'min_length': web_auth.MIN_PASSWORD_LENGTH, + } def _load_display_partial(): """Load display settings partial""" @@ -592,7 +610,11 @@ def _load_raw_json_partial(): """Load raw JSON editor partial""" if pages_v3.config_manager: main_config_data = pages_v3.config_manager.get_raw_file_content('main') - secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets') + # The web login section (password and token hashes) is managed in + # General > Security, never in this editor; its save keeps it. + from web_interface.auth import strip_auth_section + secrets_config_data = strip_auth_section( + pages_v3.config_manager.get_raw_file_content('secrets')) main_config_json = json.dumps(main_config_data, indent=4) secrets_config_json = json.dumps(secrets_config_data, indent=4) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 910d9a12..ac75e4b0 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -23,6 +23,27 @@ }; + + + + + + + + +
+
+

+ LED Matrix Control +

+

This display's settings are protected by a password.

+ + {% if error %} + + {% endif %} + +
+ +
+ + +
+ +
+ + +
+
+ + diff --git a/web_interface/templates/v3/partials/general.html b/web_interface/templates/v3/partials/general.html index e41de1f0..5d1e71a2 100644 --- a/web_interface/templates/v3/partials/general.html +++ b/web_interface/templates/v3/partials/general.html @@ -177,3 +177,246 @@ + +{% if web_login %} + +
+
+

Security

+

+ {% if web_login.enabled %} + Login is on. + Browsers on your network need the password; integrations use an API token. + {% else %} + Login is off: anyone on your network can open this page. Set a password to require one. + {% endif %} +

+
+ +
+
+

+ {# A

+
+ {% if web_login.enabled %} +
+ + +
+ {% endif %} +
+
+ + +
+
+ + +
+
+

+ At least {{ web_login.min_length }} characters. + {% if not web_login.enabled %}Write it down: if you lose it, you need SSH access to the Pi (or a browser on the Pi) to turn login off again.{% endif %} +

+
+ +
+
+
+ + {% if web_login.enabled %} +
+

Turn login off

+
+
+ + +
+ +
+
+ {% endif %} + +
+

API tokens

+

+ For Home Assistant, scripts, or the MQTT bridge on another machine, once login is on. + Send it as Authorization: Bearer <token>. + A token is shown once, when you create it. +

+
+ {% for token in web_login.tokens %} +
+
+ {{ token.name }} + {{ token.prefix }}… + created {{ (token.created_at or '')[:10] }} +
+ +
+ {% else %} +

No tokens yet.

+ {% endfor %} +
+
+
+ + +
+ +
+ +
+
+
+ +{% endif %} diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index 2e8f26d9..013784b0 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -1059,6 +1059,16 @@ ${field('mqtt-topic', 'Command topic', c.mqtt_topic)} ${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)} ${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)} + ${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')} ${field('mqtt-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}