mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-13 19:04:57 +02:00
Refactor Auth to breakout functions in TokenManager, handle token generation for OIDC
Some checks failed
Some checks failed
This commit is contained in:
parent
e24eaab3f1
commit
97afd22f81
6 changed files with 603 additions and 473 deletions
379
server/auth/TokenManager.js
Normal file
379
server/auth/TokenManager.js
Normal file
|
@ -0,0 +1,379 @@
|
|||
const { Op } = require('sequelize')
|
||||
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const requestIp = require('../libs/requestIp')
|
||||
const jwt = require('../libs/jsonwebtoken')
|
||||
|
||||
class TokenManager {
|
||||
constructor() {
|
||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
|
||||
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
|
||||
|
||||
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
||||
}
|
||||
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a token which is used to encrypt/protect the jwts.
|
||||
*/
|
||||
async initTokenSecret() {
|
||||
if (process.env.TOKEN_SECRET) {
|
||||
// User can supply their own token secret
|
||||
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
||||
} else {
|
||||
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
}
|
||||
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']
|
||||
})
|
||||
if (users.length) {
|
||||
for (const user of users) {
|
||||
user.token = this.generateAccessToken(user)
|
||||
await user.save({ hooks: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the refresh token cookie
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {string} refreshToken
|
||||
*/
|
||||
setRefreshTokenCookie(req, res, refreshToken) {
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
||||
sameSite: 'lax',
|
||||
maxAge: this.RefreshTokenExpiry * 1000,
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to validate a jwt token for a given user
|
||||
* Used to authenticate socket connections
|
||||
* TODO: Support API keys for web socket connections
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {Object} tokens data
|
||||
*/
|
||||
static validateAccessToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, global.ServerSettings.tokenSecret)
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate a jwt token for a given user
|
||||
* TODO: Old method with no expiration
|
||||
* @deprecated
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateAccessToken(user) {
|
||||
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateTempAccessToken(user) {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
type: 'access'
|
||||
}
|
||||
const options = {
|
||||
expiresIn: this.AccessTokenExpiry
|
||||
}
|
||||
try {
|
||||
return jwt.sign(payload, global.ServerSettings.tokenSecret, options)
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateRefreshToken(user) {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
type: 'refresh'
|
||||
}
|
||||
const options = {
|
||||
expiresIn: this.RefreshTokenExpiry
|
||||
}
|
||||
try {
|
||||
return jwt.sign(payload, global.ServerSettings.tokenSecret, options)
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tokens and session for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @param {import('express').Request} req
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
|
||||
*/
|
||||
async createTokensAndSession(user, req) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
const userAgent = req.headers['user-agent']
|
||||
const accessToken = this.generateTempAccessToken(user)
|
||||
const refreshToken = this.generateRefreshToken(user)
|
||||
|
||||
// Calculate expiration time for the refresh token
|
||||
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate tokens for a given session
|
||||
*
|
||||
* @param {import('../models/Session')} session
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
|
||||
*/
|
||||
async rotateTokensForSession(session, user, req, res) {
|
||||
// Generate new tokens
|
||||
const newAccessToken = this.generateTempAccessToken(user)
|
||||
const newRefreshToken = this.generateRefreshToken(user)
|
||||
|
||||
// Calculate new expiration time
|
||||
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
// Update the session with the new refresh token and expiration
|
||||
session.refreshToken = newRefreshToken
|
||||
session.expiresAt = newExpiresAt
|
||||
await session.save()
|
||||
|
||||
// Set new refresh token cookie
|
||||
this.setRefreshTokenCookie(req, res, newRefreshToken)
|
||||
|
||||
return {
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the jwt is valid
|
||||
*
|
||||
* @param {Object} jwt_payload
|
||||
* @param {Function} done - passportjs callback
|
||||
*/
|
||||
async jwtAuthCheck(jwt_payload, done) {
|
||||
if (jwt_payload.type === 'api') {
|
||||
// Api key based authentication
|
||||
const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
|
||||
|
||||
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(`[TokenManager] API key ${apiKey.id} is expired - deactivated`)
|
||||
return
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserById(apiKey.userId)
|
||||
done(null, user)
|
||||
} else {
|
||||
// JWT based authentication
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// TODO: Temporary flag to report old tokens to users
|
||||
// May be a better place for this but here means we dont have to decode the token again
|
||||
if (!jwt_payload.exp && !user.isOldToken) {
|
||||
Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)
|
||||
user.isOldToken = true
|
||||
} else if (jwt_payload.exp && user.isOldToken !== undefined) {
|
||||
delete user.isOldToken
|
||||
}
|
||||
|
||||
// approve login
|
||||
done(null, user)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh token
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>}
|
||||
*/
|
||||
async handleRefreshToken(refreshToken, req, res) {
|
||||
try {
|
||||
// Verify the refresh token
|
||||
const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret)
|
||||
|
||||
if (decoded.type !== 'refresh') {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`)
|
||||
return {
|
||||
error: 'Invalid token type'
|
||||
}
|
||||
}
|
||||
|
||||
const session = await Database.sessionModel.findOne({
|
||||
where: { refreshToken: refreshToken }
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if session is expired in database
|
||||
if (session.expiresAt < new Date()) {
|
||||
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
|
||||
await session.destroy()
|
||||
return {
|
||||
error: 'Refresh token expired'
|
||||
}
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserById(decoded.userId)
|
||||
if (!user?.isActive) {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`)
|
||||
return {
|
||||
error: 'User not found or inactive'
|
||||
}
|
||||
}
|
||||
|
||||
const newTokens = await this.rotateTokensForSession(session, user, req, res)
|
||||
return {
|
||||
accessToken: newTokens.accessToken,
|
||||
refreshToken: newTokens.refreshToken,
|
||||
user
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
Logger.info(`[TokenManager] Refresh token expired, cleaning up session`)
|
||||
|
||||
// Clean up the expired session from database
|
||||
try {
|
||||
await Database.sessionModel.destroy({
|
||||
where: { refreshToken: refreshToken }
|
||||
})
|
||||
Logger.info(`[TokenManager] Expired session cleaned up`)
|
||||
} catch (cleanupError) {
|
||||
Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`)
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Refresh token expired'
|
||||
}
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[TokenManager] Refresh token error: ${error.message}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all JWT sessions for a given user
|
||||
* If user is current user and refresh token is valid, rotate tokens for the current session
|
||||
*
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
||||
*/
|
||||
async invalidateJwtSessionsForUser(user, req, res) {
|
||||
const currentRefreshToken = req.cookies.refresh_token
|
||||
if (req.user.id === user.id && currentRefreshToken) {
|
||||
// Current user is the same as the user to invalidate sessions for
|
||||
// So rotate token for current session
|
||||
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
|
||||
if (currentSession) {
|
||||
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
|
||||
|
||||
// Invalidate all sessions for the user except the current one
|
||||
await Database.sessionModel.destroy({
|
||||
where: {
|
||||
id: {
|
||||
[Op.ne]: currentSession.id
|
||||
},
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
return newTokens.accessToken
|
||||
} else {
|
||||
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Current user is not the same as the user to invalidate sessions for (or no refresh token)
|
||||
// So invalidate all sessions for the user
|
||||
await Database.sessionModel.destroy({ where: { userId: user.id } })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TokenManager
|
Loading…
Add table
Add a link
Reference in a new issue