Shut the parts kiosk behind a device token, and stop the health probe leaking
This commit is contained in:
@@ -431,11 +431,54 @@ def adjust_item(item_id: int):
|
||||
return success_response(item.to_dict(), message='Stock adjusted')
|
||||
|
||||
|
||||
# --- kiosk: UNauthenticated by decision record --------------------------------
|
||||
# The take endpoint is the product's first open WRITE. The proposal's decision
|
||||
# record sets the bar it must meet: decrement-only, badge-attributed, bounded,
|
||||
# physically rate-limited. It can reduce stock of an active item and nothing
|
||||
# else; identity comes from the badge resolved server-side, never the client.
|
||||
# --- kiosk: off by default, bound to a provisioned device token ---------------
|
||||
# The take endpoint is the product's only open WRITE. The original decision
|
||||
# record justified that with "physically rate-limited: it serves a touch screen
|
||||
# on the shop floor". Nothing in code enforced the physical part, and an HTTP
|
||||
# route is reachable from anywhere the app is, so the justification did not
|
||||
# survive contact with a scanner.
|
||||
#
|
||||
# What was actually reachable with no credential: bare digits in
|
||||
# _kiosk_find_item enumerate the catalogue without knowing a barcode, a badge is
|
||||
# a short numeric SSO, and this route answered 422 for an unknown badge and 200
|
||||
# for a known one - an oracle for finding real ones. The write then decremented
|
||||
# stock and stamped the ledger, which is the audit trail, with whichever SSO the
|
||||
# caller supplied.
|
||||
#
|
||||
# So a badge is ATTRIBUTION, never authorization. Both kiosk routes now require
|
||||
# the kiosk to be switched on for this site AND the device token an administrator
|
||||
# provisioned. The read is gated too, not just the write: leaving it open would
|
||||
# keep the enumeration oracle running against a kiosk that is supposedly off.
|
||||
#
|
||||
# The token is per-DEVICE, not per-page. A kiosk is a browser on a touch screen,
|
||||
# so a token embedded in the served page would be readable by anyone who can
|
||||
# load it. An admin opens the kiosk once with ?token=..., the page keeps it in
|
||||
# that browser's localStorage and sends it as X-Kiosk-Token thereafter.
|
||||
|
||||
import hmac
|
||||
|
||||
# Largest single take the kiosk records. A shop-floor grab is single digits; 25
|
||||
# leaves real use untouched and denies draining a bin in one request.
|
||||
KIOSK_MAX_TAKE_QUANTITY = 25
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when this site has the kiosk on AND the caller carries its token."""
|
||||
enabled = Setting.get('printedparts_kiosk_enabled', default=False)
|
||||
if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')):
|
||||
return False
|
||||
expected = (Setting.get('printedparts_kiosk_token') or '').strip()
|
||||
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
if not expected or not presented:
|
||||
return False
|
||||
# compare_digest, not ==: the token is a secret and == leaks its length and
|
||||
# matching prefix through timing.
|
||||
return hmac.compare_digest(presented, expected)
|
||||
|
||||
|
||||
def _kiosk_shut():
|
||||
"""404, not 403 - a caller without the token does not learn the door exists."""
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
||||
|
||||
def _kiosk_find_item(itemcode):
|
||||
"""Resolve a scanned or typed code to an active item.
|
||||
@@ -468,7 +511,9 @@ def _kiosk_find_item(itemcode):
|
||||
|
||||
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
|
||||
def kiosk_item(itemcode):
|
||||
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
|
||||
"""Item summary for a scanned bin barcode. Kiosk-token gated."""
|
||||
if not _kiosk_open():
|
||||
return _kiosk_shut()
|
||||
item = _kiosk_find_item(itemcode)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
@@ -478,7 +523,13 @@ def kiosk_item(itemcode):
|
||||
|
||||
@printedparts_bp.route('/kiosk/take', methods=['POST'])
|
||||
def kiosk_take():
|
||||
"""Take parts from a bin. Body: {itemcode, badge, quantity}."""
|
||||
"""Take parts from a bin. Body: {itemcode, badge, quantity}.
|
||||
|
||||
The badge attributes the ledger row. It does not authorize the write - the
|
||||
kiosk flag and the device token do.
|
||||
"""
|
||||
if not _kiosk_open():
|
||||
return _kiosk_shut()
|
||||
data = request.get_json() or {}
|
||||
|
||||
item = _kiosk_find_item(data.get('itemcode'))
|
||||
@@ -487,9 +538,17 @@ def kiosk_take():
|
||||
'No part matches that barcode', http_code=404)
|
||||
|
||||
quantity = data.get('quantity')
|
||||
if not isinstance(quantity, int) or quantity < 1:
|
||||
# isinstance(True, int) is True in Python, so True would have passed as a
|
||||
# quantity of 1. Reject bool explicitly.
|
||||
if (not isinstance(quantity, int) or isinstance(quantity, bool)
|
||||
or quantity < 1):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Enter how many you are taking')
|
||||
if quantity > KIOSK_MAX_TAKE_QUANTITY:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Take at most {KIOSK_MAX_TAKE_QUANTITY} at a time - see the '
|
||||
f'parts team')
|
||||
if quantity > item.quantityonhand:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
@@ -498,8 +557,14 @@ def kiosk_take():
|
||||
|
||||
try:
|
||||
sso, name = resolve_badge(data.get('badge'))
|
||||
except BadgeError as badge_error:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
||||
except BadgeError:
|
||||
# ONE message for every badge failure on the ANONYMOUS route. This is
|
||||
# the only place it matters: distinguishing "malformed" from "not
|
||||
# recognized" let a caller with no account sort guesses into real
|
||||
# employee numbers. The gated restock/adjust handlers keep the specific
|
||||
# message, because there the person is known and the detail is help.
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not accepted - see the parts team',
|
||||
http_code=422)
|
||||
|
||||
# Print-file revision the scanned part carried: explicit field, else the
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { printedpartsApi, settingsApi } from '@/api'
|
||||
import { printedpartsApi, settingsApi, KIOSK_TOKEN_KEY } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||
|
||||
@@ -116,7 +116,28 @@ 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()
|
||||
// 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.
|
||||
|
||||
@@ -150,6 +150,24 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'category': 'printedparts',
|
||||
'description': 'Default low-stock threshold for new items',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_kiosk_enabled',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'printedparts',
|
||||
'description': 'Serve the parts kiosk routes. Off by default: '
|
||||
'they are the only unauthenticated write in the '
|
||||
'product, so a site opts in',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_kiosk_token',
|
||||
'value': '',
|
||||
'valuetype': 'secret',
|
||||
'category': 'printedparts',
|
||||
'description': 'Device token the kiosk sends as X-Kiosk-Token. '
|
||||
'Generate one, then open the kiosk once as '
|
||||
'/parts-kiosk?token=<value> on each screen',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_unknown_badge',
|
||||
'value': 'deny',
|
||||
|
||||
Reference in New Issue
Block a user