From 96c5a51eacc8edefe183acf4c44501d931620a1c Mon Sep 17 00:00:00 2001 From: alex-sviridov Date: Mon, 29 Sep 2025 15:05:10 +0200 Subject: [PATCH] refactored proxuauthstrategy and added some env variables --- client/pages/config/authentication.vue | 40 ++++- index.js | 3 + server/auth/ProxyAuthStrategy.js | 177 +++++++--------------- server/objects/settings/ServerSettings.js | 8 + server/routers/ApiRouter.js | 1 + 5 files changed, 103 insertions(+), 126 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index be0f26463..8fcdf6cb9 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -136,7 +136,16 @@
- +
+
+ +
+
+ + Test + +
+

{{ $strings.LabelProxyHeaderNameDescription }}

@@ -183,6 +192,7 @@ export default { enableProxyAuth: false, showCustomLoginMessage: false, savingSettings: false, + testingProxyHeader: false, openIdSigningAlgorithmsSupportedByIssuer: [], newAuthSettings: {} } @@ -278,6 +288,34 @@ export default { this.$toast.error(errorMsg) }) }, + async testProxyHeader() { + if (!this.newAuthSettings.authProxyHeaderName?.trim()) { + this.$toast.error('Header name is required') + return + } + + this.testingProxyHeader = true + + try { + const response = await this.$axios.$get('/api/test-proxy-header', { + params: { + headerName: this.newAuthSettings.authProxyHeaderName + } + }) + + if (response.headerFound) { + this.$toast.success(`Header "${this.newAuthSettings.authProxyHeaderName}" found with value: "${response.headerValue}"`) + } else { + this.$toast.warning(`Header "${this.newAuthSettings.authProxyHeaderName}" not found in request`) + } + } catch (error) { + console.error('Failed to test proxy header', error) + const errorMsg = error.response?.data?.message || 'Failed to test proxy header' + this.$toast.error(errorMsg) + } finally { + this.testingProxyHeader = false + } + }, validateOpenID() { let isValid = true if (!this.newAuthSettings.authOpenIDIssuerURL) { diff --git a/index.js b/index.js index 7379322e8..f3c39199c 100644 --- a/index.js +++ b/index.js @@ -31,6 +31,9 @@ if (isDev || options['prod-with-dev-env']) { if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath + if (devEnv.AuthProxyEnabled) process.env.AUTH_PROXY_ENABLED = '1' + if (devEnv.AuthProxyHeaderName) process.env.AUTH_PROXY_HEADER_NAME = devEnv.AuthProxyHeaderName + if (devEnv.AuthProxyLogoutURL) process.env.AUTH_PROXY_LOGOUT_URL = devEnv.AuthProxyLogoutURL process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf' } diff --git a/server/auth/ProxyAuthStrategy.js b/server/auth/ProxyAuthStrategy.js index d99731df4..0656b434b 100644 --- a/server/auth/ProxyAuthStrategy.js +++ b/server/auth/ProxyAuthStrategy.js @@ -2,93 +2,57 @@ 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 + * Reads username from header set by proxy middleware */ class ProxyAuthStrategy { constructor() { this.name = 'proxy' - this.strategy = null } /** - * Get the passport strategy instance - * @returns {ProxyStrategy} + * Passport authenticate method + * @param {import('express').Request} req + * @param {Object} options */ - getStrategy() { - if (!this.strategy) { - this.strategy = new ProxyStrategy(this.verifyUser.bind(this)) + 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) } - return this.strategy + + 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.getStrategy()) + passport.use(this.name, this) } /** @@ -96,72 +60,35 @@ class ProxyAuthStrategy { */ 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 + * @returns {Promise} User object */ - async verifyUser(req, username, done) { - try { - // Normalize username (trim and lowercase, following existing pattern) - const normalizedUsername = username.trim().toLowerCase() + async verifyUser(username) { + 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) + if (!normalizedUsername) { + throw new Error('Empty username') } - } + const user = await Database.userModel.getUserByUsername(normalizedUsername) - /** - * 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}`) - } + if (!user) { + throw new Error('User not found') + } - /** - * 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}`) + if (!user.isActive) { + throw new Error('User account is disabled') + } + + // Update user's last seen + user.lastSeen = new Date() + await user.save() + + return user } } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 178e6a913..34907c6ce 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -158,6 +158,14 @@ class ServerSettings { 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) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6446ecc80..b23921bfd 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -351,6 +351,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)) }