Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat: Implement Forward Authentication #3302

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion client/pages/config/authentication.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,21 @@
</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">
<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">
<ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
</div>
Expand All @@ -116,6 +131,10 @@
</template>

<script>
import { isPrivate } from 'ip'
import { isV4Format, isV6Format } from 'ip'


export default {
async asyncData({ store, redirect, app }) {
if (!store.getters['user/getIsAdminOrUp']) {
Expand All @@ -139,6 +158,7 @@ export default {
return {
enableLocalAuth: false,
enableOpenIDAuth: false,
enableForwardAuth: false,
showCustomLoginMessage: false,
savingSettings: false,
openIdSigningAlgorithmsSupportedByIssuer: [],
Expand Down Expand Up @@ -286,8 +306,48 @@ export default {

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() {
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
if (!this.enableLocalAuth && !this.enableOpenIDAuth && !this.enableForwardAuth) {
this.$toast.error('Must have at least one authentication method enabled')
return
}
Expand All @@ -296,6 +356,18 @@ export default {
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()) {
this.newAuthSettings.authLoginCustomMessage = null
}
Expand Down
4 changes: 4 additions & 0 deletions client/strings/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
"HeaderEreaderSettings": "Ereader Settings",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderForwardAuthentication": "Forward Authentication",
"HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
Expand Down Expand Up @@ -323,6 +324,9 @@
"LabelFontScale": "Font scale",
"LabelFontStrikethrough": "Strikethrough",
"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",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"express-session": "^1.17.3",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"ip": "^2.0.1",
"lru-cache": "^10.0.3",
"nodemailer": "^6.9.13",
"openid-client": "^5.6.1",
Expand Down
45 changes: 42 additions & 3 deletions server/Auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const ExtractJwt = require('passport-jwt').ExtractJwt
const OpenIDClient = require('openid-client')
const Database = require('./Database')
const Logger = require('./Logger')
const ForwardStrategy = require('./utils/ForwardStrategy')
const ip = require('ip')

/**
* @class Class for handling all the authentication related functionality.
Expand All @@ -34,6 +36,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(
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -699,6 +733,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
Expand All @@ -713,7 +752,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
Expand Down Expand Up @@ -752,8 +791,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)
}
}

Expand Down
25 changes: 24 additions & 1 deletion server/controllers/MiscController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
26 changes: 25 additions & 1 deletion server/objects/settings/ServerSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ class ServerSettings {
this.authOpenIDGroupClaim = ''
this.authOpenIDAdvancedPermsClaim = ''

// Forward Auth
this.authForwardAuthPattern = '127.0.0.1/32' // default to exact localhost
this.authForwardAuthPath = ''
this.authForwardAuthEnabled = false

if (settings) {
this.construct(settings)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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()
}
}
Expand Down
53 changes: 53 additions & 0 deletions server/utils/ForwardStrategy.js
Original file line number Diff line number Diff line change
@@ -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