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
+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.