mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-04 10:14:36 +02:00
Merge branch 'advplyr:master' into audible-confidence-score
This commit is contained in:
commit
e9a705587a
66 changed files with 3288 additions and 931 deletions
911
server/Auth.js
911
server/Auth.js
File diff suppressed because it is too large
Load diff
|
@ -42,6 +42,16 @@ class Database {
|
|||
return this.models.user
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Session')} */
|
||||
get sessionModel() {
|
||||
return this.models.session
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/ApiKey')} */
|
||||
get apiKeyModel() {
|
||||
return this.models.apiKey
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Library')} */
|
||||
get libraryModel() {
|
||||
return this.models.library
|
||||
|
@ -311,6 +321,8 @@ class Database {
|
|||
|
||||
buildModels(force = false) {
|
||||
require('./models/User').init(this.sequelize)
|
||||
require('./models/Session').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)
|
||||
|
@ -656,6 +668,9 @@ class Database {
|
|||
* Series should have atleast one Book
|
||||
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
||||
* 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
|
||||
|
@ -785,6 +800,40 @@ WHERE EXISTS (
|
|||
where: { id: duplicateMediaProgress.id }
|
||||
})
|
||||
}
|
||||
|
||||
// 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions from the database
|
||||
*/
|
||||
async cleanupExpiredSessions() {
|
||||
try {
|
||||
const deletedCount = await this.sessionModel.cleanupExpiredSessions()
|
||||
if (deletedCount > 0) {
|
||||
Logger.info(`[Database] Cleaned up ${deletedCount} expired sessions`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[Database] Error cleaning up expired sessions: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async createTextSearchQuery(query) {
|
||||
|
|
|
@ -156,14 +156,11 @@ class Server {
|
|||
}
|
||||
|
||||
await Database.init(false)
|
||||
// Create or set JWT secret in token manager
|
||||
await this.auth.tokenManager.initTokenSecret()
|
||||
|
||||
await Logger.logManager.init()
|
||||
|
||||
// Create token secret if does not exist (Added v2.1.0)
|
||||
if (!Database.serverSettings.tokenSecret) {
|
||||
await this.auth.initTokenSecret()
|
||||
}
|
||||
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await CacheManager.ensureCachePaths()
|
||||
|
||||
|
@ -264,7 +261,7 @@ class Server {
|
|||
// enable express-session
|
||||
app.use(
|
||||
expressSession({
|
||||
secret: global.ServerSettings.tokenSecret,
|
||||
secret: this.auth.tokenManager.TokenSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
|
@ -309,7 +306,9 @@ class Server {
|
|||
})
|
||||
)
|
||||
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
|
||||
router.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// Skip JSON parsing for internal-api routes
|
||||
router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' }))
|
||||
|
||||
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
|
||||
router.use('/hls', this.hlsRouter.router)
|
||||
|
@ -404,6 +403,7 @@ class Server {
|
|||
const handle = nextApp.getRequestHandler()
|
||||
await nextApp.prepare()
|
||||
router.get('*', (req, res) => handle(req, res))
|
||||
router.post('/internal-api/*', (req, res) => handle(req, res))
|
||||
}
|
||||
|
||||
const unixSocketPrefix = 'unix/'
|
||||
|
@ -428,7 +428,7 @@ class Server {
|
|||
Logger.info(`[Server] Initializing new server`)
|
||||
const newRoot = req.body.newRoot
|
||||
const rootUsername = newRoot.username || 'root'
|
||||
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
|
||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const SocketIO = require('socket.io')
|
||||
const Logger = require('./Logger')
|
||||
const Database = require('./Database')
|
||||
const Auth = require('./Auth')
|
||||
const TokenManager = require('./auth/TokenManager')
|
||||
|
||||
/**
|
||||
* @typedef SocketClient
|
||||
|
@ -231,18 +231,22 @@ class SocketAuthority {
|
|||
* When setting up a socket connection the user needs to be associated with a socket id
|
||||
* for this the client will send a 'auth' event that includes the users API token
|
||||
*
|
||||
* Sends event 'init' to the socket. For admins this contains an array of users online.
|
||||
* For failed authentication it sends event 'auth_failed' with a message
|
||||
*
|
||||
* @param {SocketIO.Socket} socket
|
||||
* @param {string} token JWT
|
||||
*/
|
||||
async authenticateSocket(socket, token) {
|
||||
// we don't use passport to authenticate the jwt we get over the socket connection.
|
||||
// it's easier to directly verify/decode it.
|
||||
const token_data = Auth.validateAccessToken(token)
|
||||
// TODO: Support API keys for web socket connections
|
||||
const token_data = TokenManager.validateAccessToken(token)
|
||||
|
||||
if (!token_data?.userId) {
|
||||
// Token invalid
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('invalid_token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
}
|
||||
|
||||
// get the user via the id from the decoded jwt.
|
||||
|
@ -250,7 +254,11 @@ class SocketAuthority {
|
|||
if (!user) {
|
||||
// user not found
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('invalid_token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
}
|
||||
if (!user.isActive) {
|
||||
Logger.error('Cannot validate socket - user is not active')
|
||||
return socket.emit('auth_failed', { message: 'Invalid user' })
|
||||
}
|
||||
|
||||
const client = this.clients[socket.id]
|
||||
|
@ -260,13 +268,18 @@ class SocketAuthority {
|
|||
}
|
||||
|
||||
if (client.user !== undefined) {
|
||||
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username)
|
||||
if (client.user.id === user.id) {
|
||||
// Allow re-authentication of a socket to the same user
|
||||
Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`)
|
||||
} else {
|
||||
// Allow re-authentication of a socket to a different user but shouldn't happen
|
||||
Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`)
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`)
|
||||
}
|
||||
|
||||
client.user = user
|
||||
|
||||
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
|
||||
|
||||
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||
|
||||
// Update user lastSeen without firing sequelize bulk update hooks
|
||||
|
|
186
server/auth/LocalAuthStrategy.js
Normal file
186
server/auth/LocalAuthStrategy.js
Normal file
|
@ -0,0 +1,186 @@
|
|||
const passport = require('passport')
|
||||
const LocalStrategy = require('../libs/passportLocal')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const bcrypt = require('../libs/bcryptjs')
|
||||
const requestIp = require('../libs/requestIp')
|
||||
|
||||
/**
|
||||
* Local authentication strategy using username/password
|
||||
*/
|
||||
class LocalAuthStrategy {
|
||||
constructor() {
|
||||
this.name = 'local'
|
||||
this.strategy = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the passport strategy instance
|
||||
* @returns {LocalStrategy}
|
||||
*/
|
||||
getStrategy() {
|
||||
if (!this.strategy) {
|
||||
this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this))
|
||||
}
|
||||
return this.strategy
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the strategy with passport
|
||||
*/
|
||||
init() {
|
||||
passport.use(this.name, this.getStrategy())
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the strategy from passport
|
||||
*/
|
||||
unuse() {
|
||||
passport.unuse(this.name)
|
||||
this.strategy = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user credentials
|
||||
* @param {import('express').Request} req
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {Function} done - Passport callback
|
||||
*/
|
||||
async verifyCredentials(req, username, password, done) {
|
||||
// Load the user given it's username
|
||||
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||
|
||||
if (!user?.isActive) {
|
||||
if (user) {
|
||||
this.logFailedLoginAttempt(req, user.username, 'User is not active')
|
||||
} else {
|
||||
this.logFailedLoginAttempt(req, username, 'User not found')
|
||||
}
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.type === 'root' && !user.pash) {
|
||||
if (password) {
|
||||
// deny login
|
||||
this.logFailedLoginAttempt(req, user.username, 'Root user has no password set')
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
// approve login
|
||||
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||
|
||||
done(null, user)
|
||||
return
|
||||
} else if (!user.pash) {
|
||||
this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID')
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await bcrypt.compare(password, user.pash)
|
||||
if (compare) {
|
||||
// approve login
|
||||
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
// deny login
|
||||
this.logFailedLoginAttempt(req, user.username, 'Invalid password')
|
||||
done(null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed login attempts
|
||||
* @param {import('express').Request} req
|
||||
* @param {string} username
|
||||
* @param {string} message
|
||||
*/
|
||||
logFailedLoginAttempt(req, username, message) {
|
||||
if (!req || !username || !message) return
|
||||
Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password with bcrypt
|
||||
* @param {string} password
|
||||
* @returns {Promise<string>} hash
|
||||
*/
|
||||
hashPassword(password) {
|
||||
return new Promise((resolve) => {
|
||||
bcrypt.hash(password, 8, (err, hash) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(hash)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password with user's hashed password
|
||||
* @param {string} password
|
||||
* @param {import('../models/User')} user
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
return bcrypt.compare(password, user.pash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
* @param {import('../models/User')} user
|
||||
* @param {string} password
|
||||
* @param {string} newPassword
|
||||
*/
|
||||
async changePassword(user, password, newPassword) {
|
||||
// Only root can have an empty password
|
||||
if (user.type !== 'root' && !newPassword) {
|
||||
return {
|
||||
error: 'Invalid new password - Only root can have an empty password'
|
||||
}
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await this.comparePassword(password, user)
|
||||
if (!compare) {
|
||||
return {
|
||||
error: 'Invalid password'
|
||||
}
|
||||
}
|
||||
|
||||
let pw = ''
|
||||
if (newPassword) {
|
||||
pw = await this.hashPassword(newPassword)
|
||||
if (!pw) {
|
||||
return {
|
||||
error: 'Hash failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await user.update({ pash: pw })
|
||||
Logger.info(`[LocalAuth] User "${user.username}" changed password`)
|
||||
return {
|
||||
success: true
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error)
|
||||
return {
|
||||
error: 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LocalAuthStrategy
|
488
server/auth/OidcAuthStrategy.js
Normal file
488
server/auth/OidcAuthStrategy.js
Normal file
|
@ -0,0 +1,488 @@
|
|||
const { Request, Response } = require('express')
|
||||
const passport = require('passport')
|
||||
const OpenIDClient = require('openid-client')
|
||||
const axios = require('axios')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
* OpenID Connect authentication strategy
|
||||
*/
|
||||
class OidcAuthStrategy {
|
||||
constructor() {
|
||||
this.name = 'openid-client'
|
||||
this.strategy = null
|
||||
this.client = null
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the passport strategy instance
|
||||
* @returns {OpenIDClient.Strategy}
|
||||
*/
|
||||
getStrategy() {
|
||||
if (!this.strategy) {
|
||||
this.strategy = new OpenIDClient.Strategy(
|
||||
{
|
||||
client: this.getClient(),
|
||||
params: {
|
||||
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||
scope: this.getScope()
|
||||
}
|
||||
},
|
||||
this.verifyCallback.bind(this)
|
||||
)
|
||||
}
|
||||
return this.strategy
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OpenID Connect client
|
||||
* @returns {OpenIDClient.Client}
|
||||
*/
|
||||
getClient() {
|
||||
if (!this.client) {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
throw new Error('OpenID Connect settings are not valid')
|
||||
}
|
||||
|
||||
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
|
||||
OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 })
|
||||
|
||||
const openIdIssuerClient = new OpenIDClient.Issuer({
|
||||
issuer: global.ServerSettings.authOpenIDIssuerURL,
|
||||
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
||||
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
||||
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
||||
jwks_uri: global.ServerSettings.authOpenIDJwksURL,
|
||||
end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL
|
||||
}).Client
|
||||
|
||||
this.client = new openIdIssuerClient({
|
||||
client_id: global.ServerSettings.authOpenIDClientID,
|
||||
client_secret: global.ServerSettings.authOpenIDClientSecret,
|
||||
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
|
||||
})
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scope string for the OpenID Connect request
|
||||
* @returns {string}
|
||||
*/
|
||||
getScope() {
|
||||
let scope = 'openid profile email'
|
||||
if (global.ServerSettings.authOpenIDGroupClaim) {
|
||||
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
||||
}
|
||||
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
||||
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the strategy with passport
|
||||
*/
|
||||
init() {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)
|
||||
return
|
||||
}
|
||||
passport.use(this.name, this.getStrategy())
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the strategy from passport
|
||||
*/
|
||||
unuse() {
|
||||
passport.unuse(this.name)
|
||||
this.strategy = null
|
||||
this.client = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify callback for OpenID Connect authentication
|
||||
* @param {Object} tokenset
|
||||
* @param {Object} userinfo
|
||||
* @param {Function} done - Passport callback
|
||||
*/
|
||||
async verifyCallback(tokenset, userinfo, done) {
|
||||
try {
|
||||
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||
|
||||
if (!userinfo.sub) {
|
||||
throw new Error('Invalid userinfo, no sub')
|
||||
}
|
||||
|
||||
if (!this.validateGroupClaim(userinfo)) {
|
||||
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||
}
|
||||
|
||||
let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this)
|
||||
|
||||
if (!user?.isActive) {
|
||||
throw new Error('User not active or not found')
|
||||
}
|
||||
|
||||
await this.setUserGroup(user, userinfo)
|
||||
await this.updateUserPermissions(user, userinfo)
|
||||
|
||||
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||
user.openid_id_token = tokenset.id_token
|
||||
|
||||
return done(null, user)
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||
return done(null, null, 'Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the presence and content of the group claim in userinfo.
|
||||
* @param {Object} userinfo
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validateGroupClaim(userinfo) {
|
||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName)
|
||||
// Allow no group claim when configured like this
|
||||
return true
|
||||
|
||||
// If configured it must exist in userinfo
|
||||
if (!userinfo[groupClaimName]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user group based on group claim in userinfo.
|
||||
* @param {import('../models/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
async setUserGroup(user, userinfo) {
|
||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName)
|
||||
// No group claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||
|
||||
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||
|
||||
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||
if (userType) {
|
||||
if (user.type === 'root') {
|
||||
// Check OpenID Group
|
||||
if (userType !== 'admin') {
|
||||
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
|
||||
} else {
|
||||
// If root user is logging in via OpenID, we will not change the type
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (user.type !== userType) {
|
||||
Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
|
||||
user.type = userType
|
||||
await user.save()
|
||||
}
|
||||
} else {
|
||||
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user permissions based on the advanced permissions claim.
|
||||
* @param {import('../models/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
async updateUserPermissions(user, userinfo) {
|
||||
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
|
||||
if (!absPermissionsClaim)
|
||||
// No advanced permissions claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (user.type === 'admin' || user.type === 'root') return
|
||||
|
||||
const absPermissions = userinfo[absPermissionsClaim]
|
||||
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
||||
|
||||
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
|
||||
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE parameters for the authorization request
|
||||
* @param {Request} req
|
||||
* @param {boolean} isMobileFlow
|
||||
* @returns {Object|{error: string}}
|
||||
*/
|
||||
generatePkce(req, isMobileFlow) {
|
||||
if (isMobileFlow) {
|
||||
if (!req.query.code_challenge) {
|
||||
return {
|
||||
error: 'code_challenge required for mobile flow (PKCE)'
|
||||
}
|
||||
}
|
||||
if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
|
||||
return {
|
||||
error: 'Only S256 code_challenge_method method supported'
|
||||
}
|
||||
}
|
||||
return {
|
||||
code_challenge: req.query.code_challenge,
|
||||
code_challenge_method: req.query.code_challenge_method || 'S256'
|
||||
}
|
||||
} else {
|
||||
const code_verifier = OpenIDClient.generators.codeVerifier()
|
||||
const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
||||
return { code_challenge, code_challenge_method: 'S256', code_verifier }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a redirect URI is valid
|
||||
* @param {string} uri
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValidRedirectUri(uri) {
|
||||
// Check if the redirect_uri is in the whitelist
|
||||
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for OpenID Connect
|
||||
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
|
||||
* @param {Request} req
|
||||
* @returns {{ authorizationUrl: string }|{status: number, error: string}}
|
||||
*/
|
||||
getAuthorizationUrl(req) {
|
||||
const client = this.getClient()
|
||||
const strategy = this.getStrategy()
|
||||
const sessionKey = strategy._key
|
||||
|
||||
try {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
||||
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||
|
||||
// Only allow code flow (for mobile clients)
|
||||
if (req.query.response_type && req.query.response_type !== 'code') {
|
||||
Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Invalid response_type, only code supported'
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a state on web flow or if no state supplied
|
||||
const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
|
||||
|
||||
// Redirect URL for the SSO provider
|
||||
let redirectUri
|
||||
if (isMobileFlow) {
|
||||
// Mobile required redirect uri
|
||||
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
|
||||
// where we will handle the redirect to it
|
||||
if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {
|
||||
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Invalid redirect_uri'
|
||||
}
|
||||
}
|
||||
// We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
|
||||
// for the request to mobile-redirect and as such the session is not shared
|
||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||
} else {
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||
|
||||
if (req.query.state) {
|
||||
Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Invalid state, not allowed on web flow'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the strategy's redirect_uri for this request
|
||||
strategy._params.redirect_uri = redirectUri
|
||||
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
||||
|
||||
const pkceData = this.generatePkce(req, isMobileFlow)
|
||||
if (pkceData.error) {
|
||||
return {
|
||||
status: 400,
|
||||
error: pkceData.error
|
||||
}
|
||||
}
|
||||
|
||||
req.session[sessionKey] = {
|
||||
...req.session[sessionKey],
|
||||
state: state,
|
||||
max_age: strategy._params.max_age,
|
||||
response_type: 'code',
|
||||
code_verifier: pkceData.code_verifier, // not null if web flow
|
||||
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
||||
sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||
}
|
||||
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
...strategy._params,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
response_type: 'code',
|
||||
scope: this.getScope(),
|
||||
code_challenge: pkceData.code_challenge,
|
||||
code_challenge_method: pkceData.code_challenge_method
|
||||
})
|
||||
|
||||
return {
|
||||
authorizationUrl,
|
||||
isMobileFlow
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`)
|
||||
return {
|
||||
status: 500,
|
||||
error: error.message || 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end session URL for logout
|
||||
* @param {Request} req
|
||||
* @param {string} idToken
|
||||
* @param {string} authMethod
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getEndSessionUrl(req, idToken, authMethod) {
|
||||
const client = this.getClient()
|
||||
|
||||
if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {
|
||||
let postLogoutRedirectUri = null
|
||||
|
||||
if (authMethod === 'openid') {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const host = req.get('host')
|
||||
// TODO: ABS does currently not support subfolders for installation
|
||||
// If we want to support it we need to include a config for the serverurl
|
||||
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||
}
|
||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
||||
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||
// the post_logout_redirect_uri parameter at all and for other providers
|
||||
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
|
||||
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
|
||||
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
|
||||
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
|
||||
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
|
||||
|
||||
return client.endSessionUrl({
|
||||
id_token_hint: idToken,
|
||||
post_logout_redirect_uri: postLogoutRedirectUri
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} OpenIdIssuerConfig
|
||||
* @property {string} issuer
|
||||
* @property {string} authorization_endpoint
|
||||
* @property {string} token_endpoint
|
||||
* @property {string} userinfo_endpoint
|
||||
* @property {string} end_session_endpoint
|
||||
* @property {string} jwks_uri
|
||||
* @property {string} id_token_signing_alg_values_supported
|
||||
*
|
||||
* Get OpenID Connect configuration from an issuer URL
|
||||
* @param {string} issuerUrl
|
||||
* @returns {Promise<OpenIdIssuerConfig|{status: number, error: string}>}
|
||||
*/
|
||||
async getIssuerConfig(issuerUrl) {
|
||||
// Strip trailing slash
|
||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||
|
||||
// Append config pathname and validate URL
|
||||
let configUrl = null
|
||||
try {
|
||||
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
||||
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
||||
throw new Error('Invalid pathname')
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
||||
return {
|
||||
status: 400,
|
||||
error: "Invalid request. Query param 'issuer' is invalid"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(configUrl.toString())
|
||||
return {
|
||||
issuer: data.issuer,
|
||||
authorization_endpoint: data.authorization_endpoint,
|
||||
token_endpoint: data.token_endpoint,
|
||||
userinfo_endpoint: data.userinfo_endpoint,
|
||||
end_session_endpoint: data.end_session_endpoint,
|
||||
jwks_uri: data.jwks_uri,
|
||||
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Failed to get openid configuration'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mobile redirect for OAuth2 callback
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
handleMobileRedirect(req, res) {
|
||||
try {
|
||||
// Extract the state parameter from the request
|
||||
const { state, code } = req.query
|
||||
|
||||
// Check if the state provided is in our list
|
||||
if (!state || !this.openIdAuthSession.has(state)) {
|
||||
Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
||||
return res.status(400).send('State parameter mismatch')
|
||||
}
|
||||
|
||||
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
||||
|
||||
if (!mobile_redirect_uri) {
|
||||
Logger.error('[OidcAuth] No redirect URI')
|
||||
return res.status(400).send('No redirect URI')
|
||||
}
|
||||
|
||||
this.openIdAuthSession.delete(state)
|
||||
|
||||
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
// Redirect to the overwrite URI saved in the map
|
||||
res.redirect(redirectUri)
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`)
|
||||
res.status(500).send('Internal Server Error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OidcAuthStrategy
|
406
server/auth/TokenManager.js
Normal file
406
server/auth/TokenManager.js
Normal file
|
@ -0,0 +1,406 @@
|
|||
const { Op } = require('sequelize')
|
||||
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const requestIp = require('../libs/requestIp')
|
||||
const jwt = require('../libs/jsonwebtoken')
|
||||
|
||||
class TokenManager {
|
||||
/** @type {string} JWT secret key */
|
||||
static TokenSecret = null
|
||||
|
||||
constructor() {
|
||||
/** @type {number} Refresh token expiry in seconds */
|
||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
|
||||
/** @type {number} Access token expiry in seconds */
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
||||
get TokenSecret() {
|
||||
return TokenManager.TokenSecret
|
||||
}
|
||||
|
||||
/**
|
||||
* Token secret is used to sign and verify JWTs
|
||||
* Set by ENV variable "JWT_SECRET_KEY" or generated and stored on server settings if not set
|
||||
*/
|
||||
async initTokenSecret() {
|
||||
if (process.env.JWT_SECRET_KEY) {
|
||||
// Use user supplied token secret
|
||||
Logger.info('[TokenManager] JWT secret key set from ENV variable')
|
||||
TokenManager.TokenSecret = process.env.JWT_SECRET_KEY
|
||||
} else if (!Database.serverSettings.tokenSecret) {
|
||||
// Generate new token secret and store it on server settings
|
||||
Logger.info('[TokenManager] JWT secret key not found, generating one')
|
||||
TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
Database.serverSettings.tokenSecret = TokenManager.TokenSecret
|
||||
await Database.updateServerSettings()
|
||||
} else {
|
||||
// Use existing token secret from server settings
|
||||
TokenManager.TokenSecret = Database.serverSettings.tokenSecret
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, TokenManager.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 }, TokenManager.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, TokenManager.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, TokenManager.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, TokenManager.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
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a refresh token - used for logout
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async invalidateRefreshToken(refreshToken) {
|
||||
if (!refreshToken) {
|
||||
Logger.error(`[TokenManager] No refresh token provided to invalidate`)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
|
||||
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TokenManager
|
207
server/controllers/ApiKeyController.js
Normal file
207
server/controllers/ApiKeyController.js
Normal file
|
@ -0,0 +1,207 @@
|
|||
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() {}
|
||||
|
||||
/**
|
||||
* GET: /api/api-keys
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getAll(req, res) {
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/api-keys
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @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)
|
||||
}
|
||||
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 apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, 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,
|
||||
userId: req.body.userId,
|
||||
isActive: !!req.body.isActive,
|
||||
createdByUserId: req.user.id
|
||||
})
|
||||
apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({
|
||||
attributes: ['id', 'username', 'type']
|
||||
})
|
||||
|
||||
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
||||
return res.json({
|
||||
apiKey: {
|
||||
apiKey, // Actual key only shown to user on creation
|
||||
...apiKeyInstance.toJSON()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/api-keys/:id
|
||||
* 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, {
|
||||
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)
|
||||
}
|
||||
if (apiKey.isActive !== req.body.isActive) {
|
||||
apiKey.isActive = req.body.isActive
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
await apiKey.save()
|
||||
apiKey.dataValues.user = await apiKey.getUser({
|
||||
attributes: ['id', 'username', 'type']
|
||||
})
|
||||
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
||||
} else {
|
||||
Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
apiKey: apiKey.toJSON()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/api-keys/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
|
||||
if (!apiKey) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await apiKey.destroy()
|
||||
Logger.info(`[ApiKeyController] Deleted API key "${apiKey.name}"`)
|
||||
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @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()
|
|
@ -273,12 +273,24 @@ class MeController {
|
|||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
updatePassword(req, res) {
|
||||
async updatePassword(req, res) {
|
||||
if (req.user.isGuest) {
|
||||
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
|
||||
return res.sendStatus(500)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
this.auth.userChangePassword(req, res)
|
||||
|
||||
const { password, newPassword } = req.body
|
||||
if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') {
|
||||
return res.status(400).send('Missing or invalid password or new password')
|
||||
}
|
||||
|
||||
const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword)
|
||||
|
||||
if (result.error) {
|
||||
return res.status(400).send(result.error)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -127,8 +127,8 @@ class UserController {
|
|||
}
|
||||
|
||||
const userId = uuidv4()
|
||||
const pash = await this.auth.hashPass(req.body.password)
|
||||
const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username })
|
||||
const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)
|
||||
const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
|
||||
const userType = req.body.type || 'user'
|
||||
|
||||
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
|
||||
|
@ -237,6 +237,7 @@ class UserController {
|
|||
|
||||
let hasUpdates = false
|
||||
let shouldUpdateToken = false
|
||||
let shouldInvalidateJwtSessions = false
|
||||
// When changing username create a new API token
|
||||
if (updatePayload.username && updatePayload.username !== user.username) {
|
||||
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
|
||||
|
@ -245,12 +246,13 @@ class UserController {
|
|||
}
|
||||
user.username = updatePayload.username
|
||||
shouldUpdateToken = true
|
||||
shouldInvalidateJwtSessions = true
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Updating password
|
||||
if (updatePayload.password) {
|
||||
user.pash = await this.auth.hashPass(updatePayload.password)
|
||||
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
|
@ -325,9 +327,24 @@ class UserController {
|
|||
|
||||
if (hasUpdates) {
|
||||
if (shouldUpdateToken) {
|
||||
user.token = await this.auth.generateAccessToken(user)
|
||||
user.token = this.auth.generateAccessToken(user)
|
||||
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
||||
}
|
||||
|
||||
// Handle JWT session invalidation for username changes
|
||||
if (shouldInvalidateJwtSessions) {
|
||||
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
||||
if (newAccessToken) {
|
||||
user.accessToken = newAccessToken
|
||||
// Refresh tokens are only returned for mobile clients
|
||||
// Mobile apps currently do not use this API endpoint so always set to null
|
||||
user.refreshToken = null
|
||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
|
||||
} else {
|
||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
|
||||
}
|
||||
}
|
||||
|
||||
await user.save()
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
|
||||
}
|
||||
|
|
|
@ -31,10 +31,12 @@ class CronManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initialize open session cleanup cron
|
||||
* Initialize open session & auth session cleanup cron
|
||||
* Runs every day at 00:30
|
||||
* 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() {
|
||||
|
@ -42,6 +44,8 @@ class CronManager {
|
|||
Logger.debug('[CronManager] Open session cleanup cron executing')
|
||||
ShareManager.closeStaleOpenShareSessions()
|
||||
await this.playbackSessionManager.closeStaleOpenSessions()
|
||||
await Database.cleanupExpiredSessions()
|
||||
await Database.deactivateExpiredApiKeys()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
163
server/migrations/v2.26.0-create-auth-tables.js
Normal file
163
server/migrations/v2.26.0-create-auth-tables.js
Normal file
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.26.0'
|
||||
const migrationName = `${migrationVersion}-create-auth-tables`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration creates a sessions table and apiKeys table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
logger.info(`${loggerPrefix} table "sessions" already exists`)
|
||||
} else {
|
||||
// Create table
|
||||
logger.info(`${loggerPrefix} creating table "sessions"`)
|
||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||
await queryInterface.createTable('sessions', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
ipAddress: DataTypes.STRING,
|
||||
userAgent: DataTypes.STRING,
|
||||
refreshToken: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
allowNull: false,
|
||||
onDelete: 'CASCADE'
|
||||
}
|
||||
})
|
||||
logger.info(`${loggerPrefix} created table "sessions"`)
|
||||
}
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('apiKeys')) {
|
||||
logger.info(`${loggerPrefix} table "apiKeys" already exists`)
|
||||
} else {
|
||||
// Create table
|
||||
logger.info(`${loggerPrefix} creating table "apiKeys"`)
|
||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||
await queryInterface.createTable('apiKeys', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
expiresAt: DataTypes.DATE,
|
||||
lastUsedAt: DataTypes.DATE,
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
permissions: DataTypes.JSON,
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users',
|
||||
as: 'createdByUser'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'SET NULL'
|
||||
}
|
||||
})
|
||||
logger.info(`${loggerPrefix} created table "apiKeys"`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script removes the sessions table and apiKeys table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
logger.info(`${loggerPrefix} dropping table "sessions"`)
|
||||
// Drop table
|
||||
await queryInterface.dropTable('sessions')
|
||||
logger.info(`${loggerPrefix} dropped table "sessions"`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} table "sessions" does not exist`)
|
||||
}
|
||||
|
||||
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 "apiKeys" does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
272
server/models/ApiKey.js
Normal file
272
server/models/ApiKey.js
Normal file
|
@ -0,0 +1,272 @@
|
|||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { LRUCache } = require('lru-cache')
|
||||
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 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)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {Date} */
|
||||
this.lastUsedAt
|
||||
/** @type {boolean} */
|
||||
this.isActive
|
||||
/** @type {ApiKeyPermissions} */
|
||||
this.permissions
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {UUIDV4} */
|
||||
this.createdByUserId
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate expired api keys
|
||||
* @returns {Promise<number>} Number of api keys affected
|
||||
*/
|
||||
static async deactivateExpiredApiKeys() {
|
||||
const [affectedCount] = await ApiKey.update(
|
||||
{
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
where: {
|
||||
isActive: true,
|
||||
expiresAt: {
|
||||
[Op.lt]: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return affectedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new api key
|
||||
* @param {string} tokenSecret
|
||||
* @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(tokenSecret, keyId, name, expiresIn) {
|
||||
const options = {}
|
||||
if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) {
|
||||
options.expiresIn = expiresIn
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
jwt.sign(
|
||||
{
|
||||
keyId,
|
||||
name,
|
||||
type: 'api'
|
||||
},
|
||||
tokenSecret,
|
||||
options,
|
||||
(err, token) => {
|
||||
if (err) {
|
||||
Logger.error(`[ApiKey] Error generating API key: ${err}`)
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(token)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
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: '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)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ApiKey
|
88
server/models/Session.js
Normal file
88
server/models/Session.js
Normal file
|
@ -0,0 +1,88 @@
|
|||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
|
||||
class Session extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.ipAddress
|
||||
/** @type {string} */
|
||||
this.userAgent
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./User').User} */
|
||||
this.user
|
||||
}
|
||||
|
||||
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {
|
||||
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions from the database
|
||||
* @returns {Promise<number>} Number of sessions deleted
|
||||
*/
|
||||
static async cleanupExpiredSessions() {
|
||||
const deletedCount = await Session.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
|
||||
},
|
||||
ipAddress: DataTypes.STRING,
|
||||
userAgent: DataTypes.STRING,
|
||||
refreshToken: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'session'
|
||||
}
|
||||
)
|
||||
|
||||
const { user } = sequelize.models
|
||||
user.hasMany(Session, {
|
||||
onDelete: 'CASCADE',
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
}
|
||||
})
|
||||
Session.belongsTo(user)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Session
|
|
@ -190,7 +190,7 @@ class User extends Model {
|
|||
static async createRootUser(username, pash, auth) {
|
||||
const userId = uuidv4()
|
||||
|
||||
const token = await auth.generateAccessToken({ id: userId, username })
|
||||
const token = auth.generateAccessToken({ id: userId, username })
|
||||
|
||||
const newUser = {
|
||||
id: userId,
|
||||
|
@ -208,6 +208,96 @@ class User extends Model {
|
|||
return this.create(newUser)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an existing user by OpenID subject identifier, or by email/username based on server settings,
|
||||
* or creates a new user if configured to do so.
|
||||
*
|
||||
* @param {Object} userinfo
|
||||
* @param {import('../Auth')} auth
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
static async findOrCreateUserFromOpenIdUserInfo(userinfo, auth) {
|
||||
let user = await this.getUserByOpenIDSub(userinfo.sub)
|
||||
|
||||
// Matched by sub
|
||||
if (user) {
|
||||
Logger.debug(`[User] openid: User found by sub`)
|
||||
return user
|
||||
}
|
||||
|
||||
// Match existing user by email
|
||||
if (global.ServerSettings.authOpenIDMatchExistingBy === 'email') {
|
||||
if (userinfo.email) {
|
||||
// Only disallow when email_verified explicitly set to false (allow both if not set or true)
|
||||
if (userinfo.email_verified === false) {
|
||||
Logger.warn(`[User] openid: User not found and email "${userinfo.email}" is not verified`)
|
||||
return null
|
||||
} else {
|
||||
Logger.info(`[User] openid: User not found, checking existing with email "${userinfo.email}"`)
|
||||
user = await this.getUserByEmail(userinfo.email)
|
||||
|
||||
if (user?.authOpenIDSub) {
|
||||
Logger.warn(`[User] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
|
||||
return null // User is linked to a different OpenID subject; do not proceed.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.warn(`[User] openid: User not found and no email in userinfo`)
|
||||
// We deny login, because if the admin whishes to match email, it makes sense to require it
|
||||
return null
|
||||
}
|
||||
}
|
||||
// Match existing user by username
|
||||
else if (global.ServerSettings.authOpenIDMatchExistingBy === 'username') {
|
||||
let username
|
||||
|
||||
if (userinfo.preferred_username) {
|
||||
Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`)
|
||||
username = userinfo.preferred_username
|
||||
} else if (userinfo.username) {
|
||||
Logger.info(`[User] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`)
|
||||
username = userinfo.username
|
||||
} else {
|
||||
Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`)
|
||||
return null
|
||||
}
|
||||
|
||||
user = await this.getUserByUsername(username)
|
||||
|
||||
if (user?.authOpenIDSub) {
|
||||
Logger.warn(`[User] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`)
|
||||
return null // User is linked to a different OpenID subject; do not proceed.
|
||||
}
|
||||
}
|
||||
|
||||
// Found existing user via email or username
|
||||
if (user) {
|
||||
if (!user.isActive) {
|
||||
Logger.warn(`[User] openid: User found but is not active`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Update user with OpenID sub
|
||||
if (!user.extraData) user.extraData = {}
|
||||
user.extraData.authOpenIDSub = userinfo.sub
|
||||
user.changed('extraData', true)
|
||||
await user.save()
|
||||
|
||||
Logger.debug(`[User] openid: User found by email/username`)
|
||||
return user
|
||||
}
|
||||
|
||||
// If no existing user was matched, auto-register if configured
|
||||
if (global.ServerSettings.authOpenIDAutoRegister) {
|
||||
Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
|
||||
user = await this.createUserFromOpenIdUserInfo(userinfo, auth)
|
||||
return user
|
||||
}
|
||||
|
||||
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user from openid userinfo
|
||||
* @param {Object} userinfo
|
||||
|
@ -220,7 +310,7 @@ class User extends Model {
|
|||
const username = userinfo.preferred_username || userinfo.name || userinfo.sub
|
||||
const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
|
||||
|
||||
const token = await auth.generateAccessToken({ id: userId, username })
|
||||
const token = auth.generateAccessToken({ id: userId, username })
|
||||
|
||||
const newUser = {
|
||||
id: userId,
|
||||
|
@ -520,7 +610,11 @@ class User extends Model {
|
|||
username: this.username,
|
||||
email: this.email,
|
||||
type: this.type,
|
||||
// TODO: Old non-expiring token
|
||||
token: this.type === 'root' && hideRootToken ? '' : this.token,
|
||||
// TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck
|
||||
// Necessary to detect apps using old tokens that no longer match the old token stored on the user
|
||||
isOldToken: this.isOldToken,
|
||||
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
|
||||
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
|
||||
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
|
||||
|
|
|
@ -7,6 +7,7 @@ const User = require('../../models/User')
|
|||
class ServerSettings {
|
||||
constructor(settings) {
|
||||
this.id = 'server-settings'
|
||||
/** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */
|
||||
this.tokenSecret = null
|
||||
|
||||
// Scanner
|
||||
|
|
|
@ -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) {
|
||||
|
@ -181,7 +182,7 @@ class ApiRouter {
|
|||
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
|
||||
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
|
||||
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
|
||||
this.router.patch('/me/password', MeController.updatePassword.bind(this))
|
||||
this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this))
|
||||
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
|
||||
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
|
||||
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
|
||||
|
@ -325,6 +326,14 @@ 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.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this))
|
||||
this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this))
|
||||
this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))
|
||||
this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this))
|
||||
|
||||
//
|
||||
// Misc Routes
|
||||
//
|
||||
|
|
|
@ -206,6 +206,11 @@ class LibraryItemScanner {
|
|||
async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {
|
||||
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)
|
||||
|
||||
if (!libraryItemScanData.libraryFiles.length) {
|
||||
Logger.info(`[LibraryItemScanner] Library item at path "${libraryItemPath}" has no files - ignoring`)
|
||||
return null
|
||||
}
|
||||
|
||||
const scanLogger = new ScanLogger()
|
||||
scanLogger.verbose = true
|
||||
scanLogger.setData('libraryItem', libraryItemScanData.relPath)
|
||||
|
|
|
@ -606,6 +606,11 @@ class LibraryScanner {
|
|||
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
|
||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`)
|
||||
continue
|
||||
} else if (!(await fs.pathExists(fullPath))) {
|
||||
Logger.info(`[LibraryScanner] File update group "${itemDir}" does not exist - ignoring`)
|
||||
|
||||
itemGroupingResults[itemDir] = ScanResult.NOTHING
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if a library item is a subdirectory of this dir
|
||||
|
|
|
@ -109,7 +109,7 @@ function getIno(path) {
|
|||
.stat(path, { bigint: true })
|
||||
.then((data) => String(data.ino))
|
||||
.catch((err) => {
|
||||
Logger.error('[Utils] Failed to get ino for path', path, err)
|
||||
Logger.warn(`[Utils] Failed to get ino for path "${path}"`, err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
|
82
server/utils/rateLimiterFactory.js
Normal file
82
server/utils/rateLimiterFactory.js
Normal file
|
@ -0,0 +1,82 @@
|
|||
const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit')
|
||||
const Logger = require('../Logger')
|
||||
const requestIp = require('../libs/requestIp')
|
||||
|
||||
/**
|
||||
* Factory for creating authentication rate limiters
|
||||
*/
|
||||
class RateLimiterFactory {
|
||||
static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes
|
||||
static DEFAULT_MAX = 40 // 40 attempts
|
||||
|
||||
constructor() {
|
||||
this.authRateLimiter = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiter
|
||||
* @returns {RateLimitRequestHandler}
|
||||
*/
|
||||
getAuthRateLimiter() {
|
||||
if (this.authRateLimiter) {
|
||||
return this.authRateLimiter
|
||||
}
|
||||
|
||||
// Disable by setting max to 0
|
||||
if (process.env.RATE_LIMIT_AUTH_MAX === '0') {
|
||||
this.authRateLimiter = (req, res, next) => next()
|
||||
Logger.info(`[RateLimiterFactory] Authentication rate limiting disabled by ENV variable`)
|
||||
return this.authRateLimiter
|
||||
}
|
||||
|
||||
let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS
|
||||
if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) {
|
||||
windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW)
|
||||
if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) {
|
||||
Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`)
|
||||
}
|
||||
}
|
||||
|
||||
let max = RateLimiterFactory.DEFAULT_MAX
|
||||
if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) {
|
||||
max = parseInt(process.env.RATE_LIMIT_AUTH_MAX)
|
||||
if (max !== RateLimiterFactory.DEFAULT_MAX) {
|
||||
Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`)
|
||||
}
|
||||
}
|
||||
|
||||
let message = 'Too many authentication requests'
|
||||
if (process.env.RATE_LIMIT_AUTH_MESSAGE) {
|
||||
message = process.env.RATE_LIMIT_AUTH_MESSAGE
|
||||
}
|
||||
|
||||
this.authRateLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
// Override keyGenerator to handle proxy IPs
|
||||
return requestIp.getClientIp(req) || req.ip
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const userAgent = req.get('User-Agent') || 'Unknown'
|
||||
const endpoint = req.path
|
||||
const method = req.method
|
||||
const ip = requestIp.getClientIp(req) || req.ip
|
||||
|
||||
Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`)
|
||||
|
||||
res.status(429).json({
|
||||
error: message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`)
|
||||
|
||||
return this.authRateLimiter
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RateLimiterFactory()
|
Loading…
Add table
Add a link
Reference in a new issue