feat: proxy authenfication added

This commit is contained in:
alex-sviridov 2025-09-29 12:17:59 +02:00
parent 03da194953
commit 4875125ae9
6 changed files with 315 additions and 2 deletions

View File

@ -122,6 +122,32 @@
</div>
</transition>
</div>
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="enableProxyAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderProxyAuthentication }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/reverse_proxy_authentication" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
</div>
<transition name="slide">
<div v-if="enableProxyAuth" class="flex flex-wrap pt-4">
<ui-text-input-with-label ref="proxyHeaderName" v-model="newAuthSettings.authProxyHeaderName" :disabled="savingSettings" :label="$strings.LabelProxyHeaderName" :placeholder="'X-Remote-User'" class="mb-2" />
<p class="text-sm text-gray-300 mb-4">
{{ $strings.LabelProxyHeaderNameDescription }}
</p>
<ui-text-input-with-label ref="proxyLogoutURL" v-model="newAuthSettings.authProxyLogoutURL" :disabled="savingSettings" :label="$strings.LabelProxyLogoutUrl" :placeholder="'https://proxy.example.com/logout'" class="mb-2" />
<p class="text-sm text-gray-300 mb-4">
{{ $strings.LabelProxyLogoutUrlDescription }}
</p>
</div>
</transition>
</div>
<div class="w-full flex items-center justify-between p-4">
<p v-if="enableOpenIDAuth" class="text-sm text-warning">{{ $strings.MessageAuthenticationOIDCChangesRestart }}</p>
<ui-btn color="bg-success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
@ -154,6 +180,7 @@ export default {
return {
enableLocalAuth: false,
enableOpenIDAuth: false,
enableProxyAuth: false,
showCustomLoginMessage: false,
savingSettings: false,
openIdSigningAlgorithmsSupportedByIssuer: [],
@ -323,7 +350,7 @@ export default {
return isValid
},
async saveSettings() {
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
if (!this.enableLocalAuth && !this.enableOpenIDAuth && !this.enableProxyAuth) {
this.$toast.error('Must have at least one authentication method enabled')
return
}
@ -332,6 +359,11 @@ export default {
return
}
if (this.enableProxyAuth && !this.newAuthSettings.authProxyHeaderName?.trim()) {
this.$toast.error('Authentication Header Name is required for proxy authentication')
return
}
if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {
this.newAuthSettings.authLoginCustomMessage = null
}
@ -339,6 +371,7 @@ export default {
this.newAuthSettings.authActiveAuthMethods = []
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
if (this.enableProxyAuth) this.newAuthSettings.authActiveAuthMethods.push('proxy')
this.savingSettings = true
this.$axios
@ -366,6 +399,7 @@ export default {
}
this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid')
this.enableProxyAuth = this.authMethods.includes('proxy')
this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage
}
},

View File

@ -222,6 +222,32 @@ export default {
}
this.processing = false
},
async attemptProxyAuth() {
this.error = null
this.processing = true
try {
const authRes = await this.$axios.$post('/auth/proxy').catch((error) => {
console.error('Proxy auth failed', error.response)
if (error.response?.data?.message) {
this.error = error.response.data.message
}
return false
})
if (authRes?.error) {
this.error = authRes.error
} else if (authRes) {
this.setUser(authRes)
return
}
} catch (error) {
console.error('Proxy auth error', error)
this.error = 'Proxy authentication failed'
}
this.processing = false
},
checkAuth() {
const token = localStorage.getItem('token')
if (!token) return false
@ -307,6 +333,11 @@ export default {
} else {
this.login_openid = false
}
if (authMethods.includes('proxy')) {
// Auto-attempt proxy authentication
this.attemptProxyAuth()
}
}
},
async mounted() {

View File

@ -175,6 +175,7 @@
"HeaderOpenListeningSessions": "Open Listening Sessions",
"HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderOtherFiles": "Other Files",
"HeaderProxyAuthentication": "Proxy Authentication",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue",
@ -531,6 +532,10 @@
"LabelProgress": "Progress",
"LabelProvider": "Provider",
"LabelProviderAuthorizationValue": "Authorization Header Value",
"LabelProxyHeaderName": "Authentication Header Name",
"LabelProxyHeaderNameDescription": "The name of the header that your proxy uses to pass the authenticated username to Audiobookshelf.",
"LabelProxyLogoutUrl": "Custom Logout URL",
"LabelProxyLogoutUrlDescription": "The URL users will be redirected to when they click the logout button. This should log the user out of your authenfication proxy.",
"LabelPubDate": "Pub Date",
"LabelPublishYear": "Publish Year",
"LabelPublishedDate": "Published {0}",

View File

@ -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

View File

@ -0,0 +1,168 @@
const passport = require('passport')
const Database = require('../Database')
const Logger = require('../Logger')
/**
* Custom strategy for proxy authentication
* Reads username from configurable header set by proxy middleware
*/
class ProxyStrategy {
constructor(verify) {
this.name = 'proxy'
this.verify = verify
}
authenticate(req, options) {
const headerName = global.ServerSettings.authProxyHeaderName
const ip = req.ip || req.connection?.remoteAddress || 'Unknown'
const method = req.method
const url = req.originalUrl || req.url
// Log all proxy auth attempts for debugging
Logger.debug(`[ProxyAuthStrategy] ${method} ${url} from IP ${ip}`)
Logger.debug(`[ProxyAuthStrategy] Configured header name: ${headerName}`)
// Log all headers for debugging (but mask sensitive ones)
const headers = {}
for (const [key, value] of Object.entries(req.headers)) {
if (key.toLowerCase().includes('authorization') || key.toLowerCase().includes('cookie')) {
headers[key] = '[MASKED]'
} else {
headers[key] = value
}
}
Logger.debug(`[ProxyAuthStrategy] Request headers:`, headers)
if (!headerName) {
Logger.warn(`[ProxyAuthStrategy] Proxy header name not configured for ${method} ${url} from IP ${ip}`)
return this.fail({ message: 'Proxy header name not configured' }, 500)
}
const username = req.get(headerName)
Logger.debug(`[ProxyAuthStrategy] Header ${headerName} value: "${username}"`)
if (!username) {
Logger.warn(`[ProxyAuthStrategy] No ${headerName} header found for ${method} ${url} from IP ${ip}`)
return this.fail({ message: `No ${headerName} header found` }, 401)
}
const verified = (err, user, info) => {
if (err) {
return this.error(err)
}
if (!user) {
return this.fail(info, 401)
}
return this.success(user, info)
}
try {
this.verify(req, username, verified)
} catch (ex) {
return this.error(ex)
}
}
}
/**
* Proxy authentication strategy using configurable header
*/
class ProxyAuthStrategy {
constructor() {
this.name = 'proxy'
this.strategy = null
}
/**
* Get the passport strategy instance
* @returns {ProxyStrategy}
*/
getStrategy() {
if (!this.strategy) {
this.strategy = new ProxyStrategy(this.verifyUser.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 from proxy header
* @param {import('express').Request} req
* @param {string} username
* @param {Function} done - Passport callback
*/
async verifyUser(req, username, done) {
try {
// Normalize username (trim and lowercase, following existing pattern)
const normalizedUsername = username.trim().toLowerCase()
if (!normalizedUsername) {
const headerName = global.ServerSettings.authProxyHeaderName
this.logFailedLoginAttempt(req, username, `Empty username in ${headerName} header`)
return done(null, false, { message: `Invalid username in ${headerName} header` })
}
// Look up user in database
let user = await Database.userModel.getUserByUsername(normalizedUsername)
if (user && !user.isActive) {
this.logFailedLoginAttempt(req, normalizedUsername, 'User is not active')
return done(null, false, { message: 'User account is disabled' })
}
if (!user) {
this.logFailedLoginAttempt(req, normalizedUsername, 'User not found')
return done(null, false, { message: 'User not found' })
}
// Update user's last seen
user.lastSeen = new Date()
await user.save()
this.logSuccessfulLoginAttempt(req, user.username)
return done(null, user)
} catch (error) {
Logger.error(`[ProxyAuthStrategy] Authentication error:`, error)
return done(error)
}
}
/**
* Log failed login attempt
* @param {import('express').Request} req
* @param {string} username
* @param {string} reason
*/
logFailedLoginAttempt(req, username, reason) {
const ip = req.ip || req.connection?.remoteAddress || 'Unknown'
Logger.warn(`[ProxyAuthStrategy] Failed login attempt for "${username}" from IP ${ip}: ${reason}`)
}
/**
* Log successful login attempt
* @param {import('express').Request} req
* @param {string} username
*/
logSuccessfulLoginAttempt(req, username) {
const ip = req.ip || req.connection?.remoteAddress || 'Unknown'
Logger.info(`[ProxyAuthStrategy] Successful proxy login for "${username}" from IP ${ip}`)
}
}
module.exports = ProxyAuthStrategy

View File

@ -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,6 +151,9 @@ 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']
}
@ -200,6 +207,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 +256,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 +290,7 @@ class ServerSettings {
}
get supportedAuthMethods() {
return ['local', 'openid']
return ['local', 'openid', 'proxy']
}
/**
@ -285,6 +304,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,