mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -43,6 +43,41 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
TLS-terminating proxy needs nothing more: a portless `Host` matches an
|
||||
`https://` page.
|
||||
|
||||
### Optional web login
|
||||
|
||||
- The web interface can require a password, **off by default**: a device that
|
||||
does not set one behaves exactly as before. Set it under **General >
|
||||
Security**; from then on every page and API route needs a login (a session
|
||||
cookie, 30 days, kept across restarts) or an API token. Unauthenticated page
|
||||
loads go to the new `/login` page, HTMX requests get `HX-Redirect` to it,
|
||||
and API calls get `401` JSON (`AUTH_REQUIRED` / `INVALID_TOKEN`). Wrong
|
||||
passwords are rate-limited per address (5 a minute, 30 an hour, through the
|
||||
existing flask-limiter). Log out from the header. Changing the password
|
||||
signs every other browser out. (`web_interface/auth.py`)
|
||||
- **API tokens** for Home Assistant, scripts and the MQTT bridge: create,
|
||||
list and revoke them in the same section, send them as
|
||||
`Authorization: Bearer <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
|
||||
|
||||
- On-demand no longer restarts a running display. `POST
|
||||
|
||||
+17
-2
@@ -61,8 +61,23 @@ Out of scope (please report upstream):
|
||||
LEDMatrix is designed for trusted local networks. Several limitations
|
||||
are intentional rather than vulnerabilities:
|
||||
|
||||
- **No web UI authentication.** The web interface assumes the network
|
||||
it's running on is trusted. Don't expose port 5000 to the internet.
|
||||
- **Web UI authentication is optional and off by default.** Out of the
|
||||
box the web interface assumes the network it's running on is trusted.
|
||||
Setting a password under **General > Security** makes every page and
|
||||
API route require a login or an API token (`Authorization: Bearer`),
|
||||
with wrong passwords rate-limited per address
|
||||
(`web_interface/auth.py`). Deliberately left open even then: requests
|
||||
from the Pi itself (loopback without proxy headers; a reverse proxy on
|
||||
the Pi must add `X-Forwarded-For`, or every request it relays counts as
|
||||
local), the Wi-Fi setup flow while the Pi is in access-point mode,
|
||||
static files, and a status-only `/api/v3/health`. The password is a
|
||||
werkzeug hash and tokens are stored as SHA-256, in
|
||||
`config/config_secrets.json`, which no API returns. There is no TLS:
|
||||
over plain HTTP the password and tokens cross the LAN in the clear, so
|
||||
still don't expose port 5000 to the internet; put a TLS reverse proxy
|
||||
or a VPN in front for remote access. Anyone with shell access to the Pi
|
||||
can turn login off (`scripts/reset_web_password.py`), which is the
|
||||
documented recovery path.
|
||||
"Trusted network" does not mean "trusted websites", though: any page
|
||||
a LAN user opens could make their browser POST to the Pi. So the
|
||||
interface refuses a `POST`/`PUT`/`PATCH`/`DELETE` whose `Origin` (or
|
||||
|
||||
+117
-6
@@ -29,6 +29,28 @@ API; call it server-side instead. Behind a reverse proxy, pass the original
|
||||
`Host` through, port included (nginx: `proxy_set_header Host $http_host;`;
|
||||
`$host` drops the port) -- `X-Forwarded-Host` is not read.
|
||||
|
||||
**Authentication (optional, off by default).** With no web password set,
|
||||
nothing below needs credentials. Once one is set (General > Security, or
|
||||
[`POST /auth/password`](#web-login-and-api-tokens)), every route needs a login
|
||||
session or an API token:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer lmx_..." http://your-pi-ip:5000/api/v3/display/current
|
||||
```
|
||||
|
||||
Without either, an API route answers `401` with
|
||||
`{"status": "error", "error_code": "AUTH_REQUIRED", "message": ...}`, a
|
||||
`WWW-Authenticate: Bearer realm="LEDMatrix"` header and the login page's URL
|
||||
in `X-LEDMatrix-Login`; an unknown or revoked token gets `"error_code":
|
||||
"INVALID_TOKEN"`. Browser page loads are redirected to `/login` instead, and
|
||||
HTMX requests get `401` with `HX-Redirect: /login?...`. Never asked for
|
||||
credentials: requests from the Pi itself (loopback, with no `X-Forwarded-For`,
|
||||
`X-Real-IP`, `Forwarded` or `X-Forwarded-Host` header), `/static/*`, the
|
||||
captive-portal probe URLs, `/login`, `/api/v3/health` (status only, see
|
||||
[Health Check](#health-check)), and -- only while the Pi is in access-point
|
||||
mode -- `/setup`, `GET /wifi/status`, `GET /wifi/scan` and
|
||||
`POST /wifi/connect`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Configuration](#configuration)
|
||||
@@ -46,6 +68,7 @@ API; call it server-side instead. Behind a reverse proxy, pass the original
|
||||
- [Health and Status](#health-and-status)
|
||||
- [Schedule (dim/power)](#schedule-dimpower)
|
||||
- [Integrations](#integrations)
|
||||
- [Web login and API tokens](#web-login-and-api-tokens)
|
||||
- [Plugin-specific endpoints](#plugin-specific-endpoints)
|
||||
- [Starlark Apps](#starlark-apps)
|
||||
|
||||
@@ -224,7 +247,10 @@ times `07:00`-`23:00`. At least one day must be enabled.
|
||||
|
||||
Retrieve `config/config_secrets.json` with every set value replaced by eight
|
||||
bullet characters (`"••••••••"`). Empty values and `YOUR_*` placeholders
|
||||
are returned as-is, so a client can tell "set" from "not set".
|
||||
are returned as-is, so a client can tell "set" from "not set". The
|
||||
`web_auth` section (the web login's password hash, API-token hashes and
|
||||
cookie key) is left out entirely; [Get Main Configuration](#get-main-configuration)
|
||||
leaves it out too.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -249,7 +275,8 @@ Replace `config/config.json` with the JSON body (advanced use only).
|
||||
Save the secrets file (advanced use only). Masked values (`"••••••••"`)
|
||||
and blank strings in the body are dropped, and the rest is merged onto the
|
||||
stored secrets, so posting back the GET response unchanged changes nothing.
|
||||
A secret cannot be cleared by blanking it here.
|
||||
A secret cannot be cleared by blanking it here. A `web_auth` key in the body
|
||||
is ignored; the stored login settings are kept.
|
||||
|
||||
---
|
||||
|
||||
@@ -1963,6 +1990,10 @@ Health of the web interface, display service, config file, plugin system and
|
||||
display snapshot. `data.status` is `healthy` or `degraded`, with
|
||||
`data.services` and `data.checks`.
|
||||
|
||||
Open even when the web login is on, for uptime monitors; a caller that is not
|
||||
logged in (and has no token) then gets only `{"status": "success", "data":
|
||||
{"status": "healthy" | "degraded"}}`.
|
||||
|
||||
### Hardware Status
|
||||
|
||||
**GET** `/api/v3/hardware/status`
|
||||
@@ -2028,20 +2059,100 @@ enabled.
|
||||
|
||||
Home Assistant MQTT bridge service state and settings: `data.service`,
|
||||
`data.config_exists`, `data.config_path`, `data.config` (password
|
||||
omitted), `data.password_set`, `data.env_override_prefix`.
|
||||
omitted), `data.password_set`, `data.api_token_set`, `data.env_override_prefix`.
|
||||
|
||||
**PUT** `/api/v3/integrations/mqtt-bridge/config`
|
||||
|
||||
Write `integrations/mqtt_bridge/bridge_config.json`. Only the keys you send
|
||||
change. The password is write-only: omit `mqtt_password` to keep it, send a
|
||||
value to replace it, or send `"clear_password": true`. A password with
|
||||
value to replace it, or send `"clear_password": true`. The web-login API token
|
||||
the bridge sends (`ledmatrix_api_token`, needed only when login is on and the
|
||||
bridge runs on another machine) is write-only the same way, cleared with
|
||||
`"clear_api_token": true`. A password with
|
||||
`mqtt_tls` off is refused unless `allow_insecure_mqtt` is true. Returns
|
||||
`data.password_set` and `data.restart_required` (the bridge must be
|
||||
restarted to pick up changes). See
|
||||
`data.password_set`, `data.api_token_set` and `data.restart_required` (the
|
||||
bridge must be restarted to pick up changes). See
|
||||
[integrations/mqtt_bridge/README.md](../integrations/mqtt_bridge/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Web login and API tokens
|
||||
|
||||
The optional password and API tokens (`web_auth` in
|
||||
`config/config_secrets.json`, `web_interface/auth.py`). No route here returns
|
||||
the password hash, a token hash or the cookie key. A request authenticated by
|
||||
an API token gets `403` `TOKEN_NOT_ALLOWED` from every route in this section:
|
||||
tokens are for integrations, not for changing who can log in. Wrong current
|
||||
passwords (`403` `WRONG_PASSWORD`) count against the same per-address limit as
|
||||
the login page: 5 a minute, 30 an hour, then `429`.
|
||||
|
||||
Lost password: run `sudo python3 scripts/reset_web_password.py` on the Pi.
|
||||
|
||||
### Login status
|
||||
|
||||
**GET** `/api/v3/auth/status`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"signed_in": true,
|
||||
"access": "session",
|
||||
"min_password_length": 8,
|
||||
"tokens": [
|
||||
{"id": "3f9c1a2b4d5e6f70", "name": "Home Assistant", "prefix": "lmx_Ab3d",
|
||||
"created_at": "2026-09-29T20:14:03+00:00"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`access` is how this request got in: `open` (login off), `session`,
|
||||
`localhost`, `ap-setup` or `token`.
|
||||
|
||||
### Set or change the password
|
||||
|
||||
**POST** `/api/v3/auth/password`
|
||||
|
||||
Body: `{"new_password": "...", "current_password": "..."}`.
|
||||
`current_password` is required once login is on. At least 8 characters, no
|
||||
leading or trailing space (`400` `WEAK_PASSWORD`). Setting the first password
|
||||
turns login on. Every existing login session ends; the caller's own browser
|
||||
is signed in again with the answer.
|
||||
|
||||
### Turn login off
|
||||
|
||||
**POST** `/api/v3/auth/disable`
|
||||
|
||||
Body: `{"current_password": "..."}`. Removes the password; API tokens are
|
||||
kept (and are needed again if login is turned back on).
|
||||
|
||||
### API tokens
|
||||
|
||||
**GET** `/api/v3/auth/tokens` — `data.tokens`, as in the status answer.
|
||||
|
||||
**POST** `/api/v3/auth/tokens` — body `{"name": "Home Assistant"}` (1-60
|
||||
characters). Answers `201` with `data.token`, the token itself (`lmx_` plus 43
|
||||
characters), and `data.record`. **The token is never shown again**; only its
|
||||
SHA-256 is stored. At most 50 tokens.
|
||||
|
||||
**DELETE** `/api/v3/auth/tokens/<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
|
||||
|
||||
A handful of endpoints belong to individual plugins. The music plugin's
|
||||
|
||||
@@ -130,6 +130,34 @@ Configure basic system settings:
|
||||
Click **Save** to write changes to `config/config.json`. Most changes
|
||||
require a display service restart from **Overview**.
|
||||
|
||||
Below the settings, the **Security** section (its own buttons, not the Save
|
||||
button) controls the optional login:
|
||||
|
||||
- **Web interface password** — off by default. Setting one turns login on:
|
||||
browsers on your network then see a login page, and stay logged in for 30
|
||||
days (across restarts). The browser you set it from stays logged in.
|
||||
Changing the password logs every other browser out. **Turn login off**
|
||||
needs the current password. A **Log out** button appears in the header
|
||||
while you are logged in. Five wrong passwords in a minute (or 30 in an
|
||||
hour) from one address make it wait.
|
||||
- **API tokens** — for Home Assistant, scripts, or the MQTT bridge on another
|
||||
machine. Give it a name, click **Create token**, and copy the token right
|
||||
away: it is shown once. Revoke it here when it is no longer needed.
|
||||
- Never asked for a password: a browser on the Pi itself, and the Wi-Fi setup
|
||||
page while the Pi is in access-point mode (so you can always get it back on
|
||||
a network).
|
||||
|
||||
**Forgot the password?** SSH into the Pi and run:
|
||||
|
||||
```bash
|
||||
sudo python3 ~/LEDMatrix/scripts/reset_web_password.py
|
||||
```
|
||||
|
||||
(use the folder LEDMatrix is installed in). Login is off again right away,
|
||||
no restart needed, and you can set a new password. API tokens are kept; add
|
||||
`--revoke-tokens` to delete them too. Alternatively, open
|
||||
`http://localhost:5000` in a browser on the Pi itself.
|
||||
|
||||
### Display Tab
|
||||
|
||||
Configure your LED matrix hardware:
|
||||
@@ -346,6 +374,14 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
- `POST /api/v3/plugins/install` — Install a plugin from the store
|
||||
- `POST /api/v3/plugins/install-from-url` — Install a plugin from a GitHub URL
|
||||
|
||||
If the optional login is on, send an API token (General > Security):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer lmx_..." http://your-pi-ip:5000/api/v3/display/current
|
||||
```
|
||||
|
||||
Scripts running on the Pi itself need no token.
|
||||
|
||||
**Note:** See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete API documentation.
|
||||
|
||||
---
|
||||
@@ -408,9 +444,17 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
## Security Considerations
|
||||
|
||||
**Network Access:**
|
||||
- The interface is accessible to anyone on your local network
|
||||
- No authentication is currently implemented
|
||||
- Recommended for trusted networks only
|
||||
- By default the interface is accessible to anyone on your local network
|
||||
- An optional password (General > Security) makes every page and API call
|
||||
need a login or an API token; see [General Tab](#general-tab). Requests
|
||||
from the Pi itself and the Wi-Fi setup flow in access-point mode stay open,
|
||||
and `/api/v3/health` answers only its overall status without a login
|
||||
- The interface speaks plain HTTP, so the password and tokens cross your
|
||||
network unencrypted: still recommended for trusted networks only
|
||||
- Behind a reverse proxy **on the Pi**, make it send `X-Forwarded-For`
|
||||
(nginx: `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`).
|
||||
Without it every proxied request looks like it comes from the Pi itself,
|
||||
which is never asked to log in
|
||||
|
||||
**Other websites:**
|
||||
- A web page you open elsewhere could otherwise make your browser send
|
||||
|
||||
@@ -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
|
||||
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
|
||||
certificate verification and exists only for a self-signed broker on a
|
||||
trusted LAN; it logs a warning when used.
|
||||
|
||||
@@ -66,6 +66,10 @@ DEFAULTS = {
|
||||
"mqtt_tls": False,
|
||||
"mqtt_tls_insecure": False,
|
||||
"ledmatrix_api_base": "http://localhost:5000",
|
||||
# Only needed when the web interface's optional login is on AND the bridge
|
||||
# reaches it from another machine: requests from the Pi itself never need
|
||||
# one. Create it under General > Security; sent as a Bearer token.
|
||||
"ledmatrix_api_token": None,
|
||||
"request_timeout": 15,
|
||||
"on_demand_duration": None,
|
||||
"log_level": "INFO",
|
||||
@@ -125,10 +129,13 @@ class LEDMatrixClient:
|
||||
"""
|
||||
|
||||
def __init__(self, api_base: str, timeout: int = 15,
|
||||
session: Optional[requests.Session] = None):
|
||||
session: Optional[requests.Session] = None,
|
||||
api_token: Optional[str] = None):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.session = session or requests.Session()
|
||||
if api_token:
|
||||
self.session.headers["Authorization"] = f"Bearer {api_token}"
|
||||
|
||||
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||
url = f"{self.api_base}/api/v3{path}"
|
||||
@@ -403,7 +410,8 @@ class Bridge:
|
||||
self.status_topic = f"{self.command_topic}/status"
|
||||
self.state_topic = f"{self.command_topic}/state"
|
||||
self.availability_topic = f"{self.command_topic}/availability"
|
||||
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"])
|
||||
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"],
|
||||
api_token=config.get("ledmatrix_api_token") or None)
|
||||
self.handler = CommandHandler(self.client, config.get("on_demand_duration"))
|
||||
self._stop = threading.Event()
|
||||
self._mqtt = None
|
||||
|
||||
@@ -37,6 +37,7 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
|
||||
| `prove_security.py` | keep | Security property checks run by pre-commit |
|
||||
| `render_bench.py` | diagnostic | Benchmarks the render loop against the panel's real refresh rate on a synthetic strip |
|
||||
| `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG |
|
||||
| `reset_web_password.py` | keep | Turns the optional web login off when the password is lost (`sudo python3 scripts/reset_web_password.py`; docs/WEB_INTERFACE_GUIDE.md) |
|
||||
| `run_plugin_tests.py` | dev-only | Discovers and runs plugin test suites |
|
||||
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
|
||||
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
|
||||
|
||||
@@ -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())
|
||||
@@ -43,11 +43,14 @@ CORE_CONFIG_KEYS = frozenset({
|
||||
})
|
||||
|
||||
#: Top-level keys of ``config_secrets.json`` that belong to the core rather than
|
||||
#: to a plugin: the GitHub token the Plugin Store reads, and the historical
|
||||
#: ``youtube`` section. Plugin secrets are namespaced by plugin id, so anything
|
||||
#: deciding whether a secrets section is a plugin's needs this as well as
|
||||
#: ``CORE_CONFIG_KEYS``.
|
||||
#: to a plugin: the GitHub token the Plugin Store reads, the historical
|
||||
#: ``youtube`` section, and ``web_auth`` (the optional web login's password
|
||||
#: hash and API-token hashes, web_interface/auth.py) -- which orphan-plugin
|
||||
#: cleanup would otherwise delete, logging everyone out. Plugin secrets are
|
||||
#: namespaced by plugin id, so anything deciding whether a secrets section is a
|
||||
#: plugin's needs this as well as ``CORE_CONFIG_KEYS``.
|
||||
CORE_SECRETS_KEYS = frozenset({
|
||||
'github',
|
||||
'youtube',
|
||||
'web_auth',
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
|
||||
from src.plugin_system.plugin_dirs import PluginDirectoryIndex
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.logging_config import get_logger
|
||||
@@ -293,11 +293,11 @@ class StateReconciliation:
|
||||
# Top-level config keys that are NOT plugins. The core keys come from the
|
||||
# shared list in src/core_config_keys.py -- a private copy here missed
|
||||
# #581's 'auto_update' and reported it as a plugin missing from disk.
|
||||
# 'github'/'youtube' are the historical secrets-file keys. The secrets file
|
||||
# CORE_SECRETS_KEYS are the core's own secrets-file keys. The secrets file
|
||||
# itself is read at run time too (ignored_config_keys): load_config() merges
|
||||
# it in, and naming its keys one by one let a 'data' key become a phantom
|
||||
# plugin permanently reported as "in config but not on disk".
|
||||
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
|
||||
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | CORE_SECRETS_KEYS
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from config file."""
|
||||
|
||||
Vendored
+50
@@ -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_delete",
|
||||
|
||||
@@ -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
@@ -64,8 +64,11 @@ config_manager = ConfigManager()
|
||||
# server's own host; requests with neither header (curl, Home Assistant, the
|
||||
# MQTT bridge) are not from a browser and pass. There are no CSRF tokens:
|
||||
# neither the HTMX forms nor the fetch() calls carry one. Anyone who can reach
|
||||
# the port directly can still use the API, so exposing the UI beyond a trusted
|
||||
# network still needs real authentication.
|
||||
# the port directly can still use the API unless the optional login is on:
|
||||
# web_interface/auth.py (registered below the captive-portal redirect) adds a
|
||||
# password and API tokens. It is off until a password is set in General >
|
||||
# Security, and even then leaves requests from the Pi itself and the Wi-Fi
|
||||
# setup flow in access-point mode open.
|
||||
|
||||
# Initialize rate limiting (prevent accidental abuse, not security)
|
||||
try:
|
||||
@@ -504,6 +507,8 @@ def captive_portal_redirect():
|
||||
'/connecttest.txt', # Windows detection
|
||||
'/success.txt', # Firefox detection
|
||||
'/favicon.ico', # Favicon
|
||||
'/login', # Optional web login (web_interface/auth.py)
|
||||
'/logout',
|
||||
]
|
||||
|
||||
for allowed_path in allowed_paths:
|
||||
@@ -513,6 +518,13 @@ def captive_portal_redirect():
|
||||
# Redirect to lightweight captive portal setup page (not the full UI)
|
||||
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
||||
|
||||
# Optional login (off until a password is set in General > Security). After
|
||||
# the captive-portal redirect, so in AP mode an unknown path still lands on
|
||||
# /setup rather than on the login page; the setup flow itself stays open.
|
||||
from web_interface import auth as web_auth
|
||||
web_auth.init_app(app, config_manager, limiter=limiter,
|
||||
is_ap_mode_active=is_ap_mode_active)
|
||||
|
||||
# Append a content-version query param (file mtime) to every static URL so the
|
||||
# long-lived `immutable` cache (see add_security_headers below) is actually safe:
|
||||
# when a static file changes its URL changes, so browsers refetch it. Without
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
settings = dict(_MQTT_BRIDGE_DEFAULTS)
|
||||
# Write-only credentials: never in _MQTT_BRIDGE_DEFAULTS, which is what
|
||||
# the GET route echoes back.
|
||||
settings['mqtt_password'] = None
|
||||
settings['ledmatrix_api_token'] = None
|
||||
try:
|
||||
if _MQTT_BRIDGE_CONFIG.is_file():
|
||||
with open(_MQTT_BRIDGE_CONFIG, encoding='utf-8') as handle:
|
||||
@@ -2320,6 +2323,7 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402,F401
|
||||
plugins,
|
||||
starlark,
|
||||
system,
|
||||
web_login,
|
||||
wifi,
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from src.display_geometry import ORIENTATION_ROTATE_DEGREES
|
||||
from src.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message
|
||||
from src.pi5_matrix_support import is_raspberry_pi_5
|
||||
from web_interface.cache import invalidate_cache
|
||||
from web_interface.auth import SECTION as _WEB_AUTH_SECTION, strip_auth_section
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
@@ -78,7 +79,11 @@ def get_main_config():
|
||||
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
||||
|
||||
config = api_v3.config_manager.load_config()
|
||||
return jsonify({'status': 'success', 'data': _redact_credentials(config)})
|
||||
# load_config() merges config_secrets.json in, web_auth (the login
|
||||
# password hash, token hashes and cookie key) included. No client needs
|
||||
# any of it; /api/v3/auth/* manages it.
|
||||
return jsonify({'status': 'success',
|
||||
'data': _redact_credentials(strip_auth_section(config))})
|
||||
@api_v3.route('/config/schedule', methods=['GET'])
|
||||
def get_schedule_config():
|
||||
"""Get current schedule configuration"""
|
||||
@@ -470,6 +475,11 @@ def save_main_config():
|
||||
if key in data:
|
||||
data[key] = data[key] == 'on'
|
||||
|
||||
# The login settings are secrets with their own routes
|
||||
# (/api/v3/auth/*); a web_auth key here would land in config.json.
|
||||
if isinstance(data, dict):
|
||||
data.pop(_WEB_AUTH_SECTION, None)
|
||||
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
|
||||
@@ -1148,8 +1158,10 @@ def get_secrets_config():
|
||||
# credentials. It was handing all of them to anyone who could reach
|
||||
# the port. Values are masked; empty and YOUR_* placeholders are left
|
||||
# alone so a client can still tell "set" from "not set".
|
||||
# web_auth is left out altogether, not masked: it is managed by
|
||||
# /api/v3/auth/*, and the raw save below keeps whatever is stored.
|
||||
return jsonify({'status': 'success',
|
||||
'data': mask_all_secret_values(config)})
|
||||
'data': mask_all_secret_values(strip_auth_section(config))})
|
||||
def _raw_config_save_error(e):
|
||||
"""The 500 both raw-config save routes answer a failed save with.
|
||||
|
||||
@@ -1245,6 +1257,10 @@ def save_raw_secrets_config():
|
||||
# The cost is that a secret can no longer be cleared by blanking it.
|
||||
# That needs its own affordance; a control that erases credentials as
|
||||
# a side effect of saving an unrelated one is not it.
|
||||
# The login section never reaches this editor (see the GET above) and
|
||||
# is not written from it: a hand-typed password_hash would be a
|
||||
# plaintext that no password matches. The stored one is kept.
|
||||
data.pop(_WEB_AUTH_SECTION, None)
|
||||
current = api_v3.config_manager.get_raw_file_content('secrets') or {}
|
||||
merged = deep_merge(current, strip_masked_values(data))
|
||||
api_v3.config_manager.save_raw_file_content('secrets', merged)
|
||||
|
||||
@@ -17,6 +17,7 @@ from src.common.path_safety import safe_path_component
|
||||
from src.common import sync_manager as _sync
|
||||
from src import error_aggregator as _errors
|
||||
from web_interface import display_preview
|
||||
from web_interface.auth import request_is_authenticated
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
@@ -124,6 +125,11 @@ def get_health():
|
||||
if not all_healthy:
|
||||
health_status['status'] = 'degraded'
|
||||
|
||||
if not request_is_authenticated():
|
||||
# Web login is on and this caller has not logged in: the route
|
||||
# stays open for uptime monitors, but says only up or degraded.
|
||||
return jsonify({'status': 'success',
|
||||
'data': {'status': health_status['status']}})
|
||||
return jsonify({'status': 'success', 'data': health_status})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
@@ -444,6 +450,8 @@ def get_mqtt_bridge():
|
||||
'config': safe,
|
||||
# Enough to render "a password is set" without disclosing it.
|
||||
'password_set': bool(password),
|
||||
# Likewise the web-login API token (only needed off-Pi).
|
||||
'api_token_set': bool(config.get('ledmatrix_api_token')),
|
||||
'env_override_prefix': 'LEDMATRIX_MQTT_',
|
||||
}
|
||||
})
|
||||
@@ -497,6 +505,15 @@ def update_mqtt_bridge_config():
|
||||
else:
|
||||
config['mqtt_password'] = existing_password
|
||||
|
||||
# The web-login API token is write-only the same way.
|
||||
if _coerce_to_bool(data.get('clear_api_token')):
|
||||
config['ledmatrix_api_token'] = None
|
||||
elif 'ledmatrix_api_token' in data and str(data['ledmatrix_api_token']).strip() != '':
|
||||
new_token = str(data['ledmatrix_api_token']).strip()
|
||||
if len(new_token) > 200:
|
||||
return jsonify({'status': 'error', 'message': 'API token is too long'}), 400
|
||||
config['ledmatrix_api_token'] = new_token
|
||||
|
||||
# CWE-319: a password with TLS off is sent in the clear. On a trusted
|
||||
# LAN that is a normal, deliberate setup, so this is refused rather
|
||||
# than forbidden -- allow_insecure_mqtt is the explicit acknowledgement.
|
||||
@@ -534,6 +551,7 @@ def update_mqtt_bridge_config():
|
||||
message += ' Restart the bridge for them to take effect.'
|
||||
return jsonify({'status': 'success', 'message': message,
|
||||
'data': {'password_set': bool(config.get('mqtt_password')),
|
||||
'api_token_set': bool(config.get('ledmatrix_api_token')),
|
||||
'restart_required': service['active']}})
|
||||
except Exception as e:
|
||||
logger.exception('Error saving MQTT bridge settings')
|
||||
|
||||
@@ -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()}})
|
||||
@@ -475,7 +475,25 @@ def _load_general_partial():
|
||||
auto_update_status = None
|
||||
return render_template('v3/partials/general.html',
|
||||
main_config=main_config,
|
||||
auto_update_status=auto_update_status)
|
||||
auto_update_status=auto_update_status,
|
||||
web_login=_web_login_state())
|
||||
|
||||
|
||||
def _web_login_state():
|
||||
"""What the General tab's Security section shows; None hides it.
|
||||
|
||||
None when the app has no login store (a bare test app), so the section
|
||||
only appears where it can work. Never includes a hash.
|
||||
"""
|
||||
from web_interface import auth as web_auth
|
||||
store = web_auth.get_store()
|
||||
if store is None:
|
||||
return None
|
||||
return {
|
||||
'enabled': store.is_enabled(),
|
||||
'tokens': store.list_tokens(),
|
||||
'min_length': web_auth.MIN_PASSWORD_LENGTH,
|
||||
}
|
||||
|
||||
def _load_display_partial():
|
||||
"""Load display settings partial"""
|
||||
@@ -592,7 +610,11 @@ def _load_raw_json_partial():
|
||||
"""Load raw JSON editor partial"""
|
||||
if pages_v3.config_manager:
|
||||
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
||||
secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets')
|
||||
# The web login section (password and token hashes) is managed in
|
||||
# General > Security, never in this editor; its save keeps it.
|
||||
from web_interface.auth import strip_auth_section
|
||||
secrets_config_data = strip_auth_section(
|
||||
pages_v3.config_manager.get_raw_file_content('secrets'))
|
||||
main_config_json = json.dumps(main_config_data, indent=4)
|
||||
secrets_config_json = json.dumps(secrets_config_data, indent=4)
|
||||
|
||||
|
||||
@@ -23,6 +23,27 @@
|
||||
};
|
||||
</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) -->
|
||||
<script>
|
||||
(function() {
|
||||
@@ -352,6 +373,16 @@
|
||||
class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></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 -->
|
||||
<button id="theme-toggle"
|
||||
type="button"
|
||||
|
||||
@@ -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>
|
||||
</form>
|
||||
</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 <token></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 }}…</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-client-id', 'Client ID', c.mqtt_client_id)}
|
||||
${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 > 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-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}
|
||||
<label class="block">
|
||||
@@ -1113,6 +1123,8 @@
|
||||
|
||||
const clearBtn = document.getElementById('mqtt-clear-password');
|
||||
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
||||
const clearTokenBtn = document.getElementById('mqtt-clear-api-token');
|
||||
if (clearTokenBtn) clearTokenBtn.addEventListener('click', () => clearMqttApiToken());
|
||||
}
|
||||
|
||||
window.loadMqttBridge = function() {
|
||||
@@ -1145,6 +1157,8 @@
|
||||
// Only send a password when one was typed; blank means "leave it alone".
|
||||
const pw = val('mqtt-password');
|
||||
if (pw) body.mqtt_password = pw;
|
||||
const apiToken = val('mqtt-api-token');
|
||||
if (apiToken) body.ledmatrix_api_token = apiToken;
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -1180,6 +1194,21 @@
|
||||
.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() {
|
||||
// Installing pulls dependencies, so it is slower than the other actions
|
||||
// and worth saying so rather than leaving a spinner unexplained.
|
||||
|
||||
Reference in New Issue
Block a user