Compare commits
4
Commits
36f09b2253
...
1987b0ee71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1987b0ee71 | ||
|
|
ff962a41c4 | ||
|
|
d6769d5207 | ||
|
|
f215c64085 |
@@ -262,7 +262,10 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
|
||||
- **Session lockout** after configured inactivity
|
||||
- **Scheduled lockout** at specific time daily
|
||||
- **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)
|
||||
|
||||
### 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
|
||||
- **Watchdog support** - Auto-revert bad WiFi configs
|
||||
- **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)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -900,11 +908,14 @@ The PIN file controls access to hidden sites (duration = -1):
|
||||
- **Default:** `1234`
|
||||
- **Configure via:** Main Menu → Core Settings → Sites → Configure Hidden Sites PIN
|
||||
- **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
|
||||
# Set custom PIN
|
||||
echo "5678" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin
|
||||
# Set custom PIN (the menu does this for you)
|
||||
echo "sha256:$(echo -n 5678 | sha256sum | cut -d' ' -f1)" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin
|
||||
|
||||
# Disable PIN protection
|
||||
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:**
|
||||
- Full black screen (no content visible)
|
||||
- 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)
|
||||
- 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -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
@@ -66,9 +66,14 @@ let lockoutAtTime="";
|
||||
let lockoutActiveStart="";
|
||||
let lockoutActiveEnd="";
|
||||
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
|
||||
let isLockedOut=false;
|
||||
let lockoutMode='unlock'; // 'boot' while the boot lock screen is up
|
||||
const lockoutThrottle=createCodeThrottle();
|
||||
let lockoutWindow=null;
|
||||
let lockoutTimer=null;
|
||||
let lockoutActivityTime=Date.now();
|
||||
@@ -103,6 +108,9 @@ function loadConfig(){
|
||||
lockoutActiveStart=config.lockoutActiveStart||"";
|
||||
lockoutActiveEnd=config.lockoutActiveEnd||"";
|
||||
requirePasswordOnBoot=(config.requirePasswordOnBoot===true);
|
||||
lockoutCodeType=(config.lockoutCodeType==='pin')?'pin':'password';
|
||||
bootPassword=config.bootPassword||'';
|
||||
bootCodeType=(config.bootCodeType==='pin')?'pin':'password';
|
||||
autheliaURL=config.autheliaURL||'';
|
||||
autheliaUsername=config.autheliaUsername||'';
|
||||
autheliaEncryptedPassword=config.autheliaEncryptedPassword||'';
|
||||
@@ -118,6 +126,7 @@ function loadConfig(){
|
||||
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
|
||||
if(lockoutActiveStart&&lockoutActiveEnd)console.log('[CONFIG] Active hours:',lockoutActiveStart,'-',lockoutActiveEnd);
|
||||
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] ╚═══════════════════════════════╝');
|
||||
|
||||
@@ -153,11 +162,88 @@ function markKeyboardActivity(){
|
||||
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
|
||||
function showLockoutScreen(){
|
||||
function showLockoutScreen(mode){
|
||||
if(isLockedOut||!enablePasswordProtection||!lockoutPassword)return;
|
||||
|
||||
isLockedOut=true;
|
||||
lockoutMode=(mode==='boot')?'boot':'unlock';
|
||||
console.log('[LOCKOUT] Showing lockout screen');
|
||||
closeWifiWindow();
|
||||
|
||||
@@ -193,81 +279,9 @@ function showLockoutScreen(){
|
||||
}
|
||||
});
|
||||
|
||||
lockoutWindow.loadURL('data:text/html;charset=utf-8,'+encodeURIComponent(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<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>
|
||||
`));
|
||||
// The boot screen takes the separate boot code when one is set.
|
||||
const codeType=(lockoutMode==='boot'&&bootPassword)?bootCodeType:lockoutCodeType;
|
||||
lockoutWindow.loadFile(path.join(__dirname,'lockscreen.html'),{query:{type:codeType,mode:lockoutMode}});
|
||||
|
||||
lockoutWindow.on('closed',()=>{
|
||||
lockoutWindow=null;
|
||||
@@ -1190,25 +1204,16 @@ function showHiddenTab(index){
|
||||
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
|
||||
// admin menu can't be reached. Gated by the hidden-tab PIN (.jitsi-pin,
|
||||
// same rules as pin-entry.html: no file = 1234, NOPIN = none). The root
|
||||
// admin menu can't be reached. Gated by the kiosk's unlock code - the
|
||||
// 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
|
||||
// may run through sudo and nothing else (/etc/sudoers.d/kiosk-wifi).
|
||||
const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
|
||||
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
|
||||
const WIFI_MAX_PIN_TRIES=5;
|
||||
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';
|
||||
}
|
||||
let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
|
||||
|
||||
function runWifiHelper(action,input){
|
||||
return new Promise(resolve=>{
|
||||
@@ -1260,8 +1265,7 @@ function showWifiWindow(){
|
||||
wifiWindow.focus();
|
||||
return;
|
||||
}
|
||||
wifiUnlocked=(readHiddenPin()===null);
|
||||
wifiPinTries=0;
|
||||
wifiUnlocked=!wifiGate();
|
||||
let width=720,height=820;
|
||||
if(mainWindow&&!mainWindow.isDestroyed()){
|
||||
const[w,h]=mainWindow.getContentSize();
|
||||
@@ -1287,37 +1291,23 @@ function showWifiWindow(){
|
||||
console.log('[WIFI] WiFi screen opened');
|
||||
}
|
||||
|
||||
// Shift+W+S+F+H, all held together, opens the WiFi screen. A globalShortcut
|
||||
// can only have one non-modifier key, so this watches the raw key events
|
||||
// of every page/window instead (before-input-event): a key counts as held
|
||||
// from its keyDown (autorepeat keeps refreshing it) until its keyUp, or
|
||||
// for 1.5s if the keyUp never arrives (focus moved mid-press). Physical
|
||||
// keys (input.code), so it's the same keys on any keyboard layout. The
|
||||
// key presses that complete the chord are swallowed, not typed into the
|
||||
// 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();
|
||||
// Which code guards the WiFi screen: the unlock code (PIN or password)
|
||||
// when password protection is on, else the hidden-sites PIN; null if
|
||||
// neither is set (hidden PIN turned off and no lock code).
|
||||
function wifiGate(){
|
||||
if(enablePasswordProtection&&lockoutPassword)return{kind:'lock',codeType:lockoutCodeType};
|
||||
if(hiddenPinRequired())return{kind:'hidden',codeType:'pin'};
|
||||
return null;
|
||||
}
|
||||
|
||||
app.on('web-contents-created',(e,contents)=>{
|
||||
contents.on('before-input-event',wifiChordInput);
|
||||
});
|
||||
function checkWifiCode(code){
|
||||
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)=>{
|
||||
if(!wifiWindow||wifiWindow.isDestroyed()||event.sender!==wifiWindow.webContents){
|
||||
@@ -1326,20 +1316,15 @@ ipcMain.handle('wifi-request',async(event,req)=>{
|
||||
resetWifiIdleTimer();
|
||||
const action=req&&req.action;
|
||||
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(wifiUnlocked)return{ok:true};
|
||||
if(String(req.pin||'')===readHiddenPin()){
|
||||
wifiUnlocked=true;
|
||||
return{ok:true};
|
||||
}
|
||||
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'};
|
||||
const result=checkWifiCode(req.pin);
|
||||
if(result.ok)wifiUnlocked=true;
|
||||
return result;
|
||||
}
|
||||
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
|
||||
if(action==='status'||action==='scan')return runWifiHelper(action);
|
||||
@@ -1455,6 +1440,7 @@ async function autheliaAuthenticate(){
|
||||
|
||||
async function createWindow(){
|
||||
tabs=loadConfig();
|
||||
migrateHiddenPin();
|
||||
await autheliaAuthenticate();
|
||||
|
||||
mainWindow=new BrowserWindow({
|
||||
@@ -1585,7 +1571,7 @@ async function createWindow(){
|
||||
if(enablePasswordProtection&&lockoutPassword&&requirePasswordOnBoot&&fs.existsSync(bootFlag)){
|
||||
console.log('[LOCKOUT] Boot detected, requiring password BEFORE showing sites');
|
||||
fs.unlinkSync(bootFlag);
|
||||
showLockoutScreen();
|
||||
showLockoutScreen('boot');
|
||||
}else{
|
||||
attachView(startIndex);
|
||||
startMasterTimer();
|
||||
@@ -1609,14 +1595,27 @@ async function createWindow(){
|
||||
ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();});
|
||||
ipcMain.on('keyboard-activity',()=>{markKeyboardActivity();});
|
||||
ipcMain.on('show-pause-dialog',()=>{showPauseDialog();});
|
||||
ipcMain.on('check-lockout-password',(event,hash)=>{
|
||||
if(hash===lockoutPassword){
|
||||
// The lock screen sends the code as typed; compared here as a SHA-256
|
||||
// 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();
|
||||
}else{
|
||||
if(lockoutWindow&&!lockoutWindow.isDestroyed()){
|
||||
lockoutWindow.webContents.send('password-incorrect');
|
||||
}
|
||||
return;
|
||||
}
|
||||
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)=>{
|
||||
@@ -1789,9 +1788,10 @@ async function createWindow(){
|
||||
globalShortcut.register('Control+Alt+Delete',showPowerMenu);
|
||||
globalShortcut.register('Control+Alt+P',showPowerMenu);
|
||||
globalShortcut.register('Control+Alt+Escape',showPowerMenu);
|
||||
// Five keys on purpose - not something a visitor hits by accident.
|
||||
if(!globalShortcut.register('Control+Alt+Shift+Super+W',showWifiWindow)){
|
||||
console.error('[WIFI] Could not register Ctrl+Alt+Shift+Super+W');
|
||||
// WiFi screen - code-gated (wifiGate), so an easy-to-press combo is fine.
|
||||
// Fn isn't part of it: it never reaches the computer on most keyboards.
|
||||
if(!globalShortcut.register('Control+Alt+Super+W',showWifiWindow)){
|
||||
console.error('[WIFI] Could not register Ctrl+Alt+Super+W');
|
||||
}
|
||||
globalShortcut.register('Control+K',()=>{
|
||||
if(htmlKeyboardWindow&&!htmlKeyboardWindow.isDestroyed()){
|
||||
|
||||
+33
-19
@@ -78,20 +78,13 @@
|
||||
<button class="btn" id="submit" onclick="submitPin()">✓ Submit</button>
|
||||
<button class="btn" id="cancel" onclick="cancel()">✗ Cancel</button>
|
||||
</div>
|
||||
<div class="info">Default PIN: 1234 (4-8 digits)</div>
|
||||
<div class="info">4-8 digits</div>
|
||||
</div>
|
||||
<script>
|
||||
const {ipcRenderer} = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pinFile = path.join(__dirname, '.jitsi-pin');
|
||||
let correctPin = '1234';
|
||||
let enteredPin = '';
|
||||
try {
|
||||
const stored = fs.readFileSync(pinFile, 'utf8').trim();
|
||||
if (stored !== 'NOPIN') correctPin = stored;
|
||||
else correctPin = null;
|
||||
} catch(e) {}
|
||||
let blockedUntil = 0;
|
||||
let blockTimer = null;
|
||||
function updateDisplay() {
|
||||
const display = document.getElementById('pin-display');
|
||||
if (enteredPin.length === 0) {
|
||||
@@ -103,6 +96,7 @@
|
||||
}
|
||||
}
|
||||
function addDigit(digit) {
|
||||
if (Date.now() < blockedUntil) return;
|
||||
if (enteredPin.length < 8) {
|
||||
enteredPin += digit;
|
||||
updateDisplay();
|
||||
@@ -111,20 +105,40 @@
|
||||
}
|
||||
function backspace() { enteredPin = enteredPin.slice(0, -1); updateDisplay(); }
|
||||
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) {
|
||||
document.getElementById('error').textContent = '❌ PIN must be 4-8 digits';
|
||||
document.getElementById('error').style.display = 'block';
|
||||
showError('PIN must be 4-8 digits');
|
||||
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');
|
||||
} else {
|
||||
document.getElementById('error').textContent = '❌ Incorrect PIN';
|
||||
document.getElementById('error').style.display = 'block';
|
||||
enteredPin = '';
|
||||
updateDisplay();
|
||||
return;
|
||||
}
|
||||
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'); }
|
||||
document.addEventListener('keydown', (e) => {
|
||||
|
||||
+5
-1
@@ -82,7 +82,7 @@
|
||||
<h2>📶 WiFi</h2>
|
||||
|
||||
<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">
|
||||
<div class="actions">
|
||||
<button class="primary" id="pin-ok">✓ OK</button>
|
||||
@@ -242,6 +242,10 @@
|
||||
|
||||
request('needs-pin').then(r => {
|
||||
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').focus();
|
||||
} else {
|
||||
|
||||
+17
-1
@@ -81,6 +81,12 @@ LOCKOUT_AT_TIME=""
|
||||
LOCKOUT_ACTIVE_START=""
|
||||
LOCKOUT_ACTIVE_END=""
|
||||
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_USERNAME=""
|
||||
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" == "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_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 "")
|
||||
@@ -290,10 +303,13 @@ save_config() {
|
||||
--arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \
|
||||
--arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \
|
||||
--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 autheliaUsername "${AUTHELIA_USERNAME:-}" \
|
||||
--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
|
||||
for idx in "${!URLS[@]}"; do
|
||||
|
||||
+2
-2
@@ -221,7 +221,7 @@ provision_configure_display() {
|
||||
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
|
||||
# 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
|
||||
@@ -239,7 +239,7 @@ provision_install_wifi_helper() {
|
||||
sudo mkdir -p "$SUDOERS_D_DIR"
|
||||
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-wifi"
|
||||
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
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
+19
-4
@@ -6,6 +6,12 @@
|
||||
# flat PIN file rather than config.json - a fourth shape for the framework
|
||||
# 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.
|
||||
################################################################################
|
||||
|
||||
@@ -22,8 +28,10 @@ hidden_pin_status() {
|
||||
current_pin=$(sudo -u "$KIOSK_USER" cat "$pin_file" 2>/dev/null)
|
||||
if [[ "$current_pin" == "NOPIN" ]]; then
|
||||
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
|
||||
echo "Current: PIN set (${#current_pin} digits)"
|
||||
echo "Current: PIN set"
|
||||
fi
|
||||
else
|
||||
echo "Current: not configured (default: 1234)"
|
||||
@@ -43,29 +51,36 @@ hidden_pin_menu() {
|
||||
# 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() {
|
||||
local value="$1"
|
||||
local pin_file
|
||||
pin_file=$(hidden_pin_file)
|
||||
|
||||
[[ "$value" == "NOPIN" ]] || value="sha256:$(hidden_pin_hash "$value")"
|
||||
sudo mkdir -p "$KIOSK_DIR"
|
||||
echo "$value" | sudo -u "$KIOSK_USER" tee "$pin_file" > /dev/null
|
||||
sudo -u "$KIOSK_USER" chmod 600 "$pin_file"
|
||||
log_warning "Restart the kiosk display for this to take effect"
|
||||
}
|
||||
|
||||
action_set_pin() {
|
||||
echo
|
||||
local new_pin confirm_pin
|
||||
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
|
||||
echo "❌ PIN must be 4-8 digits"
|
||||
continue
|
||||
fi
|
||||
|
||||
read -r -p "Confirm PIN: " confirm_pin
|
||||
read -r -s -p "Confirm PIN: " confirm_pin
|
||||
echo
|
||||
|
||||
if [[ "$new_pin" == "$confirm_pin" ]]; then
|
||||
write_pin "$new_pin"
|
||||
|
||||
+109
-24
@@ -2,6 +2,11 @@
|
||||
################################################################################
|
||||
# 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
|
||||
# sensitive field: the lockout password is SHA-256 hashed before it's
|
||||
# ever written to disk (matching the Electron app's comparison logic in
|
||||
@@ -31,7 +36,11 @@ lockout_status() {
|
||||
else
|
||||
echo "Daily lock time: not set"
|
||||
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
|
||||
echo "Password protection: disabled"
|
||||
fi
|
||||
@@ -40,10 +49,11 @@ lockout_status() {
|
||||
lockout_menu_builder() {
|
||||
if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then
|
||||
MENU_LABELS=(
|
||||
"Change lockout password"
|
||||
"Change unlock password/PIN (currently: $(lockout_code_label "$LOCKOUT_CODE_TYPE"))"
|
||||
"Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)"
|
||||
"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"
|
||||
)
|
||||
MENU_HANDLERS=(
|
||||
@@ -51,6 +61,7 @@ lockout_menu_builder() {
|
||||
action_change_timeout
|
||||
action_change_daily_lock
|
||||
action_toggle_boot_password
|
||||
action_set_boot_code
|
||||
action_disable_protection
|
||||
)
|
||||
else
|
||||
@@ -68,31 +79,74 @@ lockout_menu() {
|
||||
# Shared helpers
|
||||
################################################################################
|
||||
|
||||
# Prompts for a new password twice, hashes it, and assigns to
|
||||
# LOCKOUT_PASSWORD. Returns 1 (without saving) if the user gives up.
|
||||
prompt_and_hash_password() {
|
||||
local pass1 pass2
|
||||
# "PIN" / "password" for menus and status lines.
|
||||
lockout_code_label() {
|
||||
[[ "$1" == "pin" ]] && echo "PIN" || echo "password"
|
||||
}
|
||||
|
||||
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
|
||||
read -r -s -p "Enter password: " pass1
|
||||
echo
|
||||
read -r -s -p "Confirm password: " pass2
|
||||
echo
|
||||
read -r -p "Choose [1-2]: " choice
|
||||
case "$choice" in
|
||||
1) CODE_TYPE="password"; break ;;
|
||||
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
|
||||
echo "❌ Password cannot be empty"
|
||||
echo "❌ The $label cannot be empty"
|
||||
continue
|
||||
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
|
||||
echo "❌ Passwords don't match, try again"
|
||||
echo "❌ They don't match, try again"
|
||||
continue
|
||||
fi
|
||||
|
||||
LOCKOUT_PASSWORD=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
|
||||
CODE_HASH=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1)
|
||||
return 0
|
||||
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
|
||||
################################################################################
|
||||
@@ -101,13 +155,14 @@ action_enable_protection() {
|
||||
echo
|
||||
echo "Add password protection with automatic lockout:"
|
||||
echo " • Blank screen after an inactivity period"
|
||||
echo " • Password required to unlock"
|
||||
echo " • Password required after display schedule wake-up"
|
||||
echo " • Password or PIN required to unlock"
|
||||
echo " • Required after display schedule wake-up"
|
||||
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 "Set lockout password:"
|
||||
prompt_and_hash_password
|
||||
|
||||
echo
|
||||
@@ -123,8 +178,16 @@ action_enable_protection() {
|
||||
fi
|
||||
|
||||
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"
|
||||
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
|
||||
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
fi
|
||||
@@ -140,6 +203,9 @@ action_disable_protection() {
|
||||
LOCKOUT_TIMEOUT=0
|
||||
LOCKOUT_AT_TIME=""
|
||||
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
LOCKOUT_CODE_TYPE="password"
|
||||
BOOT_PASSWORD=""
|
||||
BOOT_CODE_TYPE="password"
|
||||
log_success "Password protection disabled"
|
||||
save_config
|
||||
}
|
||||
@@ -147,7 +213,7 @@ action_disable_protection() {
|
||||
action_change_password() {
|
||||
echo
|
||||
prompt_and_hash_password
|
||||
log_success "Password updated"
|
||||
log_success "Unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE") updated"
|
||||
save_config
|
||||
}
|
||||
|
||||
@@ -178,10 +244,29 @@ action_change_daily_lock() {
|
||||
action_toggle_boot_password() {
|
||||
if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then
|
||||
REQUIRE_PASSWORD_ON_BOOT="false"
|
||||
log_warning "Password on boot disabled"
|
||||
log_warning "Lock on boot disabled"
|
||||
else
|
||||
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
|
||||
save_config
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# 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.
|
||||
#
|
||||
# The kiosk user may run this one script via passwordless sudo
|
||||
|
||||
@@ -44,6 +44,10 @@ const SCALAR_DEFAULTS = {
|
||||
lockoutTimeout: 0,
|
||||
lockoutAtTime: '',
|
||||
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() {
|
||||
@@ -94,6 +98,8 @@ function loadConfig() {
|
||||
out[key] = key in existing ? existing[key] : def;
|
||||
}
|
||||
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';
|
||||
return out;
|
||||
}
|
||||
@@ -122,11 +128,24 @@ function saveConfig(patch) {
|
||||
if (typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0) {
|
||||
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) {
|
||||
merged.lockoutPassword = '';
|
||||
merged.lockoutTimeout = 0;
|
||||
merged.lockoutAtTime = '';
|
||||
merged.requirePasswordOnBoot = false;
|
||||
merged.lockoutCodeType = 'password';
|
||||
merged.bootPassword = '';
|
||||
merged.bootCodeType = 'password';
|
||||
}
|
||||
|
||||
if (Array.isArray(patch.tabs)) {
|
||||
|
||||
+41
-1
@@ -196,10 +196,18 @@ const dailyLockEnabled = document.getElementById('daily-lock-enabled');
|
||||
const lockoutAtTime = document.getElementById('lockout-at-time');
|
||||
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() {
|
||||
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);
|
||||
bootLockEnabled.addEventListener('change', refreshLockoutFieldVisibility);
|
||||
bootCodeMode.addEventListener('change', refreshLockoutFieldVisibility);
|
||||
|
||||
dailyLockEnabled.addEventListener('change', () => {
|
||||
lockoutAtTime.disabled = !dailyLockEnabled.checked;
|
||||
@@ -221,6 +229,23 @@ lockoutForm.addEventListener('submit', async (e) => {
|
||||
showMessage('Set a lockout password before enabling password protection', true);
|
||||
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 };
|
||||
if (enable) {
|
||||
@@ -228,6 +253,14 @@ lockoutForm.addEventListener('submit', async (e) => {
|
||||
patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10);
|
||||
patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : '';
|
||||
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 {
|
||||
@@ -236,6 +269,8 @@ lockoutForm.addEventListener('submit', async (e) => {
|
||||
populateAll(currentConfig);
|
||||
lockoutForm.querySelector('[name=newLockoutPassword]').value = '';
|
||||
document.getElementById('lockout-password-confirm').value = '';
|
||||
lockoutForm.querySelector('[name=newBootPassword]').value = '';
|
||||
document.getElementById('boot-password-confirm').value = '';
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
@@ -252,12 +287,17 @@ function populateAll(config) {
|
||||
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
|
||||
|
||||
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;
|
||||
dailyLockEnabled.checked = !!config.lockoutAtTime;
|
||||
lockoutAtTime.disabled = !config.lockoutAtTime;
|
||||
lockoutAtTime.value = config.lockoutAtTime || '';
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
+36
-3
@@ -115,19 +115,26 @@
|
||||
<section class="page" id="page-lockout">
|
||||
<div class="page-header">
|
||||
<h1>Password Protection & 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 class="card">
|
||||
<form id="lockout-form">
|
||||
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
|
||||
|
||||
<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>
|
||||
<span id="password-label">Set lockout password</span>
|
||||
<input type="password" name="newLockoutPassword" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
Confirm password/PIN
|
||||
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
@@ -142,7 +149,33 @@
|
||||
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
|
||||
</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 class="button-row"><button type="submit">Save Password Protection & Lockout</button></div>
|
||||
|
||||
+42
-1
@@ -114,18 +114,59 @@ app.put('/api/config', (req, res) => {
|
||||
|
||||
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 (typeof body.newLockoutPassword !== 'string' || body.newLockoutPassword.length === 0) {
|
||||
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;
|
||||
}
|
||||
// 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
|
||||
// front - lockout.sh has no path that enables protection without one.
|
||||
if (patch.enablePasswordProtection === true) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,85 @@ async function main() {
|
||||
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 () => {
|
||||
const res = await fetch(`${base}/api/config`, {
|
||||
method: 'PUT',
|
||||
|
||||
Reference in New Issue
Block a user