diff --git a/Readme.md b/Readme.md index 77af9a2..30977ae 100644 --- a/Readme.md +++ b/Readme.md @@ -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 @@ -1047,7 +1050,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,6 +1528,7 @@ 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. +- **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** - 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". - **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. diff --git a/kiosk-app/lockscreen.html b/kiosk-app/lockscreen.html new file mode 100644 index 0000000..71ac07b --- /dev/null +++ b/kiosk-app/lockscreen.html @@ -0,0 +1,165 @@ + + + + + Locked + + + +
+

Session Locked

+ + + + + +
+
+ + + diff --git a/kiosk-app/main.js b/kiosk-app/main.js index cf8908d..11cb9c3 100644 --- a/kiosk-app/main.js +++ b/kiosk-app/main.js @@ -66,9 +66,15 @@ 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 +let lockoutFailures=0; +let lockoutBlockedUntil=0; let lockoutWindow=null; let lockoutTimer=null; let lockoutActivityTime=Date.now(); @@ -103,6 +109,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 +127,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] ╚═══════════════════════════════╝'); @@ -154,10 +164,11 @@ function markKeyboardActivity(){ } // 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 +204,9 @@ function showLockoutScreen(){ } }); - lockoutWindow.loadURL('data:text/html;charset=utf-8,'+encodeURIComponent(` - - - - - - - -
-

Session Locked

- - -
Incorrect password
-
- - - - `)); + // 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; @@ -1585,7 +1524,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 +1548,31 @@ 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){ - unlockScreen(); - }else{ - if(lockoutWindow&&!lockoutWindow.isDestroyed()){ - lockoutWindow.webContents.send('password-incorrect'); - } + // 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. + ipcMain.on('check-lockout-password',(event,code)=>{ + if(!lockoutWindow||lockoutWindow.isDestroyed()||event.sender!==lockoutWindow.webContents)return; + if(Date.now(){ diff --git a/lib/config.sh b/lib/config.sh index 3b9fd50..cf3d7d1 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -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 diff --git a/menus/lockout.sh b/menus/lockout.sh index 9810c1a..805e02a 100644 --- a/menus/lockout.sh +++ b/menus/lockout.sh @@ -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 } diff --git a/webui/lib/config.js b/webui/lib/config.js index e61f63b..ba293c0 100644 --- a/webui/lib/config.js +++ b/webui/lib/config.js @@ -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)) { diff --git a/webui/public/app.js b/webui/public/app.js index 705c859..0d6a939 100644 --- a/webui/public/app.js +++ b/webui/public/app.js @@ -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(); } diff --git a/webui/public/index.html b/webui/public/index.html index 48d6670..d898f4a 100644 --- a/webui/public/index.html +++ b/webui/public/index.html @@ -115,19 +115,26 @@
+ @@ -142,7 +149,33 @@ - + +
+ +
+ + + +
+
diff --git a/webui/server.js b/webui/server.js index 45f3157..7fa80e8 100644 --- a/webui/server.js +++ b/webui/server.js @@ -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'); } } diff --git a/webui/test/api.test.js b/webui/test/api.test.js index 87c2513..03b3c94 100644 --- a/webui/test/api.test.js +++ b/webui/test/api.test.js @@ -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',