Update API Keys to be tied to a user, add apikey lru-cache, handle deactivating expired keys

This commit is contained in:
advplyr 2025-06-30 14:53:11 -05:00
parent af1ff12dbb
commit 4d32a22de9
13 changed files with 335 additions and 217 deletions

View file

@ -65,7 +65,9 @@ class Auth {
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
secretOrKey: Database.serverSettings.tokenSecret
secretOrKey: Database.serverSettings.tokenSecret,
// Handle expiration manaully in order to disable api keys that are expired
ignoreExpiration: true
},
this.jwtAuthCheck.bind(this)
)
@ -1044,6 +1046,7 @@ class Auth {
}
await Database.updateServerSettings()
// TODO: Old method of non-expiring tokens
// New token secret creation added in v2.1.0 so generate new API tokens for each user
const users = await Database.userModel.findAll({
attributes: ['id', 'username', 'token']
@ -1057,22 +1060,49 @@ class Auth {
}
/**
* Checks if the user in the validated jwt_payload really exists and is active.
* Checks if the user or api key in the validated jwt_payload exists and is active.
* @param {Object} jwt_payload
* @param {function} done
*/
async jwtAuthCheck(jwt_payload, done) {
// load user by id from the jwt token
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
if (jwt_payload.type === 'api') {
const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
if (!user?.isActive) {
// deny login
done(null, null)
return
if (!apiKey?.isActive) {
done(null, null)
return
}
// Check if the api key is expired and deactivate it
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
done(null, null)
apiKey.isActive = false
await apiKey.save()
Logger.info(`[Auth] API key ${apiKey.id} is expired - deactivated`)
return
}
const user = await Database.userModel.getUserById(apiKey.userId)
done(null, user)
} else {
// Check if the jwt is expired
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
done(null, null)
return
}
// load user by id from the jwt token
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
if (!user?.isActive) {
// deny login
done(null, null)
return
}
// approve login
done(null, user)
}
// approve login
done(null, user)
return
}
/**

View file

@ -670,6 +670,7 @@ class Database {
* Remove playback sessions that are 3 seconds or less
* Remove duplicate mediaProgresses
* Remove expired auth sessions
* Deactivate expired api keys
*/
async cleanDatabase() {
// Remove invalid Podcast records
@ -802,6 +803,23 @@ WHERE EXISTS (
// Remove expired Session records
await this.cleanupExpiredSessions()
// Deactivate expired api keys
await this.deactivateExpiredApiKeys()
}
/**
* Deactivate expired api keys
*/
async deactivateExpiredApiKeys() {
try {
const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys()
if (affectedCount > 0) {
Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`)
}
} catch (error) {
Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`)
}
}
/**

View file

@ -20,7 +20,19 @@ class ApiKeyController {
* @param {Response} res
*/
async getAll(req, res) {
const apiKeys = await Database.apiKeyModel.findAll()
const apiKeys = await Database.apiKeyModel.findAll({
include: [
{
model: Database.userModel,
attributes: ['id', 'username', 'type']
},
{
model: Database.userModel,
as: 'createdByUser',
attributes: ['id', 'username', 'type']
}
]
})
return res.json({
apiKeys: apiKeys.map((a) => a.toJSON())
@ -42,10 +54,21 @@ class ApiKeyController {
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
return res.sendStatus(400)
}
if (!req.body.userId || typeof req.body.userId !== 'string') {
Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`)
return res.sendStatus(400)
}
const user = await Database.userModel.getUserById(req.body.userId)
if (!user) {
Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`)
return res.sendStatus(400)
}
if (user.type === 'root' && !req.user.isRoot) {
Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`)
return res.sendStatus(403)
}
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) {
@ -60,9 +83,9 @@ class ApiKeyController {
id: keyId,
name: req.body.name,
expiresAt,
permissions,
userId: req.user.id,
isActive: !!req.body.isActive
userId: req.body.userId,
isActive: !!req.body.isActive,
createdByUserId: req.user.id
})
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
@ -76,34 +99,64 @@ class ApiKeyController {
/**
* PATCH: /api/api-keys/:id
* Only isActive and permissions can be updated because name and expiresIn are in the JWT
* Only isActive and userId 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)
const apiKey = await Database.apiKeyModel.findByPk(req.params.id, {
include: {
model: Database.userModel
}
})
if (!apiKey) {
return res.sendStatus(404)
}
// Only root user can update root user API keys
if (apiKey.user.type === 'root' && !req.user.isRoot) {
Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`)
return res.sendStatus(403)
}
let hasUpdates = false
if (req.body.userId !== undefined) {
if (typeof req.body.userId !== 'string') {
Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`)
return res.sendStatus(400)
}
const user = await Database.userModel.getUserById(req.body.userId)
if (!user) {
Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`)
return res.sendStatus(400)
}
if (user.type === 'root' && !req.user.isRoot) {
Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`)
return res.sendStatus(403)
}
if (apiKey.userId !== req.body.userId) {
apiKey.userId = req.body.userId
hasUpdates = true
}
}
if (req.body.isActive !== undefined) {
if (typeof req.body.isActive !== 'boolean') {
return res.sendStatus(400)
}
apiKey.isActive = req.body.isActive
if (apiKey.isActive !== req.body.isActive) {
apiKey.isActive = req.body.isActive
hasUpdates = true
}
}
if (req.body.permissions && Object.keys(req.body.permissions).length > 0) {
const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions)
apiKey.permissions = permissions
if (hasUpdates) {
await apiKey.save()
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
} else {
Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`)
}
await apiKey.save()
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
return res.json({
apiKey: apiKey.toJSON()
})

View file

@ -36,6 +36,7 @@ class CronManager {
* Closes open share sessions that have not been updated in 24 hours
* Closes open playback sessions that have not been updated in 36 hours
* Cleans up expired auth sessions
* Deactivates expired api keys
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
*/
initOpenSessionCleanupCron() {
@ -44,6 +45,7 @@ class CronManager {
ShareManager.closeStaleOpenShareSessions()
await this.playbackSessionManager.closeStaleOpenSessions()
await Database.cleanupExpiredSessions()
await Database.deactivateExpiredApiKeys()
})
}

View file

@ -80,7 +80,11 @@ async function up({ context: { queryInterface, logger } }) {
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
name: {
type: DataTypes.STRING,
allowNull: false
},
description: DataTypes.TEXT,
expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE,
isActive: {
@ -105,6 +109,17 @@ async function up({ context: { queryInterface, logger } }) {
},
key: 'id'
},
onDelete: 'CASCADE'
},
createdByUserId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'users',
as: 'createdByUser'
},
key: 'id'
},
onDelete: 'SET NULL'
}
})

View file

@ -1,5 +1,6 @@
const { DataTypes, Model, Op } = require('sequelize')
const jwt = require('jsonwebtoken')
const { LRUCache } = require('lru-cache')
const Logger = require('../Logger')
/**
@ -17,6 +18,32 @@ const Logger = require('../Logger')
* @property {string[]} itemTagsSelected
*/
class ApiKeyCache {
constructor() {
this.cache = new LRUCache({ max: 100 })
}
getById(id) {
const apiKey = this.cache.get(id)
return apiKey
}
set(apiKey) {
apiKey.fromCache = true
this.cache.set(apiKey.id, apiKey)
}
delete(apiKeyId) {
this.cache.delete(apiKeyId)
}
maybeInvalidate(apiKey) {
if (!apiKey.fromCache) this.delete(apiKey.id)
}
}
const apiKeyCache = new ApiKeyCache()
class ApiKey extends Model {
constructor(values, options) {
super(values, options)
@ -25,13 +52,15 @@ class ApiKey extends Model {
this.id
/** @type {string} */
this.name
/** @type {string} */
this.description
/** @type {Date} */
this.expiresAt
/** @type {Date} */
this.lastUsedAt
/** @type {boolean} */
this.isActive
/** @type {Object} */
/** @type {ApiKeyPermissions} */
this.permissions
/** @type {Date} */
this.createdAt
@ -39,6 +68,8 @@ class ApiKey extends Model {
this.updatedAt
/** @type {UUIDV4} */
this.userId
/** @type {UUIDV4} */
this.createdByUserId
// Expanded properties
@ -104,18 +135,24 @@ class ApiKey extends Model {
}
/**
* Clean up expired api keys from the database
* @returns {Promise<number>} Number of api keys deleted
* Deactivate expired api keys
* @returns {Promise<number>} Number of api keys affected
*/
static async cleanupExpiredApiKeys() {
const deletedCount = await ApiKey.destroy({
where: {
expiresAt: {
[Op.lt]: new Date()
static async deactivateExpiredApiKeys() {
const [affectedCount] = await ApiKey.update(
{
isActive: false
},
{
where: {
isActive: true,
expiresAt: {
[Op.lt]: new Date()
}
}
}
})
return deletedCount
)
return affectedCount
}
/**
@ -152,6 +189,24 @@ class ApiKey extends Model {
})
}
/**
* Get an api key by id, from cache or database
* @param {string} apiKeyId
* @returns {Promise<ApiKey | null>}
*/
static async getById(apiKeyId) {
if (!apiKeyId) return null
const cachedApiKey = apiKeyCache.getById(apiKeyId)
if (cachedApiKey) return cachedApiKey
const apiKey = await ApiKey.findByPk(apiKeyId)
if (!apiKey) return null
apiKeyCache.set(apiKey)
return apiKey
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
@ -164,7 +219,11 @@ class ApiKey extends Model {
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
name: {
type: DataTypes.STRING,
allowNull: false
},
description: DataTypes.TEXT,
expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE,
isActive: {
@ -182,9 +241,30 @@ class ApiKey extends Model {
const { user } = sequelize.models
user.hasMany(ApiKey, {
onDelete: 'SET NULL'
onDelete: 'CASCADE'
})
ApiKey.belongsTo(user)
user.hasMany(ApiKey, {
foreignKey: 'createdByUserId',
onDelete: 'SET NULL'
})
ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' })
}
async update(values, options) {
apiKeyCache.maybeInvalidate(this)
return await super.update(values, options)
}
async save(options) {
apiKeyCache.maybeInvalidate(this)
return await super.save(options)
}
async destroy(options) {
apiKeyCache.delete(this.id)
await super.destroy(options)
}
}