diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 32e7e694a..50fa7a06f 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -70,11 +70,6 @@ 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 9293a6d1e..7cf46567b 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -351,6 +351,9 @@ 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 deleted file mode 100644 index 96442a17f..000000000 --- a/client/components/modals/ApiKeyCreatedModal.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/client/components/modals/ApiKeyModal.vue b/client/components/modals/ApiKeyModal.vue deleted file mode 100644 index c00de195a..000000000 --- a/client/components/modals/ApiKeyModal.vue +++ /dev/null @@ -1,317 +0,0 @@ - - - diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue deleted file mode 100644 index 72fbe6913..000000000 --- a/client/components/tables/ApiKeysTable.vue +++ /dev/null @@ -1,167 +0,0 @@ - - - - - diff --git a/client/pages/config.vue b/client/pages/config.vue index c4fe24468..5fa145e55 100644 --- a/client/pages/config.vue +++ b/client/pages/config.vue @@ -53,7 +53,6 @@ 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 deleted file mode 100644 index 99ae9c52b..000000000 --- a/client/pages/config/api-keys/index.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 62443e0b1..f62889124 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -1,6 +1,5 @@ { "ButtonAdd": "Add", - "ButtonAddApiKey": "Add API Key", "ButtonAddChapters": "Add Chapters", "ButtonAddDevice": "Add Device", "ButtonAddLibrary": "Add Library", @@ -21,7 +20,6 @@ "ButtonChooseAFolder": "Choose a folder", "ButtonChooseFiles": "Choose files", "ButtonClearFilter": "Clear Filter", - "ButtonClose": "Close", "ButtonCloseFeed": "Close Feed", "ButtonCloseSession": "Close Open Session", "ButtonCollections": "Collections", @@ -121,7 +119,6 @@ "HeaderAccount": "Account", "HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider", "HeaderAdvanced": "Advanced", - "HeaderApiKeys": "API Keys", "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudioTracks": "Audio Tracks", "HeaderAudiobookTools": "Audiobook File Management Tools", @@ -165,7 +162,6 @@ "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", @@ -210,7 +206,6 @@ "HeaderTableOfContents": "Table of Contents", "HeaderTools": "Tools", "HeaderUpdateAccount": "Update Account", - "HeaderUpdateApiKey": "Update API Key", "HeaderUpdateAuthor": "Update Author", "HeaderUpdateDetails": "Update Details", "HeaderUpdateLibrary": "Update Library", @@ -240,8 +235,6 @@ "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)", @@ -353,9 +346,6 @@ "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)", @@ -418,7 +408,6 @@ "LabelLastSeen": "Last Seen", "LabelLastTime": "Last Time", "LabelLastUpdate": "Last Update", - "LabelLastUsed": "Last Used", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Single page", "LabelLayoutSplitPage": "Split page", @@ -466,7 +455,6 @@ "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", @@ -742,7 +730,6 @@ "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?", @@ -1013,8 +1000,6 @@ "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 b632d040e..f94b5d198 100644 --- a/server/Database.js +++ b/server/Database.js @@ -47,9 +47,9 @@ class Database { return this.models.session } - /** @type {typeof import('./models/ApiKey')} */ - get apiKeyModel() { - return this.models.apiKey + /** @type {typeof import('./models/ApiToken')} */ + get apiTokenModel() { + return this.models.apiToken } /** @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/ApiKey').init(this.sequelize) + require('./models/ApiToken').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 deleted file mode 100644 index 776ddcbe9..000000000 --- a/server/controllers/ApiKeyController.js +++ /dev/null @@ -1,146 +0,0 @@ -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-auth-tables.js b/server/migrations/v2.26.0-create-sessions-table.js similarity index 80% rename from server/migrations/v2.26.0-create-auth-tables.js rename to server/migrations/v2.26.0-create-sessions-table.js index 2c86411e7..aad49f8fb 100644 --- a/server/migrations/v2.26.0-create-auth-tables.js +++ b/server/migrations/v2.26.0-create-sessions-table.js @@ -8,11 +8,11 @@ */ const migrationVersion = '2.26.0' -const migrationName = `${migrationVersion}-create-auth-tables` +const migrationName = `${migrationVersion}-create-sessions-table` const loggerPrefix = `[${migrationVersion} migration]` /** - * This upward migration creates a sessions table and apiKeys table. + * This upward migration creates a sessions table and apiTokens table. * * @param {MigrationOptions} options - an object containing the migration context. * @returns {Promise} - A promise that resolves when the migration is complete. @@ -68,19 +68,23 @@ async function up({ context: { queryInterface, logger } }) { } // Check if table exists - if (await queryInterface.tableExists('apiKeys')) { - logger.info(`${loggerPrefix} table "apiKeys" already exists`) + if (await queryInterface.tableExists('apiTokens')) { + logger.info(`${loggerPrefix} table "apiTokens" already exists`) } else { // Create table - logger.info(`${loggerPrefix} creating table "apiKeys"`) + logger.info(`${loggerPrefix} creating table "apiTokens"`) const DataTypes = queryInterface.sequelize.Sequelize.DataTypes - await queryInterface.createTable('apiKeys', { + await queryInterface.createTable('apiTokens', { 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: { @@ -105,17 +109,18 @@ async function up({ context: { queryInterface, logger } }) { }, key: 'id' }, - onDelete: 'SET NULL' + allowNull: false, + onDelete: 'CASCADE' } }) - logger.info(`${loggerPrefix} created table "apiKeys"`) + logger.info(`${loggerPrefix} created table "apiTokens"`) } logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) } /** - * This downward migration script removes the sessions table and apiKeys table. + * This downward migration script removes the sessions table and apiTokens table. * * @param {MigrationOptions} options - an object containing the migration context. * @returns {Promise} - A promise that resolves when the migration is complete. @@ -134,12 +139,12 @@ async function down({ context: { queryInterface, logger } }) { logger.info(`${loggerPrefix} table "sessions" does not exist`) } - if (await queryInterface.tableExists('apiKeys')) { - logger.info(`${loggerPrefix} dropping table "apiKeys"`) - await queryInterface.dropTable('apiKeys') - logger.info(`${loggerPrefix} dropped table "apiKeys"`) + if (await queryInterface.tableExists('apiTokens')) { + logger.info(`${loggerPrefix} dropping table "apiTokens"`) + await queryInterface.dropTable('apiTokens') + logger.info(`${loggerPrefix} dropped table "apiTokens"`) } else { - logger.info(`${loggerPrefix} table "apiKeys" does not exist`) + logger.info(`${loggerPrefix} table "apiTokens" does not exist`) } logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js deleted file mode 100644 index 54cc036a3..000000000 --- a/server/models/ApiKey.js +++ /dev/null @@ -1,191 +0,0 @@ -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 new file mode 100644 index 000000000..753fba6f3 --- /dev/null +++ b/server/models/ApiToken.js @@ -0,0 +1,90 @@ +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 8966ff66e..ecb1555f1 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -34,7 +34,6 @@ 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) { @@ -326,14 +325,6 @@ 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 //