From fe0317054095184c04a5d3b8f262a801588d3fc9 Mon Sep 17 00:00:00 2001 From: Matthew Nickson Date: Fri, 6 Jan 2023 20:41:25 +0000 Subject: [PATCH 01/17] [empty commit] pull request for #1685 Add API key for metrics page 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 000000000..151b6918d --- /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 2544f1972..acf6f87a4 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 1a1addc6e..4bc2217cb 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 151b6918d..fc3a405bf 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 ef96c7f44..3efbe8fdc 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 000000000..777519b9b --- /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 5473cecd4..a13fe4666 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 000000000..a80dca830 --- /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 b38bef3ce..fd2d1b7f4 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 d8e96aa82..dfc540fa6 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 378af06a5..114fd6473 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 000000000..9633f0073 --- /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 000000000..9203e276e --- /dev/null +++ b/src/pages/ManageAPIKeys.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index 874049682..9251085d8 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 380488264..50b394c90 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 15edee932..15b77cb4c 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 9633f0073..a2e4434e5 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 777519b9b..4f786cd29 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 fd19b0e44..f2c59d66f 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 183a5bb38..8696aa709 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 a80dca830..cf124cad3 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 15b77cb4c..2dc487cb4 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 f2c59d66f..99084d164 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 2dc487cb4..007d8072e 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 a2e4434e5..e6b602335 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 4f786cd29..1b27a60f6 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 99084d164..eddae4c3c 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 6f185beb9..ec77f1a4e 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 c40505924..ccfc0ce6d 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 eddae4c3c..c42a74c40 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 cf124cad3..546226f69 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} | 135 +++++------ 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, 290 insertions(+), 287 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 000000000..106ad8c74 --- /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 ccfc0ce6d..3ecd53b7f 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 a076a4d36..d3c153df1 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 af86356ed..b9493f098 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 5e57a9325..328043b6f 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 a3f6066b9..7b5facab2 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 546226f69..69b0b60de 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 106ad8c74..7a4c2464c 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 3ecd53b7f..757789937 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 7a4c2464c..745efd4ab 100644 --- a/src/components/APIKeyDialog.vue +++ b/src/components/APIKeyDialog.vue @@ -22,25 +22,30 @@
    - - -
    - - +
    +
    + +
    +
    +
    + + +
    +