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:
47
server.js
47
server.js
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user