const axios = require('axios') const passport = require('passport') const { Request, Response, NextFunction } = require('express') const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') const requestIp = require('./libs/requestIp') const LocalStrategy = require('./libs/passportLocal') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const OpenIDClient = require('openid-client') const Database = require('./Database') const Logger = require('./Logger') /** * @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() } /** * 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 }, this.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: '/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 this.findOrCreateUser(userinfo) 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') } } ) ) } /** * Finds an existing user by OpenID subject identifier, or by email/username based on server settings, * or creates a new user if configured to do so. * * @returns {Promise} */ async findOrCreateUser(userinfo) { let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) // Matched by sub if (user) { Logger.debug(`[Auth] openid: User found by sub`) return user } // Match existing user by email if (Database.serverSettings.authOpenIDMatchExistingBy === 'email') { if (userinfo.email) { // Only disallow when email_verified explicitly set to false (allow both if not set or true) if (userinfo.email_verified === false) { Logger.warn(`[Auth] openid: User not found and email "${userinfo.email}" is not verified`) return null } else { Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) user = await Database.userModel.getUserByEmail(userinfo.email) if (user?.authOpenIDSub) { Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) return null // User is linked to a different OpenID subject; do not proceed. } } } else { Logger.warn(`[Auth] openid: User not found and no email in userinfo`) // We deny login, because if the admin whishes to match email, it makes sense to require it return null } } // Match existing user by username else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username') { let username if (userinfo.preferred_username) { Logger.info(`[Auth] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`) username = userinfo.preferred_username } else if (userinfo.username) { Logger.info(`[Auth] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`) username = userinfo.username } else { Logger.warn(`[Auth] openid: User not found and neither preferred_username nor username in userinfo`) return null } user = await Database.userModel.getUserByUsername(username) if (user?.authOpenIDSub) { Logger.warn(`[Auth] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`) return null // User is linked to a different OpenID subject; do not proceed. } } // Found existing user via email or username if (user) { if (!user.isActive) { Logger.warn(`[Auth] openid: User found but is not active`) return null } // Update user with OpenID sub if (!user.extraData) user.extraData = {} user.extraData.authOpenIDSub = userinfo.sub user.changed('extraData', true) await user.save() Logger.debug(`[Auth] openid: User found by email/username`) return user } // If no existing user was matched, auto-register if configured if (Database.serverSettings.authOpenIDAutoRegister) { Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) return user } Logger.warn(`[Auth] openid: User not found and auto-register is disabled`) return null } /** * 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) { // get userLogin json (information about the user, server and the session) const data_json = await this.getUserLoginResponsePayload(req.user) if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { // REST request - send data res.json(data_json) } 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=${data_json.user.token}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } } } /** * 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) => { // return the user login response json if the login was successfull res.json(await this.getUserLoginResponsePayload(req.user)) }) // 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('/auth/openid/mobile-redirect', hostUrl).toString() } else { redirectUri = new URL('/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', (req, res) => { // 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}/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 }) } }) }) } /** * middleware to use in express to only allow authenticated users. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ isAuthenticated(req, res, next) { // check if session cookie says that we are authenticated if (req.isAuthenticated()) { next() } else { // try JWT to authenticate passport.authenticate('jwt')(req, res, next) } } /** * Function to generate a jwt token for a given user * * @param {{ id:string, username:string }} user * @returns {string} token */ generateAccessToken(user) { return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) } /** * Function to validate a jwt token for a given user * * @param {string} token * @returns {Object} tokens data */ static validateAccessToken(token) { try { return jwt.verify(token, global.ServerSettings.tokenSecret) } catch (err) { return null } } /** * Generate a token which is used to encrpt/protect the jwts. */ async initTokenSecret() { if (process.env.TOKEN_SECRET) { // User can supply their own token secret Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET } else { Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') } await Database.updateServerSettings() // New token secret creation added in v2.1.0 so generate new API tokens for each user const users = await Database.userModel.findAll({ attributes: ['id', 'username', 'token'] }) if (users.length) { for (const user of users) { user.token = await this.generateAccessToken(user) await user.save({ hooks: false }) } } } /** * Checks if the user in the validated jwt_payload really exists and is active. * @param {Object} jwt_payload * @param {function} done */ async jwtAuthCheck(jwt_payload, done) { // load user by id from the jwt token const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) if (!user?.isActive) { // deny login done(null, null) return } // approve login done(null, user) return } /** * 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) } }) }) } /** * 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 } } /** * * @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' }) } } Database.userModel .update( { pash: pw }, { where: { id: matchingUser.id } } ) .then(() => { 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' }) }) } } module.exports = Auth