feat(web): optional web login and API tokens, off by default (stacked on #674) (#683)

Optional web login, off by default: a device that sets no password behaves
exactly as before. Set under General > Security; then every page and API
route needs a session login or an API token (Authorization: Bearer).
Loopback, the Wi-Fi setup flow in AP mode, static files, captive-portal
probes and a reduced /api/v3/health stay open. Secrets live in the web_auth
section of config_secrets.json and no API returns them.
scripts/reset_web_password.py turns login off. Stacked on #674.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 09:09:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ba38a83c2c
commit e3c85cece6
23 changed files with 2310 additions and 26 deletions
+35
View File
@@ -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 TLS-terminating proxy needs nothing more: a portless `Host` matches an
`https://` page. `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 <token>`. 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/<id>`.
### Fixes ### Fixes
- On-demand no longer restarts a running display. `POST - On-demand no longer restarts a running display. `POST
+17 -2
View File
@@ -61,8 +61,23 @@ Out of scope (please report upstream):
LEDMatrix is designed for trusted local networks. Several limitations LEDMatrix is designed for trusted local networks. Several limitations
are intentional rather than vulnerabilities: are intentional rather than vulnerabilities:
- **No web UI authentication.** The web interface assumes the network - **Web UI authentication is optional and off by default.** Out of the
it's running on is trusted. Don't expose port 5000 to the internet. 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 "Trusted network" does not mean "trusted websites", though: any page
a LAN user opens could make their browser POST to the Pi. So the a LAN user opens could make their browser POST to the Pi. So the
interface refuses a `POST`/`PUT`/`PATCH`/`DELETE` whose `Origin` (or interface refuses a `POST`/`PUT`/`PATCH`/`DELETE` whose `Origin` (or
+117 -6
View File
@@ -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` through, port included (nginx: `proxy_set_header Host $http_host;`;
`$host` drops the port) -- `X-Forwarded-Host` is not read. `$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 ## Table of Contents
- [Configuration](#configuration) - [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) - [Health and Status](#health-and-status)
- [Schedule (dim/power)](#schedule-dimpower) - [Schedule (dim/power)](#schedule-dimpower)
- [Integrations](#integrations) - [Integrations](#integrations)
- [Web login and API tokens](#web-login-and-api-tokens)
- [Plugin-specific endpoints](#plugin-specific-endpoints) - [Plugin-specific endpoints](#plugin-specific-endpoints)
- [Starlark Apps](#starlark-apps) - [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 Retrieve `config/config_secrets.json` with every set value replaced by eight
bullet characters (`"••••••••"`). Empty values and `YOUR_*` placeholders 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**: **Response**:
```json ```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 (`"••••••••"`) Save the secrets file (advanced use only). Masked values (`"••••••••"`)
and blank strings in the body are dropped, and the rest is merged onto the 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. 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 display snapshot. `data.status` is `healthy` or `degraded`, with
`data.services` and `data.checks`. `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 ### Hardware Status
**GET** `/api/v3/hardware/status` **GET** `/api/v3/hardware/status`
@@ -2028,20 +2059,100 @@ enabled.
Home Assistant MQTT bridge service state and settings: `data.service`, Home Assistant MQTT bridge service state and settings: `data.service`,
`data.config_exists`, `data.config_path`, `data.config` (password `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` **PUT** `/api/v3/integrations/mqtt-bridge/config`
Write `integrations/mqtt_bridge/bridge_config.json`. Only the keys you send 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 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 `mqtt_tls` off is refused unless `allow_insecure_mqtt` is true. Returns
`data.password_set` and `data.restart_required` (the bridge must be `data.password_set`, `data.api_token_set` and `data.restart_required` (the
restarted to pick up changes). See bridge must be restarted to pick up changes). See
[integrations/mqtt_bridge/README.md](../integrations/mqtt_bridge/README.md). [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/<id>` — revoke; it stops working on the next
request. `404` for an unknown id.
Send a token as `Authorization: Bearer <token>`.
### 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 ## Plugin-specific endpoints
A handful of endpoints belong to individual plugins. The music plugin's A handful of endpoints belong to individual plugins. The music plugin's
+47 -3
View File
@@ -130,6 +130,34 @@ Configure basic system settings:
Click **Save** to write changes to `config/config.json`. Most changes Click **Save** to write changes to `config/config.json`. Most changes
require a display service restart from **Overview**. 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 ### Display Tab
Configure your LED matrix hardware: 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` — Install a plugin from the store
- `POST /api/v3/plugins/install-from-url` — Install a plugin from a GitHub URL - `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. **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 ## Security Considerations
**Network Access:** **Network Access:**
- The interface is accessible to anyone on your local network - By default the interface is accessible to anyone on your local network
- No authentication is currently implemented - An optional password (General > Security) makes every page and API call
- Recommended for trusted networks only 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:** **Other websites:**
- A web page you open elsewhere could otherwise make your browser send - A web page you open elsewhere could otherwise make your browser send
+7
View File
@@ -96,6 +96,13 @@ environment as `LEDMATRIX_MQTT_<KEY>` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say),
which keeps a broker password out of a file on disk — put it in a systemd which keeps a broker password out of a file on disk — put it in a systemd
drop-in with `Environment=` or `EnvironmentFile=` instead. 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 <token>`.
Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips 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 certificate verification and exists only for a self-signed broker on a
trusted LAN; it logs a warning when used. trusted LAN; it logs a warning when used.
@@ -66,6 +66,10 @@ DEFAULTS = {
"mqtt_tls": False, "mqtt_tls": False,
"mqtt_tls_insecure": False, "mqtt_tls_insecure": False,
"ledmatrix_api_base": "http://localhost:5000", "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, "request_timeout": 15,
"on_demand_duration": None, "on_demand_duration": None,
"log_level": "INFO", "log_level": "INFO",
@@ -125,10 +129,13 @@ class LEDMatrixClient:
""" """
def __init__(self, api_base: str, timeout: int = 15, 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.api_base = api_base.rstrip("/")
self.timeout = timeout self.timeout = timeout
self.session = session or requests.Session() 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]: def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
url = f"{self.api_base}/api/v3{path}" url = f"{self.api_base}/api/v3{path}"
@@ -403,7 +410,8 @@ class Bridge:
self.status_topic = f"{self.command_topic}/status" self.status_topic = f"{self.command_topic}/status"
self.state_topic = f"{self.command_topic}/state" self.state_topic = f"{self.command_topic}/state"
self.availability_topic = f"{self.command_topic}/availability" 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.handler = CommandHandler(self.client, config.get("on_demand_duration"))
self._stop = threading.Event() self._stop = threading.Event()
self._mqtt = None self._mqtt = None
+1
View File
@@ -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 | | `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_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 | | `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 | | `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 | | `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 | | `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
+104
View File
@@ -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())
+7 -4
View File
@@ -43,11 +43,14 @@ CORE_CONFIG_KEYS = frozenset({
}) })
#: Top-level keys of ``config_secrets.json`` that belong to the core rather than #: 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 #: to a plugin: the GitHub token the Plugin Store reads, the historical
#: ``youtube`` section. Plugin secrets are namespaced by plugin id, so anything #: ``youtube`` section, and ``web_auth`` (the optional web login's password
#: deciding whether a secrets section is a plugin's needs this as well as #: hash and API-token hashes, web_interface/auth.py) -- which orphan-plugin
#: ``CORE_CONFIG_KEYS``. #: 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({ CORE_SECRETS_KEYS = frozenset({
'github', 'github',
'youtube', 'youtube',
'web_auth',
}) })
+3 -3
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path 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.plugin_dirs import PluginDirectoryIndex
from src.plugin_system.state_manager import PluginStateManager from src.plugin_system.state_manager import PluginStateManager
from src.logging_config import get_logger 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 # 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 # 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. # #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 # 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 # 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". # 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]]: def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file.""" """Get plugin state from config file."""
+50
View File
@@ -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/<token_id>",
"api_v3.revoke_api_token",
[
"DELETE",
"OPTIONS"
]
],
[ [
"/api/v3/backup/<path:filename>", "/api/v3/backup/<path:filename>",
"api_v3.backup_delete", "api_v3.backup_delete",
+661
View File
@@ -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/<name>')
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
+14 -2
View File
@@ -64,8 +64,11 @@ config_manager = ConfigManager()
# server's own host; requests with neither header (curl, Home Assistant, the # 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: # 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 # 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 # the port directly can still use the API unless the optional login is on:
# network still needs real authentication. # 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) # Initialize rate limiting (prevent accidental abuse, not security)
try: try:
@@ -504,6 +507,8 @@ def captive_portal_redirect():
'/connecttest.txt', # Windows detection '/connecttest.txt', # Windows detection
'/success.txt', # Firefox detection '/success.txt', # Firefox detection
'/favicon.ico', # Favicon '/favicon.ico', # Favicon
'/login', # Optional web login (web_interface/auth.py)
'/logout',
] ]
for allowed_path in allowed_paths: 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) # Redirect to lightweight captive portal setup page (not the full UI)
return redirect(url_for('pages_v3.captive_setup'), code=302) 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 # 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: # 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 # when a static file changes its URL changes, so browsers refetch it. Without
+632
View File
@@ -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 <token>`` (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 <token>".')
@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
@@ -2238,7 +2238,10 @@ def _read_mqtt_bridge_config() -> Dict[str, Any]:
name shadows it. name shadows it.
""" """
settings = dict(_MQTT_BRIDGE_DEFAULTS) 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['mqtt_password'] = None
settings['ledmatrix_api_token'] = None
try: try:
if _MQTT_BRIDGE_CONFIG.is_file(): if _MQTT_BRIDGE_CONFIG.is_file():
with open(_MQTT_BRIDGE_CONFIG, encoding='utf-8') as handle: 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, plugins,
starlark, starlark,
system, system,
web_login,
wifi, wifi,
) )
+18 -2
View File
@@ -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.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message
from src.pi5_matrix_support import is_raspberry_pi_5 from src.pi5_matrix_support import is_raspberry_pi_5
from web_interface.cache import invalidate_cache 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 import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these # 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 return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
config = api_v3.config_manager.load_config() 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']) @api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config(): def get_schedule_config():
"""Get current schedule configuration""" """Get current schedule configuration"""
@@ -470,6 +475,11 @@ def save_main_config():
if key in data: if key in data:
data[key] = data[key] == 'on' 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: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 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 # credentials. It was handing all of them to anyone who could reach
# the port. Values are masked; empty and YOUR_* placeholders are left # the port. Values are masked; empty and YOUR_* placeholders are left
# alone so a client can still tell "set" from "not set". # 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', 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): def _raw_config_save_error(e):
"""The 500 both raw-config save routes answer a failed save with. """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. # 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 # That needs its own affordance; a control that erases credentials as
# a side effect of saving an unrelated one is not it. # 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 {} current = api_v3.config_manager.get_raw_file_content('secrets') or {}
merged = deep_merge(current, strip_masked_values(data)) merged = deep_merge(current, strip_masked_values(data))
api_v3.config_manager.save_raw_file_content('secrets', merged) api_v3.config_manager.save_raw_file_content('secrets', merged)
+18
View File
@@ -17,6 +17,7 @@ from src.common.path_safety import safe_path_component
from src.common import sync_manager as _sync from src.common import sync_manager as _sync
from src import error_aggregator as _errors from src import error_aggregator as _errors
from web_interface import display_preview from web_interface import display_preview
from web_interface.auth import request_is_authenticated
import web_interface.blueprints.api_v3 as _pkg import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these # Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch. # as module attributes, and a value binding would not see the patch.
@@ -124,6 +125,11 @@ def get_health():
if not all_healthy: if not all_healthy:
health_status['status'] = 'degraded' 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}) return jsonify({'status': 'success', 'data': health_status})
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True) logger.error("%s failed", request.path, exc_info=True)
@@ -444,6 +450,8 @@ def get_mqtt_bridge():
'config': safe, 'config': safe,
# Enough to render "a password is set" without disclosing it. # Enough to render "a password is set" without disclosing it.
'password_set': bool(password), '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_', 'env_override_prefix': 'LEDMATRIX_MQTT_',
} }
}) })
@@ -497,6 +505,15 @@ def update_mqtt_bridge_config():
else: else:
config['mqtt_password'] = existing_password 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 # 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 # LAN that is a normal, deliberate setup, so this is refused rather
# than forbidden -- allow_insecure_mqtt is the explicit acknowledgement. # 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.' message += ' Restart the bridge for them to take effect.'
return jsonify({'status': 'success', 'message': message, return jsonify({'status': 'success', 'message': message,
'data': {'password_set': bool(config.get('mqtt_password')), 'data': {'password_set': bool(config.get('mqtt_password')),
'api_token_set': bool(config.get('ledmatrix_api_token')),
'restart_required': service['active']}}) 'restart_required': service['active']}})
except Exception as e: except Exception as e:
logger.exception('Error saving MQTT bridge settings') logger.exception('Error saving MQTT bridge settings')
@@ -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.<function>` 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/<token_id>', 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()}})
+24 -2
View File
@@ -475,7 +475,25 @@ def _load_general_partial():
auto_update_status = None auto_update_status = None
return render_template('v3/partials/general.html', return render_template('v3/partials/general.html',
main_config=main_config, 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(): def _load_display_partial():
"""Load display settings partial""" """Load display settings partial"""
@@ -592,7 +610,11 @@ def _load_raw_json_partial():
"""Load raw JSON editor partial""" """Load raw JSON editor partial"""
if pages_v3.config_manager: if pages_v3.config_manager:
main_config_data = pages_v3.config_manager.get_raw_file_content('main') 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) main_config_json = json.dumps(main_config_data, indent=4)
secrets_config_json = json.dumps(secrets_config_data, indent=4) secrets_config_json = json.dumps(secrets_config_data, indent=4)
+31
View File
@@ -23,6 +23,27 @@
}; };
</script> </script>
<!-- Optional web login (web_interface/auth.py): a fetch() answered 401
with X-LEDMatrix-Login -- the session ran out, or login was turned on
from another browser -- goes to the login page instead of leaving
every panel showing an error. HTMX requests follow HX-Redirect on
their own. Only a same-origin answer can expose that header. -->
<script>
(function() {
if (typeof window.fetch !== 'function') return;
var originalFetch = window.fetch;
window.fetch = function() {
return originalFetch.apply(this, arguments).then(function(response) {
var login = response.status === 401 && response.headers.get('X-LEDMatrix-Login');
if (login && login.charAt(0) === '/' && login.charAt(1) !== '/') {
window.location.assign(login);
}
return response;
});
};
})();
</script>
<!-- Theme initialization (must run before CSS to prevent flash) --> <!-- Theme initialization (must run before CSS to prevent flash) -->
<script> <script>
(function() { (function() {
@@ -352,6 +373,16 @@
class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></div> class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></div>
</div> </div>
{% if web_auth_state and web_auth_state.signed_in %}
<!-- Log out (only shown when the optional web login is on) -->
<form method="post" action="{{ url_for('ledmatrix_auth.logout') }}" class="flex items-center">
<button type="submit" class="theme-toggle-btn p-2 rounded-md"
title="Log out" aria-label="Log out">
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
</button>
</form>
{% endif %}
<!-- Theme toggle --> <!-- Theme toggle -->
<button id="theme-toggle" <button id="theme-toggle"
type="button" type="button"
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log in - LED Matrix Control Panel</title>
<meta name="theme-color" content="#111827">
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='v3/icons/icon-192.png') }}">
<script>
// Same theme choice as the main interface (base.html), before CSS loads.
(function() {
var theme = null;
try { theme = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
if (!theme) {
try {
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} catch (e) { theme = 'light'; }
}
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
<style>
.login-wrap { max-width: 24rem; margin: 0 auto; padding: 4rem 1rem; }
.login-error { border: 1px solid #fecaca; border-radius: 0.375rem; padding: 0.75rem; }
.login-help { margin-top: 1.5rem; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<main class="login-wrap">
<div class="bg-white rounded-lg shadow p-6">
<h1 class="text-lg font-semibold text-gray-900 mb-2">
<i class="fas fa-tv text-blue-600 mr-2" aria-hidden="true"></i>LED Matrix Control
</h1>
<p class="text-sm text-gray-600 mb-6">This display's settings are protected by a password.</p>
{% if error %}
<div class="login-error bg-red-50 text-red-600 text-sm mb-4" role="alert">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('ledmatrix_auth.login') }}" class="space-y-4">
<input type="hidden" name="next" value="{{ next_path }}">
<div class="form-group">
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" id="password" name="password" required autofocus
autocomplete="current-password" class="form-control mt-1 w-full">
</div>
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md w-full">
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i>Log in
</button>
</form>
<p class="login-help text-sm text-gray-600">
Forgot it? On the Pi, run
<code class="bg-gray-100 font-mono">sudo python3 ~/LEDMatrix/scripts/reset_web_password.py</code>
(use the folder LEDMatrix is installed in), or open the interface from the Pi itself.
</p>
</div>
</main>
</body>
</html>
@@ -177,3 +177,246 @@
</div> </div>
</form> </form>
</div> </div>
{% if web_login %}
<!-- Web login (optional; web_interface/auth.py). Off until a password is set. -->
<div id="web-login-settings" class="bg-white rounded-lg shadow p-6 mt-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900"><i class="fas fa-lock mr-2" aria-hidden="true"></i>Security</h2>
<p class="mt-1 text-sm text-gray-600">
{% if web_login.enabled %}
<span class="text-green-700 font-semibold">Login is on.</span>
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 %}
</p>
</div>
<div class="space-y-6">
<div class="form-group" id="setting-general-web_login" data-setting-key="web_auth.password">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{# A <label> so the settings search indexes it; no `for`, so
the password inputs keep their own labels. #}
<label>{% if web_login.enabled %}Change the web interface password{% else %}Web interface password{% endif %}{{ ui.help_tip('Optional. With a password set, every page and API call needs a login or an API token.\nAlways allowed without one: this Pi itself (localhost), and the Wi-Fi setup page while the Pi is in access-point mode.\nForgot it? On the Pi run: sudo python3 ~/LEDMatrix/scripts/reset_web_password.py', 'Web interface password') }}</label>
</h3>
<form class="space-y-4" onsubmit="window.webLogin.setPassword(this); return false;" autocomplete="off">
{% if web_login.enabled %}
<div>
<label for="web-login-current" class="block text-sm font-medium text-gray-700">Current password</label>
<input type="password" id="web-login-current" name="current_password" required
autocomplete="current-password" class="form-control mt-1">
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="web-login-new" class="block text-sm font-medium text-gray-700">New password</label>
<input type="password" id="web-login-new" name="new_password" required
minlength="{{ web_login.min_length }}" autocomplete="new-password" class="form-control mt-1">
</div>
<div>
<label for="web-login-confirm" class="block text-sm font-medium text-gray-700">Type it again</label>
<input type="password" id="web-login-confirm" name="confirm_password" required
minlength="{{ web_login.min_length }}" autocomplete="new-password" class="form-control mt-1">
</div>
</div>
<p class="text-sm text-gray-600">
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 %}
</p>
<div class="flex justify-end">
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-key mr-2" aria-hidden="true"></i>{% if web_login.enabled %}Change password{% else %}Set password and turn login on{% endif %}
</button>
</div>
</form>
</div>
{% if web_login.enabled %}
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Turn login off</h3>
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.disable(this); return false;" autocomplete="off">
<div class="flex-1">
<label for="web-login-disable-current" class="block text-sm font-medium text-gray-700">Current password</label>
<input type="password" id="web-login-disable-current" name="current_password" required
autocomplete="current-password" class="form-control mt-1">
</div>
<button type="submit" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md">
<i class="fas fa-lock-open mr-2" aria-hidden="true"></i>Turn login off
</button>
</form>
</div>
{% endif %}
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">API tokens</h3>
<p class="text-sm text-gray-600 mb-4">
For Home Assistant, scripts, or the MQTT bridge on another machine, once login is on.
Send it as <code class="bg-gray-100 font-mono">Authorization: Bearer &lt;token&gt;</code>.
A token is shown once, when you create it.
</p>
<div id="web-login-tokens" class="space-y-2 mb-4">
{% for token in web_login.tokens %}
<div class="flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2" data-token-id="{{ token.id }}">
<div class="text-sm">
<span class="font-semibold text-gray-900">{{ token.name }}</span>
<span class="font-mono text-gray-600 ml-2">{{ token.prefix }}&hellip;</span>
<span class="text-gray-600 ml-2">created {{ (token.created_at or '')[:10] }}</span>
</div>
<button type="button" class="text-sm text-red-600 hover:underline"
data-token-name="{{ token.name }}"
onclick="window.webLogin.revoke(this.closest('[data-token-id]').dataset.tokenId, this.dataset.tokenName)">
Revoke
</button>
</div>
{% else %}
<p class="text-sm text-gray-600" data-empty>No tokens yet.</p>
{% endfor %}
</div>
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.createToken(this); return false;" autocomplete="off">
<div class="flex-1">
<label for="web-login-token-name" class="block text-sm font-medium text-gray-700">New token name</label>
<input type="text" id="web-login-token-name" name="name" required maxlength="60"
placeholder="Home Assistant" class="form-control mt-1">
</div>
<button type="submit" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md">
<i class="fas fa-plus mr-2" aria-hidden="true"></i>Create token
</button>
</form>
<div id="web-login-new-token" class="hidden mt-4 border border-amber-300 bg-amber-50 rounded-md p-4" role="status">
<p class="text-sm font-semibold text-gray-900 mb-2">Copy this token now. It is not shown again.</p>
<div class="flex flex-wrap items-center gap-2">
<code id="web-login-new-token-value" class="font-mono break-all bg-white border border-gray-200 rounded-md px-4 py-2 flex-1"></code>
<button type="button" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md"
onclick="window.webLogin.copyToken()">
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy
</button>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
var API = '/api/v3/auth';
function notify(message, type) {
if (typeof window.showNotification === 'function') window.showNotification(message, type);
else window.alert(message);
}
function send(method, url, body) {
return fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: body === undefined ? undefined : JSON.stringify(body)
}).then(function(r) {
return r.json().catch(function() { return {}; }).then(function(d) { return {ok: r.ok, d: d}; });
});
}
function reloadSection() {
if (window.htmx) {
window.htmx.ajax('GET', '/v3/partials/general', {target: '#general-content', swap: 'innerHTML'});
} else {
window.location.reload();
}
}
function field(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : '';
}
function tokenRow(record) {
var row = document.createElement('div');
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
row.dataset.tokenId = record.id;
var text = document.createElement('div');
text.className = 'text-sm';
[['font-semibold text-gray-900', record.name],
['font-mono text-gray-600 ml-2', record.prefix + '…'],
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
var span = document.createElement('span');
span.className = part[0];
span.textContent = part[1];
text.appendChild(span);
});
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'text-sm text-red-600 hover:underline';
btn.textContent = 'Revoke';
btn.addEventListener('click', function() { window.webLogin.revoke(record.id, record.name); });
row.appendChild(text);
row.appendChild(btn);
return row;
}
window.webLogin = {
setPassword: function(form) {
var next = field(form, 'new_password');
if (next !== field(form, 'confirm_password')) {
notify('The two new passwords do not match.', 'error');
return;
}
var body = {new_password: next};
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
send('POST', API + '/password', body).then(function(res) {
notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection();
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
disable: function(form) {
if (!window.confirm('Turn login off? Anyone on your network will be able to open this page.')) return;
send('POST', API + '/disable', {current_password: field(form, 'current_password')}).then(function(res) {
notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection();
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
createToken: function(form) {
send('POST', API + '/tokens', {name: field(form, 'name')}).then(function(res) {
if (!res.ok) {
notify(res.d.message || 'Could not create the token', 'error');
return;
}
var list = document.getElementById('web-login-tokens');
if (list) {
var empty = list.querySelector('[data-empty]');
if (empty) empty.remove();
list.appendChild(tokenRow(res.d.data.record));
}
document.getElementById('web-login-new-token-value').textContent = res.d.data.token;
document.getElementById('web-login-new-token').classList.remove('hidden');
form.reset();
notify(res.d.message || 'Token created', 'success');
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
copyToken: function() {
var box = document.getElementById('web-login-new-token-value');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.textContent).then(function() { notify('Token copied', 'success'); });
return;
}
// Plain http on a LAN is not a secure context: select it instead.
var range = document.createRange();
range.selectNodeContents(box);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
},
revoke: function(id, name) {
if (!window.confirm('Revoke the token "' + name + '"? Anything using it stops working.')) return;
send('DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
if (!res.ok) return;
var rows = document.querySelectorAll('#web-login-tokens [data-token-id]');
Array.prototype.forEach.call(rows, function(row) {
if (row.dataset.tokenId === id) row.remove();
});
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
}
};
})();
</script>
{% endif %}
@@ -1059,6 +1059,16 @@
${field('mqtt-topic', 'Command topic', c.mqtt_topic)} ${field('mqtt-topic', 'Command topic', c.mqtt_topic)}
${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)} ${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)}
${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)} ${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)}
<label class="block">
<span class="text-xs font-medium text-gray-700">LEDMatrix API token</span>
<input id="mqtt-api-token" type="password" value="" autocomplete="off"
placeholder="${data.api_token_set ? 'unchanged — leave blank to keep' : 'none set'}"
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
<span class="text-xs text-gray-500">
Only when web login is on and the bridge runs on another machine (General &gt; Security).
${data.api_token_set ? '<button type="button" id="mqtt-clear-api-token" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
</span>
</label>
${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')} ${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"')} ${field('mqtt-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}
<label class="block"> <label class="block">
@@ -1113,6 +1123,8 @@
const clearBtn = document.getElementById('mqtt-clear-password'); const clearBtn = document.getElementById('mqtt-clear-password');
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword()); if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
const clearTokenBtn = document.getElementById('mqtt-clear-api-token');
if (clearTokenBtn) clearTokenBtn.addEventListener('click', () => clearMqttApiToken());
} }
window.loadMqttBridge = function() { window.loadMqttBridge = function() {
@@ -1145,6 +1157,8 @@
// Only send a password when one was typed; blank means "leave it alone". // Only send a password when one was typed; blank means "leave it alone".
const pw = val('mqtt-password'); const pw = val('mqtt-password');
if (pw) body.mqtt_password = pw; if (pw) body.mqtt_password = pw;
const apiToken = val('mqtt-api-token');
if (apiToken) body.ledmatrix_api_token = apiToken;
return body; return body;
} }
@@ -1180,6 +1194,21 @@
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message)); .catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
} }
function clearMqttApiToken() {
if (!confirm('Remove the stored LEDMatrix API token from the bridge settings?')) return;
fetch(MQTT_BRIDGE_URL + '/config', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({clear_api_token: true})
})
.then(r => r.json())
.then(d => {
showResult('result-mqtt-save', d.status === 'success', d.message || 'Token cleared');
loadMqttBridge();
})
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
}
window.installMqttBridge = function() { window.installMqttBridge = function() {
// Installing pulls dependencies, so it is slower than the other actions // Installing pulls dependencies, so it is slower than the other actions
// and worth saying so rather than leaving a spinner unexplained. // and worth saying so rather than leaving a spinner unexplained.