Admit kiosk callers by network position, not only by device token
The device token assumes a handful of fixed screens an administrator provisions once. That is not how these kiosks are used: most people open the page on their own PC, and there is no login, so per-browser provisioning would mean touching every PC on the floor and losing it again on the next reimage or cleared profile. A screen in that state did not look refused either - the USB kiosk rendered a working page whose every call answered 404. Both kiosks now admit a caller on either ground, the same shape the GE-Enforce client endpoints already use: the per-device token, or a source address inside an allowlist. Wall screens keep the token and are not confined to the allowlist; everyone else is admitted by where they are on the network. Still gated on the site's enable flag, so nothing opens by itself, and both allowlists ship empty - token-only until a site enters its ranges. - shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0). On the surface because both plugins need the identical gate, and a CIDR parser copied into two of them is where a fail-open creeps in later. They fail closed on an empty setting, an unparseable caller address and a malformed entry, and they read remote_addr rather than raw X-Forwarded-For - so the IIS rewrite rule is what makes them meaningful, which the module says at length. - Separate settings per kiosk, usb_kiosk_allowed_cidrs and printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same reason the tokens are separate. - Both settings pages get a picker listing the network plugin's subnets with their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has to work at a site that does not run that plugin and must not break when an inventory row is deleted. Entries matching no subnet are shown rather than hidden, since a stale one left by an inventory edit looks exactly like a hand-typed range. - USBKiosk says it was not admitted instead of pretending to work, which PartsKiosk already did. - The USB settings page keyed text-vs-boolean off the token alone. Adding a second text setting to the boolean path would have saved it as the word 'true' - the bug that replaced the kiosk token and 404'd every provisioned screen. Both load and save now key off a list, with a spec on it. The trade, stated plainly in the admin UI: anyone on an allowed network can check a drive in or out against a badge number they guess, and the record carries that person's name. Badges are attribution, not authorization. Keeping the ranges narrow is what bounds it; login is the alternative and was ruled out because these users do not sign in.
This commit is contained in:
@@ -12,6 +12,16 @@
|
||||
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="refused" class="kiosk-step">
|
||||
<p class="kiosk-prompt">This screen is not set up for the kiosk.</p>
|
||||
<p class="kiosk-hint">
|
||||
The kiosk is on, but this browser was not admitted. A wall screen is
|
||||
provisioned once by opening it with its device token; a PC is admitted
|
||||
by being on an allowed network. An administrator sets both in Settings
|
||||
under USB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
||||
|
||||
@@ -145,6 +155,12 @@ const error = ref('')
|
||||
const doneMessage = ref('')
|
||||
const busy = ref(false)
|
||||
const disabled = ref(false)
|
||||
// The kiosk is on, but the server refused THIS caller: no device token in this
|
||||
// browser and no allowed network. The server answers 404 rather than 403 so the
|
||||
// route does not advertise itself, which means the client cannot know until it
|
||||
// asks. Without this the page rendered as a working kiosk whose every call
|
||||
// failed - the parts kiosk already says so, and this one did not.
|
||||
const refused = ref(false)
|
||||
const manualBadge = ref('')
|
||||
const entryInput = ref(null)
|
||||
let resetTimer = null
|
||||
@@ -214,7 +230,11 @@ async function startSession (value) {
|
||||
step.value = 'pick'
|
||||
focusFilter()
|
||||
} catch (sessionError) {
|
||||
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
||||
if (sessionError.response?.status === 404) {
|
||||
refused.value = true
|
||||
} else {
|
||||
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
focusEntry()
|
||||
|
||||
@@ -55,6 +55,23 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Networks allowed without the token</label>
|
||||
<NetworkAllowlistPicker v-model="values.usb_kiosk_allowed_cidrs" />
|
||||
<p class="field-hint">
|
||||
For people who open the kiosk on their own PC. A browser on one of
|
||||
these networks is admitted with no token and no login, so keep this to
|
||||
the shop-floor ranges rather than everything. Leave empty to require
|
||||
the token from every caller. Wall screens keep using the token either
|
||||
way.
|
||||
</p>
|
||||
<p class="field-hint token-warn">
|
||||
Anyone on an allowed network can check a drive in or out against a
|
||||
badge number they guess, and the record carries that person's name.
|
||||
Badges are attribution, not authorization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
|
||||
@@ -82,12 +99,19 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
||||
|
||||
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
|
||||
'usb_kiosk_token']
|
||||
'usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
|
||||
|
||||
// Keys whose value is text. Everything else is a switch stored as true/false.
|
||||
// Saving a text setting through the boolean path is what replaced the kiosk
|
||||
// token with the word 'true' and 404'd every provisioned screen.
|
||||
const TEXTKEYS = ['usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
|
||||
|
||||
const values = ref({
|
||||
usb_kiosk_enabled: false,
|
||||
usb_kiosk_allowed_cidrs: '',
|
||||
usb_kiosk_allow_return_for_others: true,
|
||||
usb_kiosk_token: '',
|
||||
})
|
||||
@@ -115,9 +139,9 @@ async function load () {
|
||||
const response = await settingsApi.list({ category: 'usb' })
|
||||
for (const row of (response.data.data || [])) {
|
||||
if (!KEYS.includes(row.key)) continue
|
||||
// The token is text (the server sends a mask once set); only the two
|
||||
// switches are booleans.
|
||||
values.value[row.key] = row.key === 'usb_kiosk_token'
|
||||
// Text settings stay text (the server sends a mask for the token once it
|
||||
// is set); only the switches are booleans.
|
||||
values.value[row.key] = TEXTKEYS.includes(row.key)
|
||||
? (row.value || '')
|
||||
: asBool(row.value)
|
||||
}
|
||||
@@ -132,10 +156,10 @@ async function save () {
|
||||
error.value = ''
|
||||
try {
|
||||
for (const key of KEYS) {
|
||||
// Saving the token as true/false replaced the real token with the word
|
||||
// 'true', and every provisioned kiosk then got 404. The server keeps the
|
||||
// stored token when it receives the mask back unchanged.
|
||||
const value = key === 'usb_kiosk_token'
|
||||
// Saving a text setting through the boolean path replaced the real token
|
||||
// with the word 'true', and every provisioned kiosk then got 404. The
|
||||
// server keeps the stored token when it receives the mask back unchanged.
|
||||
const value = TEXTKEYS.includes(key)
|
||||
? String(values.value[key] ?? '')
|
||||
: (values.value[key] ? 'true' : 'false')
|
||||
await settingsApi.update(key, value)
|
||||
|
||||
@@ -49,9 +49,23 @@ describe('USBSettings save', () => {
|
||||
usb_kiosk_enabled: 'true',
|
||||
usb_kiosk_allow_return_for_others: 'false',
|
||||
usb_kiosk_token: MASK,
|
||||
usb_kiosk_allowed_cidrs: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves the network allowlist as text, not as true/false', async () => {
|
||||
// Same trap the token fell into: a text setting sent through the boolean
|
||||
// path becomes the word 'true', and the gate then admits nobody while the
|
||||
// settings page still shows something plausible.
|
||||
const wrapper = await render([
|
||||
{ key: 'usb_kiosk_enabled', value: 'true' },
|
||||
{ key: 'usb_kiosk_allowed_cidrs', value: '192.0.2.0/24, 198.51.100.0/24' },
|
||||
])
|
||||
await wrapper.find('button.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
expect(saved().usb_kiosk_allowed_cidrs).toBe('192.0.2.0/24, 198.51.100.0/24')
|
||||
})
|
||||
|
||||
it('saves a generated token as the token itself', async () => {
|
||||
const wrapper = await render([
|
||||
{ key: 'usb_kiosk_enabled', value: 'true' },
|
||||
|
||||
Reference in New Issue
Block a user