feat(auth): Implement caching for scanauth
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
69
src/nats/StationKV.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user