From 327cb78f1a5be8df03f905f33d2214939ec34d2d Mon Sep 17 00:00:00 2001 From: Zaid Abdulla Date: Sun, 23 Dec 2018 03:41:18 -0800 Subject: [PATCH] Implement authentication and access control --- client/app.js | 14 +++-- client/components/device-actions.js | 5 +- client/components/summon-button.js | 44 +++++++++------ client/components/tags.js | 20 ++++--- client/css/app.css | 10 ++++ client/device-page.js | 13 +++-- client/devices-page.js | 84 +++++++++++++++++------------ client/drawer-component.js | 55 ++++++++++--------- client/error-page.js | 4 +- client/faults-page.js | 11 ++-- client/layout.js | 2 + client/login-page.js | 72 +++++++++++++++++++++++++ client/menu.js | 23 +++++--- client/overview-page.js | 9 ++-- client/store.js | 80 ++++++++++++++------------- client/task-queue.js | 30 ++++++++--- client/user-menu.js | 49 +++++++++++++++++ common/authorizer.js | 78 +++++++++++++++++++++++++++ common/filter.js | 8 +-- common/validators/pass.js | 5 ++ common/validators/test.js | 14 +++++ config.json | 11 ++++ package.json | 4 +- server/api-functions.js | 4 +- server/api.js | 51 ++++++++++++++---- server/config.js | 7 +++ server/server.js | 60 +++++++++++++++++++++ 27 files changed, 600 insertions(+), 167 deletions(-) create mode 100644 client/login-page.js create mode 100644 client/user-menu.js create mode 100644 common/authorizer.js create mode 100644 common/validators/pass.js create mode 100644 common/validators/test.js diff --git a/client/app.js b/client/app.js index 9a9ef0d..fef3830 100644 --- a/client/app.js +++ b/client/app.js @@ -4,11 +4,15 @@ import m from "mithril"; import layout from "./layout"; import * as store from "./store"; +import * as loginPage from "./login-page"; import * as overviewPage from "./overview-page"; import * as devicesPage from "./devices-page"; import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; import * as faultsPage from "./faults-page"; +import Authorizer from "../common/authorizer"; + +window.authorizer = new Authorizer(window.permissionSets); let state; @@ -29,17 +33,18 @@ function pagify(pageName, page) { const component = { render: () => { lastRenderTimestamp = Date.now(); - let c; - if (state && state.error) c = m(errorPage.component, state); - else c = m(layout, { page: pageName }, m(page.component, state)); + let p; + if (state && state.error) p = m(errorPage.component, state); + else p = m(page.component, state); fulfill(); - return c; + return m(layout, { page: pageName }, p); } }; component.onmatch = args => { pageVisitTimestamp = Date.now(); if (!page.init) { + state = null; fulfill(); return; } @@ -64,6 +69,7 @@ function pagify(pageName, page) { } m.route(document.body, "/overview", { + "/login": pagify("login", loginPage), "/overview": pagify("overview", overviewPage), "/devices": pagify("devices", devicesPage), "/devices/:id": pagify("devices", devicePage), diff --git a/client/components/device-actions.js b/client/components/device-actions.js index 30b4bfd..ccfd728 100644 --- a/client/components/device-actions.js +++ b/client/components/device-actions.js @@ -50,11 +50,12 @@ const component = { title: "Delete device", onclick: () => { if (!confirm("Deleting this device. Are you sure?")) return; + const deviceId = device["DeviceID.ID"].value[0]; store - .deleteResource("devices", device["DeviceID.ID"].value[0]) + .deleteResource("devices", deviceId) .then(() => { - notifications.push("success", "Device deleted"); + notifications.push("success", `${deviceId}: Device deleted`); m.route.set("/devices"); }) .catch(err => { diff --git a/client/components/summon-button.js b/client/components/summon-button.js index 81c2f7f..850b8cb 100644 --- a/client/components/summon-button.js +++ b/client/components/summon-button.js @@ -31,23 +31,33 @@ const component = { }; taskQueue - .commit([task], (deviceId, connectionRequestStatus, tasks2) => { - for (let t of tasks2) - if (t.status === "stale") taskQueue.deleteTask(t); - if (connectionRequestStatus !== "OK") - notifications.push( - "error", - `${deviceId}: ${connectionRequestStatus}` - ); - else if (tasks2[0].status === "stale") - notifications.push( - "error", - `${deviceId}: No contact from device` - ); - else if (tasks2[0].status === "fault") - notifications.push("error", `${deviceId}: Refresh faulted`); - else notifications.push("success", `${deviceId}: Summoned`); - }) + .commit( + [task], + (deviceId, err, connectionRequestStatus, tasks2) => { + if (err) + return notifications.push( + "error", + `${deviceId}: ${err.message}` + ); + + for (let t of tasks2) + if (t.status === "stale") taskQueue.deleteTask(t); + + if (connectionRequestStatus !== "OK") + notifications.push( + "error", + `${deviceId}: ${connectionRequestStatus}` + ); + else if (tasks2[0].status === "stale") + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + else if (tasks2[0].status === "fault") + notifications.push("error", `${deviceId}: Refresh faulted`); + else notifications.push("success", `${deviceId}: Summoned`); + } + ) .then(() => { e.target.disabled = false; store.fulfill(0, Date.now()); diff --git a/client/components/tags.js b/client/components/tags.js index 6ba3750..76bf2cf 100644 --- a/client/components/tags.js +++ b/client/components/tags.js @@ -29,14 +29,18 @@ const component = { { onclick: e => { e.target.disabled = true; + const deviceId = device["DeviceID.ID"].value[0]; store - .updateTags(device["DeviceID.ID"].value[0], { [tag]: false }) + .updateTags(deviceId, { [tag]: false }) .then(() => { e.target.disabled = false; - notifications.push("success", `Tag '${tag}' unassigned`); + notifications.push("success", `${deviceId}: Tags updated`); store.fulfill(0, Date.now()); }) - .catch(err => notifications.push("error", err.message)); + .catch(err => { + e.target.disabled = false; + notifications.push("error", `${deviceId}: ${err.message}`); + }); } }, "✕" @@ -50,6 +54,7 @@ const component = { { onclick: e => { e.target.disabled = true; + const deviceId = device["DeviceID.ID"].value[0]; const tag = prompt(`Enter tag to assign to device:`); if (!tag) { e.target.disabled = false; @@ -57,13 +62,16 @@ const component = { } store - .updateTags(device["DeviceID.ID"].value[0], { [tag]: true }) + .updateTags(deviceId, { [tag]: true }) .then(() => { e.target.disabled = false; - notifications.push("success", `Tag '${tag}' assigned`); + notifications.push("success", `${deviceId}: Tags updated`); store.fulfill(0, Date.now()); }) - .catch(err => notifications.push("error", err.message)); + .catch(err => { + e.target.disabled = false; + notifications.push("error", `${deviceId}: ${err.message}`); + }); } }, "🞢" diff --git a/client/css/app.css b/client/css/app.css index 55259a7..b0bae5a 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -229,11 +229,17 @@ i { } } } + & > .logo { margin: 10px; display: inline; height: 56px; } + + & > .user-menu { + float: right; + margin: 5px; + } } #content { @@ -308,6 +314,10 @@ h3 { margin-bottom: 0.5em; } +p.error { + color: #a00; +} + /* Remove button padding in FF */ input::-moz-focus-inner { border: 0; diff --git a/client/device-page.js b/client/device-page.js index b3de49c..8eef07e 100644 --- a/client/device-page.js +++ b/client/device-page.js @@ -8,11 +8,14 @@ import * as store from "./store"; import * as components from "./components"; function init(args) { - return new Promise(resolve => { - resolve({ - deviceId: args.id, - deviceFilter: new Filter(["=", "DeviceID.ID", args.id]) - }); + if (!window.authorizer.hasAccess("devices", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + return Promise.resolve({ + deviceId: args.id, + deviceFilter: new Filter(["=", "DeviceID.ID", args.id]) }); } diff --git a/client/devices-page.js b/client/devices-page.js index 4645494..3c2a823 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -12,7 +12,10 @@ import * as taskQueue from "./task-queue"; import * as notifications from "./notifications"; function init(args) { - return new Promise(resolve => { + return new Promise((resolve, reject) => { + if (!window.authorizer.hasAccess("devices", 2)) + return reject(new Error("You are not authorized to view this page")); + let filter = new Filter(args.filter); let indexParameters = Object.values(config.get("ui.index")).map(p => Object.assign({}, p, { @@ -177,14 +180,21 @@ function renderActions(selected) { const ids = Array.from(selected); if (!confirm(`Deleting ${ids.length} devices. Are you sure?`)) return; - Promise.all(ids.map(id => store.deleteResource("devices", id))) - .then(() => { - notifications.push("success", `${ids.size} devices deleted`); - store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - }); + let counter = 1; + for (let id of ids) { + ++counter; + store + .deleteResource("devices", id) + .then(() => { + notifications.push("success", `${id}: Deleted`); + if (--counter === 0) store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", `${id}: ${err.message}`); + if (--counter === 0) store.fulfill(0, Date.now()); + }); + } + if (--counter === 0) store.fulfill(0, Date.now()); } }, "Delete" @@ -202,19 +212,21 @@ function renderActions(selected) { const tag = prompt(`Enter tag to assign to ${ids.length} devices:`); if (!tag) return; - Promise.all( - ids.map(id => { - store.updateTags(id, { [tag]: true }); - }) - ) - .then(() => { - notifications.push( - "success", - `Tag '${tag}' assigned to ${ids.length} devices` - ); - store.fulfill(0, Date.now()); - }) - .catch(err => notifications.push("error", err.message)); + let counter = 1; + for (let id of ids) { + ++counter; + store + .updateTags(id, { [tag]: true }) + .then(() => { + notifications.push("success", `${id}: Tags updated`); + if (--counter === 0) store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", `${id}: ${err.message}`); + if (--counter === 0) store.fulfill(0, Date.now()); + }); + } + if (--counter === 0) store.fulfill(0, Date.now()); } }, "Tag" @@ -234,19 +246,21 @@ function renderActions(selected) { ); if (!tag) return; - Promise.all( - ids.map(id => { - store.updateTags(id, { [tag]: false }); - }) - ) - .then(() => { - notifications.push( - "success", - `Tag '${tag}' unassigned from ${ids.length} devices` - ); - store.fulfill(0, Date.now()); - }) - .catch(err => notifications.push("error", err.message)); + let counter = 1; + for (let id of ids) { + ++counter; + store + .updateTags(id, { [tag]: false }) + .then(() => { + notifications.push("success", `${id}: Tags updated`); + if (--counter === 0) store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", `${id}: ${err.message}`); + if (--counter === 0) store.fulfill(0, Date.now()); + }); + } + if (--counter === 0) store.fulfill(0, Date.now()); } }, "Untag" diff --git a/client/drawer-component.js b/client/drawer-component.js index 3af1f3c..f40a0ac 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -243,41 +243,46 @@ const component = { t => t.status === "queued" ); taskQueue - .commit(tasks, (deviceId, connectionRequestStatus, tasks2) => { - if (connectionRequestStatus !== "OK") { - notifications.push( - "error", - `${deviceId}: ${connectionRequestStatus}` - ); - return; - } - - for (let t of tasks2) - if (t.status === "stale") { - notifications.push( + .commit( + tasks, + (deviceId, err, connectionRequestStatus, tasks2) => { + if (err) + return notifications.push( "error", - `${deviceId}: No contact from device` + `${deviceId}: ${err.message}` ); - return; - } else if (t.status === "fault") { + + if (connectionRequestStatus !== "OK") { notifications.push( "error", - `${deviceId}: Task(s) faulted` + `${deviceId}: ${connectionRequestStatus}` ); return; } - notifications.push( - "success", - `${deviceId}: Task(s) committed` - ); - }) + for (let t of tasks2) + if (t.status === "stale") { + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + return; + } else if (t.status === "fault") { + notifications.push( + "error", + `${deviceId}: Task(s) faulted` + ); + return; + } + + notifications.push( + "success", + `${deviceId}: Task(s) committed` + ); + } + ) .then(() => { store.fulfill(0, Date.now()); - }) - .catch(err => { - notifications.push("error", err.message); - throw err; }); } }, diff --git a/client/error-page.js b/client/error-page.js index 4398d87..afe4f49 100644 --- a/client/error-page.js +++ b/client/error-page.js @@ -1,9 +1,11 @@ "use strict"; +import m from "mithril"; + const component = { view: function(vnode) { document.title = "Error! - GenieACS"; - return vnode.attrs.error; + return m("p.error", vnode.attrs.error); } }; diff --git a/client/faults-page.js b/client/faults-page.js index 571ef10..391138b 100644 --- a/client/faults-page.js +++ b/client/faults-page.js @@ -9,10 +9,13 @@ import * as store from "./store"; import * as notifications from "./notifications"; function init(args) { - return new Promise(resolve => { - let filter = new Filter(args.filter); - resolve({ filter: filter }); - }); + if (!window.authorizer.hasAccess("devices", 2)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + let filter = new Filter(args.filter); + return Promise.resolve({ filter: filter }); } function renderTable(faultsResponse, total, selected, showMoreCallback) { diff --git a/client/layout.js b/client/layout.js index 8177219..c4bc4b1 100644 --- a/client/layout.js +++ b/client/layout.js @@ -4,12 +4,14 @@ import m from "mithril"; import menu from "./menu"; import drawerComponent from "./drawer-component"; +import userMenu from "./user-menu"; const layout = { view: vnode => { return [ m("#header", [ m("img.logo", { src: "logo.svg" }), + m(userMenu), m(menu, { page: vnode.attrs.page }), m(drawerComponent) ]), diff --git a/client/login-page.js b/client/login-page.js new file mode 100644 index 0000000..ee12d77 --- /dev/null +++ b/client/login-page.js @@ -0,0 +1,72 @@ +"use strict"; + +import m from "mithril"; + +import * as store from "./store"; +import * as notifications from "./notifications"; + +function init(args) { + return Promise.resolve(args); +} + +const component = { + view: vnode => { + if (window.username) m.route.set(vnode.attrs.continue || "/"); + + document.title = "Login - GenieACS"; + return [ + m("h1", "Log in"), + m( + "form", + m( + "p", + m("label", { for: "username" }, "Username"), + m("input", { + name: "username", + type: "text", + value: vnode.state.username, + oncreate: vnode2 => { + vnode2.dom.focus(); + }, + oninput: m.withAttr("value", v => (vnode.state.username = v)) + }) + ), + m( + "p", + m("label", { for: "password" }, "Password"), + m("input", { + name: "password", + type: "password", + value: vnode.state.password, + oninput: m.withAttr("value", v => (vnode.state.password = v)) + }) + ), + m( + "p", + m( + "button.primary", + { + type: "submit", + onclick: e => { + e.target.disabled = true; + store + .logIn(vnode.state.username, vnode.state.password) + .then(() => { + location.reload(); + }) + .catch(err => { + notifications.push("error", err.message); + e.target.disabled = false; + }); + return false; + } + }, + "Login" + ) + ) + ) + ]; + } +}; + +export { init, component }; diff --git a/client/menu.js b/client/menu.js index 6a94754..05b3b94 100644 --- a/client/menu.js +++ b/client/menu.js @@ -6,26 +6,35 @@ const menu = { view: vnode => { const active = { [vnode.attrs.page]: "active" }; - return m( - "nav", - m("ul", [ + let tabs = []; + if (window.authorizer.hasAccess("devices", 1)) + tabs.push( m( "li", { class: active["overview"] }, m("a", { href: "#!/overview" }, "Overview") - ), + ) + ); + + if (window.authorizer.hasAccess("devices", 2)) + tabs.push( m( "li", { class: active["devices"] }, m("a", { href: "#!/devices" }, "Devices") - ), + ) + ); + + if (window.authorizer.hasAccess("faults", 2)) + tabs.push( m( "li", { class: active["faults"] }, m("a", { href: "#!/faults" }, "Faults") ) - ]) - ); + ); + + return m("nav", m("ul", tabs)); } }; diff --git a/client/overview-page.js b/client/overview-page.js index 30378eb..740bfc6 100644 --- a/client/overview-page.js +++ b/client/overview-page.js @@ -24,9 +24,12 @@ function queryCharts(charts) { } const init = function() { - return new Promise(resolve => { - resolve({ charts: queryCharts(CHARTS) }); - }); + if (!window.authorizer.hasAccess("devices", 1)) + return Promise.reject( + new Error("You are not authorized to view this page") + ); + + return Promise.resolve({ charts: queryCharts(CHARTS) }); }; const component = { diff --git a/client/store.js b/client/store.js index 33e2493..5ef7948 100644 --- a/client/store.js +++ b/client/store.js @@ -323,43 +323,33 @@ function getTimestamp() { return fulfillTimestamp; } -function postTasks(tasks, callback) { +function postTasks(deviceId, tasks) { + for (let t of tasks) { + t.status = "pending"; + t.device = deviceId; + } + return new Promise((resolve, reject) => { - let devices = {}; - for (let t of tasks) { - t.status = "pending"; - devices[t.device] = devices[t.device] || []; - devices[t.device].push(t); - } - - let promises = []; - for (let [deviceId, tasks2] of Object.entries(devices)) - promises.push( - m.request({ - method: "POST", - url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, - data: tasks2, - extract: xhr => { - const connectionRequestStatus = xhr.getResponseHeader( - "Connection-Request" - ); - let st = JSON.parse(xhr.response); - for (let [i, t] of st.entries()) { - tasks2[i]._id = t._id; - tasks2[i].status = t.status; - tasks2[i].fault = t.fault; - } - if (callback) callback(deviceId, connectionRequestStatus, tasks2); + m + .request({ + method: "POST", + url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, + data: tasks, + extract: xhr => { + if (xhr.status !== 200) throw new Error(xhr.response); + const connectionRequestStatus = xhr.getResponseHeader( + "Connection-Request" + ); + let st = JSON.parse(xhr.response); + for (let [i, t] of st.entries()) { + tasks[i]._id = t._id; + tasks[i].status = t.status; + tasks[i].fault = t.fault; } - }) - ); - - Promise.all(promises) - .then(() => resolve(tasks)) - .catch(err => { - callback = null; - reject(err); - }); + resolve(connectionRequestStatus); + } + }) + .catch(reject); }); } @@ -394,6 +384,22 @@ function evaluateExpression(exp, device) { return exps.get(device); } +function logIn(username, password) { + return m.request({ + method: "POST", + url: "/login", + background: true, + data: { username, password } + }); +} + +function logOut() { + return m.request({ + method: "POST", + url: "/logout" + }); +} + export { count, fetch, @@ -403,5 +409,7 @@ export { postTasks, updateTags, deleteResource, - evaluateExpression + evaluateExpression, + logIn, + logOut }; diff --git a/client/task-queue.js b/client/task-queue.js index 76a8d41..b856a1e 100644 --- a/client/task-queue.js +++ b/client/task-queue.js @@ -37,16 +37,32 @@ function stageSpv(task) { } function commit(tasks, callback) { + let devices = {}; for (let t of tasks) { - t.status = "pending"; - queue.add(t); + devices[t.device] = devices[t.device] || []; + devices[t.device].push(t); + queueTask(t); } - return store.postTasks(tasks, (deviceId, connectionRequestStatus, tasks2) => { - for (let t of tasks2) - if (t.status === "pending") t.status = "stale"; - else if (t.status === "done") queue.delete(t); - if (callback) callback(deviceId, connectionRequestStatus, tasks2); + return new Promise(resolve => { + let counter = 1; + for (let [deviceId, tasks2] of Object.entries(devices)) + store + .postTasks(deviceId, tasks) + .then(connectionRequestStatus => { + for (let t of tasks2) + if (t.status === "pending") t.status = "stale"; + else if (t.status === "done") queue.delete(t); + callback(deviceId, null, connectionRequestStatus, tasks2); + if (--counter === 0) resolve(); + }) + .catch(err => { + for (let t of tasks2) t.status = "stale"; + callback(deviceId, err, null, tasks2); + if (--counter === 0) resolve(); + }); + + if (--counter === 0) resolve(); }); } diff --git a/client/user-menu.js b/client/user-menu.js new file mode 100644 index 0000000..7eeff90 --- /dev/null +++ b/client/user-menu.js @@ -0,0 +1,49 @@ +"use strict"; + +import m from "mithril"; + +import * as store from "./store"; +import * as notifications from "./notifications"; + +const userMenu = { + view: () => { + if (window.username) + return m( + "div.user-menu", + window.username, + m( + "button", + { + onclick: e => { + e.target.disabled = true; + store + .logOut() + .then(() => { + location.hash = ""; + location.reload(); + }) + .catch(err => { + e.target.disabled = false; + notifications.push("error", err.message); + }); + return false; + } + }, + "Log out" + ) + ); + else + return m( + "div.user-menu", + m( + "a", + { + href: "#!/login?" + m.buildQueryString({ continue: m.route.get() }) + }, + "Log in" + ) + ); + } +}; + +export default userMenu; diff --git a/common/authorizer.js b/common/authorizer.js new file mode 100644 index 0000000..b7bae2c --- /dev/null +++ b/common/authorizer.js @@ -0,0 +1,78 @@ +"use strict"; + +const validators = { + pass: require("./validators/pass"), + test: require("./validators/test") +}; + +class Authorizer { + constructor(permissionSets) { + this.permissionSets = permissionSets; + this.validtorCache = new WeakMap(); + this.hasAccessCache = {}; + } + + hasAccess(resourceType, access) { + const cacheKey = `${resourceType}-${access}`; + if (cacheKey in this.hasAccessCache) + return this.hasAccessCache[cacheKey]; + + let has = false; + for (const permissionSet of this.permissionSets) { + for (const perm of permissionSet) { + if (perm[resourceType]) { + if (perm[resourceType].access >= access) { + has = true; + break; + } + } + } + } + + this.hasAccessCache[cacheKey] = has; + return has; + } + + getValidator(resourceType, resource) { + if (this.validtorCache.has(resource)) + return this.validtorCache.get(resource); + + let funcs = {}; + + for (let permissionSet of this.permissionSets) + for (let perm of permissionSet) + if (perm[resourceType]) { + if (perm[resourceType].access >= 3) { + if (perm[resourceType].validate) + for (let [k, v] of Object.entries(perm[resourceType].validate)) + funcs[k] = v; + } else { + funcs = {}; + } + } + + const validator = (mutationType, mutation, any) => { + let valid = false; + for (let [k, v] of Object.entries(funcs)) + if (v) { + let res = validators[k]( + resourceType, + resource, + mutationType, + mutation, + any + ); + + if (res > 0) valid = true; + else if (res < 0) return false; + } + + return valid; + }; + + this.validtorCache.set(resource, validator); + return validator; + } +} + +module.exports = Authorizer; diff --git a/common/filter.js b/common/filter.js index 4a25086..a0ebd62 100644 --- a/common/filter.js +++ b/common/filter.js @@ -17,9 +17,10 @@ function* permute(arr) { class Filter { constructor(str) { - if (Array.isArray(str)) this.ast = str; + if (!str) this.ast = null; + else if (Array.isArray(str)) this.ast = str; else if (str instanceof Filter) this.ast = str.ast; - else if (!str) this.ast = null; + else if (Array.isArray(str.ast) || str.ast === null) this.ast = str.ast; else this.ast = filterParser.parse(str); } @@ -33,6 +34,7 @@ class Filter { or(fltr) { if (!fltr || !this.ast) return this; + else if (!fltr.ast) return fltr; let f = ["OR"]; @@ -46,7 +48,7 @@ class Filter { } and(fltr) { - if (!fltr) return this; + if (!fltr || !fltr.ast) return this; else if (!this.ast) return fltr; let f = ["AND"]; diff --git a/common/validators/pass.js b/common/validators/pass.js new file mode 100644 index 0000000..e01267a --- /dev/null +++ b/common/validators/pass.js @@ -0,0 +1,5 @@ +function pass() { + return 1; +} + +module.exports = pass; diff --git a/common/validators/test.js b/common/validators/test.js new file mode 100644 index 0000000..6795cbc --- /dev/null +++ b/common/validators/test.js @@ -0,0 +1,14 @@ +function test(resourceType, resource) { + if (resourceType === "devices") { + if (resource["Tags.test"] && resource["Tags.test"].value[0]) return 1; + } else if (resourceType === "presets") { + if (resource["_id"].startsWith("test-")) + // TODO check "test" tag too + return 1; + } else if (resourceType === "provisions") { + if (resource["_id"].startsWith("test-")) return 1; + } + return -1; +} + +module.exports = test; diff --git a/config.json b/config.json index b84f8b7..fd29d20 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,17 @@ { "server.port": 3000, "server.nbi": "http://127.0.0.1:7557/", + "server.jwtSecret": "jwtSecret", + "auth.simple.users.admin.password": "admin", + "auth.simple.users.admin.roles": "admin", + "auth.simple.users.view.password": "view", + "auth.simple.users.view.roles": "view", + "permissions.admin.1.devices.access": 3, + "permissions.admin.1.devices.validate.pass": true, + "permissions.admin.1.faults.access": 3, + "permissions.admin.1.faults.validate.pass": true, + "permissions.view.1.devices.access": 2, + "permissions.view.1.faults.access": 2, "ui.pageSize": 10, "ui.filters.1.label": "Serial number", "ui.filters.1.parameter": "DeviceID.SerialNumber", diff --git a/package.json b/package.json index 407203c..345137e 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,18 @@ "description": "", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "lint": "eslint --fix common/*.js server/*.js client/*.js client/components/*.js;prettier --write common/*.js server/*.js client/*.js client/components/*.js client/css/*.css", + "lint": "eslint --fix common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js;prettier --write common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js client/css/*.css", "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o public/app.css", "start": "node server/server" }, "author": "Zaid Abdulla ", "dependencies": { + "jsonwebtoken": "^8.2.1", "koa": "^2.5.0", "koa-body": "^2.5.0", "koa-bodyparser": "^4.2.0", "koa-compress": "^2.0.0", + "koa-jwt": "^3.3.1", "koa-router": "^7.4.0", "koa-static": "^4.0.2", "mithril": "^1.1.6", diff --git a/server/api-functions.js b/server/api-functions.js index 52ff915..8eaf815 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -80,7 +80,7 @@ function filterToQuery(filter, negate = false, res = {}) { function count(resource, filter, limit) { return new Promise((resolve, reject) => { let qs = {}; - if (filter) { + if (filter && filter.ast) { filter = filter.evaluateExpressions(); let ast = unpackTimestamps(filter.ast); @@ -117,7 +117,7 @@ function query(resource, filter, limit, skip, projection, callback) { let ret; if (!callback) ret = []; let qs = {}; - if (filter) { + if (filter && filter.ast) { filter = filter.evaluateExpressions(); let ast = unpackTimestamps(filter.ast); diff --git a/server/api.js b/server/api.js index 043b5fb..c9b78fb 100644 --- a/server/api.js +++ b/server/api.js @@ -18,11 +18,13 @@ const resources = { }; for (let [resource, update] of Object.entries(resources)) { - router.head(`/${resource}`, async ctx => { + router.head(`/${resource}`, async (ctx, next) => { let filter, limit; if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; + if (!ctx.state.authorizer.hasAccess(resource, 1)) return next(); + // Exclude temporary tasks and faults if (resource === "tasks" || resource === "faults") { let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); @@ -34,12 +36,14 @@ for (let [resource, update] of Object.entries(resources)) { ctx.body = ""; }); - router.get(`/${resource}`, async ctx => { + router.get(`/${resource}`, async (ctx, next) => { let filter, limit, skip; if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (ctx.request.query.skip) skip = +ctx.request.query.skip; + if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + // Exclude temporary tasks and faults if (resource === "tasks" || resource === "faults") { let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); @@ -57,6 +61,8 @@ for (let [resource, update] of Object.entries(resources)) { router.head(`/${resource}/:id`, async (ctx, next) => { let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = ""; @@ -64,6 +70,8 @@ for (let [resource, update] of Object.entries(resources)) { router.get(`/${resource}/:id`, async (ctx, next) => { let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!ctx.state.authorizer.hasAccess(resource, 2)) return next(); + let res = await apiFunctions.query(resource, filter); if (!res.length) return next(); ctx.body = res[0]; @@ -71,22 +79,47 @@ for (let [resource, update] of Object.entries(resources)) { // TODO add PUT, PATCH routes if (update) - router.delete(`/${resource}/:id`, async ctx => { - let found = await apiFunctions.deleteResource(resource, ctx.params.id); - if (found) ctx.status = 200; - else ctx.status = 404; + router.delete(`/${resource}/:id`, async (ctx, next) => { + const authorizer = ctx.state.authorizer; + let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!authorizer.hasAccess(resource, 2)) return next(); + let res = await apiFunctions.query(resource, filter); + if (!res.length) return next(); + + const validate = authorizer.getValidator(resource, res[0]); + if (!validate("delete")) return (ctx.status = 403); + + await apiFunctions.deleteResource(resource, ctx.params.id); ctx.body = ""; }); } -router.post("/devices/:id/tasks", async ctx => { +router.post("/devices/:id/tasks", async (ctx, next) => { + const authorizer = ctx.state.authorizer; + let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!authorizer.hasAccess("devices", 2)) return next(); + let devices = await apiFunctions.query("devices", filter); + if (!devices.length) return next(); + + const validate = authorizer.getValidator("devices", devices[0]); + for (let t of ctx.request.body) + if (!validate("task", t)) return (ctx.status = 403); + let res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body); - // TODO 404 if no such device ctx.set("Connection-Request", res.connectionRequest); ctx.body = res.tasks; }); -router.post("/devices/:id/tags", async ctx => { +router.post("/devices/:id/tags", async (ctx, next) => { + const authorizer = ctx.state.authorizer; + let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); + if (!authorizer.hasAccess("devices", 2)) return next(); + let res = await apiFunctions.query("devices", filter); + if (!res.length) return next(); + + const validate = authorizer.getValidator("devices", res[0]); + if (!validate("tags", ctx.request.body)) return (ctx.status = 403); + await apiFunctions.updateTags(ctx.params.id, ctx.request.body); ctx.body = ""; }); diff --git a/server/config.js b/server/config.js index 665c071..c94460a 100644 --- a/server/config.js +++ b/server/config.js @@ -10,6 +10,13 @@ const cache = {}; function parse(name, value) { if (/^ui\.overview\.charts\.[^.]+\.slices\.[^.]+\.filter$/.test(name)) return new Filter(value); + else if (/^permissions\.[^.]+\.[^.]+\.devices\.filter$/.test(name)) + return new Filter(value); + else if (/^auth\.[^.]+\.users\.[^.]+\.roles$/.test(name)) + return value + .split(",") + .map(x => x.trim()) + .sort(); return value; } diff --git a/server/server.js b/server/server.js index a39489f..6785e11 100644 --- a/server/server.js +++ b/server/server.js @@ -5,17 +5,73 @@ const Router = require("koa-router"); const koaStatic = require("koa-static"); const koaCompress = require("koa-compress"); const koaBody = require("koa-body"); +const koaJwt = require("koa-jwt"); +const jwt = require("jsonwebtoken"); const config = require("./config"); const api = require("./api"); +const Authorizer = require("../common/authorizer"); const koa = new Koa(); const router = new Router(); +const JWT_SECRET = config.get("server.jwtSecret"); +const JWT_COOKIE = "genieacs-ui-jwt"; + +function getPermissionSets(roles) { + const allPermissions = config.get("permissions"); + const permissionSets = roles.map(r => Object.values(allPermissions[r] || {})); + return permissionSets; +} + +koa.use( + koaJwt({ + secret: JWT_SECRET, + passthrough: true, + cookie: JWT_COOKIE + }) +); + +koa.use(async (ctx, next) => { + if (ctx.state.user && ctx.state.user.roles) + ctx.state.authorizer = new Authorizer( + getPermissionSets(ctx.state.user.roles) + ); + else ctx.state.authorizer = new Authorizer([]); + + return next(); +}); + +router.post("/login", async ctx => { + const username = ctx.request.body.username; + const password = ctx.request.body.password; + + const user = config.get(`auth.simple.users.${username}`); + + if (!user || user.password !== password) { + ctx.status = 400; + ctx.body = "Incorrect username or password"; + return; + } + + let token = jwt.sign({ username: username, roles: user.roles }, JWT_SECRET); + ctx.cookies.set(JWT_COOKIE, token); + ctx.body = JSON.stringify(token); +}); + +router.post("/logout", async ctx => { + ctx.cookies.set(JWT_COOKIE); // Delete cookie + ctx.body = ""; +}); + koa.use(koaBody()); router.use("/api", api.routes(), api.allowedMethods()); router.get("/", async ctx => { + let permissionSets = []; + if (ctx.state.user && ctx.state.user.roles) + permissionSets = getPermissionSets(ctx.state.user.roles); + ctx.body = ` @@ -26,6 +82,10 @@ router.get("/", async ctx => {