diff --git a/client/pages/account.vue b/client/pages/account.vue index b157f570..e9b5da3c 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -182,18 +182,19 @@ export default { password: this.password, newPassword: this.newPassword }) - .then((res) => { - if (res.success) { - this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) - this.resetForm() - } else { - this.$toast.error(res.error || this.$strings.ToastUnknownError) - } - this.changingPassword = false + .then(() => { + this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) + this.resetForm() }) .catch((error) => { - console.error(error) - this.$toast.error(this.$strings.ToastUnknownError) + console.error('Failed to change password', error) + 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 }) }, diff --git a/server/Auth.js b/server/Auth.js index 6b2f2bd8..e62df0b8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,17 +1,14 @@ const { Request, Response, NextFunction } = require('express') -const axios = require('axios') const passport = require('passport') -const OpenIDClient = require('openid-client') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const Database = require('./Database') const Logger = require('./Logger') 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') /** @@ -19,12 +16,12 @@ const { escapeRegExp } = require('./utils') */ class Auth { constructor() { - // Map of openId sessions indexed by oauth2 state-variable - this.openIdAuthSession = new Map() const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] this.tokenManager = new TokenManager() + this.localAuthStrategy = new LocalAuthStrategy() + this.oidcAuthStrategy = new OidcAuthStrategy() } /** @@ -117,12 +114,12 @@ class Auth { async initPassportJs() { // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes('local')) { - this.initAuthStrategyPassword() + this.localAuthStrategy.init() } // Check if we should load the openid strategy if (global.ServerSettings.authActiveAuthMethods.includes('openid')) { - this.initAuthStrategyOpenID() + this.oidcAuthStrategy.init() } // Load the JwtStrategy (always) -> for bearer token auth @@ -165,169 +162,21 @@ class Auth { }.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 - /** - * 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 * * @param {string} 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) { if (name === 'openid') { - this.initAuthStrategyOpenID() + this.oidcAuthStrategy.init() } else if (name === 'local') { - this.initAuthStrategyPassword() + this.localAuthStrategy.init() } else { 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) - router.get('/auth/openid', (req, res, next) => { - // Get the OIDC client from the strategy - // 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 + router.get('/auth/openid', (req, res) => { + const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) - 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(`[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') + if (authorizationUrlResponse.error) { + return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error) } - function generatePkce(req, isMobileFlow) { - 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 } - } - } + this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid') - function 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] === '*') - } + res.redirect(authorizationUrlResponse.authorizationUrl) }) // This will be the oauth2 callback route for mobile clients // It will redirect to an app-link like audiobookshelf://oauth - router.get('/auth/openid/mobile-redirect', (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') - } - }) + router.get('/auth/openid/mobile-redirect', (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) // openid strategy callback route (this receives the token from the configured openid login provider) router.get( '/auth/openid/callback', (req, res, next) => { - const oidcStrategy = passport._strategy('openid-client') - const sessionKey = oidcStrategy._key + const sessionKey = this.oidcAuthStrategy.getStrategy()._key if (!req.session[sessionKey]) { return res.status(400).send('No session') @@ -719,43 +442,16 @@ class Auth { 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") } - // Strip trailing slash - let issuerUrl = req.query.issuer - 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(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) - return res.status(400).send("Invalid request. Query param 'issuer' is invalid") + const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer) + if (openIdIssuerConfig.error) { + return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error) } - axios - .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`) - }) + res.json(openIdIssuerConfig) }) // Logout route @@ -782,7 +478,6 @@ class Auth { Logger.info(`[Auth] logout: No refresh token on request`) } - // TODO: invalidate possible JWTs req.logout((err) => { if (err) { res.sendStatus(500) @@ -794,36 +489,7 @@ class Auth { let logoutUrl = null if (authMethod === 'openid' || authMethod === 'openid-mobile') { - // If we are using openid, we need to redirect to the logout endpoint - // node-openid-client does not support doing it over passport - 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 - }) - } - + logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod) res.clearCookie('openid_id_token') } @@ -835,153 +501,6 @@ class Auth { }) } // #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} 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} 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} - */ - 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 diff --git a/server/auth/LocalAuthStrategy.js b/server/auth/LocalAuthStrategy.js new file mode 100644 index 00000000..e9499d13 --- /dev/null +++ b/server/auth/LocalAuthStrategy.js @@ -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} 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} + */ + 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 diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js new file mode 100644 index 00000000..c3f6cfb2 --- /dev/null +++ b/server/auth/OidcAuthStrategy.js @@ -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} + */ + 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 diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 87acd221..9451a765 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -273,12 +273,24 @@ class MeController { * @param {RequestWithUser} req * @param {Response} res */ - updatePassword(req, res) { + async updatePassword(req, res) { if (req.user.isGuest) { 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) } /**