From 6ff23332209512705706fece21959ff5164c0089 Mon Sep 17 00:00:00 2001 From: Matthew Bell Date: Sun, 18 Aug 2024 20:30:51 -0400 Subject: [PATCH 1/3] inital impl --- client/pages/config/authentication.vue | 28 +++++- client/strings/en-us.json | 1 + server/Auth.js | 40 +++++++- server/controllers/MiscController.js | 25 ++++- server/objects/settings/ServerSettings.js | 26 ++++- server/utils/ForwardStrategy.js | 111 ++++++++++++++++++++++ 6 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 server/utils/ForwardStrategy.js diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 50fa50a410..d301d6a68b 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 7420c1e75a..2c37c617f0 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 60af2a1e05..4ec7dcffb8 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 ac6afff727..d674c4bf7c 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 8ecb8ff051..232162620d 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 0000000000..50fcd49434 --- /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 +} From 628b97c6dc5fa488897b88bbcb05aba9a2a05efb Mon Sep 17 00:00:00 2001 From: Matthew Bell Date: Mon, 19 Aug 2024 16:47:44 -0400 Subject: [PATCH 2/3] ise ip instead of rolling my own ip address validation --- client/pages/config/authentication.vue | 59 +++++++++++++++--- package-lock.json | 9 +-- package.json | 1 + server/Auth.js | 15 +++-- server/objects/settings/ServerSettings.js | 2 +- server/utils/ForwardStrategy.js | 74 +++-------------------- 6 files changed, 76 insertions(+), 84 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index d301d6a68b..a7e8cbafb4 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -128,6 +128,10 @@