Files
LEDMatrix/web_interface/blueprints/api_v3/web_login.py
T
ChuckandClaude Opus 5.5 e3c85cece6 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>
2026-09-30 09:09:40 -04:00

177 lines
7.2 KiB
Python

"""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()}})