This commit is contained in:
Matthew Bell 2024-10-15 16:37:27 +01:00 committed by GitHub
commit b115835627
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 227 additions and 10 deletions

View file

@ -108,6 +108,21 @@
</div> </div>
</transition> </transition>
</div> </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">
<p class="text-lg text-gray-300 mb-2">{{ $strings.LabelForwardAuthenticationWarning }}</p>
<ui-text-input-with-label ref="forwardAuthPattern" v-model="newAuthSettings.authForwardAuthPattern" :disabled="savingSettings" :label="'IP Pattern'" class="mb-2" />
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelForwardAuthenticationPatternDescription" />
<ui-text-input-with-label ref="forwardAuthPath" v-model="newAuthSettings.authForwardAuthPath" :disabled="savingSettings" :label="'Logout Path'" class="mb-2" />
<p class="sm:pl-4 text-sm text-gray-300 mb-2">{{ $strings.LabelForwardAuthenticationLogoutDescription }}</p>
</div>
</transition>
</div>
<div class="w-full flex items-center justify-end p-4"> <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> <ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
</div> </div>
@ -116,6 +131,10 @@
</template> </template>
<script> <script>
import { isPrivate } from 'ip'
import { isV4Format, isV6Format } from 'ip'
export default { export default {
async asyncData({ store, redirect, app }) { async asyncData({ store, redirect, app }) {
if (!store.getters['user/getIsAdminOrUp']) { if (!store.getters['user/getIsAdminOrUp']) {
@ -139,6 +158,7 @@ export default {
return { return {
enableLocalAuth: false, enableLocalAuth: false,
enableOpenIDAuth: false, enableOpenIDAuth: false,
enableForwardAuth: false,
showCustomLoginMessage: false, showCustomLoginMessage: false,
savingSettings: false, savingSettings: false,
openIdSigningAlgorithmsSupportedByIssuer: [], openIdSigningAlgorithmsSupportedByIssuer: [],
@ -286,8 +306,48 @@ export default {
return isValid return isValid
}, },
validateForwardAuth(input){
let cidr = '';
let address = input;
let message = undefined;
console.log(input)
// Check to see if a cidr is included
if(input.includes('/')){
[address, cidr] = input.split('/');
}
if(isV4Format(address)) {
if(cidr && (cidr < 0 || cidr > 32)){
message = `'${cidr}' is an invalid CIDR range for ipv4, a valid range is between 0 and 32`;
}
else if (!cidr) {
// default to 32 if no cidr is provided
cidr = 32;
}
} else if (isV6Format(address)) {
if(cidr && (cidr < 0 || cidr > 128)){
message = `'${cidr}' is an invalid CIDR range for ipv6, a valid range is between 0 and 128`;
}else if (!cidr) {
// default to 128 if no cidr is provided
cidr = 128;
}
} else {
message = 'Address is not a valid ipv4 or ipv6 address';
}
if(!message && !isPrivate(address)){
message = `Address '${address}' is not a private address. For security reasons, only private addresses are allowed.`;
}
return {
message,
address,
cidr
};
},
async saveSettings() { 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') this.$toast.error('Must have at least one authentication method enabled')
return return
} }
@ -296,6 +356,18 @@ export default {
return return
} }
if(this.newAuthSettings.authForwardAuthEnabled){
const validationResults = this.validateForwardAuth(this.newAuthSettings.authForwardAuthPattern);
console.log(validationResults);
// if there is a message, then there was an error
if(validationResults.message){
this.$toast.error(validationResults.message);
return;
}
// ensure the address is in IP/CIDR format
this.newAuthSettings.authForwardAuthPattern = `${validationResults.address}/${validationResults.cidr}`;
}
if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) { if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {
this.newAuthSettings.authLoginCustomMessage = null this.newAuthSettings.authLoginCustomMessage = null
} }

View file

@ -139,6 +139,7 @@
"HeaderEreaderSettings": "Ereader Settings", "HeaderEreaderSettings": "Ereader Settings",
"HeaderFiles": "Files", "HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
"HeaderForwardAuthentication": "Forward Authentication",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files", "HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils", "HeaderItemMetadataUtils": "Item Metadata Utils",
@ -350,6 +351,9 @@
"LabelFontScale": "Font scale", "LabelFontScale": "Font scale",
"LabelFontStrikethrough": "Strikethrough", "LabelFontStrikethrough": "Strikethrough",
"LabelFormat": "Format", "LabelFormat": "Format",
"LabelForwardAuthenticationWarning": "Note: Enabling forward authentication will block the mobile application from working.",
"LabelForwardAuthenticationLogoutDescription": "The path to logout from the forward authentication provider. Ignored if empty.",
"LabelForwardAuthenticationPatternDescription": "Input must be in for format of <code>IP/CIDR</code>. For security reasons only private IPv4/IPv6 ranges are permitted. If a CIDR is not provided <code>/32</code> or <code>/128</code> will be assumed.",
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",

9
package-lock.json generated
View file

@ -15,6 +15,7 @@
"express-session": "^1.17.3", "express-session": "^1.17.3",
"graceful-fs": "^4.2.10", "graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"ip": "^2.0.1",
"lru-cache": "^10.0.3", "lru-cache": "^10.0.3",
"node-unrar-js": "^2.0.2", "node-unrar-js": "^2.0.2",
"nodemailer": "^6.9.13", "nodemailer": "^6.9.13",
@ -2379,10 +2380,10 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"node_modules/ip": { "node_modules/ip": {
"version": "2.0.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
"optional": true "license": "MIT"
}, },
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",

View file

@ -42,6 +42,7 @@
"express-session": "^1.17.3", "express-session": "^1.17.3",
"graceful-fs": "^4.2.10", "graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"ip": "^2.0.1",
"lru-cache": "^10.0.3", "lru-cache": "^10.0.3",
"node-unrar-js": "^2.0.2", "node-unrar-js": "^2.0.2",
"nodemailer": "^6.9.13", "nodemailer": "^6.9.13",

View file

@ -10,6 +10,8 @@ const ExtractJwt = require('passport-jwt').ExtractJwt
const OpenIDClient = require('openid-client') const OpenIDClient = require('openid-client')
const Database = require('./Database') const Database = require('./Database')
const Logger = require('./Logger') const Logger = require('./Logger')
const ForwardStrategy = require('./utils/ForwardStrategy')
const ip = require('ip')
/** /**
* @class Class for handling all the authentication related functionality. * @class Class for handling all the authentication related functionality.
@ -34,6 +36,9 @@ class Auth {
this.initAuthStrategyOpenID() this.initAuthStrategyOpenID()
} }
// Always load the forward strategy
passport.use('forward', new ForwardStrategy(this.forwardAuthCheck.bind(this)))
// Load the JwtStrategy (always) -> for bearer token auth // Load the JwtStrategy (always) -> for bearer token auth
passport.use( passport.use(
new JwtStrategy( new JwtStrategy(
@ -73,6 +78,35 @@ class Auth {
) )
} }
async forwardAuthCheck(_req, user, ipAddress, 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 ${ipAddress} User: ${user} Reason: No IP pattern`)
return done(null, null)
}
if (!ip.isPrivate(ipAddress)) {
Logger.warn(`[Auth] Forward strategy: Unauthorized access from ${ipAddress} User: ${user} Reason: IP not private`)
return done(null, null)
}
if (!ip.cidrSubnet(ipPattern).contains(ipAddress)) {
Logger.warn(`[Auth] Forward strategy: Unauthorized access from ${ipAddress} User: ${user} Reason: IP not in range`)
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 * Passport use LocalStrategy
*/ */
@ -699,6 +733,11 @@ class Auth {
let logoutUrl = null 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 (authMethod === 'openid' || authMethod === 'openid-mobile') {
// If we are using openid, we need to redirect to the logout endpoint // If we are using openid, we need to redirect to the logout endpoint
// node-openid-client does not support doing it over passport // node-openid-client does not support doing it over passport
@ -713,7 +752,7 @@ class Auth {
const host = req.get('host') const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation // TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl // 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 // 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 // nice would be to redirect to the app here, but for example Authentik does not implement
@ -752,8 +791,8 @@ class Auth {
if (req.isAuthenticated()) { if (req.isAuthenticated()) {
next() next()
} else { } else {
// try JWT to authenticate // try JWT and forward to authenticate
passport.authenticate('jwt')(req, res, next) passport.authenticate(['jwt', 'forward'])(req, res, next)
} }
} }

View file

@ -634,7 +634,30 @@ class MiscController {
for (const key in currentAuthenticationSettings) { for (const key in currentAuthenticationSettings) {
if (settingsUpdate[key] === undefined) continue 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)) let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) { if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
updatedAuthMethods.sort() updatedAuthMethods.sort()

View file

@ -79,6 +79,11 @@ class ServerSettings {
this.authOpenIDGroupClaim = '' this.authOpenIDGroupClaim = ''
this.authOpenIDAdvancedPermsClaim = '' this.authOpenIDAdvancedPermsClaim = ''
// Forward Auth
this.authForwardAuthPattern = '127.0.0.1/32' // default to exact localhost
this.authForwardAuthPath = ''
this.authForwardAuthEnabled = false
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
} }
@ -155,6 +160,18 @@ class ServerSettings {
this.authActiveAuthMethods = ['local'] 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 // Migrations
if (settings.storeCoverWithBook != undefined) { if (settings.storeCoverWithBook != undefined) {
// storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0 // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0
@ -240,7 +257,10 @@ class ServerSettings {
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // 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 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,
authOpenIDSamplePermissions: User.getSampleAbsPermissions() authOpenIDSamplePermissions: User.getSampleAbsPermissions()
} }
} }

View file

@ -0,0 +1,53 @@
const passport = require('passport')
const requestIp = require('../libs/requestIp')
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 ipAddress = requestIp.getClientIp(req)
if (!username) {
return this.fail('No username found')
}
if (!ipAddress) {
return this.fail('No IP address found')
}
this._verify(req, username, ipAddress, (err, user) => {
if (err) {
return this.error(err)
}
if (!user) {
return this.fail('No user found')
}
this.success(user)
})
}
}
module.exports = ForwardStrategy