diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50fa7a06..32e7e694 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -70,6 +70,11 @@ export default { title: this.$strings.HeaderUsers, path: '/config/users' }, + { + id: 'config-api-keys', + title: this.$strings.HeaderApiKeys, + path: '/config/api-keys' + }, { id: 'config-sessions', title: this.$strings.HeaderListeningSessions, diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 7cf46567..9293a6d1 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -351,9 +351,6 @@ export default { this.$toast.error(errMsg || 'Failed to create account') }) }, - toggleActive() { - this.newUser.isActive = !this.newUser.isActive - }, userTypeUpdated(type) { this.newUser.permissions = { download: type !== 'guest', diff --git a/client/components/modals/ApiKeyCreatedModal.vue b/client/components/modals/ApiKeyCreatedModal.vue new file mode 100644 index 00000000..96442a17 --- /dev/null +++ b/client/components/modals/ApiKeyCreatedModal.vue @@ -0,0 +1,60 @@ + + + diff --git a/client/components/modals/ApiKeyModal.vue b/client/components/modals/ApiKeyModal.vue new file mode 100644 index 00000000..c00de195 --- /dev/null +++ b/client/components/modals/ApiKeyModal.vue @@ -0,0 +1,317 @@ + + + diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue new file mode 100644 index 00000000..72fbe691 --- /dev/null +++ b/client/components/tables/ApiKeysTable.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/client/pages/config.vue b/client/pages/config.vue index 5fa145e5..c4fe2446 100644 --- a/client/pages/config.vue +++ b/client/pages/config.vue @@ -53,6 +53,7 @@ export default { else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions else if (pageName === 'stats') return this.$strings.HeaderYourStats else if (pageName === 'users') return this.$strings.HeaderUsers + else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'email') return this.$strings.HeaderEmail diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue new file mode 100644 index 00000000..99ae9c52 --- /dev/null +++ b/client/pages/config/api-keys/index.vue @@ -0,0 +1,68 @@ + + + diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f6288912..62443e0b 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Add", + "ButtonAddApiKey": "Add API Key", "ButtonAddChapters": "Add Chapters", "ButtonAddDevice": "Add Device", "ButtonAddLibrary": "Add Library", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Choose a folder", "ButtonChooseFiles": "Choose files", "ButtonClearFilter": "Clear Filter", + "ButtonClose": "Close", "ButtonCloseFeed": "Close Feed", "ButtonCloseSession": "Close Open Session", "ButtonCollections": "Collections", @@ -119,6 +121,7 @@ "HeaderAccount": "Account", "HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider", "HeaderAdvanced": "Advanced", + "HeaderApiKeys": "API Keys", "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudioTracks": "Audio Tracks", "HeaderAudiobookTools": "Audiobook File Management Tools", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", + "HeaderNewApiKey": "New API Key", "HeaderNewLibrary": "New Library", "HeaderNotificationCreate": "Create Notification", "HeaderNotificationUpdate": "Update Notification", @@ -206,6 +210,7 @@ "HeaderTableOfContents": "Table of Contents", "HeaderTools": "Tools", "HeaderUpdateAccount": "Update Account", + "HeaderUpdateApiKey": "Update API Key", "HeaderUpdateAuthor": "Update Author", "HeaderUpdateDetails": "Update Details", "HeaderUpdateLibrary": "Update Library", @@ -235,6 +240,8 @@ "LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", + "LabelApiKeyCreated": "API Key \"{0}\" created successfully.", + "LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.", "LabelApiToken": "API Token", "LabelAppend": "Append", "LabelAudioBitrate": "Audio Bitrate (e.g. 128k)", @@ -346,6 +353,9 @@ "LabelExample": "Example", "LabelExpandSeries": "Expand Series", "LabelExpandSubSeries": "Expand Sub Series", + "LabelExpiresAt": "Expires At", + "LabelExpiresInSeconds": "Expires in (seconds)", + "LabelExpiresNever": "Never", "LabelExplicit": "Explicit", "LabelExplicitChecked": "Explicit (checked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)", @@ -408,6 +418,7 @@ "LabelLastSeen": "Last Seen", "LabelLastTime": "Last Time", "LabelLastUpdate": "Last Update", + "LabelLastUsed": "Last Used", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Single page", "LabelLayoutSplitPage": "Split page", @@ -455,6 +466,7 @@ "LabelNewestEpisodes": "Newest Episodes", "LabelNextBackupDate": "Next backup date", "LabelNextScheduledRun": "Next scheduled run", + "LabelNoApiKeys": "No API keys", "LabelNoCustomMetadataProviders": "No custom metadata providers", "LabelNoEpisodesSelected": "No episodes selected", "LabelNotFinished": "Not Finished", @@ -730,6 +742,7 @@ "MessageChaptersNotFound": "Chapters not found", "MessageCheckingCron": "Checking cron...", "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", + "MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{0}\"?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", @@ -1000,6 +1013,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared", "ToastEpisodeUpdateSuccess": "{0} episodes updated", "ToastErrorCannotShare": "Cannot share natively on this device", + "ToastFailedToCreate": "Failed to create", + "ToastFailedToDelete": "Failed to delete", "ToastFailedToLoadData": "Failed to load data", "ToastFailedToMatch": "Failed to match", "ToastFailedToShare": "Failed to share", diff --git a/server/Database.js b/server/Database.js index f94b5d19..b632d040 100644 --- a/server/Database.js +++ b/server/Database.js @@ -47,9 +47,9 @@ class Database { return this.models.session } - /** @type {typeof import('./models/ApiToken')} */ - get apiTokenModel() { - return this.models.apiToken + /** @type {typeof import('./models/ApiKey')} */ + get apiKeyModel() { + return this.models.apiKey } /** @type {typeof import('./models/Library')} */ @@ -322,7 +322,7 @@ class Database { buildModels(force = false) { require('./models/User').init(this.sequelize) require('./models/Session').init(this.sequelize) - require('./models/ApiToken').init(this.sequelize) + require('./models/ApiKey').init(this.sequelize) require('./models/Library').init(this.sequelize) require('./models/LibraryFolder').init(this.sequelize) require('./models/Book').init(this.sequelize) diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js new file mode 100644 index 00000000..776ddcbe --- /dev/null +++ b/server/controllers/ApiKeyController.js @@ -0,0 +1,146 @@ +const { Request, Response, NextFunction } = require('express') +const uuidv4 = require('uuid').v4 +const Logger = require('../Logger') +const Database = require('../Database') + +/** + * @typedef RequestUserObject + * @property {import('../models/User')} user + * + * @typedef {Request & RequestUserObject} RequestWithUser + */ + +class ApiKeyController { + constructor() {} + + /** + * GET: /api/api-keys + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getAll(req, res) { + const apiKeys = await Database.apiKeyModel.findAll() + + return res.json({ + apiKeys: apiKeys.map((a) => a.toJSON()) + }) + } + + /** + * POST: /api/api-keys + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async create(req, res) { + if (!req.body.name || typeof req.body.name !== 'string') { + Logger.warn(`[ApiKeyController] create: Invalid name: ${req.body.name}`) + return res.sendStatus(400) + } + if (req.body.expiresIn && (typeof req.body.expiresIn !== 'number' || req.body.expiresIn <= 0)) { + Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`) + return res.sendStatus(400) + } + + const keyId = uuidv4() // Generate key id ahead of time to use in JWT + + const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) + const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn) + + if (!apiKey) { + Logger.error(`[ApiKeyController] create: Error generating API key`) + return res.sendStatus(500) + } + + // Calculate expiration time for the api key + const expiresAt = req.body.expiresIn ? new Date(Date.now() + req.body.expiresIn * 1000) : null + + const apiKeyInstance = await Database.apiKeyModel.create({ + id: keyId, + name: req.body.name, + expiresAt, + permissions, + userId: req.user.id, + isActive: !!req.body.isActive + }) + + Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) + return res.json({ + apiKey: { + apiKey, // Actual key only shown to user on creation + ...apiKeyInstance.toJSON() + } + }) + } + + /** + * PATCH: /api/api-keys/:id + * Only isActive and permissions can be updated because name and expiresIn are in the JWT + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async update(req, res) { + const apiKey = await Database.apiKeyModel.findByPk(req.params.id) + if (!apiKey) { + return res.sendStatus(404) + } + + if (req.body.isActive !== undefined) { + if (typeof req.body.isActive !== 'boolean') { + return res.sendStatus(400) + } + + apiKey.isActive = req.body.isActive + } + + if (req.body.permissions && Object.keys(req.body.permissions).length > 0) { + const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) + apiKey.permissions = permissions + } + + await apiKey.save() + + Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) + + return res.json({ + apiKey: apiKey.toJSON() + }) + } + + /** + * DELETE: /api/api-keys/:id + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async delete(req, res) { + const apiKey = await Database.apiKeyModel.findByPk(req.params.id) + if (!apiKey) { + return res.sendStatus(404) + } + + await apiKey.destroy() + Logger.info(`[ApiKeyController] Deleted API key "${apiKey.name}"`) + + return res.sendStatus(200) + } + + /** + * + * @param {RequestWithUser} req + * @param {Response} res + * @param {NextFunction} next + */ + middleware(req, res, next) { + if (!req.user.isAdminOrUp) { + Logger.error(`[ApiKeyController] Non-admin user "${req.user.username}" attempting to access api keys`) + return res.sendStatus(403) + } + + next() + } +} + +module.exports = new ApiKeyController() diff --git a/server/migrations/v2.26.0-create-sessions-table.js b/server/migrations/v2.26.0-create-auth-tables.js similarity index 80% rename from server/migrations/v2.26.0-create-sessions-table.js rename to server/migrations/v2.26.0-create-auth-tables.js index aad49f8f..2c86411e 100644 --- a/server/migrations/v2.26.0-create-sessions-table.js +++ b/server/migrations/v2.26.0-create-auth-tables.js @@ -8,11 +8,11 @@ */ const migrationVersion = '2.26.0' -const migrationName = `${migrationVersion}-create-sessions-table` +const migrationName = `${migrationVersion}-create-auth-tables` const loggerPrefix = `[${migrationVersion} migration]` /** - * This upward migration creates a sessions table and apiTokens table. + * This upward migration creates a sessions table and apiKeys table. * * @param {MigrationOptions} options - an object containing the migration context. * @returns {Promise} - A promise that resolves when the migration is complete. @@ -68,23 +68,19 @@ async function up({ context: { queryInterface, logger } }) { } // Check if table exists - if (await queryInterface.tableExists('apiTokens')) { - logger.info(`${loggerPrefix} table "apiTokens" already exists`) + if (await queryInterface.tableExists('apiKeys')) { + logger.info(`${loggerPrefix} table "apiKeys" already exists`) } else { // Create table - logger.info(`${loggerPrefix} creating table "apiTokens"`) + logger.info(`${loggerPrefix} creating table "apiKeys"`) const DataTypes = queryInterface.sequelize.Sequelize.DataTypes - await queryInterface.createTable('apiTokens', { + await queryInterface.createTable('apiKeys', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, name: DataTypes.STRING, - tokenHash: { - type: DataTypes.STRING, - allowNull: false - }, expiresAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE, isActive: { @@ -109,18 +105,17 @@ async function up({ context: { queryInterface, logger } }) { }, key: 'id' }, - allowNull: false, - onDelete: 'CASCADE' + onDelete: 'SET NULL' } }) - logger.info(`${loggerPrefix} created table "apiTokens"`) + logger.info(`${loggerPrefix} created table "apiKeys"`) } logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) } /** - * This downward migration script removes the sessions table and apiTokens table. + * This downward migration script removes the sessions table and apiKeys table. * * @param {MigrationOptions} options - an object containing the migration context. * @returns {Promise} - A promise that resolves when the migration is complete. @@ -139,12 +134,12 @@ async function down({ context: { queryInterface, logger } }) { logger.info(`${loggerPrefix} table "sessions" does not exist`) } - if (await queryInterface.tableExists('apiTokens')) { - logger.info(`${loggerPrefix} dropping table "apiTokens"`) - await queryInterface.dropTable('apiTokens') - logger.info(`${loggerPrefix} dropped table "apiTokens"`) + if (await queryInterface.tableExists('apiKeys')) { + logger.info(`${loggerPrefix} dropping table "apiKeys"`) + await queryInterface.dropTable('apiKeys') + logger.info(`${loggerPrefix} dropped table "apiKeys"`) } else { - logger.info(`${loggerPrefix} table "apiTokens" does not exist`) + logger.info(`${loggerPrefix} table "apiKeys" does not exist`) } logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js new file mode 100644 index 00000000..54cc036a --- /dev/null +++ b/server/models/ApiKey.js @@ -0,0 +1,191 @@ +const { DataTypes, Model, Op } = require('sequelize') +const jwt = require('jsonwebtoken') +const Logger = require('../Logger') + +/** + * @typedef {Object} ApiKeyPermissions + * @property {boolean} download + * @property {boolean} update + * @property {boolean} delete + * @property {boolean} upload + * @property {boolean} createEreader + * @property {boolean} accessAllLibraries + * @property {boolean} accessAllTags + * @property {boolean} accessExplicitContent + * @property {boolean} selectedTagsNotAccessible + * @property {string[]} librariesAccessible + * @property {string[]} itemTagsSelected + */ + +class ApiKey extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {Date} */ + this.expiresAt + /** @type {Date} */ + this.lastUsedAt + /** @type {boolean} */ + this.isActive + /** @type {Object} */ + this.permissions + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + /** @type {UUIDV4} */ + this.userId + + // Expanded properties + + /** @type {import('./User').User} */ + this.user + } + + /** + * Same properties as User.getDefaultPermissions + * @returns {ApiKeyPermissions} + */ + static getDefaultPermissions() { + return { + download: true, + update: true, + delete: true, + upload: true, + createEreader: true, + accessAllLibraries: true, + accessAllTags: true, + accessExplicitContent: true, + selectedTagsNotAccessible: false, // Inverts itemTagsSelected + librariesAccessible: [], + itemTagsSelected: [] + } + } + + /** + * Merge permissions from request with default permissions + * @param {ApiKeyPermissions} reqPermissions + * @returns {ApiKeyPermissions} + */ + static mergePermissionsWithDefault(reqPermissions) { + const permissions = this.getDefaultPermissions() + + if (!reqPermissions || typeof reqPermissions !== 'object') { + Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permissions: ${reqPermissions}`) + return permissions + } + + for (const key in reqPermissions) { + if (reqPermissions[key] === undefined) { + Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission key: ${key}`) + continue + } + + if (key === 'librariesAccessible' || key === 'itemTagsSelected') { + if (!Array.isArray(reqPermissions[key]) || reqPermissions[key].some((value) => typeof value !== 'string')) { + Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid ${key} value: ${reqPermissions[key]}`) + continue + } + + permissions[key] = reqPermissions[key] + } else if (typeof reqPermissions[key] !== 'boolean') { + Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission value for key ${key}. Should be boolean`) + continue + } + + permissions[key] = reqPermissions[key] + } + + return permissions + } + + /** + * Clean up expired api keys from the database + * @returns {Promise} Number of api keys deleted + */ + static async cleanupExpiredApiKeys() { + const deletedCount = await ApiKey.destroy({ + where: { + expiresAt: { + [Op.lt]: new Date() + } + } + }) + return deletedCount + } + + /** + * Generate a new api key + * @param {string} keyId + * @param {string} name + * @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration + * @returns {Promise} + */ + static async generateApiKey(keyId, name, expiresIn) { + const options = {} + if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) { + options.expiresIn = expiresIn + } + + return new Promise((resolve) => { + jwt.sign( + { + keyId, + name, + type: 'api' + }, + global.ServerSettings.tokenSecret, + options, + (err, token) => { + if (err) { + Logger.error(`[ApiKey] Error generating API key: ${err}`) + resolve(null) + } else { + resolve(token) + } + } + ) + }) + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + expiresAt: DataTypes.DATE, + lastUsedAt: DataTypes.DATE, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + permissions: DataTypes.JSON + }, + { + sequelize, + modelName: 'apiKey' + } + ) + + const { user } = sequelize.models + user.hasMany(ApiKey, { + onDelete: 'SET NULL' + }) + ApiKey.belongsTo(user) + } +} + +module.exports = ApiKey diff --git a/server/models/ApiToken.js b/server/models/ApiToken.js deleted file mode 100644 index 753fba6f..00000000 --- a/server/models/ApiToken.js +++ /dev/null @@ -1,90 +0,0 @@ -const { DataTypes, Model, Op } = require('sequelize') - -class ApiToken extends Model { - constructor(values, options) { - super(values, options) - - /** @type {UUIDV4} */ - this.id - /** @type {string} */ - this.name - /** @type {string} */ - this.tokenHash - /** @type {Date} */ - this.expiresAt - /** @type {Date} */ - this.lastUsedAt - /** @type {boolean} */ - this.isActive - /** @type {Object} */ - this.permissions - /** @type {Date} */ - this.createdAt - /** @type {UUIDV4} */ - this.userId - - // Expanded properties - - /** @type {import('./User').User} */ - this.user - } - - /** - * Clean up expired api tokens from the database - * @returns {Promise} Number of api tokens deleted - */ - static async cleanupExpiredApiTokens() { - const deletedCount = await ApiToken.destroy({ - where: { - expiresAt: { - [Op.lt]: new Date() - } - } - }) - return deletedCount - } - - /** - * Initialize model - * @param {import('../Database').sequelize} sequelize - */ - static init(sequelize) { - super.init( - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - name: DataTypes.STRING, - tokenHash: { - type: DataTypes.STRING, - allowNull: false - }, - expiresAt: DataTypes.DATE, - lastUsedAt: DataTypes.DATE, - isActive: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false - }, - permissions: DataTypes.JSON - }, - { - sequelize, - modelName: 'apiToken' - } - ) - - const { user } = sequelize.models - user.hasMany(ApiToken, { - onDelete: 'CASCADE', - foreignKey: { - allowNull: false - } - }) - ApiToken.belongsTo(user) - } -} - -module.exports = ApiToken diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index ecb1555f..8966ff66 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -34,6 +34,7 @@ const CustomMetadataProviderController = require('../controllers/CustomMetadataP const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') const StatsController = require('../controllers/StatsController') +const ApiKeyController = require('../controllers/ApiKeyController') class ApiRouter { constructor(Server) { @@ -325,6 +326,14 @@ class ApiRouter { this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this)) this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this)) + // + // API Key Routes + // + this.router.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this)) + this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this)) + this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this)) + this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this)) + // // Misc Routes //