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 })
@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) {
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);

View File

@@ -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();
}

View File

@@ -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;
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
}
}