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