Gate both kiosks behind a device token, and make an unreachable setting a test failure
This commit is contained in:
@@ -11,6 +11,54 @@ const api = axios.create({
|
||||
})
|
||||
|
||||
// Add auth token and normalize pagination params
|
||||
// Kiosk device tokens. The kiosk routes are the only ones that write without a
|
||||
// login, so each is additionally gated on a token an administrator provisioned
|
||||
// into THAT screen's browser - see the kiosk views, which claim it from a
|
||||
// ?token= URL once and strip it from the address bar. A token served inside the
|
||||
// page would be readable by anyone who could load the page, so it is per-device
|
||||
// and never shipped with the bundle.
|
||||
//
|
||||
// One entry per kiosk: the two are separate screens in separate rooms and a
|
||||
// shared token would mean provisioning one grants the other.
|
||||
export const KIOSK_TOKEN_KEYS = {
|
||||
'/printedparts/kiosk/': 'printedparts-kiosk-token',
|
||||
'/usb/kiosk/': 'usb-kiosk-token',
|
||||
}
|
||||
|
||||
function kioskTokenFor(url) {
|
||||
const path = url || ''
|
||||
for (const [prefix, storageKey] of Object.entries(KIOSK_TOKEN_KEYS)) {
|
||||
if (path.includes(prefix)) {
|
||||
try { return localStorage.getItem(storageKey) || '' } catch { return '' }
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Claim a kiosk's device token, once, from the URL an admin used to set the
|
||||
// screen up. Kept in THIS browser and stripped from the address bar straight
|
||||
// away, so it does not sit there for a shoulder to read or travel in a bookmark
|
||||
// or a screenshot.
|
||||
export function claimKioskToken(storageKey) {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const token = (url.searchParams.get('token') || '').trim()
|
||||
if (!token) return
|
||||
localStorage.setItem(storageKey, token)
|
||||
url.searchParams.delete('token')
|
||||
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
|
||||
} catch {
|
||||
// A browser refusing localStorage cannot be provisioned. Nothing to
|
||||
// recover; the routes 404 and the operator sees the standard error.
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = kioskTokenFor(config.url)
|
||||
if (token) config.headers['X-Kiosk-Token'] = token
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
@@ -1501,17 +1549,6 @@ export const measuringtoolsApi = {
|
||||
}
|
||||
|
||||
// 3D printed parts (printedparts plugin)
|
||||
// Device token for the parts kiosk, provisioned once per screen and kept in
|
||||
// that browser. Absent means the routes answer 404, which is what an
|
||||
// unprovisioned browser should see.
|
||||
export const KIOSK_TOKEN_KEY = 'printedparts-kiosk-token'
|
||||
|
||||
function kioskHeaders() {
|
||||
let token = ''
|
||||
try { token = localStorage.getItem(KIOSK_TOKEN_KEY) || '' } catch { token = '' }
|
||||
return token ? { 'X-Kiosk-Token': token } : {}
|
||||
}
|
||||
|
||||
export const printedpartsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/printedparts/items', { params })
|
||||
@@ -1552,17 +1589,11 @@ export const printedpartsApi = {
|
||||
adjust(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
|
||||
},
|
||||
// The kiosk routes are the only unauthenticated write in the product, so they
|
||||
// are gated on a per-DEVICE token rather than on the page. It lives in this
|
||||
// browser's localStorage, put there once by an admin opening the kiosk with
|
||||
// ?token=... - see PartsKiosk.vue. A token shipped inside the page would be
|
||||
// readable by anyone who could load it, which is not a gate.
|
||||
kioskItem(itemcode) {
|
||||
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`,
|
||||
{ headers: kioskHeaders() })
|
||||
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
|
||||
},
|
||||
kioskTake(data) {
|
||||
return api.post('/printedparts/kiosk/take', data, { headers: kioskHeaders() })
|
||||
return api.post('/printedparts/kiosk/take', data)
|
||||
},
|
||||
listFiles(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}/files`)
|
||||
|
||||
@@ -10,6 +10,50 @@
|
||||
is nothing in it to find and edit.
|
||||
</p>
|
||||
|
||||
<div class="card form-card">
|
||||
<h3>Collector behaviour</h3>
|
||||
<div class="form-group">
|
||||
<label>Corporate ranges (CIDR)</label>
|
||||
<input v-model="extra.collector_corporate_ranges" type="text"
|
||||
class="form-control" placeholder="10.0.0.0/8, 192.168.0.0/16" />
|
||||
<p class="field-hint">
|
||||
Comma-separated CIDRs naming this site's routable networks. The server
|
||||
picks a PC's primary address from these when the agent reports several.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Quiet report hours</label>
|
||||
<input v-model.number="extra.computers_quietreporthours" type="number"
|
||||
min="1" class="form-control" />
|
||||
<p class="field-hint">
|
||||
Hours without a collector report before a PC counts as silent on the
|
||||
dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="machinelinkAlerts" />
|
||||
<span>Alert when a PC takes over another PC's machine</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="configPoliciesEnabled" />
|
||||
<span>Configuration and business policies</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Master switch for the RMM-style policy pages. Off by default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="savingExtra" @click="saveExtra">
|
||||
{{ savingExtra ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
@@ -61,13 +105,57 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import api from '@/api'
|
||||
|
||||
const RANGES_KEY = 'computers_routableranges'
|
||||
|
||||
const ranges = ref('')
|
||||
|
||||
// The rest of the plugin's settings. They were declared and seeded but rendered
|
||||
// nowhere, so a site could only change them with SQL - see
|
||||
// tests/test_plugin_settings_reachable.py, written after that shipped twice.
|
||||
const EXTRA_KEYS = [
|
||||
'collector_corporate_ranges',
|
||||
'computers_quietreporthours',
|
||||
'computers_machinelink_alerts',
|
||||
'computers_configpolicies_enabled',
|
||||
]
|
||||
const extra = ref({
|
||||
collector_corporate_ranges: '',
|
||||
computers_quietreporthours: 24,
|
||||
computers_machinelink_alerts: 'false',
|
||||
computers_configpolicies_enabled: 'false',
|
||||
})
|
||||
const savingExtra = ref(false)
|
||||
|
||||
// Settings store strings; bridge the checkboxes rather than letting a boolean
|
||||
// round-trip into the string 'True'.
|
||||
function boolSetting(key) {
|
||||
return computed({
|
||||
get: () => String(extra.value[key]).toLowerCase() === 'true',
|
||||
set: (on) => { extra.value[key] = on ? 'true' : 'false' },
|
||||
})
|
||||
}
|
||||
const machinelinkAlerts = boolSetting('computers_machinelink_alerts')
|
||||
const configPoliciesEnabled = boolSetting('computers_configpolicies_enabled')
|
||||
|
||||
async function saveExtra() {
|
||||
savingExtra.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
try {
|
||||
for (const key of EXTRA_KEYS) {
|
||||
await settingsApi.update(key, String(extra.value[key]))
|
||||
}
|
||||
message.value = 'Saved.'
|
||||
} catch (saveError) {
|
||||
error.value = saveError?.response?.data?.data?.error?.message || 'Save failed.'
|
||||
} finally {
|
||||
savingExtra.value = false
|
||||
}
|
||||
}
|
||||
const saving = ref(false)
|
||||
const downloading = ref(false)
|
||||
const digest = ref('')
|
||||
@@ -79,6 +167,9 @@ onMounted(async () => {
|
||||
const response = await settingsApi.list({ category: 'computers' })
|
||||
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
|
||||
if (row) ranges.value = row.value || ''
|
||||
for (const entry of response.data.data || []) {
|
||||
if (EXTRA_KEYS.includes(entry.key)) extra.value[entry.key] = entry.value
|
||||
}
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<template>
|
||||
<div class="geenforce-settings">
|
||||
<h2>Reporting</h2>
|
||||
<p class="muted">
|
||||
How long a bay may go without posting an enforcement report before the
|
||||
fleet view calls it stale.
|
||||
</p>
|
||||
<label class="field-label" for="stale">Stale after (minutes)</label>
|
||||
<input id="stale" type="number" min="1" class="cidr-input"
|
||||
v-model.number="staleminutes" @blur="saveStale">
|
||||
<small class="input-hint">
|
||||
Default 30. A bay enforces on its own cycle, so set this above the cycle
|
||||
length or every bay reads as stale between runs.
|
||||
</small>
|
||||
|
||||
<h2>Client Access</h2>
|
||||
<p class="muted">
|
||||
Fleet PCs on a trusted network can reach the GE-Enforce client endpoints
|
||||
@@ -40,9 +53,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import api, { settingsApi } from '@/api'
|
||||
|
||||
const cidrs = ref('')
|
||||
// Held here rather than in /geenforce/config: that endpoint owns the CIDR
|
||||
// allowlist, and this is an ordinary site setting.
|
||||
const staleminutes = ref(30)
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const ok = ref(false)
|
||||
@@ -57,6 +73,23 @@ async function load() {
|
||||
message.value = 'Could not load config.'
|
||||
ok.value = false
|
||||
}
|
||||
try {
|
||||
const setting = unwrap(await settingsApi.get('geenforce_reportstaleminutes'))
|
||||
staleminutes.value = Number(setting?.value ?? 30) || 30
|
||||
} catch (e) {
|
||||
staleminutes.value = 30
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStale() {
|
||||
try {
|
||||
await settingsApi.update('geenforce_reportstaleminutes', String(staleminutes.value))
|
||||
ok.value = true
|
||||
message.value = 'Saved.'
|
||||
} catch (e) {
|
||||
ok.value = false
|
||||
message.value = e?.response?.data?.data?.error?.message || 'Save failed.'
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { printedpartsApi, settingsApi, KIOSK_TOKEN_KEY } from '@/api'
|
||||
import { printedpartsApi, settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||
|
||||
@@ -116,28 +116,8 @@ const scannedRevision = ref(null)
|
||||
const entryInput = ref(null)
|
||||
let resetTimer = null
|
||||
|
||||
// Claim the device token, once, from the URL an admin used to set this screen
|
||||
// up: /parts-kiosk?token=<value>. It is kept in THIS browser and stripped from
|
||||
// the address bar immediately, so it does not sit in the URL for a shoulder to
|
||||
// read, and does not travel in a bookmark or a screenshot. Every kiosk call
|
||||
// then carries it as X-Kiosk-Token; without it the routes answer 404.
|
||||
function claimTokenFromUrl() {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const token = (url.searchParams.get('token') || '').trim()
|
||||
if (!token) return
|
||||
localStorage.setItem(KIOSK_TOKEN_KEY, token)
|
||||
url.searchParams.delete('token')
|
||||
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
|
||||
} catch {
|
||||
// A browser refusing localStorage is a browser this kiosk cannot be
|
||||
// provisioned in. Nothing to recover here; the routes will 404 and the
|
||||
// operator sees the standard error.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
claimTokenFromUrl()
|
||||
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
|
||||
// A kiosk left running for weeks reads this once at load, which is fine: the
|
||||
// label format is a property of the site's physical tags, not something that
|
||||
// changes during a shift.
|
||||
|
||||
@@ -36,6 +36,38 @@
|
||||
<p class="field-hint">Seed value for new items; each item can override.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="kioskEnabled" />
|
||||
Serve the parts kiosk
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Off by default. The kiosk routes are the only ones that write without
|
||||
a login, so a site opts in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Kiosk device token</label>
|
||||
<div class="token-row">
|
||||
<input v-model="values.printedparts_kiosk_token" type="text"
|
||||
class="form-control" placeholder="not set" />
|
||||
<button type="button" class="btn btn-secondary" @click="generateToken">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Each kiosk screen is provisioned once by opening
|
||||
<code>/parts-kiosk?token=<this value></code> on it. The browser
|
||||
keeps it; the address bar does not. A screen without it gets a 404,
|
||||
and so does anyone else who finds the route.
|
||||
</p>
|
||||
<p v-if="tokenJustGenerated" class="field-hint token-warn">
|
||||
Copy this now and save. It is masked once stored, so this is the only
|
||||
time it is shown.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Unknown badge at the kiosk</label>
|
||||
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
||||
@@ -109,13 +141,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
'printedparts_label_prefix',
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_kiosk_enabled',
|
||||
'printedparts_kiosk_token',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids',
|
||||
@@ -127,12 +161,33 @@ const values = ref({
|
||||
printedparts_code_prefix: '3DP',
|
||||
printedparts_label_prefix: '',
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_kiosk_enabled: 'false',
|
||||
printedparts_kiosk_token: '',
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: '',
|
||||
printedparts_alert_roleids: '',
|
||||
printedparts_alert_supportteamid: ''
|
||||
})
|
||||
|
||||
// Settings are stored as strings. Bridge the checkbox to that rather than
|
||||
// letting a boolean reach the API and come back as the string 'True'.
|
||||
const kioskEnabled = computed({
|
||||
get: () => String(values.value.printedparts_kiosk_enabled).toLowerCase() === 'true',
|
||||
set: (on) => { values.value.printedparts_kiosk_enabled = on ? 'true' : 'false' },
|
||||
})
|
||||
|
||||
const tokenJustGenerated = ref(false)
|
||||
|
||||
// A token an operator would not guess and does not have to invent. Generated in
|
||||
// the browser so it never travels anywhere it did not need to.
|
||||
function generateToken() {
|
||||
const bytes = new Uint8Array(24)
|
||||
crypto.getRandomValues(bytes)
|
||||
values.value.printedparts_kiosk_token =
|
||||
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
||||
tokenJustGenerated.value = true
|
||||
}
|
||||
const supportTeams = ref([])
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
@@ -188,6 +243,10 @@ async function save() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.token-row { display: flex; gap: 8px; align-items: center; }
|
||||
.token-row .form-control { flex: 1 1 auto; }
|
||||
.token-warn { color: var(--warning-dark); }
|
||||
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.user-picker {
|
||||
max-height: 12rem;
|
||||
|
||||
@@ -162,7 +162,11 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
{
|
||||
'key': 'printedparts_kiosk_token',
|
||||
'value': '',
|
||||
'valuetype': 'secret',
|
||||
# string, not 'secret': the column documents string/boolean/
|
||||
# integer/json only. Masking is keyed on the NAME containing
|
||||
# 'token' (settings.py::_is_secret), so this is still hidden in
|
||||
# the API and the audit log.
|
||||
'valuetype': 'string',
|
||||
'category': 'printedparts',
|
||||
'description': 'Device token the kiosk sends as X-Kiosk-Token. '
|
||||
'Generate one, then open the kiosk once as '
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
<input v-model.number="values.printers_alert_critical_threshold"
|
||||
type="number" min="0" max="100" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Dashboard threshold (% remaining)</label>
|
||||
<input v-model.number="values.printers_dashboardpercent"
|
||||
type="number" min="0" max="100" class="form-control" />
|
||||
<p class="field-hint">
|
||||
At or below this, a printer's supply shows on the dashboard. Separate
|
||||
from the alert thresholds above: this one only decides what is
|
||||
listed, it sends nothing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -101,7 +111,8 @@ const KEYS = [
|
||||
'printers_alert_roleids',
|
||||
'printers_alert_supportteamid',
|
||||
'printers_alert_warning_threshold',
|
||||
'printers_alert_critical_threshold'
|
||||
'printers_alert_critical_threshold',
|
||||
'printers_dashboardpercent'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
@@ -110,6 +121,7 @@ const values = ref({
|
||||
printers_alert_roleids: '',
|
||||
printers_alert_supportteamid: '',
|
||||
printers_alert_warning_threshold: 5,
|
||||
printers_dashboardpercent: 5,
|
||||
printers_alert_critical_threshold: 0
|
||||
})
|
||||
const supportTeams = ref([])
|
||||
|
||||
@@ -24,6 +24,8 @@ import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import hmac
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
@@ -741,6 +743,31 @@ def _kiosk_enabled():
|
||||
return _kiosk_setting('usb_kiosk_enabled', False)
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when the kiosk is on for this site AND the caller has its token.
|
||||
|
||||
The flag alone was the whole gate, and a flag is a decision, not a
|
||||
credential: with the kiosk on, anyone who could reach the app could move a
|
||||
device between checked-out and checked-in and have it recorded against a
|
||||
badge they guessed. A badge is a short numeric SSO, so it is attribution,
|
||||
never authorization - the same reasoning that closed the parts kiosk.
|
||||
|
||||
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
|
||||
a token served inside the page would be readable by anyone who could load
|
||||
it. An admin opens the screen once as /usb-kiosk?token=..., the page keeps
|
||||
it in that browser and sends it as X-Kiosk-Token.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
return False
|
||||
expected = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
if not expected or not presented:
|
||||
return False
|
||||
# compare_digest, not ==: == leaks the token's length and matching prefix
|
||||
# through timing.
|
||||
return hmac.compare_digest(presented, expected)
|
||||
|
||||
|
||||
def _kiosk_disabled_response():
|
||||
# 404, not 403: when the kiosk is off these routes should not read as
|
||||
# "there is a door here and you may not use it".
|
||||
@@ -765,7 +792,7 @@ def kiosk_device(device_id):
|
||||
the person standing there cannot tell whether they are doing the right
|
||||
thing. No badge number, no history, no contact details.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_device(device_id)
|
||||
@@ -825,7 +852,7 @@ def kiosk_session():
|
||||
COUNT of everything else - names for those are a separate, deliberate step,
|
||||
so a passer-by does not read the whole cabinet's holders off a wall screen.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
@@ -881,7 +908,7 @@ def kiosk_others():
|
||||
who is on leave - but the names that makes visible should not be the first
|
||||
thing on a shopfloor screen.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
||||
return _kiosk_disabled_response()
|
||||
@@ -925,7 +952,7 @@ def kiosk_others():
|
||||
@usb_bp.route('/kiosk/checkout', methods=['POST'])
|
||||
def kiosk_checkout():
|
||||
"""Check a device out at the kiosk. Body: {device_id, badge}."""
|
||||
if not _kiosk_enabled():
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
@@ -998,7 +1025,7 @@ def kiosk_checkin():
|
||||
handed the device back, which is the honest record and needs no change to a
|
||||
schema this app does not own.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import api, { settingsApi } from '@/api'
|
||||
import api, { settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
|
||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||
import { filterdrives } from '../drivefilter'
|
||||
|
||||
@@ -150,6 +150,7 @@ const entryInput = ref(null)
|
||||
let resetTimer = null
|
||||
|
||||
onMounted(async () => {
|
||||
claimKioskToken(KIOSK_TOKEN_KEYS['/usb/kiosk/'])
|
||||
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen
|
||||
// that says so - a kiosk merely switched off must not look broken.
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,28 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Kiosk device token</label>
|
||||
<div class="token-row">
|
||||
<input v-model="values.usb_kiosk_token" type="text"
|
||||
class="form-control" placeholder="not set" />
|
||||
<button type="button" class="btn btn-secondary" @click="generateToken">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Provision each screen once by opening
|
||||
<code>/usb-kiosk?token=<this value></code> on it. That browser
|
||||
keeps it; the address bar does not. Turning the kiosk on is a
|
||||
decision, not a credential - without this token the routes answer 404,
|
||||
so nobody who merely finds the URL can move a drive and sign it with a
|
||||
badge they guessed.
|
||||
</p>
|
||||
<p v-if="tokenJustGenerated" class="field-hint token-warn">
|
||||
Copy this now and save. It is masked once stored.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
|
||||
@@ -61,12 +83,25 @@ import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others']
|
||||
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
|
||||
'usb_kiosk_token']
|
||||
|
||||
const values = ref({
|
||||
usb_kiosk_enabled: false,
|
||||
usb_kiosk_allow_return_for_others: true,
|
||||
usb_kiosk_token: '',
|
||||
})
|
||||
|
||||
const tokenJustGenerated = ref(false)
|
||||
|
||||
// Generated in the browser so it never travels anywhere it did not need to.
|
||||
function generateToken() {
|
||||
const bytes = new Uint8Array(24)
|
||||
crypto.getRandomValues(bytes)
|
||||
values.value.usb_kiosk_token =
|
||||
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
||||
tokenJustGenerated.value = true
|
||||
}
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
@@ -106,6 +141,10 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.token-row { display: flex; gap: 8px; align-items: center; }
|
||||
.token-row .form-control { flex: 1 1 auto; }
|
||||
.token-warn { color: var(--warning-dark); }
|
||||
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
|
||||
.check-row input { width: auto; }
|
||||
|
||||
@@ -138,6 +138,18 @@ class USBPlugin(BasePlugin):
|
||||
'a device between checked-out and checked-in - it '
|
||||
'cannot add, edit, retire or list devices.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_token',
|
||||
'value': '',
|
||||
# string: the column documents string/boolean/integer/json.
|
||||
# Masking keys on the NAME containing 'token'.
|
||||
'valuetype': 'string',
|
||||
'category': 'usb',
|
||||
'description': 'Device token the USB kiosk sends as '
|
||||
'X-Kiosk-Token. Generate one, then open the '
|
||||
'kiosk once as /usb-kiosk?token=<value> on each '
|
||||
'screen',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allow_return_for_others',
|
||||
'value': 'true',
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
<h2>Dell Warranty Lookup</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
How far ahead the dashboard counts a warranty as expiring.
|
||||
</p>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Expiring window (days)</span>
|
||||
<input type="number" min="1"
|
||||
v-model.number="settings.warranty_expiringdays"
|
||||
@blur="saveSetting('warranty_expiringdays', settings.warranty_expiringdays)"
|
||||
:disabled="saving">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
|
||||
88
tests/test_plugin_settings_reachable.py
Normal file
88
tests/test_plugin_settings_reachable.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Every setting a plugin declares must be reachable in its settings page.
|
||||
|
||||
A setting exists so a site can change it. One that is seeded into the database
|
||||
but rendered by no page is not configurable - it is a constant with extra steps,
|
||||
and the only way to change it is direct SQL.
|
||||
|
||||
This was written after shipping exactly that twice in one afternoon:
|
||||
printedparts_kiosk_enabled / _kiosk_token and then usb_kiosk_token were declared
|
||||
in get_settings(), seeded correctly, and read correctly by the API - and none of
|
||||
them appeared on any page, because each settings view carries a hardcoded KEYS
|
||||
list that nobody remembered to extend. The instructions for turning the kiosk
|
||||
back on were impossible to follow.
|
||||
|
||||
The check is deliberately crude: it greps the plugin's frontend for the key as a
|
||||
literal. That is enough to catch the failure it exists for, and it cannot be
|
||||
satisfied by accident.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[1]
|
||||
PLUGINS = REPO / 'plugins'
|
||||
|
||||
# Settings a site is not meant to edit through a page. Each needs a reason.
|
||||
EXEMPT = {
|
||||
# Written by the app itself when the agent key is minted, not typed.
|
||||
'plugin.endpoints.agentkeyprovisioned',
|
||||
'plugin.endpoints.envkeyfallback',
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _declared_settings(plugin_dir):
|
||||
"""Keys the plugin declares in get_settings_defaults(), read from the source.
|
||||
|
||||
Importing the plugin would need an app context and a database; the
|
||||
declaration is a literal list, so reading it is enough and much cheaper.
|
||||
"""
|
||||
source = (plugin_dir / 'plugin.py')
|
||||
if not source.is_file():
|
||||
return []
|
||||
text = source.read_text(encoding='utf-8')
|
||||
match = re.search(r'def get_settings_defaults\(self\).*?(?=\n def |\Z)', text, re.S)
|
||||
if not match:
|
||||
return []
|
||||
return re.findall(r"'key':\s*'([^']+)'", match.group(0))
|
||||
|
||||
|
||||
def _frontend_text(plugin_dir):
|
||||
frontend = plugin_dir / 'frontend'
|
||||
if not frontend.is_dir():
|
||||
return ''
|
||||
parts = []
|
||||
for path in frontend.rglob('*'):
|
||||
if path.suffix in ('.vue', '.js') and path.is_file():
|
||||
parts.append(path.read_text(encoding='utf-8'))
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
PLUGIN_DIRS = sorted(
|
||||
d for d in PLUGINS.iterdir()
|
||||
if d.is_dir() and (d / 'manifest.json').is_file()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin_dir', PLUGIN_DIRS, ids=lambda d: d.name)
|
||||
def test_every_declared_setting_is_reachable_in_the_ui(plugin_dir):
|
||||
declared = [k for k in _declared_settings(plugin_dir) if k not in EXEMPT]
|
||||
if not declared:
|
||||
pytest.skip('declares no settings')
|
||||
|
||||
frontend = _frontend_text(plugin_dir)
|
||||
if not frontend:
|
||||
pytest.skip('ships no frontend')
|
||||
|
||||
missing = [key for key in declared if key not in frontend]
|
||||
assert not missing, (
|
||||
'%s declares %d setting(s) that appear nowhere in its frontend, so a '
|
||||
'site cannot change them without writing SQL:\n %s\n'
|
||||
'Add them to the plugin settings view (its KEYS list AND a field), or '
|
||||
'to EXEMPT in this file with a reason. There is no backlog list: the '
|
||||
'seven this check found when it was written were all fixed, and it '
|
||||
'stays that way.'
|
||||
% (plugin_dir.name, len(missing), '\n '.join(missing)))
|
||||
@@ -16,6 +16,12 @@ import pytest
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
KIOSK_TOKEN = 'usb-kiosk-device-token-for-tests'
|
||||
# What a provisioned screen sends. Turning the kiosk on is a decision; this is
|
||||
# the credential.
|
||||
KIOSK_HEADERS = {'X-Kiosk-Token': KIOSK_TOKEN}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kiosk(db):
|
||||
"""Self-hosted USB, kiosk ON, one employee and one available drive."""
|
||||
@@ -26,6 +32,8 @@ def kiosk(db):
|
||||
valuetype='string', category='site'))
|
||||
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
|
||||
valuetype='boolean', category='usb'))
|
||||
db.session.add(Setting(key='usb_kiosk_token', value=KIOSK_TOKEN,
|
||||
valuetype='string', category='usb'))
|
||||
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
|
||||
lastname='Reyes'))
|
||||
db.session.add(DirectoryEmployee(sso=270099999, firstname='Sam',
|
||||
@@ -48,12 +56,12 @@ def _setting(db, key, value):
|
||||
|
||||
def _out(client, badge='270015376', **extra):
|
||||
return client.post('/api/usb/kiosk/checkout',
|
||||
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra))
|
||||
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
|
||||
|
||||
|
||||
def _in(client, badge='270015376', **extra):
|
||||
return client.post('/api/usb/kiosk/checkin',
|
||||
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra))
|
||||
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
|
||||
|
||||
|
||||
# --- the door itself --------------------------------------------------------
|
||||
@@ -67,7 +75,7 @@ def test_the_kiosk_does_not_exist_until_a_site_turns_it_on(client, kiosk):
|
||||
"""
|
||||
_setting(kiosk, 'usb_kiosk_enabled', 'false')
|
||||
|
||||
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404
|
||||
assert client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code == 404
|
||||
assert _out(client).status_code == 404
|
||||
assert _in(client).status_code == 404
|
||||
|
||||
@@ -81,14 +89,14 @@ def test_it_ships_off(app):
|
||||
|
||||
def test_no_login_is_required(client, kiosk):
|
||||
"""The whole point: no Authorization header anywhere in this file."""
|
||||
response = client.get('/api/usb/kiosk/device/USB-1')
|
||||
response = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['device_id'] == 'USB-1'
|
||||
|
||||
|
||||
def test_the_kiosk_cannot_create_edit_or_delete(client, kiosk):
|
||||
"""It moves a device between two states. It is not an admin surface."""
|
||||
assert client.post('/api/usb/kiosk/device/USB-1').status_code in (404, 405)
|
||||
assert client.post('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code in (404, 405)
|
||||
assert client.delete('/api/usb/kiosk/device/USB-1').status_code in (404, 405)
|
||||
# And the authenticated write routes are still gated.
|
||||
assert client.post('/api/usb/USB-1/checkout',
|
||||
@@ -113,7 +121,7 @@ def test_a_badge_it_cannot_name_is_refused(client, kiosk):
|
||||
|
||||
def test_a_missing_badge_is_refused(client, kiosk):
|
||||
assert client.post('/api/usb/kiosk/checkout',
|
||||
json={'device_id': 'USB-1'}).status_code == 400
|
||||
json={'device_id': 'USB-1'}, headers=KIOSK_HEADERS).status_code == 400
|
||||
|
||||
|
||||
# --- taking one -------------------------------------------------------------
|
||||
@@ -139,7 +147,7 @@ def test_a_drive_already_out_cannot_be_taken_again(client, kiosk):
|
||||
|
||||
def test_an_unknown_code_is_a_404(client, kiosk):
|
||||
response = client.post('/api/usb/kiosk/checkout',
|
||||
json={'device_id': 'NOPE', 'badge': '270015376'})
|
||||
json={'device_id': 'NOPE', 'badge': '270015376'}, headers=KIOSK_HEADERS)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -198,7 +206,7 @@ def test_a_drive_that_is_not_out_cannot_be_returned(client, kiosk):
|
||||
def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk):
|
||||
"""The confirm step has to show whose it is; it does not need their SSO."""
|
||||
_out(client)
|
||||
data = client.get('/api/usb/kiosk/device/USB-1').get_json()['data']
|
||||
data = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).get_json()['data']
|
||||
|
||||
assert data['holdername'] == 'Dana Reyes'
|
||||
assert '270015376' not in str(data)
|
||||
@@ -211,11 +219,11 @@ def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk):
|
||||
# real person, the same bar taking a drive already had to clear.
|
||||
|
||||
def _session(client, badge='270015376'):
|
||||
return client.post('/api/usb/kiosk/session', json={'badge': badge})
|
||||
return client.post('/api/usb/kiosk/session', json={'badge': badge}, headers=KIOSK_HEADERS)
|
||||
|
||||
|
||||
def test_the_device_list_costs_a_valid_badge(client, kiosk):
|
||||
assert client.post('/api/usb/kiosk/session', json={}).status_code == 400
|
||||
assert client.post('/api/usb/kiosk/session', json={}, headers=KIOSK_HEADERS).status_code == 400
|
||||
assert _session(client, badge='999999999').status_code == 422
|
||||
|
||||
|
||||
@@ -223,7 +231,7 @@ def test_the_list_is_not_reachable_when_the_kiosk_is_off(client, kiosk):
|
||||
_setting(kiosk, 'usb_kiosk_enabled', 'false')
|
||||
assert _session(client).status_code == 404
|
||||
assert client.post('/api/usb/kiosk/others',
|
||||
json={'badge': '270015376'}).status_code == 404
|
||||
json={'badge': '270015376'}, headers=KIOSK_HEADERS).status_code == 404
|
||||
|
||||
|
||||
def test_the_session_separates_yours_from_available(client, kiosk):
|
||||
@@ -254,7 +262,7 @@ def test_names_appear_only_on_the_deliberate_second_step(client, kiosk):
|
||||
_out(client)
|
||||
|
||||
rows = client.post('/api/usb/kiosk/others',
|
||||
json={'badge': '270099999'}).get_json()['data']
|
||||
json={'badge': '270099999'}, headers=KIOSK_HEADERS).get_json()['data']
|
||||
assert [r['device_id'] for r in rows] == ['USB-1']
|
||||
assert rows[0]['holdername'] == 'Dana Reyes'
|
||||
|
||||
@@ -265,7 +273,7 @@ def test_the_others_list_is_gone_when_returns_for_others_are_refused(client, kio
|
||||
_out(client)
|
||||
|
||||
assert client.post('/api/usb/kiosk/others',
|
||||
json={'badge': '270099999'}).status_code == 404
|
||||
json={'badge': '270099999'}, headers=KIOSK_HEADERS).status_code == 404
|
||||
|
||||
|
||||
def test_a_retired_drive_is_not_offered(client, kiosk):
|
||||
@@ -276,3 +284,33 @@ def test_a_retired_drive_is_not_offered(client, kiosk):
|
||||
|
||||
data = _session(client).get_json()['data']
|
||||
assert data['available'] == []
|
||||
|
||||
|
||||
# --- the flag is a decision, the token is the credential ---------------------
|
||||
#
|
||||
# usb_kiosk_enabled alone used to be the whole gate. With the kiosk on, anyone
|
||||
# who could reach the app could move a drive between checked-out and checked-in
|
||||
# and have it recorded against a badge they guessed.
|
||||
|
||||
def test_kiosk_is_shut_without_the_device_token(client, kiosk):
|
||||
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404
|
||||
assert client.post('/api/usb/kiosk/checkout',
|
||||
json={'device_id': 'USB-1',
|
||||
'badge': '270015376'}).status_code == 404
|
||||
|
||||
|
||||
def test_kiosk_is_shut_with_the_wrong_device_token(client, kiosk):
|
||||
wrong = {'X-Kiosk-Token': KIOSK_TOKEN + 'x'}
|
||||
assert client.get('/api/usb/kiosk/device/USB-1',
|
||||
headers=wrong).status_code == 404
|
||||
assert client.post('/api/usb/kiosk/checkout', headers=wrong,
|
||||
json={'device_id': 'USB-1',
|
||||
'badge': '270015376'}).status_code == 404
|
||||
|
||||
|
||||
def test_the_token_alone_is_not_enough_when_the_kiosk_is_off(client, kiosk):
|
||||
"""Both halves are required: a provisioned screen at a site that has since
|
||||
switched the kiosk off gets nothing."""
|
||||
_setting(kiosk, 'usb_kiosk_enabled', 'false')
|
||||
assert client.get('/api/usb/kiosk/device/USB-1',
|
||||
headers=KIOSK_HEADERS).status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user