From 66d5408aadd083dd38fe89086794b46d5cfeb0c6 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Mon, 9 Jan 2023 20:20:09 +0000 Subject: [PATCH 02/17] Added DB schema for api keys Signed-off-by: Matthew Nickson --- db/patch-api-key-table.sql | 13 +++++++++++++ server/database.js | 1 + 2 files changed, 14 insertions(+) create mode 100644 db/patch-api-key-table.sql diff --git a/db/patch-api-key-table.sql b/db/patch-api-key-table.sql new file mode 100644 index 00000000..151b6918 --- /dev/null +++ b/db/patch-api-key-table.sql @@ -0,0 +1,13 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; +CREATE TABLE [api_key] ( + [id] INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + [key] VARCHAR(255) NOT NULL, + [name] VARCHAR(255) NOT NULL, + [user_id] INTEGER NOT NULL, + [created_date] DATETIME DEFAULT (DATETIME('now')) NOT NULL, + [active] BOOLEAN DEFAULT 1 NOT NULL, + [expires] DATETIME DEFAULT NULL, + CONSTRAINT FK_user FOREIGN KEY ([user_id]) REFERENCES [user]([id]) ON DELETE CASCADE ON UPDATE CASCADE +); +COMMIT; \ No newline at end of file diff --git a/server/database.js b/server/database.js index 2544f197..acf6f87a 100644 --- a/server/database.js +++ b/server/database.js @@ -66,6 +66,7 @@ class Database { "patch-add-radius-monitor.sql": true, "patch-monitor-add-resend-interval.sql": true, "patch-maintenance-table2.sql": true, + "patch-api-key-table.sql": true, }; /** From 0d6a8b2101449d394f7a9e44b3626d0a8fd37f37 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Tue, 14 Feb 2023 18:23:51 +0000 Subject: [PATCH 03/17] Added more options for confirm modal The ability to set the title of the modal has been added, as well as custom callbacks for the no option. Signed-off-by: Matthew Nickson --- src/components/Confirm.vue | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/Confirm.vue b/src/components/Confirm.vue index 1a1addc6..4bc2217c 100644 --- a/src/components/Confirm.vue +++ b/src/components/Confirm.vue @@ -4,7 +4,7 @@ @@ -44,8 +44,13 @@ export default { type: String, default: "No", }, + /** Title to show on modal. Defaults to translated version of "Config" */ + title: { + type: String, + default: null, + } }, - emits: [ "yes" ], + emits: [ "yes", "no" ], data: () => ({ modal: null, }), @@ -63,6 +68,12 @@ export default { yes() { this.$emit("yes"); }, + /** + * @emits string "no" Notify the parent when No is pressed + */ + no() { + this.$emit("no"); + } }, }; From ee2eb5109b7784a253e8955bfd31e0548dd71d3a Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Tue, 14 Feb 2023 19:49:04 +0000 Subject: [PATCH 04/17] Added basic web interface for API keys Web interfaces for manging API keys have been added however translation keys are still required. Signed-off-by: Matthew Nickson --- db/patch-api-key-table.sql | 2 +- server/client.js | 26 ++ server/model/api_key.js | 75 ++++++ server/server.js | 5 +- .../socket-handlers/api-key-socket-handler.js | 144 ++++++++++ src/icon.js | 2 + src/layouts/Layout.vue | 6 + src/mixins/socket.js | 36 ++- src/pages/AddAPIKey.vue | 198 ++++++++++++++ src/pages/ManageAPIKeys.vue | 255 ++++++++++++++++++ src/pages/Settings.vue | 3 + src/router.js | 10 + 12 files changed, 759 insertions(+), 3 deletions(-) create mode 100644 server/model/api_key.js create mode 100644 server/socket-handlers/api-key-socket-handler.js create mode 100644 src/pages/AddAPIKey.vue create mode 100644 src/pages/ManageAPIKeys.vue diff --git a/db/patch-api-key-table.sql b/db/patch-api-key-table.sql index 151b6918..fc3a405b 100644 --- a/db/patch-api-key-table.sql +++ b/db/patch-api-key-table.sql @@ -10,4 +10,4 @@ CREATE TABLE [api_key] ( [expires] DATETIME DEFAULT NULL, CONSTRAINT FK_user FOREIGN KEY ([user_id]) REFERENCES [user]([id]) ON DELETE CASCADE ON UPDATE CASCADE ); -COMMIT; \ No newline at end of file +COMMIT; diff --git a/server/client.js b/server/client.js index ef96c7f4..3efbe8fd 100644 --- a/server/client.js +++ b/server/client.js @@ -113,6 +113,31 @@ async function sendProxyList(socket) { return list; } +/** + * Emit API key list to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise} + */ +async function sendAPIKeyList(socket) { + const timeLogger = new TimeLogger(); + + let result = []; + const list = await R.find( + "api_key", + "user_id=?", + [ socket.userID ], + ); + + for (let bean of list) { + result.push(bean.toPublicJSON()); + } + + io.to(socket.userID).emit("apiKeyList", result); + timeLogger.print("Sent API Key List"); + + return list; +} + /** * Emits the version information to the client. * @param {Socket} socket Socket.io socket instance @@ -157,6 +182,7 @@ module.exports = { sendImportantHeartbeatList, sendHeartbeatList, sendProxyList, + sendAPIKeyList, sendInfo, sendDockerHostList }; diff --git a/server/model/api_key.js b/server/model/api_key.js new file mode 100644 index 00000000..777519b9 --- /dev/null +++ b/server/model/api_key.js @@ -0,0 +1,75 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); + +class APIKey extends BeanModel { + /** + * Get the current status of this API key + */ + getStatus() { + let expired = false; + if (expired) { + return "expired"; + } else if (this.active) { + return "active"; + } else if (!this.active) { + return "inactive"; + } + } + + /** + * Returns an object that ready to parse to JSON + * @returns {Object} + */ + toJSON() { + return { + id: this.id, + key: this.key, + name: this.name, + userID: this.user_id, + createdDate: this.created_date, + active: this.active, + expires: this.expires, + status: this.getStatus(), + }; + } + + /** + * Returns an object that ready to parse to JSON with sensitive fields + * removed + * @returns {Object} + */ + toPublicJSON() { + return { + id: this.id, + name: this.name, + userID: this.user_id, + createdDate: this.created_date, + active: this.active, + expires: this.expires, + status: this.getStatus(), + }; + } + + /** + * Create a new API Key and store it in the database + * @param {Object} key Object sent by client + * @param {int} userID ID of socket user + * @returns {Promise} + */ + static async save(key, userID) { + let bean; + bean = R.dispense("api_key"); + + bean.key = key.key; + bean.name = key.name; + bean.user_id = userID; + bean.active = key.active; + bean.expires = key.expires; + + await R.store(bean); + + return bean; + } +} + +module.exports = APIKey; diff --git a/server/server.js b/server/server.js index 5473cecd..a13fe466 100644 --- a/server/server.js +++ b/server/server.js @@ -126,7 +126,7 @@ if (config.demoMode) { } // Must be after io instantiation -const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sendInfo, sendProxyList, sendDockerHostList } = require("./client"); +const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList } = require("./client"); const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); const TwoFA = require("./2fa"); @@ -135,6 +135,7 @@ const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudfl const { proxySocketHandler } = require("./socket-handlers/proxy-socket-handler"); const { dockerSocketHandler } = require("./socket-handlers/docker-socket-handler"); const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-socket-handler"); +const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handler"); const { generalSocketHandler } = require("./socket-handlers/general-socket-handler"); const { Settings } = require("./settings"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); @@ -1490,6 +1491,7 @@ let needSetup = false; proxySocketHandler(socket); dockerSocketHandler(socket); maintenanceSocketHandler(socket); + apiKeySocketHandler(socket); generalSocketHandler(socket, server); log.debug("server", "added all socket handlers"); @@ -1597,6 +1599,7 @@ async function afterLogin(socket, user) { sendNotificationList(socket); sendProxyList(socket); sendDockerHostList(socket); + sendAPIKeyList(socket); await sleep(500); diff --git a/server/socket-handlers/api-key-socket-handler.js b/server/socket-handlers/api-key-socket-handler.js new file mode 100644 index 00000000..a80dca83 --- /dev/null +++ b/server/socket-handlers/api-key-socket-handler.js @@ -0,0 +1,144 @@ +const { checkLogin } = require("../util-server"); +const { log } = require("../../src/util"); +const { R } = require("redbean-node"); +const crypto = require("crypto"); +const passwordHash = require("../password-hash"); +const apicache = require("../modules/apicache"); +const APIKey = require("../model/api_key"); +const { sendAPIKeyList } = require("../client"); + +/** + * Handlers for Maintenance + * @param {Socket} socket Socket.io instance + */ +module.exports.apiKeySocketHandler = (socket) => { + // Add a new api key + socket.on("addAPIKey", async (key, callback) => { + try { + checkLogin(socket); + let clearKey = crypto.randomUUID(); + let hashedKey = passwordHash.generate(clearKey); + key["key"] = hashedKey; + let bean = await APIKey.save(key, socket.userID); + + log.debug("apikeys", "Added API Key"); + log.debug("apikeys", key); + + // Append key ID to start of key seperated by -, used to get + // correct hash when validating key. + let formattedKey = bean.id + "-" + clearKey; + await sendAPIKeyList(socket); + + callback({ + ok: true, + msg: "Added Successfully.", + key: formattedKey, + keyID: bean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getAPIKeyList", async (callback) => { + try { + checkLogin(socket); + await sendAPIKeyList(socket); + callback({ + ok: true, + }); + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteAPIKey", async (keyID, callback) => { + try { + checkLogin(socket); + + log.debug("apikeys", `Deleted API Key: ${keyID} User ID: ${socket.userID}`); + + await R.exec("DELETE FROM api_key WHERE id = ? AND user_id = ? ", [ + keyID, + socket.userID, + ]); + + apicache.clear(); + + callback({ + ok: true, + msg: "Deleted Successfully.", + }); + + await sendAPIKeyList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("disableAPIKey", async (keyID, callback) => { + try { + checkLogin(socket); + + log.debug("apikeys", `Disabled Key: ${keyID} User ID: ${socket.userID}`); + + await R.exec("UPDATE api_key SET active = 0 WHERE id = ? ", [ + keyID, + ]); + + apicache.clear(); + + callback({ + ok: true, + msg: "Disabled Successfully.", + }); + + await sendAPIKeyList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("enableAPIKey", async (keyID, callback) => { + try { + checkLogin(socket); + + log.debug("apikeys", `Enabled Key: ${keyID} User ID: ${socket.userID}`); + + await R.exec("UPDATE api_key SET active = 1 WHERE id = ? ", [ + keyID, + ]); + + apicache.clear(); + + callback({ + ok: true, + msg: "Enabled Successfully", + }); + + await sendAPIKeyList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/src/icon.js b/src/icon.js index b38bef3c..fd2d1b7f 100644 --- a/src/icon.js +++ b/src/icon.js @@ -44,6 +44,7 @@ import { faWrench, faHeartbeat, faFilter, + faKey, } from "@fortawesome/free-solid-svg-icons"; library.add( @@ -88,6 +89,7 @@ library.add( faWrench, faHeartbeat, faFilter, + faKey, ); export { FontAwesomeIcon }; diff --git a/src/layouts/Layout.vue b/src/layouts/Layout.vue index d8e96aa8..dfc540fa 100644 --- a/src/layouts/Layout.vue +++ b/src/layouts/Layout.vue @@ -57,6 +57,12 @@ +
  • + + {{ $t("API Keys") }} + +
  • +
  • {{ $t("Settings") }} diff --git a/src/mixins/socket.js b/src/mixins/socket.js index 378af06a..114fd647 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -34,7 +34,8 @@ export default { allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed. loggedIn: false, monitorList: { }, - maintenanceList: { }, + maintenanceList: {}, + apiKeyList: {}, heartbeatList: { }, importantHeartbeatList: { }, avgPingList: { }, @@ -134,6 +135,10 @@ export default { this.maintenanceList = data; }); + socket.on("apiKeyList", (data) => { + this.apiKeyList = data; + }); + socket.on("notificationList", (data) => { this.notificationList = data; }); @@ -461,6 +466,17 @@ export default { socket.emit("getMaintenanceList", callback); }, + /** + * Send list of API keys + * @param {socketCB} callback + */ + getAPIKeyList(callback) { + if (!callback) { + callback = () => { }; + } + socket.emit("getAPIKeyList", callback); + }, + /** * Add a monitor * @param {Object} monitor Object representing monitor to add @@ -503,6 +519,24 @@ export default { socket.emit("deleteMaintenance", maintenanceID, callback); }, + /** + * Add an API key + * @param {Object} key API key to add + * @param {socketCB} callback + */ + addAPIKey(key, callback) { + socket.emit("addAPIKey", key, callback); + }, + + /** + * Delete specified API key + * @param {int} keyID ID of key to delete + * @param {socketCB} callback + */ + deleteAPIKey(keyID, callback) { + socket.emit("deleteAPIKey", keyID, callback); + }, + /** Clear the hearbeat list */ clearData() { console.log("reset heartbeat list"); diff --git a/src/pages/AddAPIKey.vue b/src/pages/AddAPIKey.vue new file mode 100644 index 00000000..9633f007 --- /dev/null +++ b/src/pages/AddAPIKey.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/pages/ManageAPIKeys.vue b/src/pages/ManageAPIKeys.vue new file mode 100644 index 00000000..9203e276 --- /dev/null +++ b/src/pages/ManageAPIKeys.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index 87404968..9251085d 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -7,6 +7,9 @@ {{ $t("Maintenance") }} + + {{ $t("API Keys") }} +

    diff --git a/src/router.js b/src/router.js index 38048826..50b394c9 100644 --- a/src/router.js +++ b/src/router.js @@ -18,6 +18,8 @@ import NotFound from "./pages/NotFound.vue"; import DockerHosts from "./components/settings/Docker.vue"; import MaintenanceDetails from "./pages/MaintenanceDetails.vue"; import ManageMaintenance from "./pages/ManageMaintenance.vue"; +import ManageAPIKeys from "./pages/ManageAPIKeys.vue"; +import AddAPIKey from "./pages/AddAPIKey.vue"; // Settings - Sub Pages import Appearance from "./components/settings/Appearance.vue"; @@ -145,6 +147,14 @@ const routes = [ path: "/maintenance/edit/:id", component: EditMaintenance, }, + { + path: "/apikeys", + component: ManageAPIKeys + }, + { + path: "/apikeys/add", + component: AddAPIKey + }, ], }, ], From 05443f9bb7972970a8c3aa1c23d0ce36f50bf11f Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Tue, 14 Feb 2023 22:16:41 +0000 Subject: [PATCH 05/17] Added language keys Signed-off-by: Matthew Nickson --- src/lang/en.json | 16 +++++++++++++++- src/pages/AddAPIKey.vue | 2 +- src/pages/ManageAPIKeys.vue | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lang/en.json b/src/lang/en.json index 15edee93..15b77cb4 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -695,5 +695,19 @@ "Google Analytics ID": "Google Analytics ID", "Edit Tag": "Edit Tag", "Server Address": "Server Address", - "Learn More": "Learn More" + "Learn More": "Learn More", + "API Keys": "API Keys", + "Expiry": "Expiry", + "Expiry date": "Expiry date", + "Don't expire": "Don't expire", + "Continue": "Continue", + "Add Another": "Add Another", + "Key Added": "Key Added", + "apiKeyAddedMsg": "Your API key has been added. Please make a note of it as it will not be shown again.", + "Add API Key": "Add API Key", + "No API Keys": "No API Keys", + "apiKey-active": "Active", + "apiKey-expired": "Expired", + "apiKey-inactive": "Inactive", + "Expires": "Expires" } diff --git a/src/pages/AddAPIKey.vue b/src/pages/AddAPIKey.vue index 9633f007..a2e4434e 100644 --- a/src/pages/AddAPIKey.vue +++ b/src/pages/AddAPIKey.vue @@ -8,7 +8,7 @@
    - + From cd796898d037babde1ea062d72a6054d0eb518c3 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Tue, 14 Feb 2023 22:41:06 +0000 Subject: [PATCH 06/17] Added expiry check for frontend Signed-off-by: Matthew Nickson --- server/model/api_key.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/model/api_key.js b/server/model/api_key.js index 777519b9..4f786cd2 100644 --- a/server/model/api_key.js +++ b/server/model/api_key.js @@ -1,19 +1,19 @@ const { BeanModel } = require("redbean-node/dist/bean-model"); const { R } = require("redbean-node"); +const dayjs = require("dayjs"); class APIKey extends BeanModel { /** * Get the current status of this API key */ getStatus() { - let expired = false; - if (expired) { + let current = dayjs(); + let expiry = dayjs(this.expires); + if (expiry.diff(current) < 0) { return "expired"; - } else if (this.active) { - return "active"; - } else if (!this.active) { - return "inactive"; } + + return this.active ? "active" : "inactive"; } /** From e7feca1cd661b2215e4c08aadd7985f988b145b8 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Wed, 15 Feb 2023 00:39:29 +0000 Subject: [PATCH 07/17] Added API key authentication handler API key authentication is now possible by making use of the X-API-Key header. API authentication will only be enabled when a user adds their first API key, up until this point, they can still use their username and password to authenticate with API endpoints. After the user adds their first API key, they may only use API keys in future to authenticate with the API. In this commit, the prometheus /metrics endpoint has been changed over to the new authentication system. Signed-off-by: Matthew Nickson --- server/auth.js | 61 +++++++++++++++++++ server/server.js | 4 +- .../socket-handlers/api-key-socket-handler.js | 5 ++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/server/auth.js b/server/auth.js index fd19b0e4..f2c59d66 100644 --- a/server/auth.js +++ b/server/auth.js @@ -3,6 +3,8 @@ const passwordHash = require("./password-hash"); const { R } = require("redbean-node"); const { setting } = require("./util-server"); const { loginRateLimiter } = require("./rate-limiter"); +const { Settings } = require("./settings"); +const dayjs = require("dayjs"); /** * Login to web app @@ -33,6 +35,32 @@ exports.login = async function (username, password) { return null; }; +/** + * Validate a provided API key + * @param {string} key API Key passed by client + * @returns {Promise} + */ +async function validateAPIKey(key) { + if (typeof key !== "string") { + return false; + } + + let index = key.substring(0, key.indexOf("-")); + let clear = key.substring(key.indexOf("-") + 1, key.length); + console.log(index); + console.log(clear); + + let hash = await R.findOne("api_key", " id=? ", [ index ]); + + let current = dayjs(); + let expiry = dayjs(hash.expires); + if (expiry.diff(current) < 0, !hash.active) { + return false; + } + + return hash && passwordHash.verify(clear, hash.key); +} + /** * Callback for myAuthorizer * @callback myAuthorizerCB @@ -84,3 +112,36 @@ exports.basicAuth = async function (req, res, next) { next(); } }; + +/** + * Use X-API-Key header if API keys enabled, else use basic auth + * @param {express.Request} req Express request object + * @param {express.Response} res Express response object + * @param {express.NextFunction} next + */ +exports.apiAuth = async function (req, res, next) { + if (!await Settings.get("disableAuth")) { + let usingAPIKeys = await Settings.get("apiKeysEnabled"); + + loginRateLimiter.pass(null, 0).then((pass) => { + if (usingAPIKeys) { + let pwd = req.get("X-API-Key"); + if (pwd !== null && pwd !== undefined) { + validateAPIKey(pwd).then((valid) => { + if (valid) { + next(); + } else { + res.status(401).send(); + } + }); + } else { + res.status(401).send(); + } + } else { + exports.basicAuth(req, res, next); + } + }); + } else { + next(); + } +}; diff --git a/server/server.js b/server/server.js index 183a5bb3..8696aa70 100644 --- a/server/server.js +++ b/server/server.js @@ -87,7 +87,7 @@ log.debug("server", "Importing Background Jobs"); const { initBackgroundJobs, stopBackgroundJobs } = require("./jobs"); const { loginRateLimiter, twoFaRateLimiter } = require("./rate-limiter"); -const { basicAuth } = require("./auth"); +const { apiAuth } = require("./auth"); const { login } = require("./auth"); const passwordHash = require("./password-hash"); @@ -230,7 +230,7 @@ let needSetup = false; // Prometheus API metrics /metrics // With Basic Auth using the first user's username/password - app.get("/metrics", basicAuth, prometheusAPIMetrics()); + app.get("/metrics", apiAuth, prometheusAPIMetrics()); app.use("/", expressStaticGzip("dist", { enableBrotli: true, diff --git a/server/socket-handlers/api-key-socket-handler.js b/server/socket-handlers/api-key-socket-handler.js index a80dca83..cf124cad 100644 --- a/server/socket-handlers/api-key-socket-handler.js +++ b/server/socket-handlers/api-key-socket-handler.js @@ -5,6 +5,7 @@ const crypto = require("crypto"); const passwordHash = require("../password-hash"); const apicache = require("../modules/apicache"); const APIKey = require("../model/api_key"); +const { Settings } = require("../settings"); const { sendAPIKeyList } = require("../client"); /** @@ -29,6 +30,10 @@ module.exports.apiKeySocketHandler = (socket) => { let formattedKey = bean.id + "-" + clearKey; await sendAPIKeyList(socket); + // Enable API auth if the user creates a key, otherwise only basic + // auth will be used for API. + await Settings.set("apiKeysEnabled", true); + callback({ ok: true, msg: "Added Successfully.", From d553c4c4f75801c53c93236cd253dd9415c03cac Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Wed, 15 Feb 2023 00:53:42 +0000 Subject: [PATCH 08/17] Added missing translation keys Signed-off-by: Matthew Nickson --- src/lang/en.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lang/en.json b/src/lang/en.json index 15b77cb4..2dc487cb 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -709,5 +709,7 @@ "apiKey-active": "Active", "apiKey-expired": "Expired", "apiKey-inactive": "Inactive", - "Expires": "Expires" + "Expires": "Expires", + "disableAPIKeyMsg": "Are you sure you want to disable this API key?", + "deleteAPIKeyMsg": "Are you sure you want to delete this API key?" } From 01c71a0242a87e4957ee1c793be5c01c60f1f23d Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Wed, 15 Feb 2023 11:15:15 +0000 Subject: [PATCH 09/17] Fixed logic errors, removed dev leftovers Fixed a logic error where a comma was used instead of an or, also removed leftover console.logs from testing. Date picker is now dissabled when don't expire is checked. Signed-off-by: Matthew Nickson --- server/auth.js | 8 +++++--- src/lang/en.json | 3 ++- src/pages/AddAPIKey.vue | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/server/auth.js b/server/auth.js index f2c59d66..99084d16 100644 --- a/server/auth.js +++ b/server/auth.js @@ -47,14 +47,16 @@ async function validateAPIKey(key) { let index = key.substring(0, key.indexOf("-")); let clear = key.substring(key.indexOf("-") + 1, key.length); - console.log(index); - console.log(clear); let hash = await R.findOne("api_key", " id=? ", [ index ]); + if (hash === null) { + return false; + } + let current = dayjs(); let expiry = dayjs(hash.expires); - if (expiry.diff(current) < 0, !hash.active) { + if (expiry.diff(current) < 0 || !hash.active) { return false; } diff --git a/src/lang/en.json b/src/lang/en.json index 2dc487cb..007d8072 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -711,5 +711,6 @@ "apiKey-inactive": "Inactive", "Expires": "Expires", "disableAPIKeyMsg": "Are you sure you want to disable this API key?", - "deleteAPIKeyMsg": "Are you sure you want to delete this API key?" + "deleteAPIKeyMsg": "Are you sure you want to delete this API key?", + "Generate": "Generate" } diff --git a/src/pages/AddAPIKey.vue b/src/pages/AddAPIKey.vue index a2e4434e..e6b60233 100644 --- a/src/pages/AddAPIKey.vue +++ b/src/pages/AddAPIKey.vue @@ -28,6 +28,7 @@ format="yyyy-MM-dd HH:mm" modelType="yyyy-MM-dd HH:mm:ss" :required="!noExpire" + :disabled="noExpire" />
    @@ -46,7 +47,7 @@ id="monitor-submit-btn" class="btn btn-primary" type="submit" :disabled="processing" > - {{ $t("Save") }} + {{ $t("Generate") }}
    From 1d4af39820540371094a9fe2c9a7a857a7632eb1 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Wed, 15 Feb 2023 19:31:22 +0000 Subject: [PATCH 10/17] Fixed JSDoc for one method Signed-off-by: Matthew Nickson --- server/model/api_key.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/model/api_key.js b/server/model/api_key.js index 4f786cd2..1b27a60f 100644 --- a/server/model/api_key.js +++ b/server/model/api_key.js @@ -5,6 +5,7 @@ const dayjs = require("dayjs"); class APIKey extends BeanModel { /** * Get the current status of this API key + * @returns {string} active, inactive or expired */ getStatus() { let current = dayjs(); From b8720b46c3d0e0332e85a00a177a1001423a6f40 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Wed, 15 Feb 2023 21:53:49 +0000 Subject: [PATCH 11/17] Switched to using Authorization header Prometheus doesn't support using custom headers for exporters, however it does support using the Authorisation header with basic auth. As such, we switched from using X-API-Key to Authorization with the basic scheme and an empty username field. Also added a rate limit for API endpoints of 60 requests in a minute Signed-off-by: Matthew Nickson --- server/auth.js | 73 ++++++++++++++++++++++++++---------------- server/rate-limiter.js | 8 +++++ 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/server/auth.js b/server/auth.js index 99084d16..eddae4c3 100644 --- a/server/auth.js +++ b/server/auth.js @@ -2,7 +2,7 @@ const basicAuth = require("express-basic-auth"); const passwordHash = require("./password-hash"); const { R } = require("redbean-node"); const { setting } = require("./util-server"); -const { loginRateLimiter } = require("./rate-limiter"); +const { loginRateLimiter, apiRateLimiter } = require("./rate-limiter"); const { Settings } = require("./settings"); const dayjs = require("dayjs"); @@ -37,10 +37,9 @@ exports.login = async function (username, password) { /** * Validate a provided API key - * @param {string} key API Key passed by client - * @returns {Promise} + * @param {string} key API key to verify */ -async function validateAPIKey(key) { +async function verifyAPIKey(key) { if (typeof key !== "string") { return false; } @@ -64,8 +63,8 @@ async function validateAPIKey(key) { } /** - * Callback for myAuthorizer - * @callback myAuthorizerCB + * Callback for basic auth authorizers + * @callback authCallback * @param {any} err Any error encountered * @param {boolean} authorized Is the client authorized? */ @@ -74,9 +73,31 @@ async function validateAPIKey(key) { * Custom authorizer for express-basic-auth * @param {string} username * @param {string} password - * @param {myAuthorizerCB} callback + * @param {authCallback} callback */ -function myAuthorizer(username, password, callback) { +function apiAuthorizer(username, password, callback) { + // API Rate Limit + apiRateLimiter.pass(null, 0).then((pass) => { + if (pass) { + verifyAPIKey(password).then((valid) => { + callback(null, valid); + // Only allow a set number of api requests per minute + // (currently set to 60) + apiRateLimiter.removeTokens(1); + }); + } else { + callback(null, false); + } + }); +} + +/** + * Custom authorizer for express-basic-auth + * @param {string} username + * @param {string} password + * @param {authCallback} callback + */ +function userAuthorizer(username, password, callback) { // Login Rate Limit loginRateLimiter.pass(null, 0).then((pass) => { if (pass) { @@ -101,7 +122,7 @@ function myAuthorizer(username, password, callback) { */ exports.basicAuth = async function (req, res, next) { const middleware = basicAuth({ - authorizer: myAuthorizer, + authorizer: userAuthorizer, authorizeAsync: true, challenge: true, }); @@ -124,25 +145,21 @@ exports.basicAuth = async function (req, res, next) { exports.apiAuth = async function (req, res, next) { if (!await Settings.get("disableAuth")) { let usingAPIKeys = await Settings.get("apiKeysEnabled"); - - loginRateLimiter.pass(null, 0).then((pass) => { - if (usingAPIKeys) { - let pwd = req.get("X-API-Key"); - if (pwd !== null && pwd !== undefined) { - validateAPIKey(pwd).then((valid) => { - if (valid) { - next(); - } else { - res.status(401).send(); - } - }); - } else { - res.status(401).send(); - } - } else { - exports.basicAuth(req, res, next); - } - }); + let middleware; + if (usingAPIKeys) { + middleware = basicAuth({ + authorizer: apiAuthorizer, + authorizeAsync: true, + challenge: true, + }); + } else { + middleware = basicAuth({ + authorizer: userAuthorizer, + authorizeAsync: true, + challenge: true, + }); + } + middleware(req, res, next); } else { next(); } diff --git a/server/rate-limiter.js b/server/rate-limiter.js index 6f185beb..ec77f1a4 100644 --- a/server/rate-limiter.js +++ b/server/rate-limiter.js @@ -54,6 +54,13 @@ const loginRateLimiter = new KumaRateLimiter({ errorMessage: "Too frequently, try again later." }); +const apiRateLimiter = new KumaRateLimiter({ + tokensPerInterval: 60, + interval: "minute", + fireImmediately: true, + errorMessage: "Too frequently, try again later." +}); + const twoFaRateLimiter = new KumaRateLimiter({ tokensPerInterval: 30, interval: "minute", @@ -63,5 +70,6 @@ const twoFaRateLimiter = new KumaRateLimiter({ module.exports = { loginRateLimiter, + apiRateLimiter, twoFaRateLimiter, }; From 46894793fc260158779e7002d7bd79eeb848903b Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Sat, 25 Feb 2023 00:44:12 +0800 Subject: [PATCH 12/17] Update Learn More url --- src/pages/ManageAPIKeys.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ManageAPIKeys.vue b/src/pages/ManageAPIKeys.vue index c4050592..ccfc0ce6 100644 --- a/src/pages/ManageAPIKeys.vue +++ b/src/pages/ManageAPIKeys.vue @@ -59,7 +59,7 @@
    From 42a69c16ca42de0b34688b95e1f2e0188ad2811b Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Sun, 26 Feb 2023 16:47:34 +0000 Subject: [PATCH 13/17] Switched to crypto.randomBytes fpr key generation Keys are now 32 bytes long encoded in a URL safe base64 string Signed-off-by: Matthew Nickson --- server/auth.js | 7 ++++--- server/socket-handlers/api-key-socket-handler.js | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/server/auth.js b/server/auth.js index eddae4c3..c42a74c4 100644 --- a/server/auth.js +++ b/server/auth.js @@ -44,8 +44,9 @@ async function verifyAPIKey(key) { return false; } - let index = key.substring(0, key.indexOf("-")); - let clear = key.substring(key.indexOf("-") + 1, key.length); + // uk prefix + key ID is before _ + let index = key.substring(2, key.indexOf("_")); + let clear = key.substring(key.indexOf("_") + 1, key.length); let hash = await R.findOne("api_key", " id=? ", [ index ]); @@ -137,7 +138,7 @@ exports.basicAuth = async function (req, res, next) { }; /** - * Use X-API-Key header if API keys enabled, else use basic auth + * Use use API Key if API keys enabled, else use basic auth * @param {express.Request} req Express request object * @param {express.Response} res Express response object * @param {express.NextFunction} next diff --git a/server/socket-handlers/api-key-socket-handler.js b/server/socket-handlers/api-key-socket-handler.js index cf124cad..546226f6 100644 --- a/server/socket-handlers/api-key-socket-handler.js +++ b/server/socket-handlers/api-key-socket-handler.js @@ -17,7 +17,7 @@ module.exports.apiKeySocketHandler = (socket) => { socket.on("addAPIKey", async (key, callback) => { try { checkLogin(socket); - let clearKey = crypto.randomUUID(); + let clearKey = crypto.randomBytes(32).toString("base64url"); let hashedKey = passwordHash.generate(clearKey); key["key"] = hashedKey; let bean = await APIKey.save(key, socket.userID); @@ -25,9 +25,9 @@ module.exports.apiKeySocketHandler = (socket) => { log.debug("apikeys", "Added API Key"); log.debug("apikeys", key); - // Append key ID to start of key seperated by -, used to get + // Append key ID and prefix to start of key seperated by _, used to get // correct hash when validating key. - let formattedKey = bean.id + "-" + clearKey; + let formattedKey = "uk" + bean.id + "_" + clearKey; await sendAPIKeyList(socket); // Enable API auth if the user creates a key, otherwise only basic From 11fa690e0915b9be166b861cf704c76db864e59d Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Sun, 26 Feb 2023 18:07:57 +0000 Subject: [PATCH 14/17] Updated API Keys UI The UI has now been moved to the settings page. Signed-off-by: Matthew Nickson --- src/components/APIKeyDialog.vue | 214 ++++++++++++++++++ .../settings/APIKeys.vue} | 127 +++++------ src/icon.js | 2 - src/layouts/Layout.vue | 6 - src/pages/AddAPIKey.vue | 199 ---------------- src/pages/Settings.vue | 6 +- src/router.js | 15 +- 7 files changed, 286 insertions(+), 283 deletions(-) create mode 100644 src/components/APIKeyDialog.vue rename src/{pages/ManageAPIKeys.vue => components/settings/APIKeys.vue} (56%) delete mode 100644 src/pages/AddAPIKey.vue diff --git a/src/components/APIKeyDialog.vue b/src/components/APIKeyDialog.vue new file mode 100644 index 00000000..106ad8c7 --- /dev/null +++ b/src/components/APIKeyDialog.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/src/pages/ManageAPIKeys.vue b/src/components/settings/APIKeys.vue similarity index 56% rename from src/pages/ManageAPIKeys.vue rename to src/components/settings/APIKeys.vue index ccfc0ce6..3ecd53b7 100644 --- a/src/pages/ManageAPIKeys.vue +++ b/src/components/settings/APIKeys.vue @@ -1,85 +1,83 @@ diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index a076a4d3..d3c153df 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -7,9 +7,6 @@ {{ $t("Maintenance") }} - - {{ $t("API Keys") }} -

    @@ -110,6 +107,9 @@ export default { security: { title: this.$t("Security"), }, + "api-keys": { + title: this.$t("API Keys") + }, proxies: { title: this.$t("Proxies"), }, diff --git a/src/router.js b/src/router.js index af86356e..b9493f09 100644 --- a/src/router.js +++ b/src/router.js @@ -18,8 +18,7 @@ import NotFound from "./pages/NotFound.vue"; import DockerHosts from "./components/settings/Docker.vue"; import MaintenanceDetails from "./pages/MaintenanceDetails.vue"; import ManageMaintenance from "./pages/ManageMaintenance.vue"; -import ManageAPIKeys from "./pages/ManageAPIKeys.vue"; -import AddAPIKey from "./pages/AddAPIKey.vue"; +import APIKeys from "./components/settings/APIKeys.vue"; import Plugins from "./components/settings/Plugins.vue"; // Settings - Sub Pages @@ -115,6 +114,10 @@ const routes = [ path: "security", component: Security, }, + { + path: "api-keys", + component: APIKeys, + }, { path: "proxies", component: Proxies, @@ -157,14 +160,6 @@ const routes = [ path: "/maintenance/edit/:id", component: EditMaintenance, }, - { - path: "/apikeys", - component: ManageAPIKeys - }, - { - path: "/apikeys/add", - component: AddAPIKey - }, ], }, ], From 669f8700b236cc7727b6485d5f6126ee6f88e233 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Sun, 26 Feb 2023 19:36:50 +0000 Subject: [PATCH 15/17] Switched to nanoid for key generation To try and prevent any security issues, use an external package to generate key instead of doing it ourselves. Note: we have to use nanoid version 3 as nanoid version 4 requires ESM. Currently, nanoid v3 is still supported. Signed-off-by: Matthew Nickson --- package-lock.json | 5 ++--- package.json | 1 + server/socket-handlers/api-key-socket-handler.js | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e57a932..328043b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "mqtt": "~4.3.7", "mssql": "~8.1.4", "mysql2": "~2.3.3", + "nanoid": "^3.3.4", "node-cloudflared-tunnel": "~1.0.9", "node-radius-client": "~1.0.0", "nodemailer": "~6.6.5", @@ -14247,7 +14248,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", - "dev": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -29825,8 +29825,7 @@ "nanoid": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", - "dev": true + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" }, "native-duplexpair": { "version": "1.0.0", diff --git a/package.json b/package.json index a3f6066b..7b5facab 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "mqtt": "~4.3.7", "mssql": "~8.1.4", "mysql2": "~2.3.3", + "nanoid": "^3.3.4", "node-cloudflared-tunnel": "~1.0.9", "node-radius-client": "~1.0.0", "nodemailer": "~6.6.5", diff --git a/server/socket-handlers/api-key-socket-handler.js b/server/socket-handlers/api-key-socket-handler.js index 546226f6..69b0b60d 100644 --- a/server/socket-handlers/api-key-socket-handler.js +++ b/server/socket-handlers/api-key-socket-handler.js @@ -1,7 +1,7 @@ const { checkLogin } = require("../util-server"); const { log } = require("../../src/util"); const { R } = require("redbean-node"); -const crypto = require("crypto"); +const { nanoid } = require("nanoid"); const passwordHash = require("../password-hash"); const apicache = require("../modules/apicache"); const APIKey = require("../model/api_key"); @@ -17,7 +17,8 @@ module.exports.apiKeySocketHandler = (socket) => { socket.on("addAPIKey", async (key, callback) => { try { checkLogin(socket); - let clearKey = crypto.randomBytes(32).toString("base64url"); + + let clearKey = nanoid(40); let hashedKey = passwordHash.generate(clearKey); key["key"] = hashedKey; let bean = await APIKey.save(key, socket.userID); From 97e276bdb5ae959bb0657e8961c14f86a5a51427 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Mon, 27 Feb 2023 18:19:56 +0000 Subject: [PATCH 16/17] Fixed processing error with add API key Also added padding below add button Signed-off-by: Matthew Nickson --- src/components/APIKeyDialog.vue | 2 +- src/components/settings/APIKeys.vue | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/APIKeyDialog.vue b/src/components/APIKeyDialog.vue index 106ad8c7..7a4c2464 100644 --- a/src/components/APIKeyDialog.vue +++ b/src/components/APIKeyDialog.vue @@ -144,6 +144,7 @@ export default { this.$root.addAPIKey(this.key, async (res) => { this.keyaddmodal.hide(); + this.processing = false; if (res.ok) { this.clearKey = res.key; this.keymodal.show(); @@ -151,7 +152,6 @@ export default { } else { toast.error(res.msg); } - this.processing = false; }); }, } diff --git a/src/components/settings/APIKeys.vue b/src/components/settings/APIKeys.vue index 3ecd53b7..75778993 100644 --- a/src/components/settings/APIKeys.vue +++ b/src/components/settings/APIKeys.vue @@ -157,6 +157,7 @@ export default { .add-btn { padding-top: 20px; + padding-bottom: 20px; } .item { From 7e178d93dfcd15f781c0c46b4368e057d160abf9 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Mon, 27 Feb 2023 18:44:32 +0000 Subject: [PATCH 17/17] Moved location of disable expiry checkbox Co-authored-by: Nelson Chan Signed-off-by: Matthew Nickson --- src/components/APIKeyDialog.vue | 43 ++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/components/APIKeyDialog.vue b/src/components/APIKeyDialog.vue index 7a4c2464..745efd4a 100644 --- a/src/components/APIKeyDialog.vue +++ b/src/components/APIKeyDialog.vue @@ -22,25 +22,30 @@
    - - -
    - - +
    +
    + +
    +
    +
    + + +
    +