inital impl

This commit is contained in:
Matthew Bell 2024-08-18 20:30:51 -04:00
parent 27b3a44147
commit 6ff2333220
6 changed files with 225 additions and 6 deletions

View file

@ -108,6 +108,18 @@
</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="newAuthSettings.authForwardAuthEnabled" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderForwardAuthentication }}</p>
</div>
<transition name="slide">
<div v-if="newAuthSettings.authForwardAuthEnabled" class="flex flex-wrap pt-4">
<ui-text-input-with-label ref="forwardAuthPattern" v-model="newAuthSettings.authForwardAuthPattern" :disabled="savingSettings" :label="'IP Pattern'" class="mb-2" />
<ui-text-input-with-label ref="forwardAuthPath" v-model="newAuthSettings.authForwardAuthPath" :disabled="savingSettings" :label="'Logout Path'" class="mb-2" />
</div>
</transition>
</div>
<div class="w-full flex items-center justify-end p-4">
<ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
</div>
@ -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
}

View file

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

View file

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

View file

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

View file

@ -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()
}
}

View file

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