diff --git a/db/patch-api-key-table.sql b/db/patch-api-key-table.sql new file mode 100644 index 00000000..fc3a405b --- /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; 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/auth.js b/server/auth.js index fd19b0e4..c42a74c4 100644 --- a/server/auth.js +++ b/server/auth.js @@ -2,7 +2,9 @@ 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"); /** * Login to web app @@ -34,8 +36,36 @@ exports.login = async function (username, password) { }; /** - * Callback for myAuthorizer - * @callback myAuthorizerCB + * Validate a provided API key + * @param {string} key API key to verify + */ +async function verifyAPIKey(key) { + if (typeof key !== "string") { + return false; + } + + // 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 ]); + + if (hash === null) { + return false; + } + + 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 basic auth authorizers + * @callback authCallback * @param {any} err Any error encountered * @param {boolean} authorized Is the client authorized? */ @@ -44,9 +74,31 @@ exports.login = async function (username, password) { * Custom authorizer for express-basic-auth * @param {string} username * @param {string} password - * @param {myAuthorizerCB} callback + * @param {authCallback} 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 myAuthorizer(username, password, callback) { +function userAuthorizer(username, password, callback) { // Login Rate Limit loginRateLimiter.pass(null, 0).then((pass) => { if (pass) { @@ -71,7 +123,7 @@ function myAuthorizer(username, password, callback) { */ exports.basicAuth = async function (req, res, next) { const middleware = basicAuth({ - authorizer: myAuthorizer, + authorizer: userAuthorizer, authorizeAsync: true, challenge: true, }); @@ -84,3 +136,32 @@ exports.basicAuth = async function (req, res, next) { next(); } }; + +/** + * 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 + */ +exports.apiAuth = async function (req, res, next) { + if (!await Settings.get("disableAuth")) { + let usingAPIKeys = await Settings.get("apiKeysEnabled"); + 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/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/database.js b/server/database.js index f8e5856d..e4e5e4bb 100644 --- a/server/database.js +++ b/server/database.js @@ -68,6 +68,7 @@ class Database { "patch-monitor-add-resend-interval.sql": true, "patch-ping-packet-size.sql": true, "patch-maintenance-table2.sql": true, + "patch-api-key-table.sql": true, "patch-add-gamedig-monitor.sql": true, "patch-add-google-analytics-status-page-tag.sql": true, "patch-http-body-encoding.sql": true, diff --git a/server/model/api_key.js b/server/model/api_key.js new file mode 100644 index 00000000..1b27a60f --- /dev/null +++ b/server/model/api_key.js @@ -0,0 +1,76 @@ +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 + * @returns {string} active, inactive or expired + */ + getStatus() { + let current = dayjs(); + let expiry = dayjs(this.expires); + if (expiry.diff(current) < 0) { + return "expired"; + } + + return this.active ? "active" : "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/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, }; diff --git a/server/server.js b/server/server.js index 1965f38e..b7308a5a 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"); @@ -129,7 +129,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"); @@ -138,6 +138,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"); @@ -229,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, @@ -1503,6 +1504,7 @@ let needSetup = false; proxySocketHandler(socket); dockerSocketHandler(socket); maintenanceSocketHandler(socket); + apiKeySocketHandler(socket); generalSocketHandler(socket, server); pluginsHandler(socket, server); @@ -1611,6 +1613,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..69b0b60d --- /dev/null +++ b/server/socket-handlers/api-key-socket-handler.js @@ -0,0 +1,150 @@ +const { checkLogin } = require("../util-server"); +const { log } = require("../../src/util"); +const { R } = require("redbean-node"); +const { nanoid } = require("nanoid"); +const passwordHash = require("../password-hash"); +const apicache = require("../modules/apicache"); +const APIKey = require("../model/api_key"); +const { Settings } = require("../settings"); +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 = nanoid(40); + 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 and prefix to start of key seperated by _, used to get + // correct hash when validating key. + let formattedKey = "uk" + 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.", + 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/components/APIKeyDialog.vue b/src/components/APIKeyDialog.vue new file mode 100644 index 00000000..745efd4a --- /dev/null +++ b/src/components/APIKeyDialog.vue @@ -0,0 +1,219 @@ + + + + + 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"); + } }, }; diff --git a/src/components/settings/APIKeys.vue b/src/components/settings/APIKeys.vue new file mode 100644 index 00000000..75778993 --- /dev/null +++ b/src/components/settings/APIKeys.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/src/lang/en.json b/src/lang/en.json index c9889fad..01ddfeb5 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -676,5 +676,22 @@ "Edit Tag": "Edit Tag", "Server Address": "Server Address", "Learn More": "Learn More", - "Body Encoding": "Body Encoding" + "Body Encoding": "Body Encoding", + "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", + "disableAPIKeyMsg": "Are you sure you want to disable this API key?", + "deleteAPIKeyMsg": "Are you sure you want to delete this API key?", + "Generate": "Generate" } diff --git a/src/mixins/socket.js b/src/mixins/socket.js index 6bd0aafc..c98a2e3c 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -35,7 +35,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: { }, @@ -135,6 +136,10 @@ export default { this.maintenanceList = data; }); + socket.on("apiKeyList", (data) => { + this.apiKeyList = data; + }); + socket.on("notificationList", (data) => { this.notificationList = data; }); @@ -466,6 +471,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 @@ -540,6 +556,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/Settings.vue b/src/pages/Settings.vue index b034a541..d3c153df 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -107,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 b9ee2631..4d5293b8 100644 --- a/src/router.js +++ b/src/router.js @@ -18,6 +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 APIKeys from "./components/settings/APIKeys.vue"; import Plugins from "./components/settings/Plugins.vue"; // Settings - Sub Pages @@ -117,6 +118,10 @@ const routes = [ path: "security", component: Security, }, + { + path: "api-keys", + component: APIKeys, + }, { path: "proxies", component: Proxies,