diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue
index 50fa50a41..d301d6a68 100644
--- a/client/pages/config/authentication.vue
+++ b/client/pages/config/authentication.vue
@@ -108,6 +108,18 @@
+
+
+
+
{{ $strings.HeaderForwardAuthentication }}
+
+
+
+
+
+
+
+
{{ $strings.ButtonSave }}
@@ -139,6 +151,7 @@ export default {
return {
enableLocalAuth: false,
enableOpenIDAuth: false,
+ enableForwardAuth: false,
showCustomLoginMessage: false,
savingSettings: false,
openIdSigningAlgorithmsSupportedByIssuer: [],
@@ -286,8 +299,16 @@ export default {
return isValid
},
+
+ validateForwardAuth(){
+ // Checks for input in the format of:
+ // 255.255.255.255[/32] (IPv4 address with optional CIDR notation)
+ const pattern = new RegExp('^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/([0-9]|[1-2][0-9]|3[0-2]))?$')
+ return pattern.test(this.newAuthSettings.authForwardAuthPattern)
+ },
+
async saveSettings() {
- if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
+ if (!this.enableLocalAuth && !this.enableOpenIDAuth && !this.enableForwardAuth) {
this.$toast.error('Must have at least one authentication method enabled')
return
}
@@ -296,6 +317,11 @@ export default {
return
}
+ if(this.newAuthSettings.authForwardAuthEnabled && !this.validateForwardAuth()){
+ this.$toast.error('Invalid forward auth pattern')
+ return
+ }
+
if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {
this.newAuthSettings.authLoginCustomMessage = null
}
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 7420c1e75..2c37c617f 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -155,6 +155,7 @@
"HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderOtherFiles": "Other Files",
"HeaderPasswordAuthentication": "Password Authentication",
+ "HeaderForwardAuthentication": "Forward Authentication",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue",
"HeaderPlayerSettings": "Player Settings",
diff --git a/server/Auth.js b/server/Auth.js
index 60af2a1e0..4ec7dcffb 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -10,6 +10,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt
const OpenIDClient = require('openid-client')
const Database = require('./Database')
const Logger = require('./Logger')
+const { ipInRange, ForwardStrategy } = require('./utils/ForwardStrategy')
/**
* @class Class for handling all the authentication related functionality.
@@ -34,6 +35,9 @@ class Auth {
this.initAuthStrategyOpenID()
}
+ // Always load the forward strategy
+ passport.use('forward', new ForwardStrategy(this.forwardAuthCheck.bind(this)))
+
// Load the JwtStrategy (always) -> for bearer token auth
passport.use(
new JwtStrategy(
@@ -73,6 +77,31 @@ class Auth {
)
}
+ async forwardAuthCheck(_req, user, ip, done) {
+ const ipPattern = Database.serverSettings.authForwardAuthPattern
+ if (!Database.serverSettings.authForwardAuthEnabled) {
+ // silently deny access via forward strategy
+ return done(null, null)
+ }
+ if (!ipPattern) {
+ Logger.warn(`[Auth] Forward strategy: Unauthorized access from ${ip}`)
+ return done(null, null)
+ }
+ if (!ipInRange(ip, ipPattern)) {
+ Logger.warn(`[Auth] Forward strategy: Unauthorized access from ${ip}`)
+ return done(null, null)
+ }
+
+ const userObj = await Database.userModel.getUserByUsername(user.toLowerCase())
+
+ if (!userObj) {
+ Logger.info(`[Auth] Forward strategy: User not found: ${user}`)
+ return done(null, null)
+ }
+ Logger.debug(`[Auth] Forward strategy: User found: ${user}`)
+ return done(null, userObj)
+ }
+
/**
* Passport use LocalStrategy
*/
@@ -699,6 +728,11 @@ class Auth {
let logoutUrl = null
+ if (Database.serverSettings.authForwardAuthEnabled && Database.serverSettings.authForwardAuthPath) {
+ // Forward auth logout redirect
+ logoutUrl = Database.serverSettings.authForwardAuthPath
+ }
+
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
// If we are using openid, we need to redirect to the logout endpoint
// node-openid-client does not support doing it over passport
@@ -713,7 +747,7 @@ class Auth {
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}/login`
+ postLogoutRedirectUri = logoutUrl || `${protocol}://${host}/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
@@ -752,8 +786,8 @@ class Auth {
if (req.isAuthenticated()) {
next()
} else {
- // try JWT to authenticate
- passport.authenticate('jwt')(req, res, next)
+ // try JWT and forward to authenticate
+ passport.authenticate(['jwt', 'forward'])(req, res, next)
}
}
diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js
index ac6afff72..d674c4bf7 100644
--- a/server/controllers/MiscController.js
+++ b/server/controllers/MiscController.js
@@ -634,7 +634,30 @@ class MiscController {
for (const key in currentAuthenticationSettings) {
if (settingsUpdate[key] === undefined) continue
- if (key === 'authActiveAuthMethods') {
+ if (key === 'authForwardAuthPattern') {
+ const updatedValue = settingsUpdate[key]
+ if (updatedValue === '') updatedValue = null
+ let currentValue = currentAuthenticationSettings[key]
+ if (currentValue === '') currentValue = null
+
+ if (updatedValue !== currentValue) {
+ Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
+ Database.serverSettings[key] = updatedValue
+ hasUpdates = true
+ }
+ } else if (key === 'authForwardAuthEnabled') {
+ if (typeof settingsUpdate[key] !== 'boolean') {
+ Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`)
+ continue
+ }
+ let updatedValue = settingsUpdate[key]
+ let currentValue = currentAuthenticationSettings[key]
+ if (updatedValue !== currentValue) {
+ Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
+ Database.serverSettings[key] = updatedValue
+ hasUpdates = true
+ }
+ } else if (key === 'authActiveAuthMethods') {
let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
updatedAuthMethods.sort()
diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js
index 8ecb8ff05..232162620 100644
--- a/server/objects/settings/ServerSettings.js
+++ b/server/objects/settings/ServerSettings.js
@@ -79,6 +79,11 @@ class ServerSettings {
this.authOpenIDGroupClaim = ''
this.authOpenIDAdvancedPermsClaim = ''
+ // Forward Auth
+ this.authForwardAuthPattern = '127.0.0.1/0' // default to exact localhost
+ this.authForwardAuthPath = ''
+ this.authForwardAuthEnabled = false
+
if (settings) {
this.construct(settings)
}
@@ -155,6 +160,18 @@ class ServerSettings {
this.authActiveAuthMethods = ['local']
}
+ if (settings.authForwardAuthPattern != undefined) {
+ this.authForwardAuthPattern = settings.authForwardAuthPattern
+ }
+
+ if (settings.authForwardAuthEnabled != undefined) {
+ this.authForwardAuthEnabled = settings.authForwardAuthEnabled
+ }
+
+ if (settings.authForwardAuthPath != undefined) {
+ this.authForwardAuthPath = settings.authForwardAuthPath
+ }
+
// Migrations
if (settings.storeCoverWithBook != undefined) {
// storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0
@@ -240,7 +257,10 @@ class ServerSettings {
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
- authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client
+ authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
+ authForwardAuthPattern: this.authForwardAuthPattern,
+ authForwardAuthPath: this.authForwardAuthPath,
+ authForwardAuthEnabled: this.authForwardAuthEnabled
}
}
@@ -287,6 +307,10 @@ class ServerSettings {
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
+ authForwardAuthPattern: this.authForwardAuthPattern,
+ authForwardAuthPath: this.authForwardAuthPath,
+ authForwardAuthEnabled: this.authForwardAuthEnabled,
+
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
}
}
diff --git a/server/utils/ForwardStrategy.js b/server/utils/ForwardStrategy.js
new file mode 100644
index 000000000..50fcd4943
--- /dev/null
+++ b/server/utils/ForwardStrategy.js
@@ -0,0 +1,111 @@
+const passport = require('passport')
+const requestIp = require('../libs/requestIp')
+
+function ipToBinary(ip) {
+ if (ip === '::1') {
+ ip = '127.0.0.1'
+ }
+ return ip
+ .split('.')
+ .map(Number)
+ .map((num) => num.toString(2).padStart(8, '0'))
+ .join('')
+}
+
+function binaryToIp(binary) {
+ return binary
+ .match(/.{8}/g)
+ .map((bin) => parseInt(bin, 2))
+ .join('.')
+}
+
+function cidrToMask(cidr) {
+ const mask = Array(32).fill('0')
+ for (let i = 0; i < cidr; i++) {
+ mask[i] = '1'
+ }
+ return mask
+ .join('')
+ .match(/.{8}/g)
+ .map((bin) => parseInt(bin, 2))
+ .join('.')
+}
+
+/**
+ * Checks if an IP address falls within a given CIDR range.
+ * If no range is provided, it assumes the CIDR + range are in the "0.0.0.0/0" format.
+ *
+ * @param {string} ip - The IP address to check. Can be either IPv4 or IPv6.
+ * @param {string} cidr - The CIDR notation specifying the network. If `range` is not provided, this value represents the network IP and CIDR in "IP/CIDR" format.
+ * @param {string} [range] - The IP address representing the range or network. Optional. If not provided, the function assumes "0.0.0.0/0" as the default range.
+ * @returns {boolean} `true` if the IP address is within the given CIDR range; otherwise, `false`.
+ */
+function ipInRange(ip, cidr, range) {
+ // if no range is provided, assume the cidr + range are in the "0.0.0.0/0" format
+ if (!range) {
+ let [rangeIp, rangeCidr] = cidr.split('/')
+ if (!rangeCidr) {
+ rangeCidr = '0' // if no cidr is provided, assume 0 (exact match)
+ }
+ return ipInRange(ip, rangeCidr, rangeIp)
+ }
+ const mask = cidrToMask(cidr)
+ const ipBin = ipToBinary(ip)
+ const maskBin = ipToBinary(mask)
+ const rangeBin = ipToBinary(range)
+
+ // Apply the mask to the IP and range
+ const ipNetwork = ipBin.substring(0, maskBin.length)
+ const rangeNetwork = rangeBin.substring(0, maskBin.length)
+
+ return ipNetwork === rangeNetwork
+}
+
+class ForwardStrategy extends passport.Strategy {
+ /**
+ * Creates a new ForwardStrategy instance.
+ * A ForwardStrategy instance authenticates requests based on the contents of the `X-Forwarded-User` header
+ *
+ * @param {Function} verify The function to call to verify the user.
+ */
+ constructor(options, verify) {
+ super()
+ // if verify is not provided, assume the first argument is the verify function
+ if (!verify && typeof options === 'function') {
+ verify = options
+ } else if (!verify) {
+ throw new TypeError('ForwardStrategy requires a verify callback')
+ }
+ this.name = 'forward'
+ this._verify = verify
+ this._header = options.header || 'x-forwarded-user'
+ }
+
+ /**
+ * Authenticate request based on the contents of the `X-Forwarded-User` header.
+ * @param {*} req The request to authenticate.
+ * @returns {void} Calls `success`, `fail`, or `error` based on the result of the authentication.
+ */
+ authenticate(req) {
+ const username = req.headers[this._header]
+ const ip = requestIp.getClientIp(req)
+ if (!username) {
+ return this.fail('No username found')
+ }
+
+ this._verify(req, username, ip, (err, user) => {
+ if (err) {
+ return this.error(err)
+ }
+ if (!user) {
+ return this.fail('No user found')
+ }
+ this.success(user)
+ })
+ }
+}
+
+module.exports = {
+ ForwardStrategy,
+ ipInRange
+}