diff --git a/src/controllers/ScanController.ts b/src/controllers/ScanController.ts index 1691185..a2179b2 100644 --- a/src/controllers/ScanController.ts +++ b/src/controllers/ScanController.ts @@ -80,9 +80,8 @@ export class ScanController { @ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) @OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead).
Please remember that to provide the scan\'s card\'s station\'s id.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) { - const station_id = req.headers["station_id"]; - if (station_id) { - createScan.station = parseInt(station_id.toString()); + if (req.isStationAuth) { + createScan.station = req.stationId; } let scan = await createScan.toEntity(); scan = await this.trackScanRepository.save(scan); diff --git a/src/controllers/ScanStationController.ts b/src/controllers/ScanStationController.ts index 0740a20..6a779ae 100644 --- a/src/controllers/ScanStationController.ts +++ b/src/controllers/ScanStationController.ts @@ -3,6 +3,7 @@ import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { Repository, getConnectionManager } from 'typeorm'; import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors'; import { TrackNotFoundError } from '../errors/TrackErrors'; +import { deleteStationEntry } from '../nats/StationKV'; import { CreateScanStation } from '../models/actions/create/CreateScanStation'; import { UpdateScanStation } from '../models/actions/update/UpdateScanStation'; import { ScanStation } from '../models/entities/ScanStation'; @@ -85,6 +86,7 @@ export class ScanStationController { } await this.stationRepository.save(await station.update(oldStation)); + await deleteStationEntry(oldStation.prefix); return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse(); } @@ -109,6 +111,7 @@ export class ScanStationController { } const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] }); + await deleteStationEntry(station.prefix); await this.stationRepository.delete(station); return responseStation.toResponse(); } diff --git a/src/middlewares/ScanAuth.ts b/src/middlewares/ScanAuth.ts index 13ef05e..77dc09b 100644 --- a/src/middlewares/ScanAuth.ts +++ b/src/middlewares/ScanAuth.ts @@ -2,73 +2,128 @@ import crypto from 'crypto'; import { Request, Response } from 'express'; import { getConnectionManager } from 'typeorm'; import { config } from '../config'; +import { deleteStationEntry, getStationEntry, setStationEntry, StationKVEntry } from '../nats/StationKV'; import { ScanStation } from '../models/entities/ScanStation'; import authchecker from './authchecker'; +/** + * Computes the HMAC-SHA256 of the provided token using the station token secret. + */ +function computeHmac(token: string): string { + return crypto.createHmac('sha256', config.station_token_secret).update(token).digest('hex'); +} + +/** + * Constant-time comparison of two hex HMAC strings. + * Returns true if they match. + */ +function verifyHmac(provided_token: string, storedHash: string): boolean { + const expectedHash = computeHmac(provided_token); + const expectedBuf = Buffer.from(expectedHash); + const storedBuf = Buffer.from(storedHash); + return expectedBuf.length === storedBuf.length && crypto.timingSafeEqual(expectedBuf, storedBuf); +} + /** * This middleware handles the authentication of scan station api tokens. * The tokens have to be provided via Bearer authorization header. + * + * Auth flow: + * 1. Extract prefix from token (PREFIX.KEY format) + * 2. Try NATS KV cache lookup by prefix — warm path: HMAC verify, no DB + * 3. On cache miss: DB lookup → HMAC verify → write to KV cache + * 4. On no station match at all: fall back to JWT auth (SCAN:CREATE permission) + * + * On success sets req.isStationAuth = true and req.stationId on the request object. + * These are internal server-side properties — not HTTP headers, not spoofable by clients. + * * You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized(). * @param req Express request object. * @param res Express response object. * @param next Next function to call on success. */ const ScanAuth = async (req: Request, res: Response, next: () => void) => { - let provided_token: string = req.headers["authorization"]; - if (provided_token == "" || provided_token === undefined || provided_token === null) { - res.status(401).send({ http_code: 401, short: "no_token", message: "No api token provided." }); + let provided_token: string = req.headers['authorization']; + if (!provided_token) { + res.status(401).send({ http_code: 401, short: 'no_token', message: 'No api token provided.' }); return; } - try { - provided_token = provided_token.replace("Bearer ", ""); - } catch (error) { - res.status(401).send({ http_code: 401, short: "no_token", message: "No valid jwt or api token provided." }); + provided_token = provided_token.replace('Bearer ', ''); + + const prefix = provided_token.split('.')[0]; + if (!prefix) { + res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' }); return; } - let prefix = ""; - try { - prefix = provided_token.split(".")[0]; - } - finally { - if (prefix == "" || prefix == undefined || prefix == null) { - res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." }); + // --- KV cache lookup (warm path) --- + const cached = await getStationEntry(prefix); + if (cached) { + if (!cached.enabled) { + res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' }); return; } + if (!verifyHmac(provided_token, cached.tokenHash)) { + res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' }); + return; + } + req.isStationAuth = true; + req.stationId = cached.id; + next(); + return; } - const station = await getConnectionManager().get().getRepository(ScanStation).findOne({ prefix: prefix }); + // --- DB lookup (cold path) --- + const station = await getConnectionManager().get().getRepository(ScanStation).findOne({ prefix }, { relations: ['track'] }); + if (!station) { + // No station with this prefix — fall back to JWT auth let user_authorized = false; try { - let action = { request: req, response: res, context: null, next: next } - user_authorized = await authchecker(action, ["SCAN:CREATE"]); - } - finally { - if (user_authorized == false) { - res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." }); + const action = { request: req, response: res, context: null, next }; + user_authorized = await authchecker(action, ['SCAN:CREATE']); + } finally { + if (!user_authorized) { + res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' }); return; } - else { - next(); - } + next(); } + return; } - else { - if (station.enabled == false) { - res.status(401).send({ http_code: 401, short: "station_disabled", message: "Station is disabled." }); - return; - } - const expectedHash = crypto.createHmac("sha256", config.station_token_secret).update(provided_token).digest('hex'); - const expectedHashBuf = Buffer.from(expectedHash); - const providedHashBuf = Buffer.from(station.key); - if (expectedHashBuf.length !== providedHashBuf.length || !crypto.timingSafeEqual(expectedHashBuf, providedHashBuf)) { - res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." }); - return; - } - req.headers["station_id"] = station.id.toString(); - next(); + + // Station found — verify token before caching + const tokenHash = computeHmac(provided_token); + const storedBuf = Buffer.from(station.key); + const computedBuf = Buffer.from(tokenHash); + const valid = computedBuf.length === storedBuf.length && crypto.timingSafeEqual(computedBuf, storedBuf); + + if (!valid) { + res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' }); + return; } -} -export default ScanAuth; \ No newline at end of file + + if (!station.enabled) { + res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' }); + return; + } + + // Write to KV cache for subsequent requests + const entry: StationKVEntry = { + id: station.id, + enabled: station.enabled, + tokenHash, + trackId: station.track.id, + trackDistance: station.track.distance, + minimumLapTime: station.track.minimumLapTime ?? 0, + }; + await setStationEntry(prefix, entry); + + req.isStationAuth = true; + req.stationId = station.id; + next(); +}; + +export default ScanAuth; +export { deleteStationEntry }; diff --git a/src/nats/StationKV.ts b/src/nats/StationKV.ts new file mode 100644 index 0000000..439ba69 --- /dev/null +++ b/src/nats/StationKV.ts @@ -0,0 +1,69 @@ +import { KvEntry } from 'nats'; +import NatsClient from './NatsClient'; + +const BUCKET = 'station_state'; + +/** + * Cached station data stored in NATS KV. + * Keyed by station prefix — the same prefix embedded in the station token. + * Carries all fields needed for auth and scan validation so no DB read + * is required on the hot path after the first request from a station. + */ +export interface StationKVEntry { + id: number; + enabled: boolean; + /** HMAC-SHA256 of the full station token, for re-verification on cache hit. */ + tokenHash: string; + trackId: number; + /** Track distance in metres. */ + trackDistance: number; + /** Minimum lap time in seconds. 0 means no minimum (DB null mapped to 0). */ + minimumLapTime: number; +} + +async function getBucket() { + return NatsClient.getKV(BUCKET); +} + +function entryKey(prefix: string): string { + return `station.${prefix}`; +} + +/** + * Returns the cached StationKVEntry for the given prefix, or null on a cache miss. + */ +export async function getStationEntry(prefix: string): Promise { + const bucket = await getBucket(); + let entry: KvEntry | null = null; + try { + entry = await bucket.get(entryKey(prefix)); + } catch { + return null; + } + if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') { + return null; + } + return JSON.parse(entry.string()) as StationKVEntry; +} + +/** + * Writes a StationKVEntry for the given prefix. + * No TTL — entries are permanent until explicitly deleted. + */ +export async function setStationEntry(prefix: string, entry: StationKVEntry): Promise { + const bucket = await getBucket(); + await bucket.put(entryKey(prefix), JSON.stringify(entry)); +} + +/** + * Removes the cached entry for the given prefix. + * Call this on station update or delete so the next request re-fetches from DB. + */ +export async function deleteStationEntry(prefix: string): Promise { + const bucket = await getBucket(); + try { + await bucket.delete(entryKey(prefix)); + } catch { + // Entry may not exist in KV yet — that's fine + } +}