mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-13 10:55:05 +02:00
Set up ApiKey model and create Api Key endpoint
This commit is contained in:
parent
4f5123e842
commit
d96ed01ce4
6 changed files with 293 additions and 113 deletions
|
@ -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)
|
||||||
|
|
78
server/controllers/ApiKeyController.js
Normal file
78
server/controllers/ApiKeyController.js
Normal 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()
|
|
@ -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
191
server/models/ApiKey.js
Normal 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
|
|
@ -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
|
|
|
@ -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
|
||||||
//
|
//
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue