Add authentication page in config, add /auth-settings GET endpoint, remove authOpenIDCallbackURL server setting

This commit is contained in:
advplyr 2023-09-24 15:36:35 -05:00
parent 7ba10db7d4
commit e282142d3f
10 changed files with 225 additions and 45 deletions

View File

@ -104,6 +104,11 @@ export default {
id: 'config-rss-feeds', id: 'config-rss-feeds',
title: this.$strings.HeaderRSSFeeds, title: this.$strings.HeaderRSSFeeds,
path: '/config/rss-feeds' path: '/config/rss-feeds'
},
{
id: 'config-authentication',
title: this.$strings.HeaderAuthentication,
path: '/config/authentication'
} }
] ]

View File

@ -57,6 +57,7 @@ export default {
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail else if (pageName === 'email') return this.$strings.HeaderEmail
else if (pageName === 'authentication') return this.$strings.HeaderAuthentication
} }
return this.$strings.HeaderSettings return this.$strings.HeaderSettings
} }

View File

@ -0,0 +1,138 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderAuthentication">
<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="enableLocalAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">Password Authentication</p>
</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="enableOpenIDAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">OpenID Connect Authentication</p>
</div>
<div class="overflow-hidden">
<transition name="slide">
<div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4">
<ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" class="mb-2" />
<ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" />
<ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" />
<ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" />
<ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" />
<ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
</div>
</transition>
</div>
</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>
</app-settings-content>
</div>
</template>
<script>
export default {
async asyncData({ store, redirect, app }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
return
}
const authSettings = await app.$axios.$get('/api/auth-settings').catch((error) => {
console.error('Failed', error)
return null
})
if (!authSettings) {
redirect('/config')
return
}
return {
authSettings
}
},
data() {
return {
enableLocalAuth: false,
enableOpenIDAuth: false,
savingSettings: false,
newAuthSettings: {}
}
},
computed: {
authMethods() {
return this.authSettings.authActiveAuthMethods || []
}
},
methods: {
validateOpenID() {
let isValid = true
if (!this.newAuthSettings.authOpenIDIssuerURL) {
this.$toast.error('Issuer URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDAuthorizationURL) {
this.$toast.error('Authorize URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDTokenURL) {
this.$toast.error('Token URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDUserInfoURL) {
this.$toast.error('Userinfo URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDClientID) {
this.$toast.error('Client ID required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDClientSecret) {
this.$toast.error('Client Secret required')
isValid = false
}
return isValid
},
async saveSettings() {
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
this.$toast.error('Must have at least one authentication method enabled')
return
}
if (this.enableOpenIDAuth && !this.validateOpenID()) {
return
}
this.newAuthSettings.authActiveAuthMethods = []
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
this.savingSettings = true
const success = await this.$store.dispatch('updateServerSettings', this.newAuthSettings)
this.savingSettings = false
if (success) {
this.$toast.success('Server settings updated')
} else {
this.$toast.error('Failed to update server settings')
}
},
init() {
this.newAuthSettings = {
...this.authSettings
}
this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid')
}
},
mounted() {
this.init()
}
}
</script>

View File

@ -66,7 +66,7 @@ export const getters = {
export const actions = { export const actions = {
updateServerSettings({ commit }, payload) { updateServerSettings({ commit }, payload) {
var updatePayload = { const updatePayload = {
...payload ...payload
} }
return this.$axios.$patch('/api/settings', updatePayload).then((result) => { return this.$axios.$patch('/api/settings', updatePayload).then((result) => {

View File

@ -88,6 +88,7 @@
"HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAppriseNotificationSettings": "Apprise Notification Settings",
"HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAudioTracks": "Audio Tracks", "HeaderAudioTracks": "Audio Tracks",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Backups", "HeaderBackups": "Backups",
"HeaderChangePassword": "Change Password", "HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters", "HeaderChapters": "Chapters",

View File

@ -57,11 +57,10 @@ class Auth {
userInfoURL: global.ServerSettings.authOpenIDUserInfoURL, userInfoURL: global.ServerSettings.authOpenIDUserInfoURL,
clientID: global.ServerSettings.authOpenIDClientID, clientID: global.ServerSettings.authOpenIDClientID,
clientSecret: global.ServerSettings.authOpenIDClientSecret, clientSecret: global.ServerSettings.authOpenIDClientSecret,
callbackURL: global.ServerSettings.authOpenIDCallbackURL, callbackURL: '/auth/openid/callback',
scope: ["openid", "email", "profile"], scope: ["openid", "email", "profile"],
skipUserProfile: false skipUserProfile: false
}, }, async (issuer, profile, done) => {
(async function (issuer, profile, done) {
// TODO: do we want to create the users which does not exist? // TODO: do we want to create the users which does not exist?
const user = await Database.userModel.getUserByUsername(profile.username) const user = await Database.userModel.getUserByUsername(profile.username)
@ -74,7 +73,7 @@ class Auth {
// permit login // permit login
return done(null, user) return done(null, user)
}).bind(this))) }))
} }
// Load the JwtStrategy (always) -> for bearer token auth // Load the JwtStrategy (always) -> for bearer token auth
@ -111,14 +110,13 @@ class Auth {
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
paramsToCookies(req, res) { paramsToCookies(req, res) {
if (req.query.isRest && req.query.isRest.toLowerCase() == "true") { if (req.query.isRest?.toLowerCase() == "true") {
// store the isRest flag to the is_rest cookie // store the isRest flag to the is_rest cookie
res.cookie('is_rest', req.query.isRest.toLowerCase(), { res.cookie('is_rest', req.query.isRest.toLowerCase(), {
maxAge: 120000, // 2 min maxAge: 120000, // 2 min
httpOnly: true httpOnly: true
}) })
} } else {
else {
// no isRest-flag set -> set is_rest cookie to false // no isRest-flag set -> set is_rest cookie to false
res.cookie('is_rest', "false", { res.cookie('is_rest', "false", {
maxAge: 120000, // 2 min maxAge: 120000, // 2 min
@ -126,7 +124,7 @@ class Auth {
}) })
// check if we are missing a callback parameter - we need one if isRest=false // check if we are missing a callback parameter - we need one if isRest=false
if (!req.query.callback || req.query.callback === "") { if (!req.query.callback) {
res.status(400).send({ res.status(400).send({
message: 'No callback parameter' message: 'No callback parameter'
}) })
@ -151,19 +149,17 @@ class Auth {
// get userLogin json (information about the user, server and the session) // get userLogin json (information about the user, server and the session)
const data_json = await this.getUserLoginResponsePayload(req.user) const data_json = await this.getUserLoginResponsePayload(req.user)
if (req.cookies.is_rest && req.cookies.is_rest === "true") { if (req.cookies.is_rest === 'true') {
// REST request - send data // REST request - send data
res.json(data_json) res.json(data_json)
} } else {
else {
// UI request -> check if we have a callback url // UI request -> check if we have a callback url
// TODO: do we want to somehow limit the values for auth_cb? // TODO: do we want to somehow limit the values for auth_cb?
if (req.cookies.auth_cb && req.cookies.auth_cb.startsWith("http")) { if (req.cookies.auth_cb?.startsWith('http')) {
// UI request -> redirect to auth_cb url and send the jwt token as parameter // UI request -> redirect to auth_cb url and send the jwt token as parameter
res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`)
} } else {
else { res.status(400).send('No callback or already expired')
res.status(400).send("No callback or already expired")
} }
} }
} }
@ -205,7 +201,7 @@ class Auth {
// openid strategy callback route (this receives the token from the configured openid login provider) // openid strategy callback route (this receives the token from the configured openid login provider)
router.get('/auth/openid/callback', router.get('/auth/openid/callback',
passport.authenticate('openidconnect'), passport.authenticate('openidconnect', { failureRedirect: '/login', failureMessage: true }),
// on a successfull login: read the cookies and react like the client requested (callback or json) // on a successfull login: read the cookies and react like the client requested (callback or json)
this.handleLoginSuccessBasedOnCookie.bind(this) this.handleLoginSuccessBasedOnCookie.bind(this)
) )

View File

@ -163,8 +163,6 @@ class Server {
this.server = http.createServer(app) this.server = http.createServer(app)
router.use(fileUpload({ router.use(fileUpload({
defCharset: 'utf8', defCharset: 'utf8',
defParamCharset: 'utf8', defParamCharset: 'utf8',

View File

@ -117,8 +117,9 @@ class MiscController {
/** /**
* PATCH: /api/settings * PATCH: /api/settings
* Update server settings * Update server settings
* @param {*} req *
* @param {*} res * @param {import('express').Request} req
* @param {import('express').Response} res
*/ */
async updateServerSettings(req, res) { async updateServerSettings(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
@ -246,8 +247,8 @@ class MiscController {
* POST: /api/authorize * POST: /api/authorize
* Used to authorize an API token * Used to authorize an API token
* *
* @param {*} req * @param {import('express').Request} req
* @param {*} res * @param {import('express').Response} res
*/ */
async authorize(req, res) { async authorize(req, res) {
if (!req.user) { if (!req.user) {
@ -539,5 +540,19 @@ class MiscController {
res.status(400).send(error.message) res.status(400).send(error.message)
} }
} }
/**
* GET: api/auth-settings (admin only)
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
getAuthSettings(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`)
return res.sendStatus(403)
}
return res.json(Database.serverSettings.authenticationSettings)
}
} }
module.exports = new MiscController() module.exports = new MiscController()

View File

@ -64,14 +64,13 @@ class ServerSettings {
this.authGoogleOauth20ClientSecret = '' this.authGoogleOauth20ClientSecret = ''
this.authGoogleOauth20CallbackURL = '' this.authGoogleOauth20CallbackURL = ''
// generic-oauth20 settings // openid settings
this.authOpenIDIssuerURL = '' this.authOpenIDIssuerURL = ''
this.authOpenIDAuthorizationURL = '' this.authOpenIDAuthorizationURL = ''
this.authOpenIDTokenURL = '' this.authOpenIDTokenURL = ''
this.authOpenIDUserInfoURL = '' this.authOpenIDUserInfoURL = ''
this.authOpenIDClientID = '' this.authOpenIDClientID = ''
this.authOpenIDClientSecret = '' this.authOpenIDClientSecret = ''
this.authOpenIDCallbackURL = ''
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@ -126,7 +125,6 @@ class ServerSettings {
this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || '' this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || ''
this.authOpenIDClientID = settings.authOpenIDClientID || '' this.authOpenIDClientID = settings.authOpenIDClientID || ''
this.authOpenIDClientSecret = settings.authOpenIDClientSecret || '' this.authOpenIDClientSecret = settings.authOpenIDClientSecret || ''
this.authOpenIDCallbackURL = settings.authOpenIDCallbackURL || ''
if (!Array.isArray(this.authActiveAuthMethods)) { if (!Array.isArray(this.authActiveAuthMethods)) {
this.authActiveAuthMethods = ['local'] this.authActiveAuthMethods = ['local']
@ -144,16 +142,15 @@ class ServerSettings {
// remove uninitialized methods // remove uninitialized methods
// OpenID // OpenID
if (this.authActiveAuthMethods.includes('generic-oauth20') && ( if (this.authActiveAuthMethods.includes('openid') && (
this.authOpenIDIssuerURL === '' || this.authOpenIDIssuerURL === '' ||
this.authOpenIDAuthorizationURL === '' || this.authOpenIDAuthorizationURL === '' ||
this.authOpenIDTokenURL === '' || this.authOpenIDTokenURL === '' ||
this.authOpenIDUserInfoURL === '' || this.authOpenIDUserInfoURL === '' ||
this.authOpenIDClientID === '' || this.authOpenIDClientID === '' ||
this.authOpenIDClientSecret === '' || this.authOpenIDClientSecret === ''
this.authOpenIDCallbackURL === ''
)) { )) {
this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('generic-oauth20', 0), 1) this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1)
} }
// fallback to local // fallback to local
@ -228,8 +225,7 @@ class ServerSettings {
authOpenIDTokenURL: this.authOpenIDTokenURL, authOpenIDTokenURL: this.authOpenIDTokenURL,
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client authOpenIDClientSecret: this.authOpenIDClientSecret // Do not return to client
authOpenIDCallbackURL: this.authOpenIDCallbackURL
} }
} }
@ -243,13 +239,42 @@ class ServerSettings {
return json return json
} }
get authenticationSettings() {
return {
authActiveAuthMethods: this.authActiveAuthMethods,
authGoogleOauth20ClientID: this.authGoogleOauth20ClientID, // Do not return to client
authGoogleOauth20ClientSecret: this.authGoogleOauth20ClientSecret, // Do not return to client
authGoogleOauth20CallbackURL: this.authGoogleOauth20CallbackURL,
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
authOpenIDTokenURL: this.authOpenIDTokenURL,
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
authOpenIDClientSecret: this.authOpenIDClientSecret // Do not return to client
}
}
/**
* Update server settings
*
* @param {Object} payload
* @returns {boolean} true if updates were made
*/
update(payload) { update(payload) {
let hasUpdates = false let hasUpdates = false
for (const key in payload) { for (const key in payload) {
if (key === 'sortingPrefixes' && payload[key] && payload[key].length) { if (key === 'sortingPrefixes') {
const prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase()) // Sorting prefixes are updated with the /api/sorting-prefixes endpoint
if (prefixesCleaned.join(',') !== this[key].join(',')) { continue
this[key] = [...prefixesCleaned] } else if (key === 'authActiveAuthMethods') {
if (!payload[key]?.length) {
Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key])
continue
}
this.authActiveAuthMethods.sort()
payload[key].sort()
if (payload[key].join() !== this.authActiveAuthMethods.join()) {
this.authActiveAuthMethods = payload[key]
hasUpdates = true hasUpdates = true
} }
} else if (this[key] !== payload[key]) { } else if (this[key] !== payload[key]) {

View File

@ -306,6 +306,7 @@ class ApiRouter {
this.router.post('/genres/rename', MiscController.renameGenre.bind(this)) this.router.post('/genres/rename', MiscController.renameGenre.bind(this))
this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this)) this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this))
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
} }
async getDirectories(dir, relpath, excludedDirs, level = 0) { async getDirectories(dir, relpath, excludedDirs, level = 0) {