Set up ApiKey model and create Api Key endpoint

This commit is contained in:
advplyr 2025-06-30 10:12:39 -05:00
parent 4f5123e842
commit d96ed01ce4
6 changed files with 293 additions and 113 deletions

View file

@ -47,9 +47,9 @@ class Database {
return this.models.session return this.models.session
} }
/** @type {typeof import('./models/ApiToken')} */ /** @type {typeof import('./models/ApiKey')} */
get apiTokenModel() { get apiKeyModel() {
return this.models.apiToken return this.models.apiKey
} }
/** @type {typeof import('./models/Library')} */ /** @type {typeof import('./models/Library')} */
@ -322,7 +322,7 @@ class Database {
buildModels(force = false) { buildModels(force = false) {
require('./models/User').init(this.sequelize) require('./models/User').init(this.sequelize)
require('./models/Session').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/Library').init(this.sequelize)
require('./models/LibraryFolder').init(this.sequelize) require('./models/LibraryFolder').init(this.sequelize)
require('./models/Book').init(this.sequelize) require('./models/Book').init(this.sequelize)

View file

@ -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()

View file

@ -8,11 +8,11 @@
*/ */
const migrationVersion = '2.26.0' const migrationVersion = '2.26.0'
const migrationName = `${migrationVersion}-create-sessions-table` const migrationName = `${migrationVersion}-create-auth-tables`
const loggerPrefix = `[${migrationVersion} migration]` 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. * @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete. * @returns {Promise<void>} - A promise that resolves when the migration is complete.
@ -68,23 +68,19 @@ async function up({ context: { queryInterface, logger } }) {
} }
// Check if table exists // Check if table exists
if (await queryInterface.tableExists('apiTokens')) { if (await queryInterface.tableExists('apiKeys')) {
logger.info(`${loggerPrefix} table "apiTokens" already exists`) logger.info(`${loggerPrefix} table "apiKeys" already exists`)
} else { } else {
// Create table // Create table
logger.info(`${loggerPrefix} creating table "apiTokens"`) logger.info(`${loggerPrefix} creating table "apiKeys"`)
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
await queryInterface.createTable('apiTokens', { await queryInterface.createTable('apiKeys', {
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true primaryKey: true
}, },
name: DataTypes.STRING, name: DataTypes.STRING,
tokenHash: {
type: DataTypes.STRING,
allowNull: false
},
expiresAt: DataTypes.DATE, expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE,
isActive: { isActive: {
@ -109,18 +105,17 @@ async function up({ context: { queryInterface, logger } }) {
}, },
key: 'id' key: 'id'
}, },
allowNull: false, onDelete: 'SET NULL'
onDelete: 'CASCADE'
} }
}) })
logger.info(`${loggerPrefix} created table "apiTokens"`) logger.info(`${loggerPrefix} created table "apiKeys"`)
} }
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) 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. * @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete. * @returns {Promise<void>} - 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`) logger.info(`${loggerPrefix} table "sessions" does not exist`)
} }
if (await queryInterface.tableExists('apiTokens')) { if (await queryInterface.tableExists('apiKeys')) {
logger.info(`${loggerPrefix} dropping table "apiTokens"`) logger.info(`${loggerPrefix} dropping table "apiKeys"`)
await queryInterface.dropTable('apiTokens') await queryInterface.dropTable('apiKeys')
logger.info(`${loggerPrefix} dropped table "apiTokens"`) logger.info(`${loggerPrefix} dropped table "apiKeys"`)
} else { } else {
logger.info(`${loggerPrefix} table "apiTokens" does not exist`) logger.info(`${loggerPrefix} table "apiKeys" does not exist`)
} }
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)

191
server/models/ApiKey.js Normal file
View file

@ -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>} 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<string>}
*/
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

View file

@ -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>} 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

View file

@ -34,6 +34,7 @@ const CustomMetadataProviderController = require('../controllers/CustomMetadataP
const MiscController = require('../controllers/MiscController') const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController') const ShareController = require('../controllers/ShareController')
const StatsController = require('../controllers/StatsController') const StatsController = require('../controllers/StatsController')
const ApiKeyController = require('../controllers/ApiKeyController')
class ApiRouter { class ApiRouter {
constructor(Server) { 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/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this))
this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.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 // Misc Routes
// //