Forward Auth refactor and adding PROXY_FORWARD_AUTH_LOGOUT_URI env

This commit is contained in:
advplyr 2022-12-18 11:37:45 -06:00
parent 8558baf30a
commit 73a61872fa
9 changed files with 139 additions and 68 deletions

View file

@ -75,9 +75,8 @@ export default {
this.$setLanguageCode(lang) this.$setLanguageCode(lang)
}, },
logout() { logout() {
var rootSocket = this.$root.socket || {}
const logoutPayload = { const logoutPayload = {
socketId: rootSocket.id socketId: (this.$root.socket || {}).id
} }
this.$axios.$post('/logout', logoutPayload).catch((error) => { this.$axios.$post('/logout', logoutPayload).catch((error) => {
console.error(error) console.error(error)
@ -87,7 +86,13 @@ export default {
} }
this.$store.commit('libraries/setUserPlaylists', []) this.$store.commit('libraries/setUserPlaylists', [])
this.$store.commit('libraries/setCollections', []) 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() { resetForm() {
this.password = null this.password = null

View file

@ -144,20 +144,20 @@ export default {
* Checks for forward proxy authentication * Checks for forward proxy authentication
*/ */
async checkForwardAuth() { async checkForwardAuth() {
this.processing = true; this.processing = true
this.error = null; this.error = null
var authRes = await this.$axios.$post('/forwardauth').catch((error) => { var authRes = await this.$axios.$post('/forwardauth').catch((error) => {
console.error('Failed', error.response) console.error('Failed', error.response)
if (error.response) this.error = error.response.data if (error.response) this.error = error.response.data
else this.error = 'Unknown Error' else this.error = 'Unknown Error'
return false return false
}); })
if (authRes && authRes.disabled) { if (authRes && authRes.disabled) {
// disabled == true - proxy auth has not been configured on the server, nothing further to do. // disabled == true - proxy auth has not been configured on the server, nothing further to do.
this.processing = false; this.processing = false
return false; return false
} }
if (authRes && authRes.error) { if (authRes && authRes.error) {
@ -166,17 +166,16 @@ export default {
this.setUser(authRes) this.setUser(authRes)
} }
this.processing = false this.processing = false
}, },
async submitForm() { async submitForm() {
this.error = null this.error = null
this.processing = true this.processing = true
var payload = { const payload = {
username: this.username, username: this.username,
password: this.password || '' 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) console.error('Failed', error.response)
if (error.response) this.error = error.response.data if (error.response) this.error = error.response.data
else this.error = 'Unknown Error' else this.error = 'Unknown Error'
@ -190,7 +189,7 @@ export default {
this.processing = false this.processing = false
}, },
checkAuth() { checkAuth() {
var token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (!token) return false if (!token) return false
this.processing = true this.processing = true
@ -203,43 +202,44 @@ export default {
}) })
.then((res) => { .then((res) => {
this.setUser(res) this.setUser(res)
this.processing = false
return true return true
}) })
.catch((error) => { .catch((error) => {
console.error('Authorize error', error) console.error('Authorize error', error)
this.processing = false
return false return false
}) })
.finally(() => {
this.processing = false
})
}, },
checkStatus() { checkStatus() {
this.processing = true this.processing = true
this.$axios this.$axios
.$get('/status') .$get('/status')
.then((res) => { .then((res) => {
this.processing = false
this.isInit = res.isInit this.isInit = res.isInit
this.showInitScreen = !res.isInit this.showInitScreen = !res.isInit
this.$setServerLanguageCode(res.language) this.$setServerLanguageCode(res.language)
if (this.showInitScreen) { if (this.showInitScreen) {
this.ConfigPath = res.ConfigPath || '' this.ConfigPath = res.ConfigPath || ''
this.MetadataPath = res.MetadataPath || '' this.MetadataPath = res.MetadataPath || ''
} else if (res.forwardAuthUserResponse) {
// User was returned from forward auth
this.setUser(res.forwardAuthUserResponse)
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Status check failed', error) console.error('Status check failed', error)
this.processing = false
this.criticalError = 'Status check failed' this.criticalError = 'Status check failed'
}) })
.finally(() => {
this.processing = false
})
} }
}, },
async mounted() { async mounted() {
// Check for proxy auth
this.checkForwardAuth();
if (localStorage.getItem('token')) { 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 if (userfound) return // if valid user no need to check status
} }
this.checkStatus() this.checkStatus()

View file

@ -18,6 +18,8 @@ export const state = () => ({
export const getters = { export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root', getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || 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) => { getToken: (state) => {
return state.user ? state.user.token : null return state.user ? state.user.token : null
}, },

View file

@ -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) From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506)
<br /> <br />
### Forward Proxy Authentication ### Forward Proxy Authentication
Users can be authenticated via an external proxy such as Authentik. Users can be authenticated via an external proxy such as Authentik.
To use forward authentication you need to set the following environment variables: To use forward authentication you need to set the following environment variables:
* `PROXY_FORWARD_AUTH_ENABLED` - enable/disable forward authentication * `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_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 # Run from source

View file

@ -1,10 +1,12 @@
const bcrypt = require('./libs/bcryptjs') const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken') const jwt = require('./libs/jsonwebtoken')
const requestIp = require('./libs/requestIp') const requestIp = require('./libs/requestIp')
const Logger = require('./Logger') const Logger = require('./Logger')
const SocketAuthority = require('./SocketAuthority')
const User = require('./objects/user/User') const User = require('./objects/user/User')
const { getId } = require('./utils/index') const { getId } = require('./utils/index')
const { parseBool } = require('./utils/parseBool')
class Auth { class Auth {
constructor(db) { constructor(db) {
@ -97,6 +99,10 @@ class Auth {
}) })
} }
generateRandomPasswordHash() {
return hashPass(getId())
}
generateAccessToken(payload) { generateAccessToken(payload) {
return jwt.sign(payload, global.ServerSettings.tokenSecret); 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 * Checks for an environment variable of PROXY_FORWARD_AUTH_ENABLED and if enabled
* will authenticate the user from the proxy headers. * will authenticate the user from the proxy headers.
* @param {*} req * @param {*} req
* @param {*} res * @param {Array} feeds
* @param {*} feeds * @returns {Promise<(Object|false)>} User login response payload or false
* @returns
*/ */
async getUserFromProxyAuth(req, res, feeds) { async getUserFromProxyAuth(req, feeds) {
var username = null; if (!global.ForwardAuth.Enabled) {
var user = null; return false
// 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 });
} }
// Check that the proxy username header is set let username = req.headers[global.ForwardAuth.UsernameHeader]
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."); if (!username) {
return res.json({ disabled: true }); 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); let user = this.users.find(u => u.username.toLowerCase() === 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)
// If the user doesn't exist and PROXY_FORWARD AUTH_CREATE is enabled, create the user // 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)) { if (!user && global.ForwardAuth.CreateUser) {
Logger.debug('User not found, creating user', username); Logger.debug(`[Auth] Forward Auth User not found with username "${username}" - creating it`)
// TODO - user type:
// this could come from the proxy (eg a user attribute), or could be configurable from an environment variable const newUserData = {
var account = {
id: getId('usr'), 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(), createdAt: Date.now(),
type: 'user' type: 'user'
}; }
account.token = await this.generateAccessToken({ userId: account.id, username: account.username }); newUserData.token = await this.generateAccessToken({ userId: newUserData.id, username: newUserData.username })
user = new User(account);
var success = await this.db.insertEntity('user', user); user = new User(newUserData)
if (!success)
return res.status(500).send("Could not create user."); 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) if (!user) {
return res.status(401).send(`User "${username}" not found`); 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) { comparePassword(password, user) {

View file

@ -10,6 +10,7 @@ const { version } = require('../package.json')
// Utils // Utils
const dbMigration = require('./utils/dbMigration') const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms') const filePerms = require('./utils/filePerms')
const { parseBool } = require('./utils/parseBool')
const Logger = require('./Logger') const Logger = require('./Logger')
const Auth = require('./Auth') const Auth = require('./Auth')
@ -45,6 +46,12 @@ class Server {
global.ConfigPath = Path.normalize(CONFIG_PATH) global.ConfigPath = Path.normalize(CONFIG_PATH)
global.MetadataPath = Path.normalize(METADATA_PATH) global.MetadataPath = Path.normalize(METADATA_PATH)
global.RouterBasePath = ROUTER_BASE_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 // Fix backslash if not on Windows
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
@ -221,7 +228,7 @@ class Server {
} }
this.initializeServer(req, res) 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 // status check for client to see if server has been initialized
// server has been initialized if a root user exists // server has been initialized if a root user exists
const payload = { const payload = {
@ -231,7 +238,14 @@ class Server {
if (!payload.isInit) { if (!payload.isInit) {
payload.ConfigPath = global.ConfigPath payload.ConfigPath = global.ConfigPath
payload.MetadataPath = global.MetadataPath 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) res.json(payload)
}) })
router.get('/ping', (req, res) => { router.get('/ping', (req, res) => {

View file

@ -120,6 +120,13 @@ class MiscController {
Logger.error('Invalid user in authorize') Logger.error('Invalid user in authorize')
return res.sendStatus(401) 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) const userResponse = this.auth.getUserLoginResponsePayload(req.user, this.rssFeedManager.feedsArray)
res.json(userResponse) res.json(userResponse)
} }

View file

@ -23,6 +23,9 @@ class User {
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries) this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
this.itemTagsAccessible = [] // Empty if ALL item tags accessible this.itemTagsAccessible = [] // Empty if ALL item tags accessible
// Temporary values not persisted to the Db
this.isForwardAuth = false
if (user) { if (user) {
this.construct(user) this.construct(user)
} }
@ -102,7 +105,7 @@ class User {
} }
toJSONForBrowser() { toJSONForBrowser() {
return { const json = {
id: this.id, id: this.id,
username: this.username, username: this.username,
type: this.type, type: this.type,
@ -117,8 +120,15 @@ class User {
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions, permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible], 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 // Data broadcasted

View file

@ -1,8 +1,8 @@
const true_values = ["true", "yes", "y", "1", "on"]; const true_values = ["true", "yes", "y", "1", "on"]
module.exports.parseBool = (value) => { module.exports.parseBool = (value) => {
if (Object.is(value, null) || value === undefined) { 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())
} }