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 bcrypt = require('./libs/bcryptjs') const requestIp = require('./libs/requestIp') const LocalStrategy = require('./libs/passportLocal') const { escapeRegExp } = require('./utils') /** * @class Class for handling all the authentication related functionality. */ 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() } /** * Checks if the request should not be authenticated. * @param {Request} req * @returns {boolean} */ authNotNeeded(req) { return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path)) } /** * Middleware to register passport in express-session * * @param {function} middleware */ ifAuthNeeded(middleware) { return (req, res, next) => { if (this.authNotNeeded(req)) { return next() } middleware(req, res, next) } } /** * middleware to use in express to only allow authenticated users. * * @param {Request} req * @param {Response} res * @param {NextFunction} next */ isAuthenticated(req, res, next) { return passport.authenticate('jwt', { session: false })(req, res, next) } /** * Generate a token which is used to encrpt/protect the jwts. */ async initTokenSecret() { return this.tokenManager.initTokenSecret() } /** * Function to generate a jwt token for a given user * TODO: Old method with no expiration * @deprecated * * @param {{ id:string, username:string }} user * @returns {string} */ generateAccessToken(user) { return this.tokenManager.generateAccessToken(user) } /** * Invalidate all JWT sessions for a given user * If user is current user and refresh token is valid, rotate tokens for the current session * * @param {import('./models/User')} user * @param {Request} req * @param {Response} res * @returns {Promise} accessToken only if user is current user and refresh token is valid */ async invalidateJwtSessionsForUser(user, req, res) { return this.tokenManager.invalidateJwtSessionsForUser(user, req, res) } /** * Return the login info payload for a user * * @param {import('./models/User')} user * @returns {Promise} jsonPayload */ async getUserLoginResponsePayload(user) { const libraryIds = await Database.libraryModel.getAllLibraryIds() return { user: user.toOldJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), serverSettings: Database.serverSettings.toJSONForBrowser(), ereaderDevices: Database.emailSettings.getEReaderDevices(user), Source: global.Source } } // #region Passport strategies /** * Inializes all passportjs strategies and other passportjs ralated initialization. */ async initPassportJs() { // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes('local')) { this.initAuthStrategyPassword() } // Check if we should load the openid strategy if (global.ServerSettings.authActiveAuthMethods.includes('openid')) { this.initAuthStrategyOpenID() } // Load the JwtStrategy (always) -> for bearer token auth passport.use( new JwtStrategy( { jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), secretOrKey: Database.serverSettings.tokenSecret, // Handle expiration manaully in order to disable api keys that are expired ignoreExpiration: true }, this.tokenManager.jwtAuthCheck.bind(this) ) ) // define how to seralize a user (to be put into the session) passport.serializeUser(function (user, cb) { process.nextTick(function () { // only store id to session return cb( null, JSON.stringify({ id: user.id }) ) }) }) // define how to deseralize a user (use the ID to get it from the database) passport.deserializeUser( function (user, cb) { process.nextTick( async function () { const parsedUserInfo = JSON.parse(user) // load the user by ID that is stored in the session const dbUser = await Database.userModel.getUserById(parsedUserInfo.id) return cb(null, dbUser) }.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 /** * 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) } /** * Use strategy * * @param {string} name */ useAuthStrategy(name) { if (name === 'openid') { this.initAuthStrategyOpenID() } else if (name === 'local') { this.initAuthStrategyPassword() } else { Logger.error('[Auth] Invalid auth strategy ' + name) } } /** * Returns if the given auth method is API based. * * @param {string} authMethod * @returns {boolean} */ isAuthMethodAPIBased(authMethod) { return ['api', 'openid-mobile'].includes(authMethod) } /** * Stores the client's choice of login callback method in temporary cookies. * * The `authMethod` parameter specifies the authentication strategy and can have the following values: * - 'local': Standard authentication, * - 'api': Authentication for API use * - 'openid': OpenID authentication directly over web * - 'openid-mobile': OpenID authentication, but done via an mobile device * * @param {Request} req * @param {Response} res * @param {string} authMethod - The authentication method, default is 'local'. */ paramsToCookies(req, res, authMethod = 'local') { const TWO_MINUTES = 120000 // 2 minutes in milliseconds const callback = req.query.redirect_uri || req.query.callback // Additional handling for non-API based authMethod if (!this.isAuthMethodAPIBased(authMethod)) { // Store 'auth_state' if present in the request if (req.query.state) { res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true }) } // Validate and store the callback URL if (!callback) { return res.status(400).send({ message: 'No callback parameter' }) } res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true }) } // Store the authentication method for long res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true }) } /** * Informs the client in the right mode about a successfull login and the token * (clients choise is restored from cookies). * * @param {Request} req * @param {Response} res */ async handleLoginSuccessBasedOnCookie(req, res) { // Handle token generation and get userResponse object // TODO: where to check if refresh tokens should be returned? const userResponse = await this.handleLoginSuccess(req, res, false) if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { // REST request - send data res.json(userResponse) } else { // UI request -> check if we have a callback url // TODO: do we want to somehow limit the values for auth_cb? if (req.cookies.auth_cb) { let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.accessToken}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } } } /** * After login success from local or oidc * req.user is set by passport.authenticate * * attaches the access token to the user in the response * if returnTokens is true, also attaches the refresh token to the user in the response * * if returnTokens is false, sets the refresh token cookie * * @param {Request} req * @param {Response} res * @param {boolean} returnTokens */ async handleLoginSuccess(req, res, returnTokens = false) { // Create tokens and session const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(req.user, req) const userResponse = await this.getUserLoginResponsePayload(req.user) userResponse.user.refreshToken = returnTokens ? refreshToken : null userResponse.user.accessToken = accessToken if (!returnTokens) { this.tokenManager.setRefreshTokenCookie(req, res, refreshToken) } return userResponse } // #region Auth routes /** * Creates all (express) routes required for authentication. * * @param {import('express').Router} router */ async initAuthRoutes(router) { // Local strategy login route (takes username and password) router.post('/login', passport.authenticate('local'), async (req, res) => { // Check if mobile app wants refresh token in response const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' const userResponse = await this.handleLoginSuccess(req, res, returnTokens) res.json(userResponse) }) // Refresh token route router.post('/auth/refresh', async (req, res) => { let refreshToken = req.cookies.refresh_token // If x-refresh-token header is present, use it instead of the cookie // and return the refresh token in the response let shouldReturnRefreshToken = false if (req.headers['x-refresh-token']) { refreshToken = req.headers['x-refresh-token'] shouldReturnRefreshToken = true } if (!refreshToken) { Logger.error(`[Auth] Failed to refresh token. No refresh token provided`) return res.status(401).json({ error: 'No refresh token provided' }) } Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`) const refreshResponse = await this.tokenManager.handleRefreshToken(refreshToken, req, res) if (refreshResponse.error) { return res.status(401).json({ error: refreshResponse.error }) } const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user) userResponse.user.accessToken = refreshResponse.accessToken userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null res.json(userResponse) }) // 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 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') } 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 } } } 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] === '*') } }) // 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') } }) // 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 if (!req.session[sessionKey]) { return res.status(400).send('No session') } // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request // Crucial for API/Mobile clients if (req.query.code_verifier) { req.session[sessionKey].code_verifier = req.query.code_verifier } function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) { Logger.error(JSON.stringify(logMessage, null, 2)) if (response) { // Depending on the error, it can also have a body // We also log the request header the passport plugin sents for the URL const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED') Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2)) } if (isMobile) { return res.status(errorCode).send(errorMessage) } else { return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`) } } function passportCallback(req, res, next) { return (err, user, info) => { const isMobile = req.session[sessionKey]?.mobile === true if (err) { return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response) } if (!user) { // Info usually contains the error message from the SSO provider return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response) } req.logIn(user, (loginError) => { if (loginError) { return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`) } // The id_token does not provide access to the user, but is used to identify the user to the SSO provider // instead it containts a JWT with userinfo like user email, username, etc. // the client will get to know it anyway in the logout url according to the oauth2 spec // so it is safe to send it to the client, but we use strict settings res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' }) next() }) } } // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided // We set it here again because the passport param can change between requests return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next) }, // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this) ) /** * Helper route used to auto-populate the openid URLs in config/authentication * Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration" * * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { if (!req.user.isAdminOrUp) { Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`) return res.sendStatus(403) } if (!req.query.issuer) { 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") } 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`) }) }) // Logout route router.post('/logout', async (req, res) => { // Refresh token be alternatively be sent in the header const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token'] // Clear refresh token cookie res.clearCookie('refresh_token', { path: '/' }) // Invalidate the session in database using refresh token if (refreshToken) { try { Logger.info(`[Auth] logout: Invalidating session for refresh token: ${refreshToken}`) await Database.sessionModel.destroy({ where: { refreshToken } }) } catch (error) { Logger.error(`[Auth] Error destroying session: ${error.message}`) } } else { Logger.info(`[Auth] logout: No refresh token on request`) } // TODO: invalidate possible JWTs req.logout((err) => { if (err) { res.sendStatus(500) } else { const authMethod = req.cookies.auth_method res.clearCookie('auth_method') 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 }) } res.clearCookie('openid_id_token') } // Tell the user agent (browser) to redirect to the authentification provider's logout URL // (or redirect_url: null if we don't have one) res.send({ redirect_url: logoutUrl }) } }) }) } // #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