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 + }, ], }, ],