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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 09:09:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ba38a83c2c
commit e3c85cece6
23 changed files with 2310 additions and 26 deletions
+14 -2
View File
@@ -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
+632
View File
@@ -0,0 +1,632 @@
"""
Optional login for the web interface, off by default.
Nothing here changes a device that has not set a password: with no password
stored, the hook below returns immediately and every page and API route
answers exactly as before. The cross-site Origin guard
(``web_interface/origin_guard.py``) is separate and always on.
Turning it on is setting a password (General tab -> Security). From then on
every page and API route needs one of:
* a login session (the ``/login`` page; a signed cookie, 30 days);
* an API token, sent as ``Authorization: Bearer <token>`` (for Home Assistant,
scripts and the MQTT bridge when it runs on another machine).
and these stay open without either:
* requests from the Pi itself (loopback, and no proxy headers -- a reverse
proxy on the same Pi would otherwise make every request look local);
* the Wi-Fi setup flow (``/setup`` and the status/scan/connect routes it
calls) while the Pi is in access-point mode, so a Pi that lost its network
can still be put back on one;
* static assets, the captive-portal probe URLs, the login page itself, and
``/api/v3/health``, which then answers only its overall status.
Where it is stored: the ``web_auth`` section of ``config/config_secrets.json``,
the file every other credential lives in. The password is a werkzeug hash
(``generate_password_hash``); each API token is stored as a SHA-256 of the
token, which is safe for a 256-bit random value and cheap enough to check on
every request, and is shown once, when it is created. The section also holds
the key that signs login cookies, so they survive a restart and die with a
password change. This module reads that file directly (cached on its mtime)
rather than through the merged config, so a ``web_auth`` key smuggled into
``config.json`` means nothing. The config API and the raw-JSON editor leave
the section out entirely.
Lost password: ``sudo python3 scripts/reset_web_password.py`` on the Pi (or
open the interface from the Pi itself, which is always allowed). See
docs/WEB_INTERFACE_GUIDE.md.
"""
import hashlib
import hmac
import json
import logging
import os
import secrets
import threading
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import unquote, urlsplit
from flask import (Blueprint, Flask, current_app, g, jsonify, redirect,
render_template, request, session, url_for)
from flask.sessions import SecureCookieSessionInterface
from itsdangerous import URLSafeTimedSerializer
from werkzeug.security import check_password_hash, generate_password_hash
logger = logging.getLogger('web_interface.auth')
#: The config_secrets.json section this module owns.
SECTION = 'web_auth'
#: Key under Flask's app.extensions.
EXTENSION_KEY = 'ledmatrix_auth'
MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_LENGTH = 256
MAX_TOKENS = 50
MAX_TOKEN_NAME_LENGTH = 60
TOKEN_PREFIX = 'lmx_' # nosec B105 - token prefix, not a credential
SESSION_KEY = 'ledmatrix_auth'
SESSION_LIFETIME = timedelta(days=30)
#: Failed attempts only (see _counts_as_a_failure). Per client address.
LOGIN_RATE_LIMIT = '5 per minute;30 per hour'
_LOOPBACK_ADDRESSES = frozenset({'127.0.0.1', '::1', '::ffff:127.0.0.1'})
#: A request carrying any of these came through a proxy, so its loopback
#: address says nothing about where the user is.
_PROXY_HEADERS = ('X-Forwarded-For', 'X-Real-IP', 'Forwarded', 'X-Forwarded-Host')
#: Open whether or not auth is on: the login page, core static files, the
#: captive-portal probes (they answer fixed text, or redirect to /setup in AP
#: mode) and the favicon.
_ALWAYS_OPEN_ENDPOINTS = frozenset({
'static',
'ledmatrix_auth.login',
'ledmatrix_auth.logout',
'hotspot_detect', 'generate_204', 'connecttest_txt', 'success_txt',
'favicon',
})
#: Open without a session, but answering only a minimal body (see
#: request_is_authenticated).
_HEALTH_ENDPOINTS = frozenset({'api_v3.get_health'})
#: The Wi-Fi setup flow captive_setup.html drives. Open only in AP mode.
_AP_SETUP_ENDPOINTS = frozenset({
'pages_v3.captive_setup', 'pages_v3_legacy.captive_setup',
'api_v3.get_wifi_status', 'api_v3.scan_wifi_networks', 'api_v3.connect_wifi',
})
class AuthError(Exception):
"""A request to change auth settings that cannot be applied as asked.
``user_message`` is a fixed sentence written in this module for the
person on the settings page; it never carries data from anywhere else,
so it is what the routes return (never ``str()`` of an exception).
"""
def __init__(self, user_message: str):
super().__init__(user_message)
self.user_message = user_message
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def _token_digest(token: str) -> str:
return hashlib.sha256(token.encode('utf-8')).hexdigest()
class AuthStore:
"""The ``web_auth`` section of config_secrets.json.
Reads are cached against the file's (mtime, size), so the per-request
check costs one ``stat``, and a change made by another process (the reset
script, a backup restore) is seen on the next request. Writes go through
``ConfigManager.save_raw_file_content`` like every other secrets save:
atomic, with the permissions secrets get.
"""
def __init__(self, config_manager):
self._config_manager = config_manager
self._lock = threading.RLock()
self._signature: Any = object() # never equal to a real signature
self._failed_signature: Any = None
self._section: Dict[str, Any] = {}
# -- reading ---------------------------------------------------------
def _path(self) -> str:
return self._config_manager.get_secrets_path()
def _file_signature(self):
try:
st = os.stat(self._path())
except FileNotFoundError:
return None
except OSError:
return 'unreadable'
return (st.st_mtime_ns, st.st_size)
def _read_secrets_strict(self) -> Dict[str, Any]:
"""The whole secrets file; {} when absent. Raises on anything else.
Strict on purpose, unlike ConfigManager.get_raw_file_content (which
answers {} for an unreadable file): a write built on that {} would
replace every other credential in the file.
"""
try:
with open(self._path(), 'r', encoding='utf-8') as fh:
data = json.load(fh)
except FileNotFoundError:
return {}
if not isinstance(data, dict):
raise ValueError(f'{self._path()} does not hold a JSON object')
return data
def section(self) -> Dict[str, Any]:
"""The current ``web_auth`` section (a cached dict; do not mutate)."""
signature = self._file_signature()
with self._lock:
if signature == self._signature:
return self._section
try:
data = self._read_secrets_strict()
except (OSError, ValueError) as err:
# Keep the last good answer rather than guess. A file that was
# never readable leaves auth off, as the rest of the app treats
# an unreadable secrets file as "no secrets". Logged once per
# version of the file, not once per request.
if signature != self._failed_signature:
self._failed_signature = signature
logger.error("Could not read %s for web login settings: %s",
self._path(), err)
return self._section
raw = data.get(SECTION)
self._section = raw if isinstance(raw, dict) else {}
self._signature = signature
self._failed_signature = None
return self._section
def is_enabled(self) -> bool:
return bool(self.section().get('password_hash'))
def session_secret(self) -> Optional[str]:
secret = self.section().get('session_secret')
return secret if isinstance(secret, str) and secret else None
def check_password(self, password: Any) -> bool:
stored = self.section().get('password_hash')
if not stored or not isinstance(stored, str) or not isinstance(password, str):
return False
if len(password) > MAX_PASSWORD_LENGTH:
return False
try:
return check_password_hash(stored, password)
except (ValueError, TypeError) as err:
# The type only: the message could quote part of the stored value.
logger.error("The stored web login is not usable (%s); reset it "
"with scripts/reset_web_password.py", type(err).__name__)
return False
def _raw_tokens(self) -> List[Dict[str, Any]]:
tokens = self.section().get('tokens')
if not isinstance(tokens, list):
return []
return [t for t in tokens if isinstance(t, dict)]
def list_tokens(self) -> List[Dict[str, Any]]:
"""Token records without their hashes, oldest first."""
return [_public_token(t) for t in self._raw_tokens()]
def verify_token(self, presented: Any) -> Optional[Dict[str, Any]]:
"""The public record of the token ``presented`` is, or None."""
if not isinstance(presented, str) or not presented or len(presented) > 200:
return None
digest = _token_digest(presented)
match = None
for record in self._raw_tokens():
stored = record.get('hash')
# Every record is compared, so the time taken does not depend on
# which one matched.
if isinstance(stored, str) and hmac.compare_digest(stored, digest):
match = record
return _public_token(match) if match else None
# -- writing ---------------------------------------------------------
def _update(self, mutate: Callable[[Dict[str, Any]], Any]) -> Any:
with self._lock:
data = self._read_secrets_strict()
current = data.get(SECTION)
section = dict(current) if isinstance(current, dict) else {}
result = mutate(section)
if section:
data[SECTION] = section
else:
data.pop(SECTION, None)
self._config_manager.save_raw_file_content('secrets', data)
self._signature = object() # re-read on next access
return result
def set_password(self, password: str) -> None:
"""Store a new password (turning login on) and sign everyone out.
A new cookie-signing key is generated with it, so every session made
under the old password stops working.
"""
problem = password_problem(password)
if problem:
raise AuthError(problem)
hashed = generate_password_hash(password)
def mutate(section):
section['password_hash'] = hashed
section['session_secret'] = secrets.token_hex(32)
section['password_set_at'] = _now_iso()
self._update(mutate)
def disable(self) -> None:
"""Remove the password (login off). API tokens are kept."""
def mutate(section):
for key in ('password_hash', 'session_secret', 'password_set_at'):
section.pop(key, None)
self._update(mutate)
def create_token(self, name: str) -> Tuple[Dict[str, Any], str]:
"""A new token: (public record, the token itself -- shown once)."""
name = (name or '').strip()
if not name:
raise AuthError('Give the token a name, such as "Home Assistant".')
if len(name) > MAX_TOKEN_NAME_LENGTH:
raise AuthError(f'Token names are at most {MAX_TOKEN_NAME_LENGTH} characters.')
token = TOKEN_PREFIX + secrets.token_urlsafe(32)
record = {
'id': secrets.token_hex(8),
'name': name,
'hash': _token_digest(token),
'prefix': token[:len(TOKEN_PREFIX) + 4],
'created_at': _now_iso(),
}
def mutate(section):
tokens = [t for t in (section.get('tokens') or []) if isinstance(t, dict)]
if len(tokens) >= MAX_TOKENS:
raise AuthError(f'There are already {MAX_TOKENS} tokens; revoke one first.')
tokens.append(record)
section['tokens'] = tokens
self._update(mutate)
return _public_token(record), token
def revoke_token(self, token_id: str) -> bool:
"""Delete a token by id. False when there is no such token."""
def mutate(section):
tokens = [t for t in (section.get('tokens') or []) if isinstance(t, dict)]
kept = [t for t in tokens if t.get('id') != token_id]
if len(kept) == len(tokens):
return False
if kept:
section['tokens'] = kept
else:
section.pop('tokens', None)
return True
# Check first, so revoking an unknown id writes nothing.
if not any(t.get('id') == token_id for t in self._raw_tokens()):
return False
return self._update(mutate)
def _public_token(record: Dict[str, Any]) -> Dict[str, Any]:
return {key: record.get(key) for key in ('id', 'name', 'prefix', 'created_at')}
def password_problem(password: Any) -> Optional[str]:
"""Why ``password`` cannot be used, or None."""
if not isinstance(password, str) or len(password) < MIN_PASSWORD_LENGTH:
return f'Use at least {MIN_PASSWORD_LENGTH} characters.'
if len(password) > MAX_PASSWORD_LENGTH:
return f'Use at most {MAX_PASSWORD_LENGTH} characters.'
if password.strip() != password:
return 'The password cannot start or end with a space.'
return None
def strip_auth_section(data: Any) -> Any:
"""A shallow copy of ``data`` without the ``web_auth`` section.
For every response that dumps a whole config or secrets dict. The section
holds the password hash, the token hashes and the cookie-signing key; no
client needs any of them, and the dedicated /api/v3/auth routes manage it.
"""
if isinstance(data, dict) and SECTION in data:
data = {k: v for k, v in data.items() if k != SECTION}
return data
# -- request-time helpers --------------------------------------------------
def get_store(app: Optional[Flask] = None) -> Optional[AuthStore]:
app = app or current_app
return app.extensions.get(EXTENSION_KEY)
def is_local_request() -> bool:
"""From this machine, and not relayed by a proxy on it."""
address = request.remote_addr or ''
if address not in _LOOPBACK_ADDRESSES and not address.startswith('127.'):
return False
return not any(header in request.headers for header in _PROXY_HEADERS)
def bearer_token() -> Optional[str]:
header = request.headers.get('Authorization', '')
scheme, _, value = header.partition(' ')
if scheme.lower() != 'bearer':
return None
return value.strip() or None
def request_is_authenticated() -> bool:
"""True unless this request got in only through an open endpoint.
Always True when login is off. The health route uses it to decide how
much to say.
"""
return getattr(g, 'ledmatrix_auth_via', 'open') != 'unauthenticated'
def auth_via() -> str:
"""How this request was let in: open, localhost, session, token,
ap-setup, or unauthenticated (an always-open endpoint)."""
return getattr(g, 'ledmatrix_auth_via', 'open')
def sign_in_this_session() -> None:
session.clear()
session[SESSION_KEY] = True
session.permanent = True
def _leaves_this_server(path: str) -> bool:
"""Whether a browser could read ``path`` as another site, or it is not
plainly printable.
``//host`` and ``/\\host`` are protocol-relative (browsers treat ``\\``
as ``/``), so no backslash is accepted anywhere; control characters are
stripped or mangled by browsers and can smuggle either form past a
prefix check.
"""
if path.startswith('//') or '\\' in path:
return True
return any(ord(c) < 32 or ord(c) == 127 for c in path)
def safe_next(target: Any) -> str:
"""``target`` if it is a local path to return to after login, else ``/``.
Only a path on this server is accepted: it starts with a single ``/``,
has no scheme or host, and is not ``//host`` or ``/\\host`` (which
browsers read as another site) either as sent or once percent-decoded.
"""
if not isinstance(target, str) or not target.startswith('/'):
return '/'
if _leaves_this_server(target) or _leaves_this_server(unquote(target)):
return '/'
parts = urlsplit(target)
if parts.scheme or parts.netloc:
return '/'
if parts.path.rstrip('/') in ('/login', '/logout', '/v3/login', '/v3/logout'):
return '/'
# Rebuilt behind a constant '/': with the checks above, what follows it
# cannot start another authority, so the result is a path on this server.
return '/' + target[1:]
def _return_path() -> str:
"""Where the login page should send the user back to."""
if request.headers.get('HX-Request') == 'true':
current = request.headers.get('HX-Current-URL', '')
parts = urlsplit(current)
path = parts.path or '/'
return safe_next(path + (f'?{parts.query}' if parts.query else ''))
if _is_page_navigation():
path = request.full_path if request.query_string else request.path
return safe_next(path)
return '/'
def _is_page_navigation() -> bool:
if request.method not in ('GET', 'HEAD') or request.path.startswith('/api/'):
return False
mode = request.headers.get('Sec-Fetch-Mode')
if mode is not None:
return mode == 'navigate'
return 'text/html' in request.headers.get('Accept', '')
def _login_url(next_path: str) -> str:
if next_path and next_path != '/':
return url_for('ledmatrix_auth.login', next=next_path)
return url_for('ledmatrix_auth.login')
def _refuse(error_code: str, message: str):
"""401 in the form the caller can act on.
A page load is redirected to the login page. An HTMX request gets
``HX-Redirect``, which htmx follows whatever the status. Anything else
(fetch, scripts, EventSource) gets JSON, with the login URL in
``X-LEDMatrix-Login`` for the interface's own fetch() wrapper.
"""
login_url = _login_url(_return_path())
if _is_page_navigation() and request.headers.get('HX-Request') != 'true':
return redirect(login_url)
response = jsonify({'status': 'error', 'error_code': error_code, 'message': message})
response.status_code = 401
response.headers['WWW-Authenticate'] = 'Bearer realm="LEDMatrix"'
response.headers['X-LEDMatrix-Login'] = login_url
if request.headers.get('HX-Request') == 'true':
response.headers['HX-Redirect'] = login_url
return response
class _AuthSessionInterface(SecureCookieSessionInterface):
"""Signs the session cookie with the stored key once login is on.
``app.secret_key`` is random per process, which would sign everybody out
on every restart. The stored key lives next to the password and is
replaced whenever the password is, which is what signs every other
browser out after a password change.
"""
def __init__(self, store: AuthStore):
self._store = store
def get_signing_serializer(self, app):
secret = self._store.session_secret()
if not secret:
return super().get_signing_serializer(app)
return URLSafeTimedSerializer(
secret,
salt=self.salt,
serializer=self.serializer,
signer_kwargs={
'key_derivation': self.key_derivation,
'digest_method': self.digest_method,
},
)
# -- login / logout pages ----------------------------------------------------
auth_pages = Blueprint('ledmatrix_auth', __name__)
@auth_pages.route('/login', methods=['GET', 'POST'])
def login():
store = get_store()
next_path = safe_next(request.values.get('next', '/'))
if store is None or not store.is_enabled():
return redirect(next_path)
if request.method == 'GET':
if session.get(SESSION_KEY):
return redirect(next_path)
return render_template('v3/login.html', error=None, next_path=next_path)
if store.check_password(request.form.get('password', '')):
sign_in_this_session()
logger.info("Web login from %s", request.remote_addr)
return redirect(next_path)
logger.warning("Failed web login from %s", request.remote_addr)
# 401 is what the rate limit counts (see _counts_as_a_failure).
return render_template('v3/login.html', next_path=next_path,
error='That password is not right. Try again.'), 401
@auth_pages.route('/logout', methods=['POST'])
def logout():
session.clear()
store = get_store()
if store is not None and store.is_enabled():
return redirect(url_for('ledmatrix_auth.login'))
return redirect('/')
@auth_pages.errorhandler(429)
def _login_rate_limited(_error):
"""The login page's own answer to too many wrong passwords.
Scoped to this blueprint, so the API keeps its JSON 429s.
"""
return render_template(
'v3/login.html',
next_path=safe_next(request.values.get('next', '/')),
error='Too many wrong passwords. Wait a minute and try again.'), 429
def _counts_as_a_failure(response) -> bool:
"""Only wrong passwords use up the login rate limit.
401 from the login form; 403 from the settings routes that ask for the
current password.
"""
return response.status_code in (401, 403)
def init_app(app: Flask, config_manager, *, limiter=None,
is_ap_mode_active: Optional[Callable[[], bool]] = None,
store: Optional[AuthStore] = None) -> AuthStore:
"""Register the login pages, the session signer and the access hook.
Register it after the captive-portal redirect, so in AP mode an unknown
path still goes to /setup rather than to the login page.
"""
store = store or AuthStore(config_manager)
app.extensions[EXTENSION_KEY] = store
app.session_interface = _AuthSessionInterface(store)
app.config['PERMANENT_SESSION_LIFETIME'] = SESSION_LIFETIME
app.config.setdefault('SESSION_COOKIE_SAMESITE', 'Lax')
app.config.setdefault('SESSION_COOKIE_HTTPONLY', True)
app.register_blueprint(auth_pages)
ap_mode_active = is_ap_mode_active or (lambda: False)
if limiter is not None:
# flask-limiter enforces a decorated limit inside the wrapper it
# returns, not in its before_request hook, so the wrapper has to be
# what the URL map calls: decorating an already-registered function
# and discarding the result limits nothing.
limits = {'ledmatrix_auth.login': {'methods': ['POST']},
'api_v3.set_web_password': {},
'api_v3.disable_web_login': {}}
for endpoint, options in limits.items():
view = app.view_functions.get(endpoint)
if view is not None:
app.view_functions[endpoint] = limiter.limit(
LOGIN_RATE_LIMIT, deduct_when=_counts_as_a_failure, **options)(view)
else:
logger.warning("flask-limiter is not installed: failed web logins are "
"not rate-limited. Install web_interface/requirements.txt.")
@app.before_request
def _require_login():
if not store.is_enabled():
return None # login off: nothing changes
if request.method == 'OPTIONS':
return None
endpoint = request.endpoint or ''
if endpoint in _ALWAYS_OPEN_ENDPOINTS:
return None
if session.get(SESSION_KEY):
g.ledmatrix_auth_via = 'session'
return None
token = bearer_token()
if token is not None:
if store.verify_token(token):
g.ledmatrix_auth_via = 'token'
return None
return _refuse('INVALID_TOKEN', 'That API token is not valid. It may '
'have been revoked; create a new one in the web '
'interface under General > Security.')
if is_local_request():
g.ledmatrix_auth_via = 'localhost'
return None
if endpoint in _HEALTH_ENDPOINTS:
g.ledmatrix_auth_via = 'unauthenticated'
return None
if endpoint in _AP_SETUP_ENDPOINTS and ap_mode_active():
g.ledmatrix_auth_via = 'ap-setup'
return None
return _refuse('AUTH_REQUIRED', 'Log in to the LEDMatrix interface, or '
'send an API token as "Authorization: Bearer <token>".')
@app.context_processor
def _auth_template_state():
enabled = store.is_enabled()
return {'web_auth_state': {
'enabled': enabled,
'signed_in': bool(enabled and session.get(SESSION_KEY)),
}}
return store
@@ -2238,7 +2238,10 @@ def _read_mqtt_bridge_config() -> Dict[str, Any]:
name shadows it.
"""
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,
)
+18 -2
View File
@@ -15,6 +15,7 @@ from src.display_geometry import ORIENTATION_ROTATE_DEGREES
from src.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message
from src.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)
+18
View File
@@ -17,6 +17,7 @@ from src.common.path_safety import safe_path_component
from src.common import sync_manager as _sync
from src 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()}})
+24 -2
View File
@@ -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)
+31
View File
@@ -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"
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log in - LED Matrix Control Panel</title>
<meta name="theme-color" content="#111827">
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='v3/icons/icon-192.png') }}">
<script>
// Same theme choice as the main interface (base.html), before CSS loads.
(function() {
var theme = null;
try { theme = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
if (!theme) {
try {
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} catch (e) { theme = 'light'; }
}
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
<style>
.login-wrap { max-width: 24rem; margin: 0 auto; padding: 4rem 1rem; }
.login-error { border: 1px solid #fecaca; border-radius: 0.375rem; padding: 0.75rem; }
.login-help { margin-top: 1.5rem; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<main class="login-wrap">
<div class="bg-white rounded-lg shadow p-6">
<h1 class="text-lg font-semibold text-gray-900 mb-2">
<i class="fas fa-tv text-blue-600 mr-2" aria-hidden="true"></i>LED Matrix Control
</h1>
<p class="text-sm text-gray-600 mb-6">This display's settings are protected by a password.</p>
{% if error %}
<div class="login-error bg-red-50 text-red-600 text-sm mb-4" role="alert">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('ledmatrix_auth.login') }}" class="space-y-4">
<input type="hidden" name="next" value="{{ next_path }}">
<div class="form-group">
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" id="password" name="password" required autofocus
autocomplete="current-password" class="form-control mt-1 w-full">
</div>
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md w-full">
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i>Log in
</button>
</form>
<p class="login-help text-sm text-gray-600">
Forgot it? On the Pi, run
<code class="bg-gray-100 font-mono">sudo python3 ~/LEDMatrix/scripts/reset_web_password.py</code>
(use the folder LEDMatrix is installed in), or open the interface from the Pi itself.
</p>
</div>
</main>
</body>
</html>
@@ -177,3 +177,246 @@
</div>
</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 &lt;token&gt;</code>.
A token is shown once, when you create it.
</p>
<div id="web-login-tokens" class="space-y-2 mb-4">
{% for token in web_login.tokens %}
<div class="flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2" data-token-id="{{ token.id }}">
<div class="text-sm">
<span class="font-semibold text-gray-900">{{ token.name }}</span>
<span class="font-mono text-gray-600 ml-2">{{ token.prefix }}&hellip;</span>
<span class="text-gray-600 ml-2">created {{ (token.created_at or '')[:10] }}</span>
</div>
<button type="button" class="text-sm text-red-600 hover:underline"
data-token-name="{{ token.name }}"
onclick="window.webLogin.revoke(this.closest('[data-token-id]').dataset.tokenId, this.dataset.tokenName)">
Revoke
</button>
</div>
{% else %}
<p class="text-sm text-gray-600" data-empty>No tokens yet.</p>
{% endfor %}
</div>
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.createToken(this); return false;" autocomplete="off">
<div class="flex-1">
<label for="web-login-token-name" class="block text-sm font-medium text-gray-700">New token name</label>
<input type="text" id="web-login-token-name" name="name" required maxlength="60"
placeholder="Home Assistant" class="form-control mt-1">
</div>
<button type="submit" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md">
<i class="fas fa-plus mr-2" aria-hidden="true"></i>Create token
</button>
</form>
<div id="web-login-new-token" class="hidden mt-4 border border-amber-300 bg-amber-50 rounded-md p-4" role="status">
<p class="text-sm font-semibold text-gray-900 mb-2">Copy this token now. It is not shown again.</p>
<div class="flex flex-wrap items-center gap-2">
<code id="web-login-new-token-value" class="font-mono break-all bg-white border border-gray-200 rounded-md px-4 py-2 flex-1"></code>
<button type="button" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md"
onclick="window.webLogin.copyToken()">
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy
</button>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
var API = '/api/v3/auth';
function notify(message, type) {
if (typeof window.showNotification === 'function') window.showNotification(message, type);
else window.alert(message);
}
function send(method, url, body) {
return fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: body === undefined ? undefined : JSON.stringify(body)
}).then(function(r) {
return r.json().catch(function() { return {}; }).then(function(d) { return {ok: r.ok, d: d}; });
});
}
function reloadSection() {
if (window.htmx) {
window.htmx.ajax('GET', '/v3/partials/general', {target: '#general-content', swap: 'innerHTML'});
} else {
window.location.reload();
}
}
function field(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : '';
}
function tokenRow(record) {
var row = document.createElement('div');
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
row.dataset.tokenId = record.id;
var text = document.createElement('div');
text.className = 'text-sm';
[['font-semibold text-gray-900', record.name],
['font-mono text-gray-600 ml-2', record.prefix + '…'],
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
var span = document.createElement('span');
span.className = part[0];
span.textContent = part[1];
text.appendChild(span);
});
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'text-sm text-red-600 hover:underline';
btn.textContent = 'Revoke';
btn.addEventListener('click', function() { window.webLogin.revoke(record.id, record.name); });
row.appendChild(text);
row.appendChild(btn);
return row;
}
window.webLogin = {
setPassword: function(form) {
var next = field(form, 'new_password');
if (next !== field(form, 'confirm_password')) {
notify('The two new passwords do not match.', 'error');
return;
}
var body = {new_password: next};
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
send('POST', API + '/password', body).then(function(res) {
notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection();
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
disable: function(form) {
if (!window.confirm('Turn login off? Anyone on your network will be able to open this page.')) return;
send('POST', API + '/disable', {current_password: field(form, 'current_password')}).then(function(res) {
notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection();
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
createToken: function(form) {
send('POST', API + '/tokens', {name: field(form, 'name')}).then(function(res) {
if (!res.ok) {
notify(res.d.message || 'Could not create the token', 'error');
return;
}
var list = document.getElementById('web-login-tokens');
if (list) {
var empty = list.querySelector('[data-empty]');
if (empty) empty.remove();
list.appendChild(tokenRow(res.d.data.record));
}
document.getElementById('web-login-new-token-value').textContent = res.d.data.token;
document.getElementById('web-login-new-token').classList.remove('hidden');
form.reset();
notify(res.d.message || 'Token created', 'success');
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
copyToken: function() {
var box = document.getElementById('web-login-new-token-value');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.textContent).then(function() { notify('Token copied', 'success'); });
return;
}
// Plain http on a LAN is not a secure context: select it instead.
var range = document.createRange();
range.selectNodeContents(box);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
},
revoke: function(id, name) {
if (!window.confirm('Revoke the token "' + name + '"? Anything using it stops working.')) return;
send('DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
if (!res.ok) return;
var rows = document.querySelectorAll('#web-login-tokens [data-token-id]');
Array.prototype.forEach.call(rows, function(row) {
if (row.dataset.tokenId === id) row.remove();
});
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
}
};
})();
</script>
{% endif %}
@@ -1059,6 +1059,16 @@
${field('mqtt-topic', 'Command topic', c.mqtt_topic)}
${field('mqtt-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 &gt; Security).
${data.api_token_set ? '<button type="button" id="mqtt-clear-api-token" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
</span>
</label>
${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')}
${field('mqtt-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.