Gate both kiosks behind a device token, and make an unreachable setting a test failure
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user