diff --git a/access.js b/access.js index 301d64f..9e0f839 100644 --- a/access.js +++ b/access.js @@ -21,9 +21,3 @@ export async function checkAccess(db, userId, doorId) { ); return groupTicket?.granted; } - -export async function recordAudit(db, entry) { - db.collection("auditLogs").insertOne({ ...entry, timestamp: new Date() }).catch((err) => { - console.error("Failed to write audit entry", err); - }); -} \ No newline at end of file diff --git a/routes/audit.js b/routes/audit.js index fbf90ac..b6b275e 100644 --- a/routes/audit.js +++ b/routes/audit.js @@ -1,6 +1,13 @@ import { Router } from "express"; const router = Router(); +export async function recordAudit(db, entry) { + return db.collection("auditLogs").insertOne({ ...entry, timestamp: new Date() }).catch((err) => { + console.error("Failed to write audit entry", err); + throw err; + }); +} + router.get("/", async (req, res) => { const { cursor, search, action } = req.query; const query = {}; diff --git a/routes/doors.js b/routes/doors.js index 820e0e7..be0753c 100644 --- a/routes/doors.js +++ b/routes/doors.js @@ -1,30 +1,34 @@ import { Router } from "express"; import { doorHeartbeats } from "../state.js"; import { checkAccess } from "../access.js"; +import { recordDoorUnlock } from "./logs.js"; const router = Router(); +function isDoorOffline(doorId) { + // If it's been more than 1 minute, we assume something is broken... + const lastHeartbeat = doorHeartbeats.get(doorId); + if (!lastHeartbeat) return true; + return Date.now() - lastHeartbeat > 1000 * 60; +} + +function getDoorStatus(doorId) { + const lastHeartbeat = doorHeartbeats.get(doorId); + return { + guess: isDoorOffline(doorId) ? "offline" : "online", + lastHeartbeat: lastHeartbeat || 0, + }; +} + router.get("/:doorId/status", (req, res) => { - // If it's been more than 1 minute, we assume something is broken... - const lastHeartbeat = doorHeartbeats.get(req.params.doorId); - if (lastHeartbeat) { - res.json({ - guess: Date.now() - lastHeartbeat > 1000 * 60 ? "offline" : "online", - lastHeartbeat, - }); - } else { - res.json({ - guess: "offline", - lastHeartbeat: 0, - }); - } + res.json(getDoorStatus(req.params.doorId)); }); router.get("/", async (req, res) => { const doors = await req.ctx.db.collection("doors").find({}).toArray(); - const accessResults = req.ctx.authMethod === "oidc" + const accessResults = req.ctx.userId ? await Promise.all(doors.map((d) => checkAccess(req.ctx.db, req.ctx.userId, String(d._id)))) - : doors.map(() => true); + : doors.map(() => false); res.json({ doors: doors.map((door, i) => ({ @@ -36,16 +40,33 @@ router.get("/", async (req, res) => { }); router.post("/:doorId/unlock", async (req, res) => { - if (req.ctx.authMethod === "oidc") { - const granted = await checkAccess( - req.ctx.db, - req.ctx.userId, - req.params.doorId - ); - if (!granted) { - return res.status(403).json({ message: "Access denied" }); - } + if (!req.ctx.userId) { //auth method should always have an user identity + return res.status(403).json({ message: "Access denied" }); + } + + const granted = await checkAccess( + req.ctx.db, + req.ctx.userId, + req.params.doorId + ); + + if (!granted) { + return res.status(403).json({ message: "Access denied" }); } + + if (isDoorOffline(req.params.doorId)) { + return res.status(400).json({ message: "Door is offline" }); + } + + const doorDoc = await req.ctx.db.collection("doors").findOne({ _id: req.params.doorId }); + await recordDoorUnlock(req.ctx.db, { + doorId: req.params.doorId, + doorName: doorDoc?.name, + username: req.ctx.username ?? req.ctx.userId, + name: req.ctx.name, + accessType: req.ctx.authMethod + }); + req.ctx.mqtt.publish(`gk/${req.params.doorId}/unlock`, ""); res.status(204).send(null); }); diff --git a/routes/keys.js b/routes/keys.js index d5f07fb..9013f3b 100644 --- a/routes/keys.js +++ b/routes/keys.js @@ -1,36 +1,36 @@ - import { Router } from "express"; - import crypto from "crypto"; - import { REALM_NAMES } from "../constants.js"; - import { recordAudit } from "../access.js"; +import { Router } from "express"; +import crypto from "crypto"; +import { REALM_NAMES } from "../constants.js"; +import { recordAudit } from "./audit.js"; - const router = Router(); +const router = Router(); - router.post("/access", async (req, res) => { - const { reason } = req.body; - if (typeof reason != "string" || !reason.trim()) { - return res.status(422).json({ message: "Missing reason field" }); - } - console.log(`access: ${req.ctx.username} viewed keys || reason: ${reason}`); - await recordAudit(req.ctx.db, { - username: req.ctx.username, - name: req.ctx.name, - action: "Viewed Keys", - reason, - }); - res.status(204).send(null); - }); - - router.get("/by-user", async (req, res) => { - if (typeof req.query.userId != "string") { - return res.status(422).json({ message: "Missing 'userId' query param" }); +router.post("/access", async (req, res) => { + const { reason } = req.body; + if (typeof reason != "string" || !reason.trim()) { + return res.status(422).json({ message: "Missing reason field" }); } - const keys = await req.ctx.db.collection("keys").find({ userId: req.query.userId }).toArray(); - res.json(keys); - }); - - // First, PUT /keys with details of user key is for - // Receive a keyId back which is our association - // Register key using association and send back the now-randomised UID + console.log(`access: ${req.ctx.username} viewed keys || reason: ${reason}`); + await recordAudit(req.ctx.db, { + username: req.ctx.username, + name: req.ctx.name, + action: "Viewed Keys", + reason, + }); + res.status(204).send(null); +}); + +router.get("/by-user", async (req, res) => { + if (typeof req.query.userId != "string") { + return res.status(422).json({ message: "Missing 'userId' query param" }); + } + const keys = await req.ctx.db.collection("keys").find({ userId: req.query.userId }).toArray(); + res.json(keys); +}); + +// First, PUT /keys with details of user key is for +// Receive a keyId back which is our association +// Register key using association and send back the now-randomised UID // with PATCH /keys/:id router.put("/", async (req, res) => { @@ -58,6 +58,7 @@ _id: crypto.randomBytes(18).toString("hex"), userId: req.body.userId, uid: req.body.uid, + type: "physical", // Not created yet, so we'll just leave it disabled for now enabled: false, diff --git a/routes/logs.js b/routes/logs.js index 588274c..b3a1e85 100644 --- a/routes/logs.js +++ b/routes/logs.js @@ -1,8 +1,26 @@ import { Router } from "express"; -import { recordAudit } from "../access.js"; +import { recordAudit } from "./audit.js"; const router = Router(); +export async function recordDoorUnlock(db, { doorId, doorName, username, name, accessType, doorsId = null, keyId = null, uid = null, granted = true }) { + return db.collection("accessLogs").insertOne({ + timestamp: new Date(), + door: doorId, + doorName, + username, + name, + doorsId, + keyId, + uid, + granted, + accessType, + }).catch((err) => { + console.error("Failed to write accessLogs", err); + throw err; + }); +} + router.post("/access", async (req, res) => { const { reason } = req.body; if (typeof reason != "string" || !reason.trim()) { diff --git a/routes/mobile.js b/routes/mobile.js index 8f09aa1..268fb28 100644 --- a/routes/mobile.js +++ b/routes/mobile.js @@ -8,7 +8,7 @@ const router = Router(); router.use(oidcAuth(PROVISION_SCOPE)); router.get("/provision", async (req, res) => { - const stem = { userId: req.ctx.userId, mobile: true }; + const stem = { userId: req.ctx.userId, type: "mobile" }; let key = await req.ctx.db.collection("keys").findOne(stem); if (!key) { key = { diff --git a/server.js b/server.js index 37f7a09..2691864 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,7 @@ import { statsd } from "./metrics.js"; import express from "express"; import mqtt from "mqtt"; -import { MongoClient, ObjectId } from "mongodb"; +import { MongoClient } from "mongodb"; import bodyParser from "body-parser"; import morgan from "morgan"; @@ -11,6 +11,7 @@ import auth from "./auth.js"; import { hybridAuth } from "./middleware/hybridAuth.js"; import { checkAccess } from "./access.js"; import { requireGroup } from "./middleware/oidc.js"; +import { recordDoorUnlock } from "./routes/logs.js"; // API routes import memberProjects from "./routes/memberProjects.js"; @@ -173,30 +174,19 @@ connectionPromise.then(async () => { return {}; }) : Promise.resolve({}), - db.collection("doors").findOne({ - $or: [ - { _id: doorId }, - ...(ObjectId.isValid(doorId) - ? [{ _id: new ObjectId(doorId) }] - : []), - ], - }), + db.collection("doors").findOne({ _id: doorId }), checkAccess(db, key.userId, doorId), ]); const user = userData?.user || {}; const username = user.uid || null; const name = user.cn || null; - // Resolve door name const doorName = doorDoc?.name || null; - - //timestamps (DUHHH?) - const timestamp = new Date(); + const accessType = key.type; // Structured log - const logEntry = { - timestamp, - door: doorId, + await recordDoorUnlock(db, { + doorId, doorName, username, name, @@ -204,24 +194,11 @@ connectionPromise.then(async () => { keyId: key._id, uid: key.uid ?? null, granted: !!granted, - }; - - console.log(logEntry); - db.collection("accessLogs").insertOne(logEntry).catch((err) => { - console.error("Failed to insert into DB", err); + accessType, + }).catch((err) => { + console.error("Failed to insert into accessLogs", err); }); - - if (granted) { - console.log( - `[${timestamp}] ${name} (${username}) is unlocking ${doorName || doorId}` - ); - client.publish(`gk/${doorId}/unlock`); - } else { - console.log( - `[${timestamp}] Attempted unlock of ${doorName || doorId} by ${name} (${username})! Not allowed...` - ); - } } else if (topic.endsWith("/heartbeat")) { const doorId = topic.slice(3, -10); doorHeartbeats.set(doorId, Date.now());