Shared kiosk sessions: app kiosk registry, kiosk login, verify support, admin password
apps gain kiosk_path/kiosk_caps (hvac registered first). One shared kiosk password mints a long-lived hnf_kiosk_session carrying only each registered app's kiosk caps; /verify answers for kiosk sessions; changing the password bumps an epoch that invalidates signed-in tablets. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
107eaa7e35
commit
3052113d31
7 changed files with 181 additions and 1 deletions
17
src/db.js
17
src/db.js
|
|
@ -133,6 +133,13 @@ export async function initDb() {
|
|||
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_host TEXT`)
|
||||
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_port INTEGER DEFAULT 3080`)
|
||||
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS max_session_hours INTEGER`)
|
||||
// Kiosk registry: an app with a kiosk_path has a full-screen back-of-house
|
||||
// screen the shared wall-tablet shell (portal /kiosk) can launch. kiosk_caps
|
||||
// is the contract — the only capabilities a kiosk session is ever given for
|
||||
// that app (never settings/admin ones). See auth/src/kiosk.js.
|
||||
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS kiosk_path TEXT`)
|
||||
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS kiosk_caps TEXT[]`)
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS kiosk_config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||
await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS workforce_user_id TEXT`)
|
||||
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS users_workforce_user_id_idx ON users (workforce_user_id) WHERE workforce_user_id IS NOT NULL`)
|
||||
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS pending_reg_email_idx ON pending_registrations (email)`)
|
||||
|
|
@ -651,6 +658,16 @@ export async function initDb() {
|
|||
sort_order = EXCLUDED.sort_order
|
||||
`)
|
||||
|
||||
// Kiosk registrations — code is the source of truth, re-applied on every start.
|
||||
// Add a line here when an app gets a /kiosk screen (and keep kiosk_caps to
|
||||
// operational controls only: a shared back-of-house tablet must never hold a
|
||||
// settings/manage capability).
|
||||
for (const k of [
|
||||
{ slug: 'hvac', path: '/hvac/kiosk', caps: ['view', 'control'] },
|
||||
]) {
|
||||
await pool.query('UPDATE apps SET kiosk_path = $1, kiosk_caps = $2 WHERE slug = $3', [k.path, k.caps, k.slug])
|
||||
}
|
||||
|
||||
// Seed first admin user if table is empty
|
||||
const { rows } = await pool.query('SELECT COUNT(*) FROM users')
|
||||
if (parseInt(rows[0].count) === 0) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { authRoutes } from './routes/auth.js'
|
|||
import { adminRoutes } from './routes/admin.js'
|
||||
import { internalRoutes } from './routes/internal.js'
|
||||
import { registerRoutes } from './routes/register.js'
|
||||
import { kioskRoutes } from './routes/kiosk.js'
|
||||
import { startSyncJob } from './sync.js'
|
||||
|
||||
const app = Fastify({ logger: true, trustProxy: true })
|
||||
|
|
@ -20,6 +21,7 @@ app.get('/health', async () => ({ status: 'healthy' }))
|
|||
|
||||
await app.register(authRoutes, { prefix: '/api/auth' })
|
||||
await app.register(registerRoutes, { prefix: '/api/auth' })
|
||||
await app.register(kioskRoutes, { prefix: '/api/auth/kiosk' })
|
||||
await app.register(adminRoutes, { prefix: '/api/auth/admin' })
|
||||
await app.register(internalRoutes, { prefix: '/api/auth/internal' })
|
||||
|
||||
|
|
|
|||
11
src/jwt.js
11
src/jwt.js
|
|
@ -20,6 +20,17 @@ export async function signToken(payload) {
|
|||
.sign(secret)
|
||||
}
|
||||
|
||||
// Same signing as signToken but with an explicit lifetime — used for kiosk
|
||||
// sessions, which are deliberately near-permanent (enter the password once per
|
||||
// wall tablet).
|
||||
export async function signTokenFor(payload, expiresIn) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(secret)
|
||||
}
|
||||
|
||||
export async function verifyToken(token) {
|
||||
const { payload } = await jwtVerify(token, secret)
|
||||
return payload
|
||||
|
|
|
|||
71
src/kiosk.js
Normal file
71
src/kiosk.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Shared back-of-house kiosk sessions. One kiosk password (hashed in
|
||||
// kiosk_config) unlocks a long-lived session whose token carries ONLY the
|
||||
// capabilities each kiosk-enabled app registered in apps.kiosk_caps — so what a
|
||||
// shared wall tablet can do is decided by each app's own kiosk screen design,
|
||||
// not by a user/role.
|
||||
//
|
||||
// Deliberately its own cookie name (never hnf_session): sharing it caused a real
|
||||
// collision between kiosk and staff logins on the same browser (hit with the
|
||||
// hvac kiosk, 2026-08-26). Apps' backends read it as a fallback cookie.
|
||||
import { pool } from './db.js'
|
||||
import { signTokenFor, verifyToken } from './jwt.js'
|
||||
|
||||
export const KIOSK_COOKIE = 'hnf_kiosk_session'
|
||||
export const KIOSK_SESSION_SECONDS = 60 * 60 * 24 * 3650 // ~10 years, like hvac's own kiosk token
|
||||
export const KIOSK_SESSION_EXPIRY = `${KIOSK_SESSION_SECONDS}s`
|
||||
|
||||
export async function getKioskConfig(key) {
|
||||
const { rows: [r] } = await pool.query('SELECT value FROM kiosk_config WHERE key = $1', [key])
|
||||
return r?.value ?? null
|
||||
}
|
||||
|
||||
export async function setKioskConfig(key, value) {
|
||||
await pool.query(
|
||||
`INSERT INTO kiosk_config (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[key, value]
|
||||
)
|
||||
}
|
||||
|
||||
async function currentEpoch() {
|
||||
return Number(await getKioskConfig('epoch')) || 1
|
||||
}
|
||||
|
||||
// Active apps that registered a kiosk screen.
|
||||
export async function getKioskApps() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT slug, name, icon, theme_color, kiosk_path, kiosk_caps
|
||||
FROM apps WHERE active = TRUE AND kiosk_path IS NOT NULL ORDER BY name`
|
||||
)
|
||||
return rows
|
||||
}
|
||||
|
||||
export async function mintKioskToken() {
|
||||
const apps = await getKioskApps()
|
||||
return {
|
||||
apps,
|
||||
token: await signTokenFor({
|
||||
sub: 'kiosk',
|
||||
name: 'Kiosk',
|
||||
kiosk: true,
|
||||
ke: await currentEpoch(), // bumped whenever the kiosk password changes
|
||||
is_admin: false,
|
||||
offsite_allowed: true, // physical on-site device — no IP check
|
||||
apps: apps.map(a => a.slug),
|
||||
caps: apps.flatMap(a => (a.kiosk_caps || []).map(c => `${a.slug}:${c}`)),
|
||||
}, KIOSK_SESSION_EXPIRY),
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the verified kiosk payload, or null if there's no valid kiosk
|
||||
// session (missing/invalid cookie, not a kiosk token, or signed before the
|
||||
// password was last changed).
|
||||
export async function readKioskSession(request) {
|
||||
const token = request.cookies?.[KIOSK_COOKIE]
|
||||
if (!token) return null
|
||||
let payload
|
||||
try { payload = await verifyToken(token) } catch { return null }
|
||||
if (!payload.kiosk) return null
|
||||
if (payload.ke !== await currentEpoch()) return null
|
||||
return payload
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { pool } from '../db.js'
|
|||
import { hashPassword, verifyToken, verifyPassword } from '../jwt.js'
|
||||
import { getAllDepartments, getLocationsByIds, getSyncConfig, findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js'
|
||||
import { syncAllWorkforceUsers } from '../sync.js'
|
||||
import { getKioskApps, getKioskConfig, setKioskConfig } from '../kiosk.js'
|
||||
|
||||
async function requireAdmin(request, reply) {
|
||||
const token = request.cookies?.hnf_session
|
||||
|
|
@ -108,6 +109,25 @@ export async function adminRoutes(app) {
|
|||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
// ── Kiosk ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/kiosk', async () => ({
|
||||
configured: !!(await getKioskConfig('password_hash')),
|
||||
apps: (await getKioskApps()).map(a => ({ slug: a.slug, name: a.name, kiosk_path: a.kiosk_path, kiosk_caps: a.kiosk_caps || [] })),
|
||||
}))
|
||||
|
||||
// Changing the password bumps the epoch, which invalidates every signed-in
|
||||
// tablet at its next check (portal kiosk home / auth verify) — see kiosk.js.
|
||||
app.post('/kiosk-password', async (request, reply) => {
|
||||
const { password } = request.body || {}
|
||||
if (typeof password !== 'string' || password.length < 6) {
|
||||
return reply.status(400).send({ error: 'Password must be at least 6 characters' })
|
||||
}
|
||||
await setKioskConfig('password_hash', await hashPassword(password))
|
||||
await setKioskConfig('epoch', String((Number(await getKioskConfig('epoch')) || 1) + 1))
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── Apps registry ──────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/apps', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { pool } from '../db.js'
|
||||
import { verifyPassword, signToken, verifyToken } from '../jwt.js'
|
||||
import { isOnsite } from '../ip-check.js'
|
||||
import { readKioskSession, getKioskApps } from '../kiosk.js'
|
||||
|
||||
const DOMAIN = process.env.DOMAIN || 'localhost'
|
||||
const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30')
|
||||
|
|
@ -235,7 +236,19 @@ export async function authRoutes(app) {
|
|||
// take effect on the next request without requiring re-login.
|
||||
app.get('/verify', async (request, reply) => {
|
||||
const token = request.cookies?.hnf_session
|
||||
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||
if (!token) {
|
||||
// No staff session — a shared kiosk tablet has no user, so answer for it
|
||||
// directly: only apps that registered a kiosk screen, only their
|
||||
// registered kiosk caps (resolved live from the registry).
|
||||
const kiosk = await readKioskSession(request)
|
||||
const { app: kioskApp } = request.query
|
||||
if (kiosk && kioskApp) {
|
||||
const a = (await getKioskApps()).find(x => x.slug === kioskApp)
|
||||
if (!a) return reply.status(403).send({ error: 'Access denied' })
|
||||
return { user_id: 0, email: 'kiosk', name: 'Kiosk', is_admin: false, kiosk: true, app: kioskApp, caps: a.kiosk_caps || [] }
|
||||
}
|
||||
return reply.status(401).send({ error: 'Not authenticated' })
|
||||
}
|
||||
|
||||
let payload
|
||||
try { payload = await verifyToken(token) }
|
||||
|
|
|
|||
46
src/routes/kiosk.js
Normal file
46
src/routes/kiosk.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { verifyPassword } from '../jwt.js'
|
||||
import { cookieOpts, makeIpThrottle } from './auth.js'
|
||||
import {
|
||||
KIOSK_COOKIE, KIOSK_SESSION_SECONDS, getKioskConfig, mintKioskToken, readKioskSession,
|
||||
} from '../kiosk.js'
|
||||
|
||||
// Kiosk login is a single shared password, so throttle hard per IP.
|
||||
const throttle = makeIpThrottle(10, 15 * 60_000)
|
||||
|
||||
function kioskCookieOpts(request, clear = false) {
|
||||
return { ...cookieOpts(request, clear), maxAge: clear ? 0 : KIOSK_SESSION_SECONDS }
|
||||
}
|
||||
|
||||
const publicApp = a => ({ slug: a.slug, name: a.name, icon: a.icon, theme_color: a.theme_color, kiosk_path: a.kiosk_path })
|
||||
|
||||
export async function kioskRoutes(app) {
|
||||
// POST /api/auth/kiosk/login — the shared kiosk password.
|
||||
app.post('/login', async (request, reply) => {
|
||||
if (!throttle(request)) return reply.status(429).send({ error: 'Too many attempts — try again later' })
|
||||
const { password } = request.body || {}
|
||||
if (!password || typeof password !== 'string') return reply.status(400).send({ error: 'Password required' })
|
||||
|
||||
const hash = await getKioskConfig('password_hash')
|
||||
if (!hash) return reply.status(503).send({ error: 'Kiosk login has not been set up yet — an admin needs to set a kiosk password in the portal.' })
|
||||
if (!(await verifyPassword(password, hash))) return reply.status(401).send({ error: 'Incorrect password' })
|
||||
|
||||
const { token, apps } = await mintKioskToken()
|
||||
reply.setCookie(KIOSK_COOKIE, token, kioskCookieOpts(request))
|
||||
return { apps: apps.map(publicApp) }
|
||||
})
|
||||
|
||||
// GET /api/auth/kiosk/apps — the home screen's app list. Also re-mints the
|
||||
// token, so apps registered after a tablet signed in appear (and registry cap
|
||||
// changes take effect) without re-entering the password.
|
||||
app.get('/apps', async (request, reply) => {
|
||||
if (!(await readKioskSession(request))) return reply.status(401).send({ error: 'Not signed in' })
|
||||
const { token, apps } = await mintKioskToken()
|
||||
reply.setCookie(KIOSK_COOKIE, token, kioskCookieOpts(request))
|
||||
return { apps: apps.map(publicApp) }
|
||||
})
|
||||
|
||||
app.post('/logout', async (request, reply) => {
|
||||
reply.clearCookie(KIOSK_COOKIE, kioskCookieOpts(request, true))
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue