mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user