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..0f995263 --- /dev/null +++ b/server/controllers/ApiKeyController.js @@ -0,0 +1,78 @@ +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() {} + + /** + * 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 + }) + + return res.json({ + id: apiKeyInstance.id, + name: apiKeyInstance.name, + apiKey, + expiresAt: apiKeyInstance.expiresAt, + permissions: apiKeyInstance.permissions + }) + } + + /** + * + * @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..a4ec7d3c 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,11 @@ 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.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this)) + // // Misc Routes //