Lock screen: password or 4-8 digit PIN, optional separate boot code
The unlock code and the boot lock code can each be a password or a 4-8 digit PIN. A PIN gets an on-screen number pad, so the lock works on a touch-only kiosk. The boot lock can use the unlock code or its own. These are the kiosk's own codes, set in Core Settings -> Password Protection & Lockout or on the Web UI's Lockout page, never the admin (sudo) password. The lock screen moves from an inline data: URL to lockscreen.html. The code is now hashed and compared in the main process, which also slows down guessing: after every 5 wrong codes in a row, input is refused for 30s, doubling up to 5 minutes. Existing configs behave as before: missing fields mean a password, and the same code at boot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
@@ -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