feat(auth): add optional admin PIN for all API writes

Off by default. Once a PIN is set in Settings -> System & backup, every non-GET /api/*
request (plus GET /api/ammc/config) needs a signed 30-day HttpOnly cookie, which also
closes the unauthenticated /api/update/run restart trigger. A 401 opens a PIN dialog
and the pending save is retried after login. Reads stay open because the public overlay
hydrates through GET /api/state. Optional RC_ADMIN_ALLOWED_CIDRS restricts the same
routes by client address.

The PIN hash and signing secret live in data/, outside the app_state blob, so they
never leak through GET /api/state, exports or backups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-24 22:14:58 +02:00
parent bcd6e12a6c
commit 9016ce02e4
20 changed files with 627 additions and 8 deletions

View File

@@ -8,12 +8,14 @@ const QRCode = require("qrcode");
const Database = require("better-sqlite3");
const publicTrainingProfile = require("./server/public_training_profile");
const { createBackupService } = require("./server/backup_service");
const { createAuth, COOKIE_NAME: AUTH_COOKIE_NAME, TOKEN_MAX_AGE_MS } = require("./server/auth");
const PORT = Number(process.env.PORT || 8081);
const HOST = process.env.HOST || "0.0.0.0";
const DB_DIR = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMING_DATA_DIR) : path.join(__dirname, "data");
const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite");
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
const auth = createAuth({ dataDir: DB_DIR, allowedCidrs: process.env.RC_ADMIN_ALLOWED_CIDRS || "" });
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
const SRC_DIR = path.join(__dirname, "src");
const RACE_SOUND_DIR = path.join(__dirname, "rc-race-sound-pack");
@@ -152,6 +154,51 @@ app.use((req, res, next) => {
next();
});
app.use(auth.middleware);
function setAuthCookie(res, token) {
const maxAge = token ? Math.floor(TOKEN_MAX_AGE_MS / 1000) : 0;
res.setHeader("Set-Cookie", `${AUTH_COOKIE_NAME}=${token || ""}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`);
}
app.get("/api/auth/status", (req, res) => {
res.json({ enabled: auth.isEnabled(), authenticated: auth.isEnabled() ? auth.isAuthenticated(req) : true, ipAllowed: auth.isIpAllowed(req.ip) });
});
app.post("/api/auth/login", (req, res) => {
if (auth.isLoginBlocked(req.ip)) return res.status(429).json({ error: "too_many_attempts" });
if (!auth.isEnabled()) return res.json({ ok: true, enabled: false });
if (!auth.verifyPin(req.body?.pin)) {
auth.registerLoginFailure(req.ip);
return res.status(401).json({ error: "wrong_pin" });
}
setAuthCookie(res, auth.issueToken());
return res.json({ ok: true, enabled: true });
});
app.post("/api/auth/logout", (_req, res) => {
setAuthCookie(res, "");
res.json({ ok: true });
});
// Protected by the middleware once a PIN exists; while auth is off, setting the first PIN
// is open, which matches how every other admin setting behaves in that state.
app.post("/api/auth/pin", (req, res) => {
try {
auth.setPin(req.body?.newPin);
} catch (error) {
return res.status(400).json({ error: error.message });
}
setAuthCookie(res, auth.issueToken());
return res.json({ ok: true, enabled: true });
});
app.post("/api/auth/disable", (_req, res) => {
auth.disable();
setAuthCookie(res, "");
res.json({ ok: true, enabled: false });
});
app.get("/api/health", (_req, res) => {
res.json({ ok: true, dbPath: DB_PATH });
});