diff --git a/client/pages/account.vue b/client/pages/account.vue index c582c2640..6f85d2d94 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -75,9 +75,8 @@ export default { this.$setLanguageCode(lang) }, logout() { - var rootSocket = this.$root.socket || {} const logoutPayload = { - socketId: rootSocket.id + socketId: (this.$root.socket || {}).id } this.$axios.$post('/logout', logoutPayload).catch((error) => { console.error(error) @@ -87,7 +86,13 @@ export default { } this.$store.commit('libraries/setUserPlaylists', []) this.$store.commit('libraries/setCollections', []) - this.$router.push('/login') + + if (this.$store.getters['user/getForwardAuthLogoutURI']) { + // Redirect to forward auth logout uri + location.replace(this.$store.getters['user/getForwardAuthLogoutURI']) + } else { + this.$router.push('/login') + } }, resetForm() { this.password = null diff --git a/client/pages/login.vue b/client/pages/login.vue index 7a4dc5e2c..aab74fb61 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -144,20 +144,20 @@ export default { * Checks for forward proxy authentication */ async checkForwardAuth() { - this.processing = true; - this.error = null; + this.processing = true + this.error = null var authRes = await this.$axios.$post('/forwardauth').catch((error) => { console.error('Failed', error.response) if (error.response) this.error = error.response.data else this.error = 'Unknown Error' return false - }); + }) if (authRes && authRes.disabled) { // disabled == true - proxy auth has not been configured on the server, nothing further to do. - this.processing = false; - return false; + this.processing = false + return false } if (authRes && authRes.error) { @@ -166,17 +166,16 @@ export default { this.setUser(authRes) } this.processing = false - }, async submitForm() { this.error = null this.processing = true - var payload = { + const payload = { username: this.username, password: this.password || '' } - var authRes = await this.$axios.$post('/login', payload).catch((error) => { + const authRes = await this.$axios.$post('/login', payload).catch((error) => { console.error('Failed', error.response) if (error.response) this.error = error.response.data else this.error = 'Unknown Error' @@ -190,7 +189,7 @@ export default { this.processing = false }, checkAuth() { - var token = localStorage.getItem('token') + const token = localStorage.getItem('token') if (!token) return false this.processing = true @@ -203,43 +202,44 @@ export default { }) .then((res) => { this.setUser(res) - this.processing = false return true }) .catch((error) => { console.error('Authorize error', error) - this.processing = false return false }) + .finally(() => { + this.processing = false + }) }, checkStatus() { this.processing = true this.$axios .$get('/status') .then((res) => { - this.processing = false this.isInit = res.isInit this.showInitScreen = !res.isInit this.$setServerLanguageCode(res.language) if (this.showInitScreen) { this.ConfigPath = res.ConfigPath || '' this.MetadataPath = res.MetadataPath || '' + } else if (res.forwardAuthUserResponse) { + // User was returned from forward auth + this.setUser(res.forwardAuthUserResponse) } }) .catch((error) => { console.error('Status check failed', error) - this.processing = false this.criticalError = 'Status check failed' }) + .finally(() => { + this.processing = false + }) } }, async mounted() { - - // Check for proxy auth - this.checkForwardAuth(); - if (localStorage.getItem('token')) { - var userfound = await this.checkAuth() + const userfound = await this.checkAuth() if (userfound) return // if valid user no need to check status } this.checkStatus() diff --git a/client/store/user.js b/client/store/user.js index 95bf22499..f5d674fb3 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -18,6 +18,8 @@ export const state = () => ({ export const getters = { getIsRoot: (state) => state.user && state.user.type === 'root', getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), + getIsForwardAuth: (state) => state.user && state.user.isForwardAuth, + getForwardAuthLogoutURI: (state) => state.user ? state.user.forwardAuthLogoutURI : null, getToken: (state) => { return state.user ? state.user.token : null }, diff --git a/readme.md b/readme.md index 537361a7f..e67c2f1e4 100644 --- a/readme.md +++ b/readme.md @@ -261,13 +261,16 @@ Middleware relating to CORS will cause the app to report Unknown Error when logg From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506)
+ ### Forward Proxy Authentication + Users can be authenticated via an external proxy such as Authentik. To use forward authentication you need to set the following environment variables: * `PROXY_FORWARD_AUTH_ENABLED` - enable/disable forward authentication * `PROXY_FORWARD_AUTH_USERNAME` - the name of the header containing the authenticated user's username. For example, by default in Authentik the value would be `X-authentik-username`. - * `PROXY_FORWARD_AUTH_CREATE` - automatically create users if they don't exist. + * `PROXY_FORWARD_AUTH_CREATE` - automatically create users if they don't exist (with account type of `user`). + * `PROXY_FORWARD_AUTH_LOGOUT_URI` - path to redirect to when logging out. # Run from source diff --git a/server/Auth.js b/server/Auth.js index e8bf8f834..fe33128fe 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,10 +1,12 @@ const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') const requestIp = require('./libs/requestIp') + const Logger = require('./Logger') +const SocketAuthority = require('./SocketAuthority') + const User = require('./objects/user/User') const { getId } = require('./utils/index') -const { parseBool } = require('./utils/parseBool') class Auth { constructor(db) { @@ -97,6 +99,10 @@ class Auth { }) } + generateRandomPasswordHash() { + return hashPass(getId()) + } + generateAccessToken(payload) { return jwt.sign(payload, global.ServerSettings.tokenSecret); } @@ -180,59 +186,83 @@ class Auth { * Checks for an environment variable of PROXY_FORWARD_AUTH_ENABLED and if enabled * will authenticate the user from the proxy headers. * @param {*} req - * @param {*} res - * @param {*} feeds - * @returns + * @param {Array} feeds + * @returns {Promise<(Object|false)>} User login response payload or false */ - async getUserFromProxyAuth(req, res, feeds) { - var username = null; - var user = null; - - // Check if $PROXY_FORWARD_AUTH_ENABLED is defined - if (!parseBool(process.env.PROXY_FORWARD_AUTH_ENABLED)) { - Logger.debug("$PROXY_FORWARD_AUTH_ENABLED not set."); - // Respond with disabled: true - client will check for it and ignore forward auth - return res.json({ disabled: true }); + async getUserFromProxyAuth(req, feeds) { + if (!global.ForwardAuth.Enabled) { + return false } - // Check that the proxy username header is set - if (!process.env.PROXY_FORWARD_AUTH_USERNAME) { - Logger.info("$PROXY_FORWARD_AUTH_ENABLED is set, but $PROXY_FORWARD_AUTH_USERNAME has not been defined. Forward Authentication will not proceed."); - return res.json({ disabled: true }); + let username = req.headers[global.ForwardAuth.UsernameHeader] + + if (!username) { + Logger.warn(`[Auth] Forward Auth username header ${global.ForwardAuth.UsernameHeader} has no username`) + return false } - Logger.debug('Found $PROXY_FORWARD_AUTH_USERNAME header:', process.env.PROXY_FORWARD_AUTH_USERNAME); - username = req.headers[process.env.PROXY_FORWARD_AUTH_USERNAME.toLowerCase()]; - Logger.debug('Found Forwarded Username ', username); - - if (!username) - return res.status(401).send(`Username not found in headers (Header: ${process.env.PROXY_FORWARD_AUTH_USERNAME}`); - - var user = this.users.find(u => u.username.toLowerCase() === username) + let user = this.users.find(u => u.username.toLowerCase() === username) // If the user doesn't exist and PROXY_FORWARD AUTH_CREATE is enabled, create the user - if (!user && parseBool(process.env.PROXY_FORWARD_AUTH_CREATE)) { - Logger.debug('User not found, creating user', username); - // TODO - user type: - // this could come from the proxy (eg a user attribute), or could be configurable from an environment variable - var account = { + if (!user && global.ForwardAuth.CreateUser) { + Logger.debug(`[Auth] Forward Auth User not found with username "${username}" - creating it`) + + const newUserData = { id: getId('usr'), - username: username, + username, + pash: this.generateRandomPasswordHash(), // Random password will need to be reset by admin if wanting to use regular login createdAt: Date.now(), type: 'user' - }; - account.token = await this.generateAccessToken({ userId: account.id, username: account.username }); - user = new User(account); - var success = await this.db.insertEntity('user', user); - if (!success) - return res.status(500).send("Could not create user."); + } + newUserData.token = await this.generateAccessToken({ userId: newUserData.id, username: newUserData.username }) + + user = new User(newUserData) + + const success = await this.db.insertEntity('user', user) + if (!success) { + Logger.error(`[Auth] Forward Auth failed to insert new user in DB`, user.toJSON()) + return false + } + SocketAuthority.adminEmitter('user_added', user) } - if (!user) - return res.status(401).send(`User "${username}" not found`); + if (!user) { + Logger.error(`[Auth] Forward Auth user not found with username "${username}"`) + return false + } - return res.json(this.getUserLoginResponsePayload(user, feeds)) + user.isForwardAuth = true + Logger.debug(`[Auth] Forward Auth success for username "${username}"`) + return this.getUserLoginResponsePayload(user, feeds) + } + + /** + * Checks if the user in req.user is valid with forward auth headers. + * @param {*} req + * @returns {boolean} + */ + validateForwardAuthUser(req) { + if (!global.ForwardAuth.Enabled) { + return false + } + + let username = req.headers[global.ForwardAuth.UsernameHeader] + + if (!username) { + Logger.error(`[Auth] validate: Forward Auth username header ${global.ForwardAuth.UsernameHeader} has no username`) + return false + } + + // Mismatch username in header with username from token + if (req.user.username.toLowerCase() !== username) { + Logger.error(`[Auth] validate: Forward Auth username "${username}" from header ${global.ForwardAuth.UsernameHeader} does not match username "${req.user.username.toLowerCase()}" from token`) + return false + } + + req.user.isForwardAuth = true + + return true } comparePassword(password, user) { diff --git a/server/Server.js b/server/Server.js index 29d3fb9f7..28a077037 100644 --- a/server/Server.js +++ b/server/Server.js @@ -10,6 +10,7 @@ const { version } = require('../package.json') // Utils const dbMigration = require('./utils/dbMigration') const filePerms = require('./utils/filePerms') +const { parseBool } = require('./utils/parseBool') const Logger = require('./Logger') const Auth = require('./Auth') @@ -45,6 +46,12 @@ class Server { global.ConfigPath = Path.normalize(CONFIG_PATH) global.MetadataPath = Path.normalize(METADATA_PATH) global.RouterBasePath = ROUTER_BASE_PATH + global.ForwardAuth = { + Enabled: parseBool(process.env.PROXY_FORWARD_AUTH_ENABLED) && process.env.PROXY_FORWARD_AUTH_USERNAME, + UsernameHeader: (process.env.PROXY_FORWARD_AUTH_USERNAME || '').toLowerCase(), + CreateUser: parseBool(process.env.PROXY_FORWARD_AUTH_CREATE), + LogoutURI: process.env.PROXY_FORWARD_AUTH_LOGOUT_URI + } // Fix backslash if not on Windows if (process.platform !== 'win32') { @@ -221,7 +228,7 @@ class Server { } this.initializeServer(req, res) }) - router.get('/status', (req, res) => { + router.get('/status', async (req, res) => { // status check for client to see if server has been initialized // server has been initialized if a root user exists const payload = { @@ -231,7 +238,14 @@ class Server { if (!payload.isInit) { payload.ConfigPath = global.ConfigPath payload.MetadataPath = global.MetadataPath + return res.json(payload) } + + // Check forward auth for user + if (global.ForwardAuth.Enabled) { + payload.forwardAuthUserResponse = await this.auth.getUserFromProxyAuth(req, this.rssFeedManager.feedsArray) + } + res.json(payload) }) router.get('/ping', (req, res) => { diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 735038d1a..21dddf895 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -120,6 +120,13 @@ class MiscController { Logger.error('Invalid user in authorize') return res.sendStatus(401) } + + // When authorizing an API token and forward auth is enabled then validate user using forward auth headers + if (global.ForwardAuth.Enabled && !this.auth.validateForwardAuthUser(req)) { + Logger.error(`[MiscController] Authorize token with forward auth enabled failed for user "${req.user.username}"`) + return res.sendStatus(403) + } + const userResponse = this.auth.getUserLoginResponsePayload(req.user, this.rssFeedManager.feedsArray) res.json(userResponse) } diff --git a/server/objects/user/User.js b/server/objects/user/User.js index b019e19b7..0c9e854ed 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -23,6 +23,9 @@ class User { this.librariesAccessible = [] // Library IDs (Empty if ALL libraries) this.itemTagsAccessible = [] // Empty if ALL item tags accessible + // Temporary values not persisted to the Db + this.isForwardAuth = false + if (user) { this.construct(user) } @@ -102,7 +105,7 @@ class User { } toJSONForBrowser() { - return { + const json = { id: this.id, username: this.username, type: this.type, @@ -117,8 +120,15 @@ class User { settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta permissions: this.permissions, librariesAccessible: [...this.librariesAccessible], - itemTagsAccessible: [...this.itemTagsAccessible] + itemTagsAccessible: [...this.itemTagsAccessible], + isForwardAuth: this.isForwardAuth } + + if (this.isForwardAuth) { + json.forwardAuthLogoutURI = global.ForwardAuth.LogoutURI || null + } + + return json } // Data broadcasted diff --git a/server/utils/parseBool.js b/server/utils/parseBool.js index 546c5e6a1..8f75f0edf 100644 --- a/server/utils/parseBool.js +++ b/server/utils/parseBool.js @@ -1,8 +1,8 @@ -const true_values = ["true", "yes", "y", "1", "on"]; +const true_values = ["true", "yes", "y", "1", "on"] module.exports.parseBool = (value) => { if (Object.is(value, null) || value === undefined) { - return false; + return false } - return true_values.includes(String(value).toLowerCase()); + return true_values.includes(String(value).toLowerCase()) } \ No newline at end of file