mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-10 19:31:39 +00:00
inital impl
This commit is contained in:
parent
27b3a44147
commit
6ff2333220
6 changed files with 225 additions and 6 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
server/utils/ForwardStrategy.js
Normal file
111
server/utils/ForwardStrategy.js
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue