mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-13 10:55:05 +02:00
Seperate out auth strategies, update change password to return error status codes
This commit is contained in:
parent
d9cfcc86e7
commit
9c8900560c
5 changed files with 729 additions and 523 deletions
|
@ -182,18 +182,19 @@ export default {
|
||||||
password: this.password,
|
password: this.password,
|
||||||
newPassword: this.newPassword
|
newPassword: this.newPassword
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
if (res.success) {
|
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
||||||
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
this.resetForm()
|
||||||
this.resetForm()
|
|
||||||
} else {
|
|
||||||
this.$toast.error(res.error || this.$strings.ToastUnknownError)
|
|
||||||
}
|
|
||||||
this.changingPassword = false
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error)
|
console.error('Failed to change password', error)
|
||||||
this.$toast.error(this.$strings.ToastUnknownError)
|
let errorMessage = this.$strings.ToastUnknownError
|
||||||
|
if (error.response?.data && typeof error.response.data === 'string') {
|
||||||
|
errorMessage = error.response.data
|
||||||
|
}
|
||||||
|
this.$toast.error(errorMessage)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
this.changingPassword = false
|
this.changingPassword = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
539
server/Auth.js
539
server/Auth.js
|
@ -1,17 +1,14 @@
|
||||||
const { Request, Response, NextFunction } = require('express')
|
const { Request, Response, NextFunction } = require('express')
|
||||||
const axios = require('axios')
|
|
||||||
const passport = require('passport')
|
const passport = require('passport')
|
||||||
const OpenIDClient = require('openid-client')
|
|
||||||
const JwtStrategy = require('passport-jwt').Strategy
|
const JwtStrategy = require('passport-jwt').Strategy
|
||||||
const ExtractJwt = require('passport-jwt').ExtractJwt
|
const ExtractJwt = require('passport-jwt').ExtractJwt
|
||||||
|
|
||||||
const Database = require('./Database')
|
const Database = require('./Database')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const TokenManager = require('./auth/TokenManager')
|
const TokenManager = require('./auth/TokenManager')
|
||||||
|
const LocalAuthStrategy = require('./auth/LocalAuthStrategy')
|
||||||
|
const OidcAuthStrategy = require('./auth/OidcAuthStrategy')
|
||||||
|
|
||||||
const bcrypt = require('./libs/bcryptjs')
|
|
||||||
const requestIp = require('./libs/requestIp')
|
|
||||||
const LocalStrategy = require('./libs/passportLocal')
|
|
||||||
const { escapeRegExp } = require('./utils')
|
const { escapeRegExp } = require('./utils')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -19,12 +16,12 @@ const { escapeRegExp } = require('./utils')
|
||||||
*/
|
*/
|
||||||
class Auth {
|
class Auth {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Map of openId sessions indexed by oauth2 state-variable
|
|
||||||
this.openIdAuthSession = new Map()
|
|
||||||
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
|
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
|
||||||
this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
|
this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
|
||||||
|
|
||||||
this.tokenManager = new TokenManager()
|
this.tokenManager = new TokenManager()
|
||||||
|
this.localAuthStrategy = new LocalAuthStrategy()
|
||||||
|
this.oidcAuthStrategy = new OidcAuthStrategy()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -117,12 +114,12 @@ class Auth {
|
||||||
async initPassportJs() {
|
async initPassportJs() {
|
||||||
// Check if we should load the local strategy (username + password login)
|
// Check if we should load the local strategy (username + password login)
|
||||||
if (global.ServerSettings.authActiveAuthMethods.includes('local')) {
|
if (global.ServerSettings.authActiveAuthMethods.includes('local')) {
|
||||||
this.initAuthStrategyPassword()
|
this.localAuthStrategy.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should load the openid strategy
|
// Check if we should load the openid strategy
|
||||||
if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {
|
if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {
|
||||||
this.initAuthStrategyOpenID()
|
this.oidcAuthStrategy.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the JwtStrategy (always) -> for bearer token auth
|
// Load the JwtStrategy (always) -> for bearer token auth
|
||||||
|
@ -165,169 +162,21 @@ class Auth {
|
||||||
}.bind(this)
|
}.bind(this)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Passport use LocalStrategy
|
|
||||||
*/
|
|
||||||
initAuthStrategyPassword() {
|
|
||||||
passport.use(new LocalStrategy({ passReqToCallback: true }, this.localAuthCheckUserPw.bind(this)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passport use OpenIDClient.Strategy
|
|
||||||
*/
|
|
||||||
initAuthStrategyOpenID() {
|
|
||||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
|
||||||
Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
|
|
||||||
OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 })
|
|
||||||
|
|
||||||
const openIdIssuerClient = new OpenIDClient.Issuer({
|
|
||||||
issuer: global.ServerSettings.authOpenIDIssuerURL,
|
|
||||||
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
|
||||||
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
|
||||||
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
|
||||||
jwks_uri: global.ServerSettings.authOpenIDJwksURL,
|
|
||||||
end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL
|
|
||||||
}).Client
|
|
||||||
const openIdClient = new openIdIssuerClient({
|
|
||||||
client_id: global.ServerSettings.authOpenIDClientID,
|
|
||||||
client_secret: global.ServerSettings.authOpenIDClientSecret,
|
|
||||||
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
|
|
||||||
})
|
|
||||||
passport.use(
|
|
||||||
'openid-client',
|
|
||||||
new OpenIDClient.Strategy(
|
|
||||||
{
|
|
||||||
client: openIdClient,
|
|
||||||
params: {
|
|
||||||
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
|
||||||
scope: 'openid profile email'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (tokenset, userinfo, done) => {
|
|
||||||
try {
|
|
||||||
Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
|
||||||
|
|
||||||
if (!userinfo.sub) {
|
|
||||||
throw new Error('Invalid userinfo, no sub')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.validateGroupClaim(userinfo)) {
|
|
||||||
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this)
|
|
||||||
|
|
||||||
if (!user?.isActive) {
|
|
||||||
throw new Error('User not active or not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.setUserGroup(user, userinfo)
|
|
||||||
await this.updateUserPermissions(user, userinfo)
|
|
||||||
|
|
||||||
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
|
||||||
user.openid_id_token = tokenset.id_token
|
|
||||||
|
|
||||||
return done(null, user)
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`)
|
|
||||||
|
|
||||||
return done(null, null, 'Unauthorized')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the presence and content of the group claim in userinfo.
|
|
||||||
*/
|
|
||||||
validateGroupClaim(userinfo) {
|
|
||||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
|
||||||
if (!groupClaimName)
|
|
||||||
// Allow no group claim when configured like this
|
|
||||||
return true
|
|
||||||
|
|
||||||
// If configured it must exist in userinfo
|
|
||||||
if (!userinfo[groupClaimName]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the user group based on group claim in userinfo.
|
|
||||||
*
|
|
||||||
* @param {import('./models/User')} user
|
|
||||||
* @param {Object} userinfo
|
|
||||||
*/
|
|
||||||
async setUserGroup(user, userinfo) {
|
|
||||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
|
||||||
if (!groupClaimName)
|
|
||||||
// No group claim configured, don't set anything
|
|
||||||
return
|
|
||||||
|
|
||||||
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
|
||||||
|
|
||||||
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
|
||||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
|
||||||
|
|
||||||
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
|
||||||
if (userType) {
|
|
||||||
if (user.type === 'root') {
|
|
||||||
// Check OpenID Group
|
|
||||||
if (userType !== 'admin') {
|
|
||||||
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
|
|
||||||
} else {
|
|
||||||
// If root user is logging in via OpenID, we will not change the type
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.type !== userType) {
|
|
||||||
Logger.info(`[Auth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
|
|
||||||
user.type = userType
|
|
||||||
await user.save()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user permissions based on the advanced permissions claim.
|
|
||||||
*
|
|
||||||
* @param {import('./models/User')} user
|
|
||||||
* @param {Object} userinfo
|
|
||||||
*/
|
|
||||||
async updateUserPermissions(user, userinfo) {
|
|
||||||
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
|
|
||||||
if (!absPermissionsClaim)
|
|
||||||
// No advanced permissions claim configured, don't set anything
|
|
||||||
return
|
|
||||||
|
|
||||||
if (user.type === 'admin' || user.type === 'root') return
|
|
||||||
|
|
||||||
const absPermissions = userinfo[absPermissionsClaim]
|
|
||||||
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
|
||||||
|
|
||||||
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
|
|
||||||
Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unuse strategy
|
* Unuse strategy
|
||||||
*
|
*
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
*/
|
*/
|
||||||
unuseAuthStrategy(name) {
|
unuseAuthStrategy(name) {
|
||||||
passport.unuse(name)
|
if (name === 'openid') {
|
||||||
|
this.oidcAuthStrategy.unuse()
|
||||||
|
} else if (name === 'local') {
|
||||||
|
this.localAuthStrategy.unuse()
|
||||||
|
} else {
|
||||||
|
Logger.error('[Auth] Invalid auth strategy ' + name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -337,9 +186,9 @@ class Auth {
|
||||||
*/
|
*/
|
||||||
useAuthStrategy(name) {
|
useAuthStrategy(name) {
|
||||||
if (name === 'openid') {
|
if (name === 'openid') {
|
||||||
this.initAuthStrategyOpenID()
|
this.oidcAuthStrategy.init()
|
||||||
} else if (name === 'local') {
|
} else if (name === 'local') {
|
||||||
this.initAuthStrategyPassword()
|
this.localAuthStrategy.init()
|
||||||
} else {
|
} else {
|
||||||
Logger.error('[Auth] Invalid auth strategy ' + name)
|
Logger.error('[Auth] Invalid auth strategy ' + name)
|
||||||
}
|
}
|
||||||
|
@ -496,153 +345,27 @@ class Auth {
|
||||||
})
|
})
|
||||||
|
|
||||||
// openid strategy login route (this redirects to the configured openid login provider)
|
// openid strategy login route (this redirects to the configured openid login provider)
|
||||||
router.get('/auth/openid', (req, res, next) => {
|
router.get('/auth/openid', (req, res) => {
|
||||||
// Get the OIDC client from the strategy
|
const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req)
|
||||||
// We need to call the client manually, because the strategy does not support forwarding the code challenge
|
|
||||||
// for API or mobile clients
|
|
||||||
const oidcStrategy = passport._strategy('openid-client')
|
|
||||||
const client = oidcStrategy._client
|
|
||||||
const sessionKey = oidcStrategy._key
|
|
||||||
|
|
||||||
try {
|
if (authorizationUrlResponse.error) {
|
||||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error)
|
||||||
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
|
||||||
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
|
||||||
|
|
||||||
// Only allow code flow (for mobile clients)
|
|
||||||
if (req.query.response_type && req.query.response_type !== 'code') {
|
|
||||||
Logger.debug(`[Auth] OIDC Invalid response_type=${req.query.response_type}`)
|
|
||||||
return res.status(400).send('Invalid response_type, only code supported')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a state on web flow or if no state supplied
|
|
||||||
const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
|
|
||||||
|
|
||||||
// Redirect URL for the SSO provider
|
|
||||||
let redirectUri
|
|
||||||
if (isMobileFlow) {
|
|
||||||
// Mobile required redirect uri
|
|
||||||
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
|
|
||||||
// where we will handle the redirect to it
|
|
||||||
if (!req.query.redirect_uri || !isValidRedirectUri(req.query.redirect_uri)) {
|
|
||||||
Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
|
||||||
return res.status(400).send('Invalid redirect_uri')
|
|
||||||
}
|
|
||||||
// We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
|
|
||||||
// for the request to mobile-redirect and as such the session is not shared
|
|
||||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
|
||||||
|
|
||||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
|
||||||
} else {
|
|
||||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
|
||||||
|
|
||||||
if (req.query.state) {
|
|
||||||
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
|
|
||||||
return res.status(400).send('Invalid state, not allowed on web flow')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
oidcStrategy._params.redirect_uri = redirectUri
|
|
||||||
Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`)
|
|
||||||
|
|
||||||
let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow)
|
|
||||||
|
|
||||||
req.session[sessionKey] = {
|
|
||||||
...req.session[sessionKey],
|
|
||||||
state: state,
|
|
||||||
max_age: oidcStrategy._params.max_age,
|
|
||||||
response_type: 'code',
|
|
||||||
code_verifier: code_verifier, // not null if web flow
|
|
||||||
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
|
||||||
sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
|
|
||||||
}
|
|
||||||
|
|
||||||
var scope = 'openid profile email'
|
|
||||||
if (global.ServerSettings.authOpenIDGroupClaim) {
|
|
||||||
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
|
||||||
}
|
|
||||||
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
|
||||||
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorizationUrl = client.authorizationUrl({
|
|
||||||
...oidcStrategy._params,
|
|
||||||
state: state,
|
|
||||||
response_type: 'code',
|
|
||||||
scope: scope,
|
|
||||||
code_challenge,
|
|
||||||
code_challenge_method
|
|
||||||
})
|
|
||||||
|
|
||||||
this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid')
|
|
||||||
|
|
||||||
res.redirect(authorizationUrl)
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`[Auth] Error in /auth/openid route: ${error}\n${error?.stack}`)
|
|
||||||
res.status(500).send('Internal Server Error')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePkce(req, isMobileFlow) {
|
this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid')
|
||||||
if (isMobileFlow) {
|
|
||||||
if (!req.query.code_challenge) {
|
|
||||||
throw new Error('code_challenge required for mobile flow (PKCE)')
|
|
||||||
}
|
|
||||||
if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
|
|
||||||
throw new Error('Only S256 code_challenge_method method supported')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code_challenge: req.query.code_challenge,
|
|
||||||
code_challenge_method: req.query.code_challenge_method || 'S256'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const code_verifier = OpenIDClient.generators.codeVerifier()
|
|
||||||
const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
|
||||||
return { code_challenge, code_challenge_method: 'S256', code_verifier }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidRedirectUri(uri) {
|
res.redirect(authorizationUrlResponse.authorizationUrl)
|
||||||
// Check if the redirect_uri is in the whitelist
|
|
||||||
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// This will be the oauth2 callback route for mobile clients
|
// This will be the oauth2 callback route for mobile clients
|
||||||
// It will redirect to an app-link like audiobookshelf://oauth
|
// It will redirect to an app-link like audiobookshelf://oauth
|
||||||
router.get('/auth/openid/mobile-redirect', (req, res) => {
|
router.get('/auth/openid/mobile-redirect', (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res))
|
||||||
try {
|
|
||||||
// Extract the state parameter from the request
|
|
||||||
const { state, code } = req.query
|
|
||||||
|
|
||||||
// Check if the state provided is in our list
|
|
||||||
if (!state || !this.openIdAuthSession.has(state)) {
|
|
||||||
Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
|
||||||
return res.status(400).send('State parameter mismatch')
|
|
||||||
}
|
|
||||||
|
|
||||||
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
|
||||||
|
|
||||||
if (!mobile_redirect_uri) {
|
|
||||||
Logger.error('[Auth] No redirect URI')
|
|
||||||
return res.status(400).send('No redirect URI')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.openIdAuthSession.delete(state)
|
|
||||||
|
|
||||||
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
|
||||||
// Redirect to the overwrite URI saved in the map
|
|
||||||
res.redirect(redirectUri)
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`)
|
|
||||||
res.status(500).send('Internal Server Error')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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(
|
router.get(
|
||||||
'/auth/openid/callback',
|
'/auth/openid/callback',
|
||||||
(req, res, next) => {
|
(req, res, next) => {
|
||||||
const oidcStrategy = passport._strategy('openid-client')
|
const sessionKey = this.oidcAuthStrategy.getStrategy()._key
|
||||||
const sessionKey = oidcStrategy._key
|
|
||||||
|
|
||||||
if (!req.session[sessionKey]) {
|
if (!req.session[sessionKey]) {
|
||||||
return res.status(400).send('No session')
|
return res.status(400).send('No session')
|
||||||
|
@ -719,43 +442,16 @@ class Auth {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.query.issuer) {
|
if (!req.query.issuer || typeof req.query.issuer !== 'string') {
|
||||||
return res.status(400).send("Invalid request. Query param 'issuer' is required")
|
return res.status(400).send("Invalid request. Query param 'issuer' is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip trailing slash
|
const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer)
|
||||||
let issuerUrl = req.query.issuer
|
if (openIdIssuerConfig.error) {
|
||||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error)
|
||||||
|
|
||||||
// Append config pathname and validate URL
|
|
||||||
let configUrl = null
|
|
||||||
try {
|
|
||||||
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
|
||||||
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
|
||||||
throw new Error('Invalid pathname')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
|
||||||
return res.status(400).send("Invalid request. Query param 'issuer' is invalid")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
axios
|
res.json(openIdIssuerConfig)
|
||||||
.get(configUrl.toString())
|
|
||||||
.then(({ data }) => {
|
|
||||||
res.json({
|
|
||||||
issuer: data.issuer,
|
|
||||||
authorization_endpoint: data.authorization_endpoint,
|
|
||||||
token_endpoint: data.token_endpoint,
|
|
||||||
userinfo_endpoint: data.userinfo_endpoint,
|
|
||||||
end_session_endpoint: data.end_session_endpoint,
|
|
||||||
jwks_uri: data.jwks_uri,
|
|
||||||
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
|
|
||||||
res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Logout route
|
// Logout route
|
||||||
|
@ -782,7 +478,6 @@ class Auth {
|
||||||
Logger.info(`[Auth] logout: No refresh token on request`)
|
Logger.info(`[Auth] logout: No refresh token on request`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: invalidate possible JWTs
|
|
||||||
req.logout((err) => {
|
req.logout((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.sendStatus(500)
|
res.sendStatus(500)
|
||||||
|
@ -794,36 +489,7 @@ class Auth {
|
||||||
let logoutUrl = null
|
let logoutUrl = null
|
||||||
|
|
||||||
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
|
logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod)
|
||||||
// node-openid-client does not support doing it over passport
|
|
||||||
const oidcStrategy = passport._strategy('openid-client')
|
|
||||||
const client = oidcStrategy._client
|
|
||||||
|
|
||||||
if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {
|
|
||||||
let postLogoutRedirectUri = null
|
|
||||||
|
|
||||||
if (authMethod === 'openid') {
|
|
||||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
|
||||||
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}${global.RouterBasePath}/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
|
|
||||||
// the post_logout_redirect_uri parameter at all and for other providers
|
|
||||||
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
|
|
||||||
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
|
|
||||||
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
|
|
||||||
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
|
|
||||||
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
|
|
||||||
|
|
||||||
logoutUrl = client.endSessionUrl({
|
|
||||||
id_token_hint: req.cookies.openid_id_token,
|
|
||||||
post_logout_redirect_uri: postLogoutRedirectUri
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
res.clearCookie('openid_id_token')
|
res.clearCookie('openid_id_token')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -835,153 +501,6 @@ class Auth {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region Local Auth
|
|
||||||
/**
|
|
||||||
* Checks if a username and password tuple is valid and the user active.
|
|
||||||
* @param {Request} req
|
|
||||||
* @param {string} username
|
|
||||||
* @param {string} password
|
|
||||||
* @param {Promise<function>} done
|
|
||||||
*/
|
|
||||||
async localAuthCheckUserPw(req, username, password, done) {
|
|
||||||
// Load the user given it's username
|
|
||||||
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
|
||||||
|
|
||||||
if (!user?.isActive) {
|
|
||||||
if (user) {
|
|
||||||
this.logFailedLocalAuthLoginAttempt(req, user.username, 'User is not active')
|
|
||||||
} else {
|
|
||||||
this.logFailedLocalAuthLoginAttempt(req, username, 'User not found')
|
|
||||||
}
|
|
||||||
done(null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check passwordless root user
|
|
||||||
if (user.type === 'root' && !user.pash) {
|
|
||||||
if (password) {
|
|
||||||
// deny login
|
|
||||||
this.logFailedLocalAuthLoginAttempt(req, user.username, 'Root user has no password set')
|
|
||||||
done(null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// approve login
|
|
||||||
Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
|
||||||
|
|
||||||
done(null, user)
|
|
||||||
return
|
|
||||||
} else if (!user.pash) {
|
|
||||||
this.logFailedLocalAuthLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID')
|
|
||||||
done(null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check password match
|
|
||||||
const compare = await bcrypt.compare(password, user.pash)
|
|
||||||
if (compare) {
|
|
||||||
// approve login
|
|
||||||
Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
|
||||||
|
|
||||||
done(null, user)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// deny login
|
|
||||||
this.logFailedLocalAuthLoginAttempt(req, user.username, 'Invalid password')
|
|
||||||
done(null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {Request} req
|
|
||||||
* @param {string} username
|
|
||||||
* @param {string} message
|
|
||||||
*/
|
|
||||||
logFailedLocalAuthLoginAttempt(req, username, message) {
|
|
||||||
if (!req || !username || !message) return
|
|
||||||
Logger.error(`[Auth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hashes a password with bcrypt.
|
|
||||||
* @param {string} password
|
|
||||||
* @returns {Promise<string>} hash
|
|
||||||
*/
|
|
||||||
hashPass(password) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
bcrypt.hash(password, 8, (err, hash) => {
|
|
||||||
if (err) {
|
|
||||||
resolve(null)
|
|
||||||
} else {
|
|
||||||
resolve(hash)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {string} password
|
|
||||||
* @param {import('./models/User')} user
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
comparePassword(password, user) {
|
|
||||||
if (user.type === 'root' && !password && !user.pash) return true
|
|
||||||
if (!password || !user.pash) return false
|
|
||||||
return bcrypt.compare(password, user.pash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User changes their password from request
|
|
||||||
* TODO: Update responses to use error status codes
|
|
||||||
*
|
|
||||||
* @param {import('./controllers/MeController').RequestWithUser} req
|
|
||||||
* @param {Response} res
|
|
||||||
*/
|
|
||||||
async userChangePassword(req, res) {
|
|
||||||
let { password, newPassword } = req.body
|
|
||||||
newPassword = newPassword || ''
|
|
||||||
const matchingUser = req.user
|
|
||||||
|
|
||||||
// Only root can have an empty password
|
|
||||||
if (matchingUser.type !== 'root' && !newPassword) {
|
|
||||||
return res.json({
|
|
||||||
error: 'Invalid new password - Only root can have an empty password'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check password match
|
|
||||||
const compare = await this.comparePassword(password, matchingUser)
|
|
||||||
if (!compare) {
|
|
||||||
return res.json({
|
|
||||||
error: 'Invalid password'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let pw = ''
|
|
||||||
if (newPassword) {
|
|
||||||
pw = await this.hashPass(newPassword)
|
|
||||||
if (!pw) {
|
|
||||||
return res.json({
|
|
||||||
error: 'Hash failed'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await matchingUser.update({ pash: pw })
|
|
||||||
Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
|
|
||||||
res.json({
|
|
||||||
success: true
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error)
|
|
||||||
res.json({
|
|
||||||
error: 'Unknown error'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #endregion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Auth
|
module.exports = Auth
|
||||||
|
|
186
server/auth/LocalAuthStrategy.js
Normal file
186
server/auth/LocalAuthStrategy.js
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
const passport = require('passport')
|
||||||
|
const LocalStrategy = require('../libs/passportLocal')
|
||||||
|
const Database = require('../Database')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
const bcrypt = require('../libs/bcryptjs')
|
||||||
|
const requestIp = require('../libs/requestIp')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local authentication strategy using username/password
|
||||||
|
*/
|
||||||
|
class LocalAuthStrategy {
|
||||||
|
constructor() {
|
||||||
|
this.name = 'local'
|
||||||
|
this.strategy = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the passport strategy instance
|
||||||
|
* @returns {LocalStrategy}
|
||||||
|
*/
|
||||||
|
getStrategy() {
|
||||||
|
if (!this.strategy) {
|
||||||
|
this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this))
|
||||||
|
}
|
||||||
|
return this.strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the strategy with passport
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
passport.use(this.name, this.getStrategy())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the strategy from passport
|
||||||
|
*/
|
||||||
|
unuse() {
|
||||||
|
passport.unuse(this.name)
|
||||||
|
this.strategy = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify user credentials
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} password
|
||||||
|
* @param {Function} done - Passport callback
|
||||||
|
*/
|
||||||
|
async verifyCredentials(req, username, password, done) {
|
||||||
|
// Load the user given it's username
|
||||||
|
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||||
|
|
||||||
|
if (!user?.isActive) {
|
||||||
|
if (user) {
|
||||||
|
this.logFailedLoginAttempt(req, user.username, 'User is not active')
|
||||||
|
} else {
|
||||||
|
this.logFailedLoginAttempt(req, username, 'User not found')
|
||||||
|
}
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check passwordless root user
|
||||||
|
if (user.type === 'root' && !user.pash) {
|
||||||
|
if (password) {
|
||||||
|
// deny login
|
||||||
|
this.logFailedLoginAttempt(req, user.username, 'Root user has no password set')
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// approve login
|
||||||
|
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||||
|
|
||||||
|
done(null, user)
|
||||||
|
return
|
||||||
|
} else if (!user.pash) {
|
||||||
|
this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID')
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password match
|
||||||
|
const compare = await bcrypt.compare(password, user.pash)
|
||||||
|
if (compare) {
|
||||||
|
// approve login
|
||||||
|
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||||
|
|
||||||
|
done(null, user)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// deny login
|
||||||
|
this.logFailedLoginAttempt(req, user.username, 'Invalid password')
|
||||||
|
done(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log failed login attempts
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
logFailedLoginAttempt(req, username, message) {
|
||||||
|
if (!req || !username || !message) return
|
||||||
|
Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a password with bcrypt
|
||||||
|
* @param {string} password
|
||||||
|
* @returns {Promise<string>} hash
|
||||||
|
*/
|
||||||
|
hashPassword(password) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
bcrypt.hash(password, 8, (err, hash) => {
|
||||||
|
if (err) {
|
||||||
|
resolve(null)
|
||||||
|
} else {
|
||||||
|
resolve(hash)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare password with user's hashed password
|
||||||
|
* @param {string} password
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
comparePassword(password, user) {
|
||||||
|
if (user.type === 'root' && !password && !user.pash) return true
|
||||||
|
if (!password || !user.pash) return false
|
||||||
|
return bcrypt.compare(password, user.pash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change user password
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @param {string} password
|
||||||
|
* @param {string} newPassword
|
||||||
|
*/
|
||||||
|
async changePassword(user, password, newPassword) {
|
||||||
|
// Only root can have an empty password
|
||||||
|
if (user.type !== 'root' && !newPassword) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid new password - Only root can have an empty password'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password match
|
||||||
|
const compare = await this.comparePassword(password, user)
|
||||||
|
if (!compare) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid password'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pw = ''
|
||||||
|
if (newPassword) {
|
||||||
|
pw = await this.hashPassword(newPassword)
|
||||||
|
if (!pw) {
|
||||||
|
return {
|
||||||
|
error: 'Hash failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await user.update({ pash: pw })
|
||||||
|
Logger.info(`[LocalAuth] User "${user.username}" changed password`)
|
||||||
|
return {
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error)
|
||||||
|
return {
|
||||||
|
error: 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LocalAuthStrategy
|
488
server/auth/OidcAuthStrategy.js
Normal file
488
server/auth/OidcAuthStrategy.js
Normal file
|
@ -0,0 +1,488 @@
|
||||||
|
const { Request, Response } = require('express')
|
||||||
|
const passport = require('passport')
|
||||||
|
const OpenIDClient = require('openid-client')
|
||||||
|
const axios = require('axios')
|
||||||
|
const Database = require('../Database')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenID Connect authentication strategy
|
||||||
|
*/
|
||||||
|
class OidcAuthStrategy {
|
||||||
|
constructor() {
|
||||||
|
this.name = 'openid-client'
|
||||||
|
this.strategy = null
|
||||||
|
this.client = null
|
||||||
|
// Map of openId sessions indexed by oauth2 state-variable
|
||||||
|
this.openIdAuthSession = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the passport strategy instance
|
||||||
|
* @returns {OpenIDClient.Strategy}
|
||||||
|
*/
|
||||||
|
getStrategy() {
|
||||||
|
if (!this.strategy) {
|
||||||
|
this.strategy = new OpenIDClient.Strategy(
|
||||||
|
{
|
||||||
|
client: this.getClient(),
|
||||||
|
params: {
|
||||||
|
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||||
|
scope: this.getScope()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
this.verifyCallback.bind(this)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OpenID Connect client
|
||||||
|
* @returns {OpenIDClient.Client}
|
||||||
|
*/
|
||||||
|
getClient() {
|
||||||
|
if (!this.client) {
|
||||||
|
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||||
|
throw new Error('OpenID Connect settings are not valid')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
|
||||||
|
OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 })
|
||||||
|
|
||||||
|
const openIdIssuerClient = new OpenIDClient.Issuer({
|
||||||
|
issuer: global.ServerSettings.authOpenIDIssuerURL,
|
||||||
|
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
||||||
|
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
||||||
|
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
||||||
|
jwks_uri: global.ServerSettings.authOpenIDJwksURL,
|
||||||
|
end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL
|
||||||
|
}).Client
|
||||||
|
|
||||||
|
this.client = new openIdIssuerClient({
|
||||||
|
client_id: global.ServerSettings.authOpenIDClientID,
|
||||||
|
client_secret: global.ServerSettings.authOpenIDClientSecret,
|
||||||
|
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the scope string for the OpenID Connect request
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getScope() {
|
||||||
|
let scope = 'openid profile email'
|
||||||
|
if (global.ServerSettings.authOpenIDGroupClaim) {
|
||||||
|
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
||||||
|
}
|
||||||
|
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
||||||
|
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the strategy with passport
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||||
|
Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passport.use(this.name, this.getStrategy())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the strategy from passport
|
||||||
|
*/
|
||||||
|
unuse() {
|
||||||
|
passport.unuse(this.name)
|
||||||
|
this.strategy = null
|
||||||
|
this.client = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify callback for OpenID Connect authentication
|
||||||
|
* @param {Object} tokenset
|
||||||
|
* @param {Object} userinfo
|
||||||
|
* @param {Function} done - Passport callback
|
||||||
|
*/
|
||||||
|
async verifyCallback(tokenset, userinfo, done) {
|
||||||
|
try {
|
||||||
|
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||||
|
|
||||||
|
if (!userinfo.sub) {
|
||||||
|
throw new Error('Invalid userinfo, no sub')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.validateGroupClaim(userinfo)) {
|
||||||
|
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this)
|
||||||
|
|
||||||
|
if (!user?.isActive) {
|
||||||
|
throw new Error('User not active or not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.setUserGroup(user, userinfo)
|
||||||
|
await this.updateUserPermissions(user, userinfo)
|
||||||
|
|
||||||
|
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||||
|
user.openid_id_token = tokenset.id_token
|
||||||
|
|
||||||
|
return done(null, user)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||||
|
return done(null, null, 'Unauthorized')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the presence and content of the group claim in userinfo.
|
||||||
|
* @param {Object} userinfo
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
validateGroupClaim(userinfo) {
|
||||||
|
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||||
|
if (!groupClaimName)
|
||||||
|
// Allow no group claim when configured like this
|
||||||
|
return true
|
||||||
|
|
||||||
|
// If configured it must exist in userinfo
|
||||||
|
if (!userinfo[groupClaimName]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user group based on group claim in userinfo.
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @param {Object} userinfo
|
||||||
|
*/
|
||||||
|
async setUserGroup(user, userinfo) {
|
||||||
|
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||||
|
if (!groupClaimName)
|
||||||
|
// No group claim configured, don't set anything
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||||
|
|
||||||
|
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||||
|
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||||
|
|
||||||
|
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||||
|
if (userType) {
|
||||||
|
if (user.type === 'root') {
|
||||||
|
// Check OpenID Group
|
||||||
|
if (userType !== 'admin') {
|
||||||
|
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
|
||||||
|
} else {
|
||||||
|
// If root user is logging in via OpenID, we will not change the type
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.type !== userType) {
|
||||||
|
Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
|
||||||
|
user.type = userType
|
||||||
|
await user.save()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates user permissions based on the advanced permissions claim.
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @param {Object} userinfo
|
||||||
|
*/
|
||||||
|
async updateUserPermissions(user, userinfo) {
|
||||||
|
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
|
||||||
|
if (!absPermissionsClaim)
|
||||||
|
// No advanced permissions claim configured, don't set anything
|
||||||
|
return
|
||||||
|
|
||||||
|
if (user.type === 'admin' || user.type === 'root') return
|
||||||
|
|
||||||
|
const absPermissions = userinfo[absPermissionsClaim]
|
||||||
|
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
||||||
|
|
||||||
|
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
|
||||||
|
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PKCE parameters for the authorization request
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {boolean} isMobileFlow
|
||||||
|
* @returns {Object|{error: string}}
|
||||||
|
*/
|
||||||
|
generatePkce(req, isMobileFlow) {
|
||||||
|
if (isMobileFlow) {
|
||||||
|
if (!req.query.code_challenge) {
|
||||||
|
return {
|
||||||
|
error: 'code_challenge required for mobile flow (PKCE)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
|
||||||
|
return {
|
||||||
|
error: 'Only S256 code_challenge_method method supported'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code_challenge: req.query.code_challenge,
|
||||||
|
code_challenge_method: req.query.code_challenge_method || 'S256'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const code_verifier = OpenIDClient.generators.codeVerifier()
|
||||||
|
const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
||||||
|
return { code_challenge, code_challenge_method: 'S256', code_verifier }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a redirect URI is valid
|
||||||
|
* @param {string} uri
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isValidRedirectUri(uri) {
|
||||||
|
// Check if the redirect_uri is in the whitelist
|
||||||
|
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authorization URL for OpenID Connect
|
||||||
|
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
|
||||||
|
* @param {Request} req
|
||||||
|
* @returns {{ authorizationUrl: string }|{status: number, error: string}}
|
||||||
|
*/
|
||||||
|
getAuthorizationUrl(req) {
|
||||||
|
const client = this.getClient()
|
||||||
|
const strategy = this.getStrategy()
|
||||||
|
const sessionKey = strategy._key
|
||||||
|
|
||||||
|
try {
|
||||||
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||||
|
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
||||||
|
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||||
|
|
||||||
|
// Only allow code flow (for mobile clients)
|
||||||
|
if (req.query.response_type && req.query.response_type !== 'code') {
|
||||||
|
Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'Invalid response_type, only code supported'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a state on web flow or if no state supplied
|
||||||
|
const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
|
||||||
|
|
||||||
|
// Redirect URL for the SSO provider
|
||||||
|
let redirectUri
|
||||||
|
if (isMobileFlow) {
|
||||||
|
// Mobile required redirect uri
|
||||||
|
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
|
||||||
|
// where we will handle the redirect to it
|
||||||
|
if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {
|
||||||
|
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'Invalid redirect_uri'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
|
||||||
|
// for the request to mobile-redirect and as such the session is not shared
|
||||||
|
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||||
|
|
||||||
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||||
|
} else {
|
||||||
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||||
|
|
||||||
|
if (req.query.state) {
|
||||||
|
Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'Invalid state, not allowed on web flow'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the strategy's redirect_uri for this request
|
||||||
|
strategy._params.redirect_uri = redirectUri
|
||||||
|
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
||||||
|
|
||||||
|
const pkceData = this.generatePkce(req, isMobileFlow)
|
||||||
|
if (pkceData.error) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: pkceData.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session[sessionKey] = {
|
||||||
|
...req.session[sessionKey],
|
||||||
|
state: state,
|
||||||
|
max_age: strategy._params.max_age,
|
||||||
|
response_type: 'code',
|
||||||
|
code_verifier: pkceData.code_verifier, // not null if web flow
|
||||||
|
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
||||||
|
sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizationUrl = client.authorizationUrl({
|
||||||
|
...strategy._params,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
state: state,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: this.getScope(),
|
||||||
|
code_challenge: pkceData.code_challenge,
|
||||||
|
code_challenge_method: pkceData.code_challenge_method
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorizationUrl,
|
||||||
|
isMobileFlow
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`)
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
error: error.message || 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the end session URL for logout
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {string} idToken
|
||||||
|
* @param {string} authMethod
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
getEndSessionUrl(req, idToken, authMethod) {
|
||||||
|
const client = this.getClient()
|
||||||
|
|
||||||
|
if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {
|
||||||
|
let postLogoutRedirectUri = null
|
||||||
|
|
||||||
|
if (authMethod === 'openid') {
|
||||||
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||||
|
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}${global.RouterBasePath}/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
|
||||||
|
// the post_logout_redirect_uri parameter at all and for other providers
|
||||||
|
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
|
||||||
|
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
|
||||||
|
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
|
||||||
|
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
|
||||||
|
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
|
||||||
|
|
||||||
|
return client.endSessionUrl({
|
||||||
|
id_token_hint: idToken,
|
||||||
|
post_logout_redirect_uri: postLogoutRedirectUri
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} OpenIdIssuerConfig
|
||||||
|
* @property {string} issuer
|
||||||
|
* @property {string} authorization_endpoint
|
||||||
|
* @property {string} token_endpoint
|
||||||
|
* @property {string} userinfo_endpoint
|
||||||
|
* @property {string} end_session_endpoint
|
||||||
|
* @property {string} jwks_uri
|
||||||
|
* @property {string} id_token_signing_alg_values_supported
|
||||||
|
*
|
||||||
|
* Get OpenID Connect configuration from an issuer URL
|
||||||
|
* @param {string} issuerUrl
|
||||||
|
* @returns {Promise<OpenIdIssuerConfig|{status: number, error: string}>}
|
||||||
|
*/
|
||||||
|
async getIssuerConfig(issuerUrl) {
|
||||||
|
// Strip trailing slash
|
||||||
|
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||||
|
|
||||||
|
// Append config pathname and validate URL
|
||||||
|
let configUrl = null
|
||||||
|
try {
|
||||||
|
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
||||||
|
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
||||||
|
throw new Error('Invalid pathname')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: "Invalid request. Query param 'issuer' is invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(configUrl.toString())
|
||||||
|
return {
|
||||||
|
issuer: data.issuer,
|
||||||
|
authorization_endpoint: data.authorization_endpoint,
|
||||||
|
token_endpoint: data.token_endpoint,
|
||||||
|
userinfo_endpoint: data.userinfo_endpoint,
|
||||||
|
end_session_endpoint: data.end_session_endpoint,
|
||||||
|
jwks_uri: data.jwks_uri,
|
||||||
|
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'Failed to get openid configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mobile redirect for OAuth2 callback
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
handleMobileRedirect(req, res) {
|
||||||
|
try {
|
||||||
|
// Extract the state parameter from the request
|
||||||
|
const { state, code } = req.query
|
||||||
|
|
||||||
|
// Check if the state provided is in our list
|
||||||
|
if (!state || !this.openIdAuthSession.has(state)) {
|
||||||
|
Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
||||||
|
return res.status(400).send('State parameter mismatch')
|
||||||
|
}
|
||||||
|
|
||||||
|
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
||||||
|
|
||||||
|
if (!mobile_redirect_uri) {
|
||||||
|
Logger.error('[OidcAuth] No redirect URI')
|
||||||
|
return res.status(400).send('No redirect URI')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openIdAuthSession.delete(state)
|
||||||
|
|
||||||
|
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||||
|
// Redirect to the overwrite URI saved in the map
|
||||||
|
res.redirect(redirectUri)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`)
|
||||||
|
res.status(500).send('Internal Server Error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OidcAuthStrategy
|
|
@ -273,12 +273,24 @@ class MeController {
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
updatePassword(req, res) {
|
async updatePassword(req, res) {
|
||||||
if (req.user.isGuest) {
|
if (req.user.isGuest) {
|
||||||
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
|
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
this.auth.userChangePassword(req, res)
|
|
||||||
|
const { password, newPassword } = req.body
|
||||||
|
if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') {
|
||||||
|
return res.status(400).send('Missing or invalid password or new password')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return res.status(400).send(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue