diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 57fcc970..46733ca1 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1501,6 +1501,17 @@ 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 }) @@ -1541,11 +1552,17 @@ 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)}`) + return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`, + { headers: kioskHeaders() }) }, kioskTake(data) { - return api.post('/printedparts/kiosk/take', data) + return api.post('/printedparts/kiosk/take', data, { headers: kioskHeaders() }) }, listFiles(printeditemid) { return api.get(`/printedparts/items/${printeditemid}/files`) diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 122e0c1d..682d9add 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -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/', 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 diff --git a/plugins/printedparts/frontend/views/PartsKiosk.vue b/plugins/printedparts/frontend/views/PartsKiosk.vue index 01f95b9c..d3ff5d36 100644 --- a/plugins/printedparts/frontend/views/PartsKiosk.vue +++ b/plugins/printedparts/frontend/views/PartsKiosk.vue @@ -92,7 +92,7 @@