Compare commits
2
Commits
84e8f074e8
...
886745a979
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
886745a979 | ||
|
|
e0fa69d470 |
@@ -507,6 +507,7 @@ Host myserver
|
||||
- **Virtual consoles** (Ctrl+Alt+F1-F8) - Optional, configurable during install
|
||||
- **X server key combinations disabled** (Ctrl+Alt+Backspace)
|
||||
- **Right-click disabled** in kiosk app
|
||||
- **Web UI login** - when password protection is on, the Web UI asks for the same PIN/password as the lock screen
|
||||
- **Screen blanking disabled** with schedule awareness
|
||||
- **DPMS management** - Aggressive keep-alive with schedule respect
|
||||
|
||||
@@ -1596,7 +1597,7 @@ full migration pass.
|
||||
**Previous (v2.16.0):**
|
||||
- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions.
|
||||
- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
|
||||
- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk.
|
||||
- **Login follows the kiosk's password protection.** With password protection on (Core Settings → Password Protection & Lockout), the Web UI asks for the same PIN/password the lock screen uses (a session lasts 12 hours; 5 wrong tries in a row slow it down like the lock screen does; changing the code signs every browser out). With protection off there is no login, so beyond a trusted LAN turn protection on or put it behind a reverse proxy (e.g. Caddy) with Authelia forward-auth — Authelia integration is out of scope for this repo. Direct LAN access with protection off has no authentication at all — treat it like SSH access to the kiosk.
|
||||
- Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only — a network-facing process shouldn't be handed `sudo`-level system mutation (netplan, `timedatectl`, `apt`, systemd timers) without a lot more thought than this pass gives it. A "restart kiosk display" action was left out for the same reason — would need a narrow polkit grant, a follow-up.
|
||||
- Wired into Complete Uninstall (`webui_do_uninstall`) and Clone Settings (addon-presence detection) the same way every other addon is.
|
||||
- This is the single-kiosk piece of the web-based GUI mentioned in this doc's "Modular Management" notes for a while — a central multi-kiosk fleet dashboard is an intentional follow-up, not part of this pass.
|
||||
|
||||
+2
-2
@@ -424,8 +424,8 @@ run_first_time_install() {
|
||||
echo " - Audio support (PipeWire)"
|
||||
echo " - Time synchronization (NTP)"
|
||||
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
|
||||
echo " addon install/reconfigure and Update - no login of its own,"
|
||||
echo " see Addons -> Web UI)"
|
||||
echo " addon install/reconfigure and Update - asks for the kiosk's"
|
||||
echo " PIN/password when password protection is on, see Addons -> Web UI)"
|
||||
echo
|
||||
echo "OPTIONAL (configure after install, via Addons):"
|
||||
echo " - Lyrion Music Server (LMS) / Squeezelite"
|
||||
|
||||
+13
-9
@@ -30,12 +30,15 @@
|
||||
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
|
||||
# stays terminal-only for now.
|
||||
#
|
||||
# No login of its own, by design: this addon assumes it'll be put behind
|
||||
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the
|
||||
# same way other self-hosted apps get protected - Authelia integration
|
||||
# is explicitly out of scope for this repo (Authelia runs elsewhere).
|
||||
# Direct LAN access with no proxy in front has no authentication at all -
|
||||
# treat it the same as SSH access to this kiosk, which is also the
|
||||
# Login: when the kiosk's password protection is on (Core Settings ->
|
||||
# Password Protection & Lockout), the Web UI asks for the same PIN/password
|
||||
# the lock screen uses (webui/lib/auth.js). With protection off it has no
|
||||
# login, so on a network you don't fully trust, turn protection on or put
|
||||
# it behind a reverse proxy (e.g. Caddy) with Authelia forward-auth in
|
||||
# front, the same way other self-hosted apps get protected - Authelia
|
||||
# integration is explicitly out of scope for this repo (Authelia runs
|
||||
# elsewhere). Direct LAN access with protection off has no authentication
|
||||
# at all - treat it the same as SSH access to this kiosk, which is also the
|
||||
# access level the privileged helper effectively grants if reached
|
||||
# without a proxy in front: bounded to its fixed action list, not a
|
||||
# root shell, but real system mutation all the same.
|
||||
@@ -274,9 +277,10 @@ action_webui_install() {
|
||||
echo "This installs a small web app for editing Sites & Page Timing,"
|
||||
echo "Display & Interaction, and Password Protection & Lockout from a"
|
||||
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
|
||||
echo "and Asterisk Intercom, and checking for updates. It has no login"
|
||||
echo "of its own - put it behind your own reverse proxy (e.g. Caddy +"
|
||||
echo "Authelia) if it needs to be reachable beyond a trusted LAN."
|
||||
echo "and Asterisk Intercom, and checking for updates. When the kiosk's"
|
||||
echo "password protection is on it asks for the same PIN/password; when it"
|
||||
echo "is off there is no login - turn protection on, or put it behind your"
|
||||
echo "own reverse proxy (e.g. Caddy + Authelia), beyond a trusted LAN."
|
||||
echo
|
||||
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ sites_status() {
|
||||
else
|
||||
echo "Auto-rotation: off (all pages manual/hidden)"
|
||||
fi
|
||||
echo "Keys on the kiosk: Alt+Left back, Alt+Right forward, Alt+Home this page's own address,"
|
||||
echo " Ctrl+Tab / Ctrl+PageDown next site, Ctrl+Shift+Tab / Ctrl+PageUp previous"
|
||||
if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
|
||||
echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/auth.js - login for the Web UI, using the SAME code the kiosk's
|
||||
// lock screen uses (Core Settings -> Password Protection & Lockout: a PIN or
|
||||
// a password).
|
||||
//
|
||||
// Only when the kiosk's password protection is on. With it off, the Web UI
|
||||
// is open as before (it is meant for a trusted LAN, or behind a reverse
|
||||
// proxy with its own login - see server.js).
|
||||
//
|
||||
// A correct code gives the browser a random session cookie (kept in memory;
|
||||
// a restart of the service logs everyone out). Wrong codes are slowed down
|
||||
// the same way the lock screen does it: after 5 wrong tries in a row, a
|
||||
// wait of 30 s that doubles up to 5 minutes. Changing the kiosk's code logs
|
||||
// every browser out, because sessions are tied to the stored code.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { readLockSettings, hashPassword } = require('./config');
|
||||
|
||||
const COOKIE = 'kioskwebui';
|
||||
const SESSION_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||
const FREE_TRIES = 5;
|
||||
const BASE_WAIT_S = 30;
|
||||
const MAX_WAIT_S = 300;
|
||||
|
||||
const sessions = new Map(); // token -> { expires, fp }
|
||||
let failures = 0;
|
||||
let blockedUntil = 0;
|
||||
|
||||
// A short fingerprint of the stored code: sessions stop working when it changes.
|
||||
const fingerprint = (hash) => crypto.createHash('sha256').update('fp:' + hash).digest('hex');
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
for (const part of String(header || '').split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function currentSession(req, lock) {
|
||||
const token = parseCookies(req.headers.cookie)[COOKIE];
|
||||
if (!token) return null;
|
||||
const s = sessions.get(token);
|
||||
if (!s) return null;
|
||||
if (s.expires < Date.now() || s.fp !== fingerprint(lock.hash)) {
|
||||
sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function setCookie(res, token, maxAgeS) {
|
||||
// HttpOnly + SameSite=Strict: not readable by page scripts, and not sent
|
||||
// on requests that start from another site. No Secure flag - this is
|
||||
// normally plain http on a LAN.
|
||||
res.setHeader('Set-Cookie', `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAgeS}`);
|
||||
}
|
||||
|
||||
function issueSession(res, lock) {
|
||||
for (const [t, s] of sessions) if (s.expires < Date.now()) sessions.delete(t);
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
sessions.set(token, { expires: Date.now() + SESSION_MS, fp: fingerprint(lock.hash) });
|
||||
setCookie(res, token, SESSION_MS / 1000);
|
||||
}
|
||||
|
||||
function codeMatches(code, hash) {
|
||||
const a = Buffer.from(hashPassword(code), 'hex');
|
||||
const b = Buffer.from(hash, 'hex');
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// POST /api/login { code }
|
||||
function login(req, res) {
|
||||
const lock = readLockSettings();
|
||||
if (!lock.enabled) return res.json({ ok: true, required: false });
|
||||
|
||||
const wait = Math.ceil((blockedUntil - Date.now()) / 1000);
|
||||
if (wait > 0) {
|
||||
return res.status(429).json({ ok: false, waitSeconds: wait, error: `Too many wrong tries - wait ${wait}s` });
|
||||
}
|
||||
const code = req.body && typeof req.body.code === 'string' ? req.body.code : '';
|
||||
if (code && codeMatches(code, lock.hash)) {
|
||||
failures = 0;
|
||||
issueSession(res, lock);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
failures++;
|
||||
const what = lock.codeType === 'pin' ? 'PIN' : 'password';
|
||||
if (failures >= FREE_TRIES) {
|
||||
const waitS = Math.min(MAX_WAIT_S, BASE_WAIT_S * 2 ** (failures - FREE_TRIES));
|
||||
blockedUntil = Date.now() + waitS * 1000;
|
||||
return res.status(429).json({ ok: false, waitSeconds: waitS, error: `Too many wrong tries - wait ${waitS}s` });
|
||||
}
|
||||
return res.status(401).json({ ok: false, error: `Incorrect ${what}` });
|
||||
}
|
||||
|
||||
// POST /api/logout
|
||||
function logout(req, res) {
|
||||
const token = parseCookies(req.headers.cookie)[COOKIE];
|
||||
if (token) sessions.delete(token);
|
||||
setCookie(res, '', 0);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
// GET /api/auth - open to everyone: tells the page whether to show a login.
|
||||
function status(req, res) {
|
||||
const lock = readLockSettings();
|
||||
res.json({
|
||||
required: lock.enabled,
|
||||
authed: !lock.enabled || !!currentSession(req, lock),
|
||||
codeType: lock.codeType,
|
||||
});
|
||||
}
|
||||
|
||||
// Gate for everything else. With protection off it lets everything through.
|
||||
// Without a session: API calls get a 401, page requests get the login page.
|
||||
function requireLogin(loginPage) {
|
||||
return (req, res, next) => {
|
||||
const lock = readLockSettings();
|
||||
if (!lock.enabled) return next();
|
||||
if (currentSession(req, lock)) return next();
|
||||
|
||||
// The few things the login page itself needs.
|
||||
if (req.path === '/api/login' || req.path === '/api/auth' || req.path === '/style.css') return next();
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(401).json({ error: 'Login required', loginRequired: true });
|
||||
}
|
||||
res.status(401).sendFile(loginPage);
|
||||
};
|
||||
}
|
||||
|
||||
// After a successful save that left protection on (e.g. the code was just
|
||||
// set or changed), keep the person who saved it logged in.
|
||||
function keepLoggedInAfterSave(res) {
|
||||
const lock = readLockSettings();
|
||||
if (lock.enabled) issueSession(res, lock);
|
||||
}
|
||||
|
||||
module.exports = { requireLogin, login, logout, status, keepLoggedInAfterSave, _sessions: sessions };
|
||||
+14
-1
@@ -179,4 +179,17 @@ function saveConfig(patch) {
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS };
|
||||
// What the Web UI login needs: whether the kiosk's password protection is
|
||||
// on and the stored SHA-256 of its unlock code (the same one the lock screen
|
||||
// checks). Only used server-side to verify a login - never sent to a browser.
|
||||
function readLockSettings() {
|
||||
const existing = readExisting();
|
||||
const hash = typeof existing.lockoutPassword === 'string' ? existing.lockoutPassword : '';
|
||||
return {
|
||||
enabled: existing.enablePasswordProtection === true && hash.length > 0,
|
||||
hash,
|
||||
codeType: existing.lockoutCodeType === 'pin' ? 'pin' : 'password',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { loadConfig, saveConfig, readLockSettings, hashPassword, CONFIG_PATH, SCALAR_DEFAULTS };
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
// Signed out (or the kiosk's code changed): any 401 from the server sends
|
||||
// the page back to the login screen.
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = async (...args) => {
|
||||
const res = await nativeFetch(...args);
|
||||
if (res.status === 401 && !String(args[0]).includes('/api/login')) location.reload();
|
||||
return res;
|
||||
};
|
||||
|
||||
'use strict';
|
||||
|
||||
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
||||
@@ -32,6 +41,23 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
|
||||
|
||||
document.getElementById('brand-sub').textContent = location.host || 'this kiosk';
|
||||
|
||||
// When the kiosk's password protection is on, this page is behind the same
|
||||
// PIN/password - say so, and offer Sign out.
|
||||
fetch('/api/auth').then((r) => r.json()).then((s) => {
|
||||
if (!s.required) return;
|
||||
const foot = document.getElementById('sidebar-footer');
|
||||
foot.textContent = "Signed in with the kiosk's " + (s.codeType === 'pin' ? 'PIN' : 'password') + '. ';
|
||||
const out = document.createElement('a');
|
||||
out.href = '#';
|
||||
out.textContent = 'Sign out';
|
||||
out.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await fetch('/api/logout', { method: 'POST' });
|
||||
location.reload();
|
||||
});
|
||||
foot.appendChild(out);
|
||||
}).catch(() => {});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Config API (Sites / Display / Lockout) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
+13
-1
@@ -46,7 +46,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div>
|
||||
<div class="sidebar-footer" id="sidebar-footer">Open to anyone on this network while the kiosk's password protection is off. Turn it on (Lockout) and this page asks for the same PIN/password. Beyond a trusted LAN, front it with your own reverse proxy + Authelia.</div>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
@@ -65,6 +65,18 @@
|
||||
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Keyboard shortcuts</h2>
|
||||
<p class="hint" style="margin-bottom:10px">For a keyboard on the kiosk. They act on the page that's on screen, and don't work while the screen is locked.</p>
|
||||
<table class="shortcuts">
|
||||
<tr><td><kbd>Alt</kbd> + <kbd>Left</kbd></td><td>Back (previous page in this site)</td></tr>
|
||||
<tr><td><kbd>Alt</kbd> + <kbd>Right</kbd></td><td>Forward</td></tr>
|
||||
<tr><td><kbd>Alt</kbd> + <kbd>Home</kbd></td><td>Home: back to this page's own address, as set above</td></tr>
|
||||
<tr><td><kbd>Ctrl</kbd> + <kbd>Tab</kbd> / <kbd>Ctrl</kbd> + <kbd>PageDown</kbd></td><td>Next site</td></tr>
|
||||
<tr><td><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Tab</kbd> / <kbd>Ctrl</kbd> + <kbd>PageUp</kbd></td><td>Previous site</td></tr>
|
||||
</table>
|
||||
<p class="hint" style="margin-top:10px">Screens with no keyboard can show on-screen back / home / forward buttons: Display → "On-screen back / home / forward buttons". A mouse's side buttons also go back and forward.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display & Interaction -->
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kiosk Web UI - sign in</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.login { width: min(360px, 92vw); padding: 28px; border-radius: 14px; background: var(--surface, #fff);
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,.18); }
|
||||
.login h1 { font-size: 20px; margin: 0 0 6px; }
|
||||
.login p { margin: 0 0 16px; opacity: .75; font-size: 14px; }
|
||||
.login input { width: 100%; box-sizing: border-box; padding: 12px; font-size: 18px; margin-bottom: 12px; }
|
||||
.login button { width: 100%; padding: 12px; font-size: 16px; cursor: pointer; }
|
||||
#err { color: #c0392b; min-height: 20px; margin-top: 10px; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="login" id="form">
|
||||
<h1>Kiosk Web UI</h1>
|
||||
<p id="hint">Enter the kiosk's code to continue.</p>
|
||||
<input id="code" type="password" autocomplete="current-password" autofocus>
|
||||
<button type="submit" id="go">Sign in</button>
|
||||
<div id="err"></div>
|
||||
</form>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
fetch('/api/auth').then((r) => r.json()).then((s) => {
|
||||
if (s.authed) { location.href = '/'; return; }
|
||||
if (s.codeType === 'pin') {
|
||||
$('hint').textContent = "Enter the kiosk's PIN to continue.";
|
||||
$('code').setAttribute('inputmode', 'numeric');
|
||||
} else {
|
||||
$('hint').textContent = "Enter the kiosk's password to continue.";
|
||||
}
|
||||
}).catch(() => {});
|
||||
$('form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
$('go').disabled = true;
|
||||
$('err').textContent = '';
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: $('code').value }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.ok) { location.href = '/'; return; }
|
||||
$('err').textContent = data.error || 'Sign in failed';
|
||||
$('code').value = '';
|
||||
$('code').focus();
|
||||
} catch (err) {
|
||||
$('err').textContent = 'Could not reach the kiosk';
|
||||
}
|
||||
$('go').disabled = false;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -393,3 +393,14 @@ button.danger:hover { background: var(--danger-soft); }
|
||||
.sidebar-footer { display: none; }
|
||||
.main { padding: 20px; }
|
||||
}
|
||||
|
||||
/* Keyboard shortcuts list on the Sites page */
|
||||
.shortcuts { border-collapse: collapse; width: 100%; font-size: 13.5px; }
|
||||
.shortcuts td { padding: 6px 10px 6px 0; vertical-align: top; }
|
||||
.shortcuts td:first-child { white-space: nowrap; }
|
||||
kbd {
|
||||
display: inline-block; padding: 1px 6px; font: inherit; font-size: 12px;
|
||||
border: 1px solid var(--border, #cfd4da); border-bottom-width: 2px; border-radius: 4px;
|
||||
background: var(--surface-2, rgba(127,127,127,.12));
|
||||
}
|
||||
|
||||
|
||||
+20
-6
@@ -6,11 +6,12 @@
|
||||
// no privileged system mutation involved (see menus/addon_webui.sh's
|
||||
// header for why the rest of Core Settings/Addons/Advanced aren't here).
|
||||
//
|
||||
// No login of its own by design: Authelia runs elsewhere, and the admin
|
||||
// site goes behind the user's own Caddy reverse proxy with Authelia
|
||||
// forward-auth in front of it, the same way every other self-hosted app
|
||||
// they run is protected. This process only binds where it's told to
|
||||
// (BIND_ADDR/PORT below) and trusts whatever's in front of it.
|
||||
// Login: when the kiosk's password protection is on, the Web UI asks for the
|
||||
// same PIN/password the lock screen uses (lib/auth.js); with it off it is
|
||||
// open. Beyond that, no login of its own: Authelia runs elsewhere, and the
|
||||
// admin site can go behind the user's own Caddy reverse proxy with Authelia
|
||||
// forward-auth in front of it. This process only binds where it's told to
|
||||
// (BIND_ADDR/PORT below).
|
||||
//
|
||||
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
|
||||
// installs) - the same user Electron runs as, and the owner of
|
||||
@@ -22,6 +23,7 @@ const express = require('express');
|
||||
const { loadConfig, saveConfig } = require('./lib/config');
|
||||
const { ACTIONS } = require('./lib/actions');
|
||||
const { startJob, getJob } = require('./lib/jobs');
|
||||
const auth = require('./lib/auth');
|
||||
|
||||
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
||||
const WIFI_HELPER_PATH = process.env.WIFI_HELPER_PATH || '/usr/local/bin/kiosk-wifi-helper';
|
||||
@@ -29,6 +31,14 @@ const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'su
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
|
||||
// When the kiosk's password protection is on, the Web UI asks for the same
|
||||
// PIN/password (see lib/auth.js). With it off, nothing changes.
|
||||
app.post('/api/login', auth.login);
|
||||
app.post('/api/logout', auth.logout);
|
||||
app.get('/api/auth', auth.status);
|
||||
app.use(auth.requireLogin(path.join(__dirname, 'public', 'login.html')));
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const SWIPE_MODES = ['dual', 'standard'];
|
||||
@@ -179,7 +189,11 @@ app.put('/api/config', (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(saveConfig(patch));
|
||||
const saved = saveConfig(patch);
|
||||
// If this save set or changed the kiosk's code, every old session
|
||||
// just stopped working - keep the person who saved it signed in.
|
||||
auth.keepLoggedInAfterSave(res);
|
||||
res.json(saved);
|
||||
} catch (e) {
|
||||
console.error('saveConfig failed:', e);
|
||||
res.status(500).json({ error: 'Failed to save configuration' });
|
||||
|
||||
@@ -18,6 +18,20 @@ process.env.WIFI_HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-wifi-helpe
|
||||
|
||||
const app = require('../server');
|
||||
|
||||
// These tests turn password protection on partway through, and the Web UI
|
||||
// then (correctly) wants a login. A browser keeps the session cookie the
|
||||
// server hands out on a save; Node's fetch doesn't, so keep it here.
|
||||
const nodeFetch = global.fetch;
|
||||
let sessionCookie = '';
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
if (sessionCookie) headers.cookie = sessionCookie;
|
||||
const res = await nodeFetch(url, { ...opts, headers });
|
||||
const set = res.headers.get('set-cookie');
|
||||
if (set) sessionCookie = set.split(';')[0];
|
||||
return res;
|
||||
};
|
||||
|
||||
let failures = 0;
|
||||
async function check(label, fn) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
|
||||
// webui/test/auth.test.js - the Web UI login: with the kiosk's password
|
||||
// protection off the UI is open; with it on, everything needs the kiosk's
|
||||
// PIN/password. Starts the real server on a random port against a scratch
|
||||
// config.json. Run with: node test/auth.test.js
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-auth-test-'));
|
||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||
process.env.SUDO_CMD = '';
|
||||
|
||||
const app = require('../server');
|
||||
const { saveConfig } = require('../lib/config');
|
||||
|
||||
let failures = 0;
|
||||
async function check(label, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS: ${label}`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.log(`FAIL: ${label} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const post = (url, body, cookie) => fetch(base + url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const get = (url, cookie) => fetch(base + url, { headers: cookie ? { cookie } : {} });
|
||||
const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
|
||||
|
||||
await check('protection off: no login needed, /api/auth says so', async () => {
|
||||
assert.strictEqual((await get('/api/config')).status, 200);
|
||||
const a = await (await get('/api/auth')).json();
|
||||
assert.strictEqual(a.required, false);
|
||||
assert.strictEqual(a.authed, true);
|
||||
});
|
||||
|
||||
saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'hunter22' });
|
||||
|
||||
await check('protection on: API calls are refused without a login', async () => {
|
||||
const res = await get('/api/config');
|
||||
assert.strictEqual(res.status, 401);
|
||||
assert.strictEqual((await res.json()).loginRequired, true);
|
||||
assert.strictEqual((await post('/api/actions/anything/run')).status, 401);
|
||||
});
|
||||
|
||||
await check('protection on: pages get the login screen, not the app', async () => {
|
||||
const res = await get('/');
|
||||
assert.strictEqual(res.status, 401);
|
||||
const html = await res.text();
|
||||
assert.ok(html.includes('id="code"'));
|
||||
assert.ok(!html.includes('nav-item'));
|
||||
assert.strictEqual((await get('/app.js')).status, 401);
|
||||
});
|
||||
|
||||
await check('/api/auth reports a login is required, and the code type', async () => {
|
||||
const a = await (await get('/api/auth')).json();
|
||||
assert.strictEqual(a.required, true);
|
||||
assert.strictEqual(a.authed, false);
|
||||
assert.strictEqual(a.codeType, 'password');
|
||||
});
|
||||
|
||||
await check('a wrong code is refused and gives no session', async () => {
|
||||
const res = await post('/api/login', { code: 'nope' });
|
||||
assert.strictEqual(res.status, 401);
|
||||
assert.strictEqual(res.headers.get('set-cookie'), null);
|
||||
});
|
||||
|
||||
let cookie = '';
|
||||
await check('the right code gives an HttpOnly session that unlocks the API', async () => {
|
||||
const res = await post('/api/login', { code: 'hunter22' });
|
||||
assert.strictEqual(res.status, 200);
|
||||
const raw = res.headers.get('set-cookie');
|
||||
assert.ok(/HttpOnly/i.test(raw) && /SameSite=Strict/i.test(raw));
|
||||
cookie = cookieOf(res);
|
||||
assert.strictEqual((await get('/api/config', cookie)).status, 200);
|
||||
assert.strictEqual((await get('/', cookie)).status, 200);
|
||||
assert.strictEqual((await (await get('/api/auth', cookie)).json()).authed, true);
|
||||
});
|
||||
|
||||
await check('the stored code hash is never sent to the browser', async () => {
|
||||
const text = await (await get('/api/config', cookie)).text();
|
||||
assert.ok(!text.includes('lockoutPassword"'));
|
||||
assert.ok(!/[0-9a-f]{64}/.test(text));
|
||||
});
|
||||
|
||||
await check('saving settings keeps you signed in', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', cookie },
|
||||
body: JSON.stringify({ enableNavButton: false }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
});
|
||||
|
||||
await check('changing the kiosk code signs every browser out', async () => {
|
||||
saveConfig({ newLockoutPassword: 'another-one' });
|
||||
assert.strictEqual((await get('/api/config', cookie)).status, 401);
|
||||
assert.strictEqual((await post('/api/login', { code: 'hunter22' })).status, 401);
|
||||
const ok = await post('/api/login', { code: 'another-one' });
|
||||
assert.strictEqual(ok.status, 200);
|
||||
cookie = cookieOf(ok);
|
||||
});
|
||||
|
||||
await check('setting a new code from the Web UI keeps that browser signed in', async () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', cookie },
|
||||
body: JSON.stringify({ newLockoutPassword: 'third-code' }),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const fresh = cookieOf(res);
|
||||
assert.ok(fresh && fresh !== cookie);
|
||||
assert.strictEqual((await get('/api/config', fresh)).status, 200);
|
||||
assert.strictEqual((await get('/api/config', cookie)).status, 401);
|
||||
cookie = fresh;
|
||||
});
|
||||
|
||||
await check('sign out ends the session', async () => {
|
||||
assert.strictEqual((await post('/api/logout', {}, cookie)).status, 200);
|
||||
assert.strictEqual((await get('/api/config', cookie)).status, 401);
|
||||
});
|
||||
|
||||
await check('five wrong codes in a row slow logins down - even the right code waits', async () => {
|
||||
let last;
|
||||
for (let i = 0; i < 5; i++) last = await post('/api/login', { code: 'wrong' + i });
|
||||
assert.strictEqual(last.status, 429);
|
||||
assert.ok((await last.json()).waitSeconds >= 30);
|
||||
assert.strictEqual((await post('/api/login', { code: 'third-code' })).status, 429);
|
||||
});
|
||||
|
||||
server.close();
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
if (failures > 0) {
|
||||
console.log(`${failures} FAILURE(S)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ALL DONE');
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user