Merge pull request #121 from outis1one/claude/happy-knuth-hwnvbn

Claude/happy knuth hwnvbn
This commit is contained in:
Outis
2026-10-10 11:45:37 -04:00
committed by GitHub
15 changed files with 734 additions and 210 deletions
+23 -10
View File
@@ -262,7 +262,10 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
- **Session lockout** after configured inactivity - **Session lockout** after configured inactivity
- **Scheduled lockout** at specific time daily - **Scheduled lockout** at specific time daily
- **Display wake lockout** - Require password after display schedule - **Display wake lockout** - Require password after display schedule
- **Boot password** option - Require password on system startup - **Boot password** option - Require password on system startup, with the unlock code or **its own separate code**
- **Password or PIN** - each code (unlock, boot) can be a password or a **4-8 digit PIN** entered on an on-screen number pad, so it works on a touch-only kiosk
- **The kiosk's own codes** - set in Core Settings → Password Protection & Lockout or the Web UI; not the admin (sudo) password, which never needs to be shared
- **Wrong-code slowdown** - after 5 wrong codes in a row the lock screen refuses input for 30 seconds, doubling up to 5 minutes, so a PIN can't be guessed through
- **Full screen blocking** during lockout (no content visible) - **Full screen blocking** during lockout (no content visible)
### Navigation Security ### Navigation Security
@@ -492,17 +495,22 @@ Both can be used at the same time — they serve different purposes:
- **Multi-method WiFi scan** - nmcli, iw, wpa_cli fallbacks - **Multi-method WiFi scan** - nmcli, iw, wpa_cli fallbacks
- **Watchdog support** - Auto-revert bad WiFi configs - **Watchdog support** - Auto-revert bad WiFi configs
- **Emergency hotspot** - Fallback if no internet - **Emergency hotspot** - Fallback if no internet
- **WiFi from the kiosk screen** - hold **Shift+W+S+F+H** together (or press Ctrl+Alt+Shift+Super+W) to join a WiFi network without the admin console - see below - **WiFi from the kiosk screen** - press **Ctrl+Alt+Super+W** (Super = the Windows key) to join a WiFi network without the admin console, guarded by the kiosk's PIN/password - see below
### WiFi from the kiosk screen (consoles turned off) ### WiFi from the kiosk screen (consoles turned off)
With virtual consoles switched off, there's no way to reach the admin menu at the kiosk itself. To join a WiFi network anyway, plug in a keyboard and **hold Shift, W, S, F and H all at once** (or press Ctrl+Alt+Shift+Super+W). A WiFi screen opens: With virtual consoles switched off, there's no way to reach the admin menu at the kiosk itself. To join a WiFi network anyway, plug in a keyboard and press **Ctrl+Alt+Super+W** (Super = the Windows key). A WiFi screen opens:
1. Enter the hidden-sites PIN (the same one as F10 - default `1234`; skipped if the PIN is turned off). 1. Enter the kiosk's code:
- **Password protection on** (Core Settings → Password Protection & Lockout): the unlock **PIN**, or the unlock **password** if it's a password. Same slowdown after wrong tries as the lock screen.
- **Password protection off:** the hidden-sites PIN (the F10 one - default `1234`).
- Neither (protection off and the hidden-sites PIN turned off): no code is asked.
2. Pick a network from the scan (strongest first, 🔒 = needs a password), or type its name. 2. Pick a network from the scan (strongest first, 🔒 = needs a password), or type its name.
3. Type the password (empty for an open network) and press **Connect**. 3. Type the password (empty for an open network) and press **Connect**.
It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kiosk-wifi.yaml`), so the connection persists across reboots. If the kiosk gets no address within 30 seconds (usually a wrong password) the previous WiFi setting is put back. Esc or Close shuts the screen; it also closes itself after 5 minutes untouched, and five wrong PINs close it. The keys only work as a chord (typing W-S-F-H one at a time does nothing), and they don't interfere with the tab or power shortcuts. It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kiosk-wifi.yaml`), so the connection persists across reboots. If the kiosk gets no address within 30 seconds (usually a wrong password) the previous WiFi setting is put back. Esc or Close shuts the screen; it also closes itself after 5 minutes untouched. After 5 wrong codes in a row, entry pauses (30s, doubling up to 5 min).
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade. The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade.
@@ -900,11 +908,14 @@ The PIN file controls access to hidden sites (duration = -1):
- **Default:** `1234` - **Default:** `1234`
- **Configure via:** Main Menu → Core Settings → Sites → Configure Hidden Sites PIN - **Configure via:** Main Menu → Core Settings → Sites → Configure Hidden Sites PIN
- **Disable PIN:** Set content to `NOPIN` to allow any entry - **Disable PIN:** Set content to `NOPIN` to allow any entry
- **Custom PIN:** 4-8 digits - **Custom PIN:** 4-8 digits, independent of the lock screen's password/PIN (same or different, as you like)
- **Stored hashed:** the file holds `sha256:<hash>`, never the digits. A file with plain digits (set by hand, or from before this) still works and is converted to the hashed form when the kiosk app starts.
- **Wrong-PIN slowdown:** after 5 wrong PINs in a row, PIN entry is refused for 30 seconds, doubling up to 5 minutes. The hidden-sites PIN box shares this count with the WiFi screen when that uses this PIN.
- Also guards the WiFi screen (Ctrl+Alt+Super+W) when password protection is off - otherwise that uses the unlock PIN/password.
```bash ```bash
# Set custom PIN # Set custom PIN (the menu does this for you)
echo "5678" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin echo "sha256:$(echo -n 5678 | sha256sum | cut -d' ' -f1)" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin
# Disable PIN protection # Disable PIN protection
echo "NOPIN" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin echo "NOPIN" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin
@@ -1047,7 +1058,7 @@ When "Are you still here?" prompt appears (on manual or hidden sites):
**During Lockout:** **During Lockout:**
- Full black screen (no content visible) - Full black screen (no content visible)
- All browser views detached for security - All browser views detached for security
- Password prompt displayed - Password field, or number pad for a PIN (the boot screen uses the boot code if one is set)
- Limited power menu (no Reload option to prevent bypass) - Limited power menu (no Reload option to prevent bypass)
- Rotation and timers paused - Rotation and timers paused
@@ -1525,7 +1536,9 @@ full migration pass.
- **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI. - **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI.
- **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up. - **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up.
- **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing. - **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing.
- **New: WiFi from the kiosk screen** - hold Shift+W+S+F+H (or Ctrl+Alt+Shift+Super+W) to scan for and join a WiFi network right on the kiosk, PIN-protected, for kiosks with the consoles turned off. Reverts to the previous WiFi if the new one doesn't connect. See "WiFi from the kiosk screen". - **Hidden-sites PIN hashed, with the same slowdown:** the F10 PIN is now stored as a SHA-256 hash (existing PIN files convert automatically) and repeated wrong PINs pause entry (30s, doubling to 5 min), shared with the WiFi screen. The PIN pad no longer shows "Default PIN: 1234" on screen.
- **Lock screen PINs and a separate boot code:** the unlock code and the boot code can each be a password or a 4-8 digit PIN (on-screen number pad, works on touch screens), and the boot lock can have its own code. Repeated wrong codes now pause the lock screen (30s, doubling to 5 min). Set in Core Settings → Password Protection & Lockout or the Web UI's Lockout page. Existing settings keep working unchanged (password, same code at boot).
- **New: WiFi from the kiosk screen** - press Ctrl+Alt+Super+W to scan for and join a WiFi network right on the kiosk, guarded by the unlock PIN/password (or the hidden-sites PIN when password protection is off), for kiosks with the consoles turned off. Reverts to the previous WiFi if the new one doesn't connect. See "WiFi from the kiosk screen".
- **New: `iso/e2b-contig.sh`** - makes a file on an Easy2Boot (or any NTFS/FAT32/exFAT) USB drive contiguous from Linux, so ISOs that need to be in one piece boot. - **New: `iso/e2b-contig.sh`** - makes a file on an Easy2Boot (or any NTFS/FAT32/exFAT) USB drive contiguous from Linux, so ISOs that need to be in one piece boot.
- **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet. - **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet.
- **Emergency Hotspot fix:** by default it now starts only when the local network is unreachable (router *and* internet), not merely when there's no internet - on a LAN without internet it used to start on every boot. Existing setups keep the old check until Advanced → Emergency Hotspot → Reconfigure. - **Emergency Hotspot fix:** by default it now starts only when the local network is unreachable (router *and* internet), not merely when there's no internet - on a LAN without internet it used to start on every boot. Existing setups keep the old check until Advanced → Emergency Hotspot → Reconfigure.
+165
View File
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Locked</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #000;
color: #fff;
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
user-select: none;
}
.box { text-align: center; width: 100%; max-width: 400px; padding: 20px; }
h1 { font-size: 32px; margin-bottom: 30px; }
.hidden { display: none !important; }
input {
width: 100%;
padding: 15px;
font-size: 18px;
border: 2px solid #fff;
background: #000;
color: #fff;
border-radius: 5px;
margin-bottom: 20px;
}
#pin-display {
font-size: 44px;
letter-spacing: 16px;
min-height: 64px;
line-height: 64px;
border-bottom: 2px solid #555;
margin-bottom: 24px;
font-family: monospace;
}
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.pad button {
padding: 22px 0;
font-size: 30px;
font-weight: bold;
background: #222;
color: #fff;
border: 1px solid #444;
border-radius: 50px;
}
.pad button:active { background: #555; }
.pad .ok { background: #fff; color: #000; font-size: 22px; }
.pad .back { font-size: 24px; }
button.unlock {
padding: 15px 30px;
font-size: 18px;
background: #fff;
color: #000;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:disabled { opacity: 0.4; }
.error { color: #f44; margin-top: 18px; min-height: 22px; font-size: 18px; }
</style>
</head>
<body>
<div class="box">
<h1 id="title">Session Locked</h1>
<div id="password-entry" class="hidden">
<input type="password" id="password" autocomplete="off">
<button class="unlock" id="unlock">Unlock</button>
</div>
<div id="pin-entry" class="hidden">
<div id="pin-display"></div>
<div class="pad" id="pad"></div>
</div>
<div class="error" id="error"></div>
</div>
<script>
const {ipcRenderer} = require('electron');
const params = new URLSearchParams(location.search);
const isPin = params.get('type') === 'pin';
const isBoot = params.get('mode') === 'boot';
const $ = id => document.getElementById(id);
let pin = '';
let blockedUntil = 0;
let blockTimer = null;
$('title').textContent = isBoot ? 'Kiosk Locked' : 'Session Locked';
function submit(code) {
if (Date.now() < blockedUntil || !code) return;
ipcRenderer.send('check-lockout-password', code);
}
function setDisabled(on) {
for (const b of document.querySelectorAll('button')) b.disabled = on;
$('password').disabled = on;
}
if (isPin) {
$('pin-entry').classList.remove('hidden');
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '⌫', '0', 'OK'];
for (const k of keys) {
const b = document.createElement('button');
b.textContent = k;
if (k === 'OK') b.className = 'ok';
if (k === '⌫') b.className = 'back';
b.addEventListener('click', () => press(k));
$('pad').appendChild(b);
}
const show = () => { $('pin-display').textContent = '•'.repeat(pin.length); };
window.press = k => {
if (Date.now() < blockedUntil) return;
if (k === 'OK') { submit(pin); return; }
if (k === '⌫') pin = pin.slice(0, -1);
else if (pin.length < 8) pin += k;
$('error').textContent = '';
show();
};
document.addEventListener('keydown', e => {
if (e.key >= '0' && e.key <= '9') press(e.key);
else if (e.key === 'Backspace') press('⌫');
else if (e.key === 'Enter') press('OK');
});
window.clearEntry = () => { pin = ''; show(); };
show();
} else {
$('password-entry').classList.remove('hidden');
$('password').placeholder = 'Enter password to unlock';
$('password').focus();
$('unlock').addEventListener('click', () => submit($('password').value));
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') submit($('password').value); });
window.clearEntry = () => { $('password').value = ''; $('password').focus(); };
}
ipcRenderer.on('password-incorrect', (event, info) => {
clearEntry();
const wait = (info && info.waitSeconds) || 0;
const what = isPin ? 'PIN' : 'password';
if (!wait) { $('error').textContent = 'Incorrect ' + what; return; }
blockedUntil = Date.now() + wait * 1000;
setDisabled(true);
clearInterval(blockTimer);
const tick = () => {
const left = Math.ceil((blockedUntil - Date.now()) / 1000);
if (left <= 0) {
clearInterval(blockTimer);
setDisabled(false);
$('error').textContent = '';
if (!isPin) $('password').focus();
return;
}
$('error').textContent = 'Too many wrong tries - wait ' + left + 's';
};
tick();
blockTimer = setInterval(tick, 500);
});
</script>
</body>
</html>
+143 -143
View File
@@ -66,9 +66,14 @@ let lockoutAtTime="";
let lockoutActiveStart=""; let lockoutActiveStart="";
let lockoutActiveEnd=""; let lockoutActiveEnd="";
let requirePasswordOnBoot=false; let requirePasswordOnBoot=false;
let lockoutCodeType='password'; // 'password' or 'pin' (4-8 digits, keypad)
let bootPassword=''; // separate boot code hash; '' = use lockoutPassword
let bootCodeType='password';
// Password lockout state // Password lockout state
let isLockedOut=false; let isLockedOut=false;
let lockoutMode='unlock'; // 'boot' while the boot lock screen is up
const lockoutThrottle=createCodeThrottle();
let lockoutWindow=null; let lockoutWindow=null;
let lockoutTimer=null; let lockoutTimer=null;
let lockoutActivityTime=Date.now(); let lockoutActivityTime=Date.now();
@@ -103,6 +108,9 @@ function loadConfig(){
lockoutActiveStart=config.lockoutActiveStart||""; lockoutActiveStart=config.lockoutActiveStart||"";
lockoutActiveEnd=config.lockoutActiveEnd||""; lockoutActiveEnd=config.lockoutActiveEnd||"";
requirePasswordOnBoot=(config.requirePasswordOnBoot===true); requirePasswordOnBoot=(config.requirePasswordOnBoot===true);
lockoutCodeType=(config.lockoutCodeType==='pin')?'pin':'password';
bootPassword=config.bootPassword||'';
bootCodeType=(config.bootCodeType==='pin')?'pin':'password';
autheliaURL=config.autheliaURL||''; autheliaURL=config.autheliaURL||'';
autheliaUsername=config.autheliaUsername||''; autheliaUsername=config.autheliaUsername||'';
autheliaEncryptedPassword=config.autheliaEncryptedPassword||''; autheliaEncryptedPassword=config.autheliaEncryptedPassword||'';
@@ -118,6 +126,7 @@ function loadConfig(){
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime); if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
if(lockoutActiveStart&&lockoutActiveEnd)console.log('[CONFIG] Active hours:',lockoutActiveStart,'-',lockoutActiveEnd); if(lockoutActiveStart&&lockoutActiveEnd)console.log('[CONFIG] Active hours:',lockoutActiveStart,'-',lockoutActiveEnd);
console.log('[CONFIG] Require password on boot:',requirePasswordOnBoot); console.log('[CONFIG] Require password on boot:',requirePasswordOnBoot);
console.log('[CONFIG] Unlock code:',lockoutCodeType,'| boot code:',bootPassword?('separate '+bootCodeType):'same as unlock');
console.log('[CONFIG] Sites:',config.tabs?.length||0); console.log('[CONFIG] Sites:',config.tabs?.length||0);
console.log('[CONFIG] ╚═══════════════════════════════╝'); console.log('[CONFIG] ╚═══════════════════════════════╝');
@@ -153,11 +162,88 @@ function markKeyboardActivity(){
keyboardClosePending=false; keyboardClosePending=false;
} }
// Wrong-code slowdown, shared by the lock screen and the hidden-sites PIN
// (one throttle per code): after every 5 wrong codes in a row, input is
// refused for 30s, doubling up to 5 minutes - so a 4-digit PIN can't
// simply be tried through. A right code resets it.
function createCodeThrottle(){
return{failures:0,blockedUntil:0};
}
function throttleWaitSeconds(t){
return Math.max(0,Math.ceil((t.blockedUntil-Date.now())/1000));
}
// Counts a wrong code; returns the seconds input is now refused for (0 = none).
function throttleFail(t){
t.failures++;
if(t.failures%5!==0)return 0;
const wait=Math.min(300,30*Math.pow(2,t.failures/5-1));
t.blockedUntil=Date.now()+wait*1000;
return wait;
}
function sha256Hex(v){
return crypto.createHash('sha256').update(String(v==null?'':v)).digest('hex');
}
// Hidden-sites PIN (.jitsi-pin, set in Core Settings -> Hidden Sites PIN):
// "NOPIN" (no PIN), "sha256:<hex>", or - written before the PIN was
// hashed - the plain digits, which migrateHiddenPin() rewrites hashed at
// startup. No file = the default PIN 1234. Read on every check, so a
// change from the menu applies without a restart. Guards the hidden
// sites (F10) and the WiFi screen, with one shared slowdown.
const HIDDEN_PIN_FILE=path.join(__dirname,'.jitsi-pin');
const hiddenPinThrottle=createCodeThrottle();
function readHiddenPinEntry(){
try{
const stored=fs.readFileSync(HIDDEN_PIN_FILE,'utf8').trim();
if(stored==='NOPIN')return{none:true};
if(stored.startsWith('sha256:'))return{hash:stored.slice(7)};
if(stored)return{hash:sha256Hex(stored)};
}catch(e){}
return{hash:sha256Hex('1234')};
}
function migrateHiddenPin(){
try{
const stored=fs.readFileSync(HIDDEN_PIN_FILE,'utf8').trim();
if(/^[0-9]{4,8}$/.test(stored)){
fs.writeFileSync(HIDDEN_PIN_FILE,'sha256:'+sha256Hex(stored)+'\n',{mode:0o600});
console.log('[PIN] Hidden-sites PIN converted to hashed form');
}
}catch(e){}
}
function hiddenPinRequired(){
return!readHiddenPinEntry().none;
}
// checkCode(THROTTLE, HASH, CODE, LABEL) - CODE against the stored SHA-256
// HASH, with THROTTLE's slowdown. {ok} or {ok:false, error, waitSeconds?}.
function checkCode(throttle,hash,code,label){
const blocked=throttleWaitSeconds(throttle);
if(blocked)return{ok:false,waitSeconds:blocked,error:'Too many wrong tries - wait '+blocked+'s'};
if(sha256Hex(code)===hash){
throttle.failures=0;
return{ok:true};
}
const wait=throttleFail(throttle);
console.log('[CODE] Wrong',label+', attempt',throttle.failures);
return wait?{ok:false,waitSeconds:wait,error:'Too many wrong tries - wait '+wait+'s'}:{ok:false,error:'Incorrect '+label};
}
function checkHiddenPin(pin){
const entry=readHiddenPinEntry();
if(entry.none)return{ok:true};
return checkCode(hiddenPinThrottle,entry.hash,pin,'PIN');
}
// Password lockout functions // Password lockout functions
function showLockoutScreen(){ function showLockoutScreen(mode){
if(isLockedOut||!enablePasswordProtection||!lockoutPassword)return; if(isLockedOut||!enablePasswordProtection||!lockoutPassword)return;
isLockedOut=true; isLockedOut=true;
lockoutMode=(mode==='boot')?'boot':'unlock';
console.log('[LOCKOUT] Showing lockout screen'); console.log('[LOCKOUT] Showing lockout screen');
closeWifiWindow(); closeWifiWindow();
@@ -193,81 +279,9 @@ function showLockoutScreen(){
} }
}); });
lockoutWindow.loadURL('data:text/html;charset=utf-8,'+encodeURIComponent(` // The boot screen takes the separate boot code when one is set.
<!DOCTYPE html> const codeType=(lockoutMode==='boot'&&bootPassword)?bootCodeType:lockoutCodeType;
<html> lockoutWindow.loadFile(path.join(__dirname,'lockscreen.html'),{query:{type:codeType,mode:lockoutMode}});
<head>
<meta charset="UTF-8">
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{
background:#000;
color:#fff;
font-family:Arial,sans-serif;
display:flex;
justify-content:center;
align-items:center;
height:100vh;
overflow:hidden;
}
.lockout-container{
text-align:center;
max-width:400px;
}
h1{font-size:32px;margin-bottom:30px;}
input{
width:100%;
padding:15px;
font-size:18px;
border:2px solid #fff;
background:#000;
color:#fff;
border-radius:5px;
margin-bottom:20px;
}
button{
padding:15px 30px;
font-size:18px;
background:#fff;
color:#000;
border:none;
border-radius:5px;
cursor:pointer;
}
button:hover{background:#ccc;}
.error{color:#f44;margin-top:15px;display:none;}
</style>
</head>
<body>
<div class="lockout-container">
<h1>Session Locked</h1>
<input type="password" id="password" placeholder="Enter password to unlock" autofocus>
<button onclick="checkPassword()">Unlock</button>
<div class="error" id="error">Incorrect password</div>
</div>
<script>
const crypto=require('crypto');
const{ipcRenderer}=require('electron');
function checkPassword(){
const pass=document.getElementById('password').value;
const hash=crypto.createHash('sha256').update(pass).digest('hex');
ipcRenderer.send('check-lockout-password',hash);
}
document.getElementById('password').addEventListener('keydown',(e)=>{
if(e.key==='Enter')checkPassword();
});
ipcRenderer.on('password-incorrect',()=>{
document.getElementById('error').style.display='block';
document.getElementById('password').value='';
document.getElementById('password').focus();
});
</script>
</body>
</html>
`));
lockoutWindow.on('closed',()=>{ lockoutWindow.on('closed',()=>{
lockoutWindow=null; lockoutWindow=null;
@@ -1190,25 +1204,16 @@ function showHiddenTab(index){
showingHidden=true; showingHidden=true;
} }
// WiFi screen (hold Shift+W+S+F+H, or Ctrl+Alt+Shift+Super+W): join a WiFi network from the kiosk // WiFi screen (Ctrl+Alt+Super+W): join a WiFi network from the kiosk
// itself, for when the console (Ctrl+Alt+F1..F6) is switched off and the // itself, for when the console (Ctrl+Alt+F1..F6) is switched off and the
// admin menu can't be reached. Gated by the hidden-tab PIN (.jitsi-pin, // admin menu can't be reached. Gated by the kiosk's unlock code - the
// same rules as pin-entry.html: no file = 1234, NOPIN = none). The root // lock screen's PIN or password, with its slowdown - or, when password
// protection is off, the hidden-sites PIN (see wifiGate). The root
// work is done by /usr/local/bin/kiosk-wifi-helper, which the kiosk user // work is done by /usr/local/bin/kiosk-wifi-helper, which the kiosk user
// may run through sudo and nothing else (/etc/sudoers.d/kiosk-wifi). // may run through sudo and nothing else (/etc/sudoers.d/kiosk-wifi).
const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper'; const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
const WIFI_MAX_PIN_TRIES=5; let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
let wifiWindow=null,wifiUnlocked=false,wifiPinTries=0,wifiIdleTimer=null;
function readHiddenPin(){
try{
const stored=fs.readFileSync(path.join(__dirname,'.jitsi-pin'),'utf8').trim();
if(stored==='NOPIN')return null;
if(stored)return stored;
}catch(e){}
return '1234';
}
function runWifiHelper(action,input){ function runWifiHelper(action,input){
return new Promise(resolve=>{ return new Promise(resolve=>{
@@ -1260,8 +1265,7 @@ function showWifiWindow(){
wifiWindow.focus(); wifiWindow.focus();
return; return;
} }
wifiUnlocked=(readHiddenPin()===null); wifiUnlocked=!wifiGate();
wifiPinTries=0;
let width=720,height=820; let width=720,height=820;
if(mainWindow&&!mainWindow.isDestroyed()){ if(mainWindow&&!mainWindow.isDestroyed()){
const[w,h]=mainWindow.getContentSize(); const[w,h]=mainWindow.getContentSize();
@@ -1287,37 +1291,23 @@ function showWifiWindow(){
console.log('[WIFI] WiFi screen opened'); console.log('[WIFI] WiFi screen opened');
} }
// Shift+W+S+F+H, all held together, opens the WiFi screen. A globalShortcut // Which code guards the WiFi screen: the unlock code (PIN or password)
// can only have one non-modifier key, so this watches the raw key events // when password protection is on, else the hidden-sites PIN; null if
// of every page/window instead (before-input-event): a key counts as held // neither is set (hidden PIN turned off and no lock code).
// from its keyDown (autorepeat keeps refreshing it) until its keyUp, or function wifiGate(){
// for 1.5s if the keyUp never arrives (focus moved mid-press). Physical if(enablePasswordProtection&&lockoutPassword)return{kind:'lock',codeType:lockoutCodeType};
// keys (input.code), so it's the same keys on any keyboard layout. The if(hiddenPinRequired())return{kind:'hidden',codeType:'pin'};
// key presses that complete the chord are swallowed, not typed into the return null;
// page.
const WIFI_CHORD_KEYS=['KeyW','KeyS','KeyF','KeyH'];
const WIFI_CHORD_HOLD_MS=1500;
const wifiChordDown=new Map();
function wifiChordInput(event,input){
if(!WIFI_CHORD_KEYS.includes(input.code))return;
if(input.type==='keyUp'){wifiChordDown.delete(input.code);return;}
if(input.type!=='keyDown')return;
const now=Date.now();
wifiChordDown.set(input.code,now);
if(!input.shift||input.control||input.alt||input.meta)return;
const all=WIFI_CHORD_KEYS.every(k=>now-(wifiChordDown.get(k)||0)<WIFI_CHORD_HOLD_MS);
if(!all)return;
event.preventDefault();
if(input.isAutoRepeat)return;
wifiChordDown.clear();
console.log('[WIFI] Shift+W+S+F+H pressed');
showWifiWindow();
} }
app.on('web-contents-created',(e,contents)=>{ function checkWifiCode(code){
contents.on('before-input-event',wifiChordInput); const gate=wifiGate();
}); if(!gate)return{ok:true};
if(gate.kind==='lock'){
return checkCode(lockoutThrottle,lockoutPassword,code,gate.codeType==='pin'?'PIN':'password');
}
return checkHiddenPin(code);
}
ipcMain.handle('wifi-request',async(event,req)=>{ ipcMain.handle('wifi-request',async(event,req)=>{
if(!wifiWindow||wifiWindow.isDestroyed()||event.sender!==wifiWindow.webContents){ if(!wifiWindow||wifiWindow.isDestroyed()||event.sender!==wifiWindow.webContents){
@@ -1326,20 +1316,15 @@ ipcMain.handle('wifi-request',async(event,req)=>{
resetWifiIdleTimer(); resetWifiIdleTimer();
const action=req&&req.action; const action=req&&req.action;
if(action==='close'){closeWifiWindow();return{ok:true};} if(action==='close'){closeWifiWindow();return{ok:true};}
if(action==='needs-pin')return{ok:true,needsPin:!wifiUnlocked}; if(action==='needs-pin'){
const gate=wifiGate();
return{ok:true,needsPin:!wifiUnlocked,codeType:gate?gate.codeType:'pin'};
}
if(action==='unlock'){ if(action==='unlock'){
if(wifiUnlocked)return{ok:true}; if(wifiUnlocked)return{ok:true};
if(String(req.pin||'')===readHiddenPin()){ const result=checkWifiCode(req.pin);
wifiUnlocked=true; if(result.ok)wifiUnlocked=true;
return{ok:true}; return result;
}
wifiPinTries++;
console.log('[WIFI] Wrong PIN, attempt',wifiPinTries);
if(wifiPinTries>=WIFI_MAX_PIN_TRIES){
setTimeout(closeWifiWindow,1500);
return{ok:false,error:'Too many wrong PINs'};
}
return{ok:false,error:'Incorrect PIN'};
} }
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'}; if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
if(action==='status'||action==='scan')return runWifiHelper(action); if(action==='status'||action==='scan')return runWifiHelper(action);
@@ -1455,6 +1440,7 @@ async function autheliaAuthenticate(){
async function createWindow(){ async function createWindow(){
tabs=loadConfig(); tabs=loadConfig();
migrateHiddenPin();
await autheliaAuthenticate(); await autheliaAuthenticate();
mainWindow=new BrowserWindow({ mainWindow=new BrowserWindow({
@@ -1585,7 +1571,7 @@ async function createWindow(){
if(enablePasswordProtection&&lockoutPassword&&requirePasswordOnBoot&&fs.existsSync(bootFlag)){ if(enablePasswordProtection&&lockoutPassword&&requirePasswordOnBoot&&fs.existsSync(bootFlag)){
console.log('[LOCKOUT] Boot detected, requiring password BEFORE showing sites'); console.log('[LOCKOUT] Boot detected, requiring password BEFORE showing sites');
fs.unlinkSync(bootFlag); fs.unlinkSync(bootFlag);
showLockoutScreen(); showLockoutScreen('boot');
}else{ }else{
attachView(startIndex); attachView(startIndex);
startMasterTimer(); startMasterTimer();
@@ -1609,14 +1595,27 @@ async function createWindow(){
ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();}); ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();});
ipcMain.on('keyboard-activity',()=>{markKeyboardActivity();}); ipcMain.on('keyboard-activity',()=>{markKeyboardActivity();});
ipcMain.on('show-pause-dialog',()=>{showPauseDialog();}); ipcMain.on('show-pause-dialog',()=>{showPauseDialog();});
ipcMain.on('check-lockout-password',(event,hash)=>{ // The lock screen sends the code as typed; compared here as a SHA-256
if(hash===lockoutPassword){ // hash (how the menu and Web UI store it). After every 5 wrong codes in
// a row the screen refuses input for a while - 30s, doubling up to 5
// minutes - so a 4-digit PIN can't simply be tried through.
// The lock screen sends the code as typed; compared here as a SHA-256
// hash (how the menu and Web UI store it), with the wrong-code slowdown
// (see createCodeThrottle).
ipcMain.on('check-lockout-password',(event,code)=>{
if(!lockoutWindow||lockoutWindow.isDestroyed()||event.sender!==lockoutWindow.webContents)return;
const expected=(lockoutMode==='boot'&&bootPassword)?bootPassword:lockoutPassword;
const result=checkCode(lockoutThrottle,expected,code,'code');
if(result.ok){
unlockScreen(); unlockScreen();
}else{ return;
if(lockoutWindow&&!lockoutWindow.isDestroyed()){
lockoutWindow.webContents.send('password-incorrect');
}
} }
event.sender.send('password-incorrect',{waitSeconds:result.waitSeconds||0});
});
ipcMain.handle('check-hidden-pin',(event,pin)=>{
if(!pinWindow||pinWindow.isDestroyed()||event.sender!==pinWindow.webContents)return{ok:false,error:'Not allowed'};
return checkHiddenPin(pin);
}); });
ipcMain.on('get-config',(event)=>{ ipcMain.on('get-config',(event)=>{
@@ -1789,9 +1788,10 @@ async function createWindow(){
globalShortcut.register('Control+Alt+Delete',showPowerMenu); globalShortcut.register('Control+Alt+Delete',showPowerMenu);
globalShortcut.register('Control+Alt+P',showPowerMenu); globalShortcut.register('Control+Alt+P',showPowerMenu);
globalShortcut.register('Control+Alt+Escape',showPowerMenu); globalShortcut.register('Control+Alt+Escape',showPowerMenu);
// Five keys on purpose - not something a visitor hits by accident. // WiFi screen - code-gated (wifiGate), so an easy-to-press combo is fine.
if(!globalShortcut.register('Control+Alt+Shift+Super+W',showWifiWindow)){ // Fn isn't part of it: it never reaches the computer on most keyboards.
console.error('[WIFI] Could not register Ctrl+Alt+Shift+Super+W'); if(!globalShortcut.register('Control+Alt+Super+W',showWifiWindow)){
console.error('[WIFI] Could not register Ctrl+Alt+Super+W');
} }
globalShortcut.register('Control+K',()=>{ globalShortcut.register('Control+K',()=>{
if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){ if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){
+33 -19
View File
@@ -78,20 +78,13 @@
<button class="btn" id="submit" onclick="submitPin()">✓ Submit</button> <button class="btn" id="submit" onclick="submitPin()">✓ Submit</button>
<button class="btn" id="cancel" onclick="cancel()">✗ Cancel</button> <button class="btn" id="cancel" onclick="cancel()">✗ Cancel</button>
</div> </div>
<div class="info">Default PIN: 1234 (4-8 digits)</div> <div class="info">4-8 digits</div>
</div> </div>
<script> <script>
const {ipcRenderer} = require('electron'); const {ipcRenderer} = require('electron');
const fs = require('fs');
const path = require('path');
const pinFile = path.join(__dirname, '.jitsi-pin');
let correctPin = '1234';
let enteredPin = ''; let enteredPin = '';
try { let blockedUntil = 0;
const stored = fs.readFileSync(pinFile, 'utf8').trim(); let blockTimer = null;
if (stored !== 'NOPIN') correctPin = stored;
else correctPin = null;
} catch(e) {}
function updateDisplay() { function updateDisplay() {
const display = document.getElementById('pin-display'); const display = document.getElementById('pin-display');
if (enteredPin.length === 0) { if (enteredPin.length === 0) {
@@ -103,6 +96,7 @@
} }
} }
function addDigit(digit) { function addDigit(digit) {
if (Date.now() < blockedUntil) return;
if (enteredPin.length < 8) { if (enteredPin.length < 8) {
enteredPin += digit; enteredPin += digit;
updateDisplay(); updateDisplay();
@@ -111,20 +105,40 @@
} }
function backspace() { enteredPin = enteredPin.slice(0, -1); updateDisplay(); } function backspace() { enteredPin = enteredPin.slice(0, -1); updateDisplay(); }
function clearPin() { enteredPin = ''; updateDisplay(); document.getElementById('error').style.display = 'none'; } function clearPin() { enteredPin = ''; updateDisplay(); document.getElementById('error').style.display = 'none'; }
function submitPin() { function showError(text) {
document.getElementById('error').textContent = '❌ ' + text;
document.getElementById('error').style.display = 'block';
}
// Checked in the main process (the stored PIN is hashed), which also
// refuses input for a while after repeated wrong PINs.
async function submitPin() {
if (Date.now() < blockedUntil) return;
if (enteredPin.length < 4) { if (enteredPin.length < 4) {
document.getElementById('error').textContent = '❌ PIN must be 4-8 digits'; showError('PIN must be 4-8 digits');
document.getElementById('error').style.display = 'block';
return; return;
} }
if (correctPin === null || enteredPin === correctPin) { const r = await ipcRenderer.invoke('check-hidden-pin', enteredPin)
.catch(() => ({ok: false, error: 'PIN check failed'}));
enteredPin = '';
updateDisplay();
if (r.ok) {
ipcRenderer.send('pin-correct'); ipcRenderer.send('pin-correct');
} else { return;
document.getElementById('error').textContent = '❌ Incorrect PIN';
document.getElementById('error').style.display = 'block';
enteredPin = '';
updateDisplay();
} }
if (!r.waitSeconds) { showError(r.error || 'Incorrect PIN'); return; }
blockedUntil = Date.now() + r.waitSeconds * 1000;
clearInterval(blockTimer);
const tick = () => {
const left = Math.ceil((blockedUntil - Date.now()) / 1000);
if (left <= 0) {
clearInterval(blockTimer);
document.getElementById('error').style.display = 'none';
return;
}
showError('Too many wrong tries - wait ' + left + 's');
};
tick();
blockTimer = setInterval(tick, 500);
} }
function cancel() { ipcRenderer.send('pin-cancelled'); } function cancel() { ipcRenderer.send('pin-cancelled'); }
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
+5 -1
View File
@@ -82,7 +82,7 @@
<h2>📶 WiFi</h2> <h2>📶 WiFi</h2>
<div id="pin-step" class="hidden"> <div id="pin-step" class="hidden">
<label for="pin">Enter the PIN</label> <label for="pin" id="pin-label">Enter the PIN</label>
<input id="pin" type="password" inputmode="numeric" autocomplete="off"> <input id="pin" type="password" inputmode="numeric" autocomplete="off">
<div class="actions"> <div class="actions">
<button class="primary" id="pin-ok">✓ OK</button> <button class="primary" id="pin-ok">✓ OK</button>
@@ -242,6 +242,10 @@
request('needs-pin').then(r => { request('needs-pin').then(r => {
if (r.ok && r.needsPin) { if (r.ok && r.needsPin) {
if (r.codeType === 'password') {
$('pin-label').textContent = 'Enter the kiosk password';
$('pin').removeAttribute('inputmode');
}
$('pin-step').classList.remove('hidden'); $('pin-step').classList.remove('hidden');
$('pin').focus(); $('pin').focus();
} else { } else {
+17 -1
View File
@@ -81,6 +81,12 @@ LOCKOUT_AT_TIME=""
LOCKOUT_ACTIVE_START="" LOCKOUT_ACTIVE_START=""
LOCKOUT_ACTIVE_END="" LOCKOUT_ACTIVE_END=""
REQUIRE_PASSWORD_ON_BOOT="false" REQUIRE_PASSWORD_ON_BOOT="false"
# "password" or "pin" (4-8 digits, unlocked with an on-screen keypad).
LOCKOUT_CODE_TYPE="password"
# A separate code for the boot lock screen - empty means the unlock code
# above is used at boot too. Hashed like LOCKOUT_PASSWORD.
BOOT_PASSWORD=""
BOOT_CODE_TYPE="password"
AUTHELIA_URL="" AUTHELIA_URL=""
AUTHELIA_USERNAME="" AUTHELIA_USERNAME=""
AUTHELIA_ENCRYPTED_PASSWORD="" AUTHELIA_ENCRYPTED_PASSWORD=""
@@ -214,6 +220,13 @@ load_existing_config() {
boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null) boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false" [[ "$boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false"
local code_type
code_type=$(sudo -u "$KIOSK_USER" jq -r '.lockoutCodeType // "password"' "$CONFIG_PATH" 2>/dev/null)
[[ "$code_type" == "pin" ]] && LOCKOUT_CODE_TYPE="pin" || LOCKOUT_CODE_TYPE="password"
BOOT_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.bootPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
code_type=$(sudo -u "$KIOSK_USER" jq -r '.bootCodeType // "password"' "$CONFIG_PATH" 2>/dev/null)
[[ "$code_type" == "pin" ]] && BOOT_CODE_TYPE="pin" || BOOT_CODE_TYPE="password"
AUTHELIA_URL=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || echo "") AUTHELIA_URL=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
AUTHELIA_USERNAME=$(sudo -u "$KIOSK_USER" jq -r '.autheliaUsername // ""' "$CONFIG_PATH" 2>/dev/null || echo "") AUTHELIA_USERNAME=$(sudo -u "$KIOSK_USER" jq -r '.autheliaUsername // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
AUTHELIA_ENCRYPTED_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.autheliaEncryptedPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "") AUTHELIA_ENCRYPTED_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.autheliaEncryptedPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "")
@@ -290,10 +303,13 @@ save_config() {
--arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \ --arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \
--arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \ --arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \
--argjson requirePasswordOnBoot "$boot_password_json" \ --argjson requirePasswordOnBoot "$boot_password_json" \
--arg lockoutCodeType "${LOCKOUT_CODE_TYPE:-password}" \
--arg bootPassword "${BOOT_PASSWORD:-}" \
--arg bootCodeType "${BOOT_CODE_TYPE:-password}" \
--arg autheliaURL "${AUTHELIA_URL:-}" \ --arg autheliaURL "${AUTHELIA_URL:-}" \
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \ --arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \ --arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp" '. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
if [[ ${#URLS[@]} -gt 0 ]]; then if [[ ${#URLS[@]} -gt 0 ]]; then
for idx in "${!URLS[@]}"; do for idx in "${!URLS[@]}"; do
+2 -2
View File
@@ -221,7 +221,7 @@ provision_configure_display() {
log_success "Display configured" log_success "Display configured"
} }
# The kiosk app's WiFi screen (Ctrl+Alt+Shift+Super+W) - joining WiFi from # The kiosk app's WiFi screen (Ctrl+Alt+Super+W) - joining WiFi from
# the kiosk itself when the console is switched off. The app runs as the # the kiosk itself when the console is switched off. The app runs as the
# kiosk user; the netplan write is done by this one root helper, which # kiosk user; the netplan write is done by this one root helper, which
# the kiosk user may run through sudo and nothing else (it only does # the kiosk user may run through sudo and nothing else (it only does
@@ -239,7 +239,7 @@ provision_install_wifi_helper() {
sudo mkdir -p "$SUDOERS_D_DIR" sudo mkdir -p "$SUDOERS_D_DIR"
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-wifi" sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-wifi"
else else
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Shift+Super+W won't be able to connect" log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
fi fi
rm -f "$tmp" rm -f "$tmp"
} }
+19 -4
View File
@@ -6,6 +6,12 @@
# flat PIN file rather than config.json - a fourth shape for the framework # flat PIN file rather than config.json - a fourth shape for the framework
# to prove out (plain file, not JSON at all). # to prove out (plain file, not JSON at all).
# #
# The file holds "NOPIN", or "sha256:<hex>" - the PIN is never stored as
# plain digits (a file from before that is converted by the kiosk app at
# startup; plain digits are still accepted until then). No file = the
# default PIN 1234. The kiosk app reads it on every check, so changes
# apply immediately, and slows down repeated wrong guesses.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first. # Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################ ################################################################################
@@ -22,8 +28,10 @@ hidden_pin_status() {
current_pin=$(sudo -u "$KIOSK_USER" cat "$pin_file" 2>/dev/null) current_pin=$(sudo -u "$KIOSK_USER" cat "$pin_file" 2>/dev/null)
if [[ "$current_pin" == "NOPIN" ]]; then if [[ "$current_pin" == "NOPIN" ]]; then
echo "Current: no PIN (hidden pages open to anyone)" echo "Current: no PIN (hidden pages open to anyone)"
elif [[ "$current_pin" == "sha256:$(hidden_pin_hash 1234)" || "$current_pin" == "1234" ]]; then
echo "Current: default PIN (1234) - set your own"
else else
echo "Current: PIN set (${#current_pin} digits)" echo "Current: PIN set"
fi fi
else else
echo "Current: not configured (default: 1234)" echo "Current: not configured (default: 1234)"
@@ -43,29 +51,36 @@ hidden_pin_menu() {
# Actions # Actions
################################################################################ ################################################################################
hidden_pin_hash() {
echo -n "$1" | sha256sum | cut -d' ' -f1
}
# write_pin VALUE - VALUE is "NOPIN" or the PIN digits (stored hashed).
write_pin() { write_pin() {
local value="$1" local value="$1"
local pin_file local pin_file
pin_file=$(hidden_pin_file) pin_file=$(hidden_pin_file)
[[ "$value" == "NOPIN" ]] || value="sha256:$(hidden_pin_hash "$value")"
sudo mkdir -p "$KIOSK_DIR" sudo mkdir -p "$KIOSK_DIR"
echo "$value" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null echo "$value" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null
sudo -u "$KIOSK_USER" chmod 600 "$pin_file" sudo -u "$KIOSK_USER" chmod 600 "$pin_file"
log_warning "Restart the kiosk display for this to take effect"
} }
action_set_pin() { action_set_pin() {
echo echo
local new_pin confirm_pin local new_pin confirm_pin
while true; do while true; do
read -r -p "Enter new PIN (4-8 digits): " new_pin read -r -s -p "Enter new PIN (4-8 digits): " new_pin
echo
if [[ ! "$new_pin" =~ ^[0-9]{4,8}$ ]]; then if [[ ! "$new_pin" =~ ^[0-9]{4,8}$ ]]; then
echo "❌ PIN must be 4-8 digits" echo "❌ PIN must be 4-8 digits"
continue continue
fi fi
read -r -p "Confirm PIN: " confirm_pin read -r -s -p "Confirm PIN: " confirm_pin
echo
if [[ "$new_pin" == "$confirm_pin" ]]; then if [[ "$new_pin" == "$confirm_pin" ]]; then
write_pin "$new_pin" write_pin "$new_pin"
+109 -24
View File
@@ -2,6 +2,11 @@
################################################################################ ################################################################################
# menus/lockout.sh - "Password Protection & Lockout" menu. # menus/lockout.sh - "Password Protection & Lockout" menu.
# #
# The unlock code and the (optional, separate) boot code are each either
# a password or a 4-8 digit PIN - the kiosk's own codes, independent of
# the admin account's sudo password. A PIN gets an on-screen number pad
# on the lock screen, so it works on a touch-only kiosk.
#
# Fifth menu migrated. Back to config.json (like Display), but with a # Fifth menu migrated. Back to config.json (like Display), but with a
# sensitive field: the lockout password is SHA-256 hashed before it's # sensitive field: the lockout password is SHA-256 hashed before it's
# ever written to disk (matching the Electron app's comparison logic in # ever written to disk (matching the Electron app's comparison logic in
@@ -31,7 +36,11 @@ lockout_status() {
else else
echo "Daily lock time: not set" echo "Daily lock time: not set"
fi fi
echo "Password on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")" echo "Unlock code: $(lockout_code_label "$LOCKOUT_CODE_TYPE")"
echo "Lock on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")"
if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then
echo "Boot code: $(lockout_boot_code_label)"
fi
else else
echo "Password protection: disabled" echo "Password protection: disabled"
fi fi
@@ -40,10 +49,11 @@ lockout_status() {
lockout_menu_builder() { lockout_menu_builder() {
if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then
MENU_LABELS=( MENU_LABELS=(
"Change lockout password" "Change unlock password/PIN (currently: $(lockout_code_label "$LOCKOUT_CODE_TYPE"))"
"Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)" "Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)"
"Set/clear daily lock time (currently: ${LOCKOUT_AT_TIME:-not set})" "Set/clear daily lock time (currently: ${LOCKOUT_AT_TIME:-not set})"
"Toggle require password on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))" "Toggle lock on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))"
"Set boot password/PIN (currently: $(lockout_boot_code_label))"
"Disable password protection" "Disable password protection"
) )
MENU_HANDLERS=( MENU_HANDLERS=(
@@ -51,6 +61,7 @@ lockout_menu_builder() {
action_change_timeout action_change_timeout
action_change_daily_lock action_change_daily_lock
action_toggle_boot_password action_toggle_boot_password
action_set_boot_code
action_disable_protection action_disable_protection
) )
else else
@@ -68,31 +79,74 @@ lockout_menu() {
# Shared helpers # Shared helpers
################################################################################ ################################################################################
# Prompts for a new password twice, hashes it, and assigns to # "PIN" / "password" for menus and status lines.
# LOCKOUT_PASSWORD. Returns 1 (without saving) if the user gives up. lockout_code_label() {
prompt_and_hash_password() { [[ "$1" == "pin" ]] && echo "PIN" || echo "password"
local pass1 pass2 }
lockout_boot_code_label() {
if [[ -z "$BOOT_PASSWORD" ]]; then
echo "same as unlock"
else
echo "separate $(lockout_code_label "$BOOT_CODE_TYPE")"
fi
}
# True if $1 is a valid kiosk PIN: 4-8 digits.
lockout_valid_pin() {
[[ "$1" =~ ^[0-9]{4,8}$ ]]
}
# prompt_code WHAT - asks whether WHAT (e.g. "unlock") is a password or a
# 4-8 digit PIN, then for the code itself twice. Sets CODE_TYPE ("password"
# or "pin") and CODE_HASH (SHA-256 hex, matching the Electron app's check
# in main.js - never plaintext). These are the kiosk's own codes, not
# any Linux account's password.
prompt_code() {
local what="$1" choice
echo "How should the $what code be entered?"
echo " 1) Password (letters, numbers, anything - needs a keyboard)"
echo " 2) PIN (4-8 digits - on-screen number pad, works on a touch screen)"
while true; do while true; do
read -r -s -p "Enter password: " pass1 read -r -p "Choose [1-2]: " choice
echo case "$choice" in
read -r -s -p "Confirm password: " pass2 1) CODE_TYPE="password"; break ;;
echo 2) CODE_TYPE="pin"; break ;;
*) echo "❌ Enter 1 or 2" ;;
esac
done
local label pass1 pass2
label=$(lockout_code_label "$CODE_TYPE")
while true; do
read -r -s -p "Enter $what $label: " pass1
echo
if [[ -z "$pass1" ]]; then if [[ -z "$pass1" ]]; then
echo "❌ Password cannot be empty" echo "❌ The $label cannot be empty"
continue continue
fi fi
if [[ "$CODE_TYPE" == "pin" ]] && ! lockout_valid_pin "$pass1"; then
echo "❌ A PIN is 4-8 digits (0-9 only)"
continue
fi
read -r -s -p "Confirm $what $label: " pass2
echo
if [[ "$pass1" != "$pass2" ]]; then if [[ "$pass1" != "$pass2" ]]; then
echo "❌ Passwords don't match, try again" echo "❌ They don't match, try again"
continue continue
fi fi
CODE_HASH=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
LOCKOUT_PASSWORD=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
return 0 return 0
done done
} }
# Prompts for the unlock code into LOCKOUT_PASSWORD/LOCKOUT_CODE_TYPE.
prompt_and_hash_password() {
prompt_code "unlock"
LOCKOUT_CODE_TYPE="$CODE_TYPE"
LOCKOUT_PASSWORD="$CODE_HASH"
}
################################################################################ ################################################################################
# Actions # Actions
################################################################################ ################################################################################
@@ -101,13 +155,14 @@ action_enable_protection() {
echo echo
echo "Add password protection with automatic lockout:" echo "Add password protection with automatic lockout:"
echo " • Blank screen after an inactivity period" echo " • Blank screen after an inactivity period"
echo " • Password required to unlock" echo " • Password or PIN required to unlock"
echo " • Password required after display schedule wake-up" echo " • Required after display schedule wake-up"
echo " • Optional: lock at a specific time daily" echo " • Optional: lock at a specific time daily"
echo " • Optional: require password on system boot" echo " • Optional: locked on system boot, with the same or its own code"
echo
echo "These are the kiosk's own codes - not your admin (sudo) password."
echo echo
echo "Set lockout password:"
prompt_and_hash_password prompt_and_hash_password
echo echo
@@ -123,8 +178,16 @@ action_enable_protection() {
fi fi
echo echo
if ask_yes_no "Require password on system boot/power on?" "y"; then BOOT_PASSWORD=""
BOOT_CODE_TYPE="password"
if ask_yes_no "Lock the kiosk on system boot/power on?" "y"; then
REQUIRE_PASSWORD_ON_BOOT="true" REQUIRE_PASSWORD_ON_BOOT="true"
echo
if ask_yes_no "Use a different password/PIN at boot than for unlocking?" "n"; then
prompt_code "boot"
BOOT_CODE_TYPE="$CODE_TYPE"
BOOT_PASSWORD="$CODE_HASH"
fi
else else
REQUIRE_PASSWORD_ON_BOOT="false" REQUIRE_PASSWORD_ON_BOOT="false"
fi fi
@@ -140,6 +203,9 @@ action_disable_protection() {
LOCKOUT_TIMEOUT=0 LOCKOUT_TIMEOUT=0
LOCKOUT_AT_TIME="" LOCKOUT_AT_TIME=""
REQUIRE_PASSWORD_ON_BOOT="false" REQUIRE_PASSWORD_ON_BOOT="false"
LOCKOUT_CODE_TYPE="password"
BOOT_PASSWORD=""
BOOT_CODE_TYPE="password"
log_success "Password protection disabled" log_success "Password protection disabled"
save_config save_config
} }
@@ -147,7 +213,7 @@ action_disable_protection() {
action_change_password() { action_change_password() {
echo echo
prompt_and_hash_password prompt_and_hash_password
log_success "Password updated" log_success "Unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE") updated"
save_config save_config
} }
@@ -178,10 +244,29 @@ action_change_daily_lock() {
action_toggle_boot_password() { action_toggle_boot_password() {
if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then
REQUIRE_PASSWORD_ON_BOOT="false" REQUIRE_PASSWORD_ON_BOOT="false"
log_warning "Password on boot disabled" log_warning "Lock on boot disabled"
else else
REQUIRE_PASSWORD_ON_BOOT="true" REQUIRE_PASSWORD_ON_BOOT="true"
log_success "Password on boot enabled" log_success "Lock on boot enabled (boot code: $(lockout_boot_code_label))"
fi
save_config
}
action_set_boot_code() {
echo
echo "The boot lock screen can use its own password/PIN, or the unlock one."
echo "Currently: $(lockout_boot_code_label)"
echo
if ask_yes_no "Use a separate password/PIN at boot?" "y"; then
prompt_code "boot"
BOOT_CODE_TYPE="$CODE_TYPE"
BOOT_PASSWORD="$CODE_HASH"
REQUIRE_PASSWORD_ON_BOOT="true"
log_success "Boot $(lockout_code_label "$BOOT_CODE_TYPE") set (lock on boot: on)"
else
BOOT_PASSWORD=""
BOOT_CODE_TYPE="password"
log_success "Boot uses the unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE")"
fi fi
save_config save_config
} }
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
################################################################################ ################################################################################
# kiosk-wifi-helper - the root side of the kiosk app's WiFi screen # kiosk-wifi-helper - the root side of the kiosk app's WiFi screen
# (Ctrl+Alt+Shift+Super+W), for joining a WiFi network from the kiosk # (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
# itself when the console (Ctrl+Alt+F1..F6) is switched off. # itself when the console (Ctrl+Alt+F1..F6) is switched off.
# #
# The kiosk user may run this one script via passwordless sudo # The kiosk user may run this one script via passwordless sudo
+19
View File
@@ -44,6 +44,10 @@ const SCALAR_DEFAULTS = {
lockoutTimeout: 0, lockoutTimeout: 0,
lockoutAtTime: '', lockoutAtTime: '',
requirePasswordOnBoot: false, requirePasswordOnBoot: false,
// 'password' or 'pin' (4-8 digits, entered on the lock screen's
// number pad) - for the unlock code and the optional separate boot code.
lockoutCodeType: 'password',
bootCodeType: 'password',
}; };
function readExisting() { function readExisting() {
@@ -94,6 +98,8 @@ function loadConfig() {
out[key] = key in existing ? existing[key] : def; out[key] = key in existing ? existing[key] : def;
} }
out.hasLockoutPassword = typeof existing.lockoutPassword === 'string' && existing.lockoutPassword.length > 0; out.hasLockoutPassword = typeof existing.lockoutPassword === 'string' && existing.lockoutPassword.length > 0;
// A boot code of its own (empty bootPassword = the unlock code is used at boot).
out.hasBootPassword = typeof existing.bootPassword === 'string' && existing.bootPassword.length > 0;
out.dualSwipe = out.swipeMode === 'dual'; out.dualSwipe = out.swipeMode === 'dual';
return out; return out;
} }
@@ -122,11 +128,24 @@ function saveConfig(patch) {
if (typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0) { if (typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0) {
merged.lockoutPassword = hashPassword(patch.newLockoutPassword); merged.lockoutPassword = hashPassword(patch.newLockoutPassword);
} }
// Separate boot code: a new one is hashed in, clearBootPassword goes
// back to "boot uses the unlock code".
if (!('bootPassword' in merged)) merged.bootPassword = '';
if (typeof patch.newBootPassword === 'string' && patch.newBootPassword.length > 0) {
merged.bootPassword = hashPassword(patch.newBootPassword);
}
if (patch.clearBootPassword === true) {
merged.bootPassword = '';
merged.bootCodeType = 'password';
}
if (patch.enablePasswordProtection === false) { if (patch.enablePasswordProtection === false) {
merged.lockoutPassword = ''; merged.lockoutPassword = '';
merged.lockoutTimeout = 0; merged.lockoutTimeout = 0;
merged.lockoutAtTime = ''; merged.lockoutAtTime = '';
merged.requirePasswordOnBoot = false; merged.requirePasswordOnBoot = false;
merged.lockoutCodeType = 'password';
merged.bootPassword = '';
merged.bootCodeType = 'password';
} }
if (Array.isArray(patch.tabs)) { if (Array.isArray(patch.tabs)) {
+41 -1
View File
@@ -196,10 +196,18 @@ const dailyLockEnabled = document.getElementById('daily-lock-enabled');
const lockoutAtTime = document.getElementById('lockout-at-time'); const lockoutAtTime = document.getElementById('lockout-at-time');
const passwordLabel = document.getElementById('password-label'); const passwordLabel = document.getElementById('password-label');
const bootLockEnabled = document.getElementById('boot-lock-enabled');
const bootCodeMode = document.getElementById('boot-code-mode');
const PIN_RE = /^[0-9]{4,8}$/;
function refreshLockoutFieldVisibility() { function refreshLockoutFieldVisibility() {
lockoutFields.hidden = !lockoutEnabled.checked; lockoutFields.hidden = !lockoutEnabled.checked;
document.getElementById('boot-code-fields').hidden = !bootLockEnabled.checked;
document.getElementById('boot-separate-fields').hidden = bootCodeMode.value !== 'separate';
} }
lockoutEnabled.addEventListener('change', refreshLockoutFieldVisibility); lockoutEnabled.addEventListener('change', refreshLockoutFieldVisibility);
bootLockEnabled.addEventListener('change', refreshLockoutFieldVisibility);
bootCodeMode.addEventListener('change', refreshLockoutFieldVisibility);
dailyLockEnabled.addEventListener('change', () => { dailyLockEnabled.addEventListener('change', () => {
lockoutAtTime.disabled = !dailyLockEnabled.checked; lockoutAtTime.disabled = !dailyLockEnabled.checked;
@@ -221,6 +229,23 @@ lockoutForm.addEventListener('submit', async (e) => {
showMessage('Set a lockout password before enabling password protection', true); showMessage('Set a lockout password before enabling password protection', true);
return; return;
} }
const codeType = fd.get('lockoutCodeType');
if (enable && newPassword && codeType === 'pin' && !PIN_RE.test(newPassword)) {
showMessage('A PIN is 4-8 digits', true);
return;
}
const bootSeparate = fd.get('requirePasswordOnBoot') === 'on' && fd.get('bootCodeMode') === 'separate';
const newBootPassword = fd.get('newBootPassword') || '';
if (enable && bootSeparate) {
if (newBootPassword !== document.getElementById('boot-password-confirm').value) {
showMessage("Boot passwords don't match", true);
return;
}
if (newBootPassword && fd.get('bootCodeType') === 'pin' && !PIN_RE.test(newBootPassword)) {
showMessage('A boot PIN is 4-8 digits', true);
return;
}
}
const patch = { enablePasswordProtection: enable }; const patch = { enablePasswordProtection: enable };
if (enable) { if (enable) {
@@ -228,6 +253,14 @@ lockoutForm.addEventListener('submit', async (e) => {
patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10); patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10);
patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : ''; patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : '';
patch.requirePasswordOnBoot = fd.get('requirePasswordOnBoot') === 'on'; patch.requirePasswordOnBoot = fd.get('requirePasswordOnBoot') === 'on';
patch.lockoutCodeType = codeType;
if (bootSeparate) {
patch.bootCodeMode = 'separate';
patch.bootCodeType = fd.get('bootCodeType');
if (newBootPassword) patch.newBootPassword = newBootPassword;
} else if (patch.requirePasswordOnBoot) {
patch.bootCodeMode = 'same';
}
} }
try { try {
@@ -236,6 +269,8 @@ lockoutForm.addEventListener('submit', async (e) => {
populateAll(currentConfig); populateAll(currentConfig);
lockoutForm.querySelector('[name=newLockoutPassword]').value = ''; lockoutForm.querySelector('[name=newLockoutPassword]').value = '';
document.getElementById('lockout-password-confirm').value = ''; document.getElementById('lockout-password-confirm').value = '';
lockoutForm.querySelector('[name=newBootPassword]').value = '';
document.getElementById('boot-password-confirm').value = '';
} catch (err) { } catch (err) {
showMessage(err.message, true); showMessage(err.message, true);
} }
@@ -252,12 +287,17 @@ function populateAll(config) {
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60); displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
lockoutEnabled.checked = !!config.enablePasswordProtection; lockoutEnabled.checked = !!config.enablePasswordProtection;
passwordLabel.textContent = config.hasLockoutPassword ? 'New password (leave blank to keep the current one)' : 'Set lockout password'; passwordLabel.textContent = config.hasLockoutPassword ? 'New password/PIN (leave blank to keep the current one)' : 'Set unlock password/PIN';
lockoutForm.elements.lockoutTimeoutMinutes.value = config.lockoutTimeout; lockoutForm.elements.lockoutTimeoutMinutes.value = config.lockoutTimeout;
dailyLockEnabled.checked = !!config.lockoutAtTime; dailyLockEnabled.checked = !!config.lockoutAtTime;
lockoutAtTime.disabled = !config.lockoutAtTime; lockoutAtTime.disabled = !config.lockoutAtTime;
lockoutAtTime.value = config.lockoutAtTime || ''; lockoutAtTime.value = config.lockoutAtTime || '';
lockoutForm.elements.requirePasswordOnBoot.checked = !!config.requirePasswordOnBoot; lockoutForm.elements.requirePasswordOnBoot.checked = !!config.requirePasswordOnBoot;
lockoutForm.elements.lockoutCodeType.value = config.lockoutCodeType === 'pin' ? 'pin' : 'password';
bootCodeMode.value = config.hasBootPassword ? 'separate' : 'same';
lockoutForm.elements.bootCodeType.value = config.bootCodeType === 'pin' ? 'pin' : 'password';
document.getElementById('boot-password-label').textContent = config.hasBootPassword
? 'New boot password/PIN (leave blank to keep the current one)' : 'Set boot password/PIN';
refreshLockoutFieldVisibility(); refreshLockoutFieldVisibility();
} }
+36 -3
View File
@@ -115,19 +115,26 @@
<section class="page" id="page-lockout"> <section class="page" id="page-lockout">
<div class="page-header"> <div class="page-header">
<h1>Password Protection &amp; Lockout</h1> <h1>Password Protection &amp; Lockout</h1>
<p>Blank the screen after inactivity and require a password to unlock.</p> <p>Blank the screen after inactivity and require a password or PIN to unlock. These are the kiosk's own codes, not the admin (sudo) password.</p>
</div> </div>
<div class="card"> <div class="card">
<form id="lockout-form"> <form id="lockout-form">
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label> <label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
<div id="lockout-fields"> <div id="lockout-fields">
<label>
Unlock with
<select name="lockoutCodeType">
<option value="password">Password (needs a keyboard)</option>
<option value="pin">PIN, 4-8 digits (on-screen number pad)</option>
</select>
</label>
<label> <label>
<span id="password-label">Set lockout password</span> <span id="password-label">Set lockout password</span>
<input type="password" name="newLockoutPassword" autocomplete="new-password"> <input type="password" name="newLockoutPassword" autocomplete="new-password">
</label> </label>
<label> <label>
Confirm password Confirm password/PIN
<input type="password" id="lockout-password-confirm" autocomplete="new-password"> <input type="password" id="lockout-password-confirm" autocomplete="new-password">
</label> </label>
@@ -142,7 +149,33 @@
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled> <input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
</label> </label>
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label> <label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot" id="boot-lock-enabled"> Lock the kiosk on system boot</label>
<div id="boot-code-fields">
<label>
Boot code
<select name="bootCodeMode" id="boot-code-mode">
<option value="same">Same as the unlock code</option>
<option value="separate">Its own password/PIN</option>
</select>
</label>
<div id="boot-separate-fields">
<label>
Boot code type
<select name="bootCodeType">
<option value="password">Password (needs a keyboard)</option>
<option value="pin">PIN, 4-8 digits (on-screen number pad)</option>
</select>
</label>
<label>
<span id="boot-password-label">Set boot password/PIN</span>
<input type="password" name="newBootPassword" autocomplete="new-password">
</label>
<label>
Confirm boot password/PIN
<input type="password" id="boot-password-confirm" autocomplete="new-password">
</label>
</div>
</div>
</div> </div>
<div class="button-row"><button type="submit">Save Password Protection &amp; Lockout</button></div> <div class="button-row"><button type="submit">Save Password Protection &amp; Lockout</button></div>
+42 -1
View File
@@ -114,18 +114,59 @@ app.put('/api/config', (req, res) => {
if (body.requirePasswordOnBoot !== undefined) patch.requirePasswordOnBoot = !!body.requirePasswordOnBoot; if (body.requirePasswordOnBoot !== undefined) patch.requirePasswordOnBoot = !!body.requirePasswordOnBoot;
const CODE_TYPES = ['password', 'pin'];
const PIN_RE = /^[0-9]{4,8}$/;
const current = loadConfig();
if (body.lockoutCodeType !== undefined) {
if (!CODE_TYPES.includes(body.lockoutCodeType)) return badRequest(res, 'lockoutCodeType must be "password" or "pin"');
patch.lockoutCodeType = body.lockoutCodeType;
}
const lockoutType = patch.lockoutCodeType || current.lockoutCodeType;
if (body.newLockoutPassword !== undefined) { if (body.newLockoutPassword !== undefined) {
if (typeof body.newLockoutPassword !== 'string' || body.newLockoutPassword.length === 0) { if (typeof body.newLockoutPassword !== 'string' || body.newLockoutPassword.length === 0) {
return badRequest(res, 'Password cannot be empty'); return badRequest(res, 'Password cannot be empty');
} }
if (lockoutType === 'pin' && !PIN_RE.test(body.newLockoutPassword)) {
return badRequest(res, 'A PIN is 4-8 digits');
}
patch.newLockoutPassword = body.newLockoutPassword; patch.newLockoutPassword = body.newLockoutPassword;
} }
// Switching between password and PIN changes which entry screen the
// kiosk shows - the old code can't be kept across that.
if (patch.lockoutCodeType && patch.lockoutCodeType !== current.lockoutCodeType && current.hasLockoutPassword
&& !patch.newLockoutPassword && patch.enablePasswordProtection !== false) {
return badRequest(res, `Enter a new ${lockoutType === 'pin' ? 'PIN' : 'password'} when switching between password and PIN`);
}
// Boot code: "same" (use the unlock code) or "separate" (its own
// password/PIN).
if (body.bootCodeMode !== undefined) {
if (body.bootCodeMode === 'same') {
patch.clearBootPassword = true;
} else if (body.bootCodeMode === 'separate') {
const bootType = body.bootCodeType !== undefined ? body.bootCodeType : current.bootCodeType;
if (!CODE_TYPES.includes(bootType)) return badRequest(res, 'bootCodeType must be "password" or "pin"');
patch.bootCodeType = bootType;
const newBoot = body.newBootPassword;
if (newBoot !== undefined && newBoot !== '') {
if (typeof newBoot !== 'string') return badRequest(res, 'Boot password must be text');
if (bootType === 'pin' && !PIN_RE.test(newBoot)) return badRequest(res, 'A boot PIN is 4-8 digits');
patch.newBootPassword = newBoot;
} else if (!current.hasBootPassword || bootType !== current.bootCodeType) {
return badRequest(res, `Enter the boot ${bootType === 'pin' ? 'PIN' : 'password'}`);
}
} else {
return badRequest(res, 'bootCodeMode must be "same" or "separate"');
}
}
// Mirrors action_enable_protection() always requiring a password up // Mirrors action_enable_protection() always requiring a password up
// front - lockout.sh has no path that enables protection without one. // front - lockout.sh has no path that enables protection without one.
if (patch.enablePasswordProtection === true) { if (patch.enablePasswordProtection === true) {
const hasNewPassword = typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0; const hasNewPassword = typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0;
if (!hasNewPassword && !loadConfig().hasLockoutPassword) { if (!hasNewPassword && !current.hasLockoutPassword) {
return badRequest(res, 'Set a lockout password before enabling password protection'); return badRequest(res, 'Set a lockout password before enabling password protection');
} }
} }
+79
View File
@@ -141,6 +141,85 @@ async function main() {
assert.strictEqual(JSON.stringify(body).includes('hunter2'), false); assert.strictEqual(JSON.stringify(body).includes('hunter2'), false);
}); });
const put = (body) => fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const onDisk = () => JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
const sha = (v) => require('crypto').createHash('sha256').update(v).digest('hex');
await check('PUT rejects switching to a PIN without a new code', async () => {
const res = await put({ lockoutCodeType: 'pin' });
assert.strictEqual(res.status, 400);
assert.strictEqual(onDisk().lockoutCodeType || 'password', 'password');
});
await check('PUT rejects a PIN that is not 4-8 digits', async () => {
for (const pin of ['123', '123456789', '12a4']) {
const res = await put({ lockoutCodeType: 'pin', newLockoutPassword: pin });
assert.strictEqual(res.status, 400, pin);
}
});
await check('PUT switches the unlock code to a 4-digit PIN', async () => {
const res = await put({ lockoutCodeType: 'pin', newLockoutPassword: '4821' });
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.lockoutCodeType, 'pin');
assert.strictEqual(onDisk().lockoutPassword, sha('4821'));
});
await check('PUT sets a separate boot password, never echoed back', async () => {
let res = await put({ requirePasswordOnBoot: true, bootCodeMode: 'separate', bootCodeType: 'password' });
assert.strictEqual(res.status, 400, 'separate boot code without one entered');
res = await put({ requirePasswordOnBoot: true, bootCodeMode: 'separate', bootCodeType: 'password', newBootPassword: 'boot pass!' });
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.hasBootPassword, true);
assert.strictEqual(body.bootCodeType, 'password');
assert.strictEqual(JSON.stringify(body).includes('boot pass!'), false);
assert.strictEqual(onDisk().bootPassword, sha('boot pass!'));
assert.strictEqual(onDisk().lockoutPassword, sha('4821'), 'unlock PIN untouched');
});
await check('PUT keeps the boot code when saved again without a new one', async () => {
const res = await put({ bootCodeMode: 'separate', bootCodeType: 'password' });
assert.strictEqual(res.status, 200);
assert.strictEqual(onDisk().bootPassword, sha('boot pass!'));
});
await check('PUT requires a new boot code when its type changes, and checks PIN format', async () => {
let res = await put({ bootCodeMode: 'separate', bootCodeType: 'pin' });
assert.strictEqual(res.status, 400);
res = await put({ bootCodeMode: 'separate', bootCodeType: 'pin', newBootPassword: '12' });
assert.strictEqual(res.status, 400);
res = await put({ bootCodeMode: 'separate', bootCodeType: 'pin', newBootPassword: '90210777' });
assert.strictEqual(res.status, 200);
assert.strictEqual(onDisk().bootCodeType, 'pin');
assert.strictEqual(onDisk().bootPassword, sha('90210777'));
});
await check('PUT bootCodeMode "same" clears the separate boot code', async () => {
const res = await put({ bootCodeMode: 'same' });
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.hasBootPassword, false);
assert.strictEqual(onDisk().bootPassword, '');
});
await check('PUT disabling protection clears PIN type and boot code too', async () => {
await put({ bootCodeMode: 'separate', bootCodeType: 'pin', newBootPassword: '5555' });
const res = await put({ enablePasswordProtection: false });
assert.strictEqual(res.status, 200);
const d = onDisk();
assert.strictEqual(d.lockoutPassword, '');
assert.strictEqual(d.lockoutCodeType, 'password');
assert.strictEqual(d.bootPassword, '');
// back to the state the next test expects
await put({ enablePasswordProtection: true, newLockoutPassword: 'hunter2', lockoutTimeoutMinutes: 15 });
});
await check('PUT rejects a malformed lockoutAtTime', async () => { await check('PUT rejects a malformed lockoutAtTime', async () => {
const res = await fetch(`${base}/api/config`, { const res = await fetch(`${base}/api/config`, {
method: 'PUT', method: 'PUT',