mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-03 06:29:42 +00:00
Merge 09ab781cd5 into 1d0b7e383a
This commit is contained in:
commit
ecac07d7d8
9 changed files with 319 additions and 2 deletions
|
|
@ -8,6 +8,7 @@ const Logger = require('./Logger')
|
|||
const TokenManager = require('./auth/TokenManager')
|
||||
const LocalAuthStrategy = require('./auth/LocalAuthStrategy')
|
||||
const OidcAuthStrategy = require('./auth/OidcAuthStrategy')
|
||||
const ProxyAuthStrategy = require('./auth/ProxyAuthStrategy')
|
||||
|
||||
const RateLimiterFactory = require('./utils/rateLimiterFactory')
|
||||
const { escapeRegExp } = require('./utils')
|
||||
|
|
@ -26,6 +27,7 @@ class Auth {
|
|||
this.tokenManager = new TokenManager()
|
||||
this.localAuthStrategy = new LocalAuthStrategy()
|
||||
this.oidcAuthStrategy = new OidcAuthStrategy()
|
||||
this.proxyAuthStrategy = new ProxyAuthStrategy()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,6 +61,31 @@ class Auth {
|
|||
* @param {NextFunction} next
|
||||
*/
|
||||
isAuthenticated(req, res, next) {
|
||||
// If proxy auth is enabled and configured, try proxy auth first
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes('proxy') && global.ServerSettings.authProxyHeaderName) {
|
||||
const headerName = global.ServerSettings.authProxyHeaderName
|
||||
const username = req.get(headerName)
|
||||
|
||||
if (username) {
|
||||
// Try proxy authentication first
|
||||
return passport.authenticate('proxy', { session: false }, (err, user, info) => {
|
||||
if (err) {
|
||||
Logger.error('[Auth] Proxy authentication error:', err)
|
||||
return next(err)
|
||||
}
|
||||
if (user) {
|
||||
// Proxy auth succeeded
|
||||
req.user = user
|
||||
return next()
|
||||
}
|
||||
// Proxy auth failed, fall back to JWT
|
||||
Logger.debug('[Auth] Proxy auth failed, falling back to JWT:', info?.message)
|
||||
return passport.authenticate('jwt', { session: false })(req, res, next)
|
||||
})(req, res, next)
|
||||
}
|
||||
}
|
||||
|
||||
// No proxy auth or no header present, use JWT authentication
|
||||
return passport.authenticate('jwt', { session: false })(req, res, next)
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +146,11 @@ class Auth {
|
|||
this.oidcAuthStrategy.init()
|
||||
}
|
||||
|
||||
// Check if we should load the proxy strategy
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes('proxy')) {
|
||||
this.proxyAuthStrategy.init()
|
||||
}
|
||||
|
||||
// Load the JwtStrategy (always) -> for bearer token auth
|
||||
passport.use(
|
||||
new JwtStrategy(
|
||||
|
|
@ -171,6 +203,8 @@ class Auth {
|
|||
this.oidcAuthStrategy.unuse()
|
||||
} else if (name === 'local') {
|
||||
this.localAuthStrategy.unuse()
|
||||
} else if (name === 'proxy') {
|
||||
this.proxyAuthStrategy.unuse()
|
||||
} else {
|
||||
Logger.error('[Auth] Invalid auth strategy ' + name)
|
||||
}
|
||||
|
|
@ -186,6 +220,8 @@ class Auth {
|
|||
this.oidcAuthStrategy.init()
|
||||
} else if (name === 'local') {
|
||||
this.localAuthStrategy.init()
|
||||
} else if (name === 'proxy') {
|
||||
this.proxyAuthStrategy.init()
|
||||
} else {
|
||||
Logger.error('[Auth] Invalid auth strategy ' + name)
|
||||
}
|
||||
|
|
@ -325,6 +361,18 @@ class Auth {
|
|||
res.json(userResponse)
|
||||
})
|
||||
|
||||
// Proxy strategy login route (reads username from header)
|
||||
router.post('/auth/proxy', this.authRateLimiter, passport.authenticate('proxy'), async (req, res) => {
|
||||
// Check if mobile app wants refresh token in response
|
||||
const returnTokens = req.headers['x-return-tokens'] === 'true'
|
||||
|
||||
// Set auth method cookie for proxy authentication
|
||||
res.cookie('auth_method', 'proxy', { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
|
||||
|
||||
const userResponse = await this.handleLoginSuccess(req, res, returnTokens)
|
||||
res.json(userResponse)
|
||||
})
|
||||
|
||||
// Refresh token route
|
||||
router.post('/auth/refresh', this.authRateLimiter, async (req, res) => {
|
||||
let refreshToken = req.cookies.refresh_token
|
||||
|
|
@ -501,6 +549,12 @@ class Auth {
|
|||
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
|
||||
logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod)
|
||||
res.clearCookie('openid_id_token')
|
||||
} else if (authMethod === 'proxy') {
|
||||
// Use configured proxy logout URL if available
|
||||
if (global.ServerSettings.authProxyLogoutURL) {
|
||||
logoutUrl = global.ServerSettings.authProxyLogoutURL
|
||||
Logger.info(`[Auth] Redirecting proxy user to configured logout URL: ${logoutUrl}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the user agent (browser) to redirect to the authentification provider's logout URL
|
||||
|
|
|
|||
95
server/auth/ProxyAuthStrategy.js
Normal file
95
server/auth/ProxyAuthStrategy.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const passport = require('passport')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
* Proxy authentication strategy using configurable header
|
||||
* Reads username from header set by proxy middleware
|
||||
*/
|
||||
class ProxyAuthStrategy {
|
||||
constructor() {
|
||||
this.name = 'proxy'
|
||||
}
|
||||
|
||||
/**
|
||||
* Passport authenticate method
|
||||
* @param {import('express').Request} req
|
||||
* @param {Object} options
|
||||
*/
|
||||
authenticate(req, options) {
|
||||
const headerName = global.ServerSettings.authProxyHeaderName
|
||||
|
||||
if (!headerName) {
|
||||
Logger.warn(`[ProxyAuthStrategy] Proxy header name not configured`)
|
||||
return this.fail({ message: 'Proxy header name not configured' }, 500)
|
||||
}
|
||||
|
||||
const username = req.get(headerName)
|
||||
|
||||
if (!username) {
|
||||
Logger.warn(`[ProxyAuthStrategy] No ${headerName} header found`)
|
||||
return this.fail({ message: `No ${headerName} header found` }, 401)
|
||||
}
|
||||
|
||||
let clientIp = req.ip || req.socket?.remoteAddress || 'Unknown'
|
||||
// Clean up IPv6-mapped IPv4 addresses (::ffff:192.168.1.1 -> 192.168.1.1)
|
||||
if (clientIp.startsWith('::ffff:')) {
|
||||
clientIp = clientIp.substring(7)
|
||||
}
|
||||
|
||||
this.verifyUser(username)
|
||||
.then(user => {
|
||||
Logger.debug(`[ProxyAuthStrategy] Successful proxy login for "${user.username}" from IP ${clientIp}`)
|
||||
return this.success(user)
|
||||
})
|
||||
.catch(error => {
|
||||
Logger.warn(`[ProxyAuthStrategy] Failed login attempt for "${username}" from IP ${clientIp}: ${error.message}`)
|
||||
return this.fail({ message: error.message }, 401)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the strategy with passport
|
||||
*/
|
||||
init() {
|
||||
passport.use(this.name, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the strategy from passport
|
||||
*/
|
||||
unuse() {
|
||||
passport.unuse(this.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user from proxy header
|
||||
* @param {string} username
|
||||
* @returns {Promise<Object>} User object
|
||||
*/
|
||||
async verifyUser(username) {
|
||||
const normalizedUsername = username.trim().toLowerCase()
|
||||
|
||||
if (!normalizedUsername) {
|
||||
throw new Error('Empty username')
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserByUsername(normalizedUsername)
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found')
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new Error('User account is disabled')
|
||||
}
|
||||
|
||||
// Update user's last seen
|
||||
user.lastSeen = new Date()
|
||||
await user.save()
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProxyAuthStrategy
|
||||
|
|
@ -771,5 +771,32 @@ class MiscController {
|
|||
currentDailyLogs: Logger.logManager.getMostRecentCurrentDailyLogs()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/test-proxy-header
|
||||
* Test proxy header endpoint
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
testProxyHeader(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to test proxy header`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const headerName = req.query.headerName
|
||||
if (!headerName) {
|
||||
return res.status(400).json({ message: 'Header name is required' })
|
||||
}
|
||||
|
||||
const headerValue = req.headers[headerName.toLowerCase()]
|
||||
|
||||
res.json({
|
||||
headerFound: !!headerValue,
|
||||
headerValue: headerValue || null,
|
||||
headerName: headerName
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = new MiscController()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ class ServerSettings {
|
|||
this.authLoginCustomMessage = null
|
||||
this.authActiveAuthMethods = ['local']
|
||||
|
||||
// Proxy authentication settings
|
||||
this.authProxyHeaderName = null
|
||||
this.authProxyLogoutURL = null
|
||||
|
||||
// openid settings
|
||||
this.authOpenIDIssuerURL = null
|
||||
this.authOpenIDAuthorizationURL = null
|
||||
|
|
@ -147,10 +151,21 @@ class ServerSettings {
|
|||
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
||||
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
||||
|
||||
this.authProxyHeaderName = settings.authProxyHeaderName || null
|
||||
this.authProxyLogoutURL = settings.authProxyLogoutURL || null
|
||||
|
||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||
this.authActiveAuthMethods = ['local']
|
||||
}
|
||||
|
||||
// Environment variable to enable proxy authentication (only during initialization)
|
||||
if (process.env.AUTH_PROXY_ENABLED === 'true' || process.env.AUTH_PROXY_ENABLED === '1') {
|
||||
if (!this.authActiveAuthMethods.includes('proxy')) {
|
||||
Logger.info(`[ServerSettings] Enabling proxy authentication from environment variable AUTH_PROXY_ENABLED`)
|
||||
this.authActiveAuthMethods.push('proxy')
|
||||
}
|
||||
}
|
||||
|
||||
// remove uninitialized methods
|
||||
// OpenID
|
||||
if (this.authActiveAuthMethods.includes('openid') && !this.isOpenIDAuthSettingsValid) {
|
||||
|
|
@ -200,6 +215,16 @@ class ServerSettings {
|
|||
Logger.info(`[ServerSettings] Using allowIframe from environment variable`)
|
||||
this.allowIframe = true
|
||||
}
|
||||
|
||||
// Proxy authentication environment override
|
||||
if (process.env.AUTH_PROXY_HEADER_NAME) {
|
||||
Logger.info(`[ServerSettings] Using proxy header name from environment variable: ${process.env.AUTH_PROXY_HEADER_NAME}`)
|
||||
this.authProxyHeaderName = process.env.AUTH_PROXY_HEADER_NAME
|
||||
}
|
||||
if (process.env.AUTH_PROXY_LOGOUT_URL) {
|
||||
Logger.info(`[ServerSettings] Using proxy logout URL from environment variable: ${process.env.AUTH_PROXY_LOGOUT_URL}`)
|
||||
this.authProxyLogoutURL = process.env.AUTH_PROXY_LOGOUT_URL
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
|
@ -239,6 +264,8 @@ class ServerSettings {
|
|||
buildNumber: this.buildNumber,
|
||||
authLoginCustomMessage: this.authLoginCustomMessage,
|
||||
authActiveAuthMethods: this.authActiveAuthMethods,
|
||||
authProxyHeaderName: this.authProxyHeaderName,
|
||||
authProxyLogoutURL: this.authProxyLogoutURL,
|
||||
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
|
||||
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
|
||||
authOpenIDTokenURL: this.authOpenIDTokenURL,
|
||||
|
|
@ -271,7 +298,7 @@ class ServerSettings {
|
|||
}
|
||||
|
||||
get supportedAuthMethods() {
|
||||
return ['local', 'openid']
|
||||
return ['local', 'openid', 'proxy']
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -285,6 +312,8 @@ class ServerSettings {
|
|||
return {
|
||||
authLoginCustomMessage: this.authLoginCustomMessage,
|
||||
authActiveAuthMethods: this.authActiveAuthMethods,
|
||||
authProxyHeaderName: this.authProxyHeaderName,
|
||||
authProxyLogoutURL: this.authProxyLogoutURL,
|
||||
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
|
||||
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
|
||||
authOpenIDTokenURL: this.authOpenIDTokenURL,
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ class ApiRouter {
|
|||
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
|
||||
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
|
||||
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
|
||||
this.router.get('/test-proxy-header', MiscController.testProxyHeader.bind(this))
|
||||
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
|
||||
this.router.get('/logger-data', MiscController.getLoggerData.bind(this))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue