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
@@ -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)