diff --git a/Readme.md b/Readme.md index 24a9f96..6b75216 100644 --- a/Readme.md +++ b/Readme.md @@ -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. diff --git a/lib/provision.sh b/lib/provision.sh index a6764d1..5aceafe 100644 --- a/lib/provision.sh +++ b/lib/provision.sh @@ -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" diff --git a/menus/addon_webui.sh b/menus/addon_webui.sh index ec52280..4462ba6 100644 --- a/menus/addon_webui.sh +++ b/menus/addon_webui.sh @@ -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; } diff --git a/menus/sites.sh b/menus/sites.sh index b47660b..fe63d2d 100644 --- a/menus/sites.sh +++ b/menus/sites.sh @@ -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 diff --git a/webui/lib/auth.js b/webui/lib/auth.js new file mode 100644 index 0000000..0b5a1bf --- /dev/null +++ b/webui/lib/auth.js @@ -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 }; diff --git a/webui/lib/config.js b/webui/lib/config.js index ace9c7a..0a9e952 100644 --- a/webui/lib/config.js +++ b/webui/lib/config.js @@ -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 }; diff --git a/webui/public/app.js b/webui/public/app.js index 77cca32..9dd6cbd 100644 --- a/webui/public/app.js +++ b/webui/public/app.js @@ -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) */ /* ---------------------------------------------------------------------- */ diff --git a/webui/public/index.html b/webui/public/index.html index c4cf4b3..5c1c30f 100644 --- a/webui/public/index.html +++ b/webui/public/index.html @@ -46,7 +46,7 @@ - +
@@ -65,6 +65,18 @@ +
+

Keyboard shortcuts

+

For a keyboard on the kiosk. They act on the page that's on screen, and don't work while the screen is locked.

+ + + + + + +
Alt + LeftBack (previous page in this site)
Alt + RightForward
Alt + HomeHome: back to this page's own address, as set above
Ctrl + Tab / Ctrl + PageDownNext site
Ctrl + Shift + Tab / Ctrl + PageUpPrevious site
+

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.

+
diff --git a/webui/public/login.html b/webui/public/login.html new file mode 100644 index 0000000..fcdb027 --- /dev/null +++ b/webui/public/login.html @@ -0,0 +1,60 @@ + + + + + +Kiosk Web UI - sign in + + + + +
+

Kiosk Web UI

+

Enter the kiosk's code to continue.

+ + +
+
+ + + diff --git a/webui/public/style.css b/webui/public/style.css index a540215..e4c9b54 100644 --- a/webui/public/style.css +++ b/webui/public/style.css @@ -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)); +} + diff --git a/webui/server.js b/webui/server.js index a4b987c..4febb8d 100644 --- a/webui/server.js +++ b/webui/server.js @@ -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' }); diff --git a/webui/test/api.test.js b/webui/test/api.test.js index 1616ff7..1a5a5dc 100644 --- a/webui/test/api.test.js +++ b/webui/test/api.test.js @@ -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 { diff --git a/webui/test/auth.test.js b/webui/test/auth.test.js new file mode 100644 index 0000000..f39d601 --- /dev/null +++ b/webui/test/auth.test.js @@ -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();