feat(auth): Implement caching for scanauth

This commit is contained in:
2026-02-20 19:27:00 +01:00
parent 778f159405
commit 526738e487
4 changed files with 169 additions and 43 deletions

View File

@@ -80,9 +80,8 @@ export class ScanController {
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) @ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
@OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead). <br> Please remember that to provide the scan\'s card\'s station\'s id.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead). <br> 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) { async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) {
const station_id = req.headers["station_id"]; if (req.isStationAuth) {
if (station_id) { createScan.station = req.stationId;
createScan.station = parseInt(station_id.toString());
} }
let scan = await createScan.toEntity(); let scan = await createScan.toEntity();
scan = await this.trackScanRepository.save(scan); scan = await this.trackScanRepository.save(scan);

View File

@@ -3,6 +3,7 @@ import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { Repository, getConnectionManager } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors'; import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors';
import { TrackNotFoundError } from '../errors/TrackErrors'; import { TrackNotFoundError } from '../errors/TrackErrors';
import { deleteStationEntry } from '../nats/StationKV';
import { CreateScanStation } from '../models/actions/create/CreateScanStation'; import { CreateScanStation } from '../models/actions/create/CreateScanStation';
import { UpdateScanStation } from '../models/actions/update/UpdateScanStation'; import { UpdateScanStation } from '../models/actions/update/UpdateScanStation';
import { ScanStation } from '../models/entities/ScanStation'; import { ScanStation } from '../models/entities/ScanStation';
@@ -85,6 +86,7 @@ export class ScanStationController {
} }
await this.stationRepository.save(await station.update(oldStation)); await this.stationRepository.save(await station.update(oldStation));
await deleteStationEntry(oldStation.prefix);
return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse(); 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"] }); const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] });
await deleteStationEntry(station.prefix);
await this.stationRepository.delete(station); await this.stationRepository.delete(station);
return responseStation.toResponse(); return responseStation.toResponse();
} }

View File

@@ -2,73 +2,128 @@ import crypto from 'crypto';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { getConnectionManager } from 'typeorm'; import { getConnectionManager } from 'typeorm';
import { config } from '../config'; import { config } from '../config';
import { deleteStationEntry, getStationEntry, setStationEntry, StationKVEntry } from '../nats/StationKV';
import { ScanStation } from '../models/entities/ScanStation'; import { ScanStation } from '../models/entities/ScanStation';
import authchecker from './authchecker'; 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. * This middleware handles the authentication of scan station api tokens.
* The tokens have to be provided via Bearer authorization header. * 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(). * You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized().
* @param req Express request object. * @param req Express request object.
* @param res Express response object. * @param res Express response object.
* @param next Next function to call on success. * @param next Next function to call on success.
*/ */
const ScanAuth = async (req: Request, res: Response, next: () => void) => { const ScanAuth = async (req: Request, res: Response, next: () => void) => {
let provided_token: string = req.headers["authorization"]; let provided_token: string = req.headers['authorization'];
if (provided_token == "" || provided_token === undefined || provided_token === null) { if (!provided_token) {
res.status(401).send({ http_code: 401, short: "no_token", message: "No api token provided." }); res.status(401).send({ http_code: 401, short: 'no_token', message: 'No api token provided.' });
return; return;
} }
try { provided_token = provided_token.replace('Bearer ', '');
provided_token = provided_token.replace("Bearer ", "");
} catch (error) { const prefix = provided_token.split('.')[0];
res.status(401).send({ http_code: 401, short: "no_token", message: "No valid jwt or api token provided." }); if (!prefix) {
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
return; return;
} }
let prefix = ""; // --- KV cache lookup (warm path) ---
try { const cached = await getStationEntry(prefix);
prefix = provided_token.split(".")[0]; if (cached) {
} if (!cached.enabled) {
finally { res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' });
if (prefix == "" || prefix == undefined || prefix == null) {
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
return; 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) { if (!station) {
// No station with this prefix — fall back to JWT auth
let user_authorized = false; let user_authorized = false;
try { try {
let action = { request: req, response: res, context: null, next: next } const action = { request: req, response: res, context: null, next };
user_authorized = await authchecker(action, ["SCAN:CREATE"]); user_authorized = await authchecker(action, ['SCAN:CREATE']);
} } finally {
finally { if (!user_authorized) {
if (user_authorized == false) { res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
return; return;
} }
else { next();
next();
}
} }
return;
} }
else {
if (station.enabled == false) { // Station found — verify token before caching
res.status(401).send({ http_code: 401, short: "station_disabled", message: "Station is disabled." }); const tokenHash = computeHmac(provided_token);
return; const storedBuf = Buffer.from(station.key);
} const computedBuf = Buffer.from(tokenHash);
const expectedHash = crypto.createHmac("sha256", config.station_token_secret).update(provided_token).digest('hex'); const valid = computedBuf.length === storedBuf.length && crypto.timingSafeEqual(computedBuf, storedBuf);
const expectedHashBuf = Buffer.from(expectedHash);
const providedHashBuf = Buffer.from(station.key); if (!valid) {
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.' });
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." }); return;
return;
}
req.headers["station_id"] = station.id.toString();
next();
} }
}
export default ScanAuth; 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 };

69
src/nats/StationKV.ts Normal file
View File

@@ -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<StationKVEntry | null> {
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<void> {
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<void> {
const bucket = await getBucket();
try {
await bucket.delete(entryKey(prefix));
} catch {
// Entry may not exist in KV yet — that's fine
}
}