mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-11-03 19:07:00 -05:00 
			
		
		
		
	Refactor Auth to breakout functions in TokenManager, handle token generation for OIDC
This commit is contained in:
		
							parent
							
								
									e24eaab3f1
								
							
						
					
					
						commit
						97afd22f81
					
				@ -304,6 +304,7 @@ export default {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  async mounted() {
 | 
					  async mounted() {
 | 
				
			||||||
 | 
					    // Token passed as query parameter after successful oidc login
 | 
				
			||||||
    if (this.$route.query?.setToken) {
 | 
					    if (this.$route.query?.setToken) {
 | 
				
			||||||
      localStorage.setItem('token', this.$route.query.setToken)
 | 
					      localStorage.setItem('token', this.$route.query.setToken)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										587
									
								
								server/Auth.js
									
									
									
									
									
								
							
							
						
						
									
										587
									
								
								server/Auth.js
									
									
									
									
									
								
							@ -1,16 +1,17 @@
 | 
				
			|||||||
 | 
					const { Request, Response, NextFunction } = require('express')
 | 
				
			||||||
const axios = require('axios')
 | 
					const axios = require('axios')
 | 
				
			||||||
const passport = require('passport')
 | 
					const passport = require('passport')
 | 
				
			||||||
const { Op } = require('sequelize')
 | 
					const OpenIDClient = require('openid-client')
 | 
				
			||||||
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 JwtStrategy = require('passport-jwt').Strategy
 | 
				
			||||||
const ExtractJwt = require('passport-jwt').ExtractJwt
 | 
					const ExtractJwt = require('passport-jwt').ExtractJwt
 | 
				
			||||||
const OpenIDClient = require('openid-client')
 | 
					
 | 
				
			||||||
const Database = require('./Database')
 | 
					const Database = require('./Database')
 | 
				
			||||||
const Logger = require('./Logger')
 | 
					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')
 | 
					const { escapeRegExp } = require('./utils')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
@ -23,26 +24,23 @@ class Auth {
 | 
				
			|||||||
    const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
 | 
					    const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
 | 
				
			||||||
    this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
 | 
					    this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
 | 
					    this.tokenManager = new TokenManager()
 | 
				
			||||||
    this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
 | 
					 | 
				
			||||||
    if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
 | 
					 | 
				
			||||||
      Logger.info(`[Auth] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
 | 
					 | 
				
			||||||
      Logger.info(`[Auth] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Checks if the request should not be authenticated.
 | 
					   * Checks if the request should not be authenticated.
 | 
				
			||||||
   * @param {Request} req
 | 
					   * @param {Request} req
 | 
				
			||||||
   * @returns {boolean}
 | 
					   * @returns {boolean}
 | 
				
			||||||
   * @private
 | 
					 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  authNotNeeded(req) {
 | 
					  authNotNeeded(req) {
 | 
				
			||||||
    return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path))
 | 
					    return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path))
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Middleware to register passport in express-session
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {function} middleware
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
  ifAuthNeeded(middleware) {
 | 
					  ifAuthNeeded(middleware) {
 | 
				
			||||||
    return (req, res, next) => {
 | 
					    return (req, res, next) => {
 | 
				
			||||||
      if (this.authNotNeeded(req)) {
 | 
					      if (this.authNotNeeded(req)) {
 | 
				
			||||||
@ -52,6 +50,67 @@ class Auth {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * 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<string>} 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<Object>} 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.
 | 
					   * Inializes all passportjs strategies and other passportjs ralated initialization.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
@ -75,7 +134,7 @@ class Auth {
 | 
				
			|||||||
          // Handle expiration manaully in order to disable api keys that are expired
 | 
					          // Handle expiration manaully in order to disable api keys that are expired
 | 
				
			||||||
          ignoreExpiration: true
 | 
					          ignoreExpiration: true
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        this.jwtAuthCheck.bind(this)
 | 
					        this.tokenManager.jwtAuthCheck.bind(this)
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -161,7 +220,7 @@ class Auth {
 | 
				
			|||||||
              throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
 | 
					              throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            let user = await this.findOrCreateUser(userinfo)
 | 
					            let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (!user?.isActive) {
 | 
					            if (!user?.isActive) {
 | 
				
			||||||
              throw new Error('User not active or not found')
 | 
					              throw new Error('User not active or not found')
 | 
				
			||||||
@ -183,94 +242,7 @@ class Auth {
 | 
				
			|||||||
      )
 | 
					      )
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  // #endregion
 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * 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<import('./models/User')|null>}
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  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.
 | 
					   * Validates the presence and content of the group claim in userinfo.
 | 
				
			||||||
@ -418,22 +390,6 @@ class Auth {
 | 
				
			|||||||
    res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
 | 
					    res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Sets the refresh token cookie
 | 
					 | 
				
			||||||
   * @param {Request} req
 | 
					 | 
				
			||||||
   * @param {Response} res
 | 
					 | 
				
			||||||
   * @param {string} refreshToken
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  setRefreshTokenCookie(req, res, refreshToken) {
 | 
					 | 
				
			||||||
    res.cookie('refresh_token', refreshToken, {
 | 
					 | 
				
			||||||
      httpOnly: true,
 | 
					 | 
				
			||||||
      secure: req.secure || req.get('x-forwarded-proto') === 'https',
 | 
					 | 
				
			||||||
      sameSite: 'lax',
 | 
					 | 
				
			||||||
      maxAge: this.RefreshTokenExpiry * 1000,
 | 
					 | 
				
			||||||
      path: '/'
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Informs the client in the right mode about a successfull login and the token
 | 
					   * Informs the client in the right mode about a successfull login and the token
 | 
				
			||||||
   * (clients choise is restored from cookies).
 | 
					   * (clients choise is restored from cookies).
 | 
				
			||||||
@ -442,25 +398,56 @@ class Auth {
 | 
				
			|||||||
   * @param {Response} res
 | 
					   * @param {Response} res
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  async handleLoginSuccessBasedOnCookie(req, res) {
 | 
					  async handleLoginSuccessBasedOnCookie(req, res) {
 | 
				
			||||||
    // get userLogin json (information about the user, server and the session)
 | 
					    // Handle token generation and get userResponse object
 | 
				
			||||||
    const data_json = await this.getUserLoginResponsePayload(req.user)
 | 
					    // 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)) {
 | 
					    if (this.isAuthMethodAPIBased(req.cookies.auth_method)) {
 | 
				
			||||||
      // REST request - send data
 | 
					      // REST request - send data
 | 
				
			||||||
      res.json(data_json)
 | 
					      res.json(userResponse)
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      // UI request -> check if we have a callback url
 | 
					      // UI request -> check if we have a callback url
 | 
				
			||||||
      // TODO: do we want to somehow limit the values for auth_cb?
 | 
					      // TODO: do we want to somehow limit the values for auth_cb?
 | 
				
			||||||
      if (req.cookies.auth_cb) {
 | 
					      if (req.cookies.auth_cb) {
 | 
				
			||||||
        let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
 | 
					        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
 | 
					        // 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}`)
 | 
					        res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.accessToken}${stateQuery}`)
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        res.status(400).send('No callback or already expired')
 | 
					        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.
 | 
					   * Creates all (express) routes required for authentication.
 | 
				
			||||||
   *
 | 
					   *
 | 
				
			||||||
@ -469,19 +456,10 @@ class Auth {
 | 
				
			|||||||
  async initAuthRoutes(router) {
 | 
					  async initAuthRoutes(router) {
 | 
				
			||||||
    // Local strategy login route (takes username and password)
 | 
					    // Local strategy login route (takes username and password)
 | 
				
			||||||
    router.post('/login', passport.authenticate('local'), async (req, res) => {
 | 
					    router.post('/login', passport.authenticate('local'), async (req, res) => {
 | 
				
			||||||
      // return the user login response json if the login was successfull
 | 
					 | 
				
			||||||
      const userResponse = await this.getUserLoginResponsePayload(req.user)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Check if mobile app wants refresh token in response
 | 
					      // Check if mobile app wants refresh token in response
 | 
				
			||||||
      const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true'
 | 
					      const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      userResponse.user.refreshToken = returnTokens ? req.user.refreshToken : null
 | 
					      const userResponse = await this.handleLoginSuccess(req, res, returnTokens)
 | 
				
			||||||
      userResponse.user.accessToken = req.user.accessToken
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!returnTokens) {
 | 
					 | 
				
			||||||
        this.setRefreshTokenCookie(req, res, req.user.refreshToken)
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      res.json(userResponse)
 | 
					      res.json(userResponse)
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -504,67 +482,16 @@ class Auth {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`)
 | 
					      Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      try {
 | 
					      const refreshResponse = await this.tokenManager.handleRefreshToken(refreshToken, req, res)
 | 
				
			||||||
        // Verify the refresh token
 | 
					      if (refreshResponse.error) {
 | 
				
			||||||
        const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret)
 | 
					        return res.status(401).json({ error: refreshResponse.error })
 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (decoded.type !== 'refresh') {
 | 
					 | 
				
			||||||
          Logger.error(`[Auth] Failed to refresh token. Invalid token type: ${decoded.type}`)
 | 
					 | 
				
			||||||
          return res.status(401).json({ error: 'Invalid token type' })
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const session = await Database.sessionModel.findOne({
 | 
					      const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user)
 | 
				
			||||||
          where: { refreshToken: refreshToken }
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!session) {
 | 
					      userResponse.user.accessToken = refreshResponse.accessToken
 | 
				
			||||||
          Logger.error(`[Auth] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
 | 
					      userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null
 | 
				
			||||||
          return res.status(401).json({ error: 'Invalid refresh token' })
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Check if session is expired in database
 | 
					 | 
				
			||||||
        if (session.expiresAt < new Date()) {
 | 
					 | 
				
			||||||
          Logger.info(`[Auth] Session expired in database, cleaning up`)
 | 
					 | 
				
			||||||
          await session.destroy()
 | 
					 | 
				
			||||||
          return res.status(401).json({ error: 'Refresh token expired' })
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const user = await Database.userModel.getUserById(decoded.userId)
 | 
					 | 
				
			||||||
        if (!user?.isActive) {
 | 
					 | 
				
			||||||
          Logger.error(`[Auth] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`)
 | 
					 | 
				
			||||||
          return res.status(401).json({ error: 'User not found or inactive' })
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const newTokens = await this.rotateTokensForSession(session, user, req, res)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const userResponse = await this.getUserLoginResponsePayload(user)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        userResponse.user.accessToken = newTokens.accessToken
 | 
					 | 
				
			||||||
        userResponse.user.refreshToken = shouldReturnRefreshToken ? newTokens.refreshToken : null
 | 
					 | 
				
			||||||
      res.json(userResponse)
 | 
					      res.json(userResponse)
 | 
				
			||||||
      } catch (error) {
 | 
					 | 
				
			||||||
        if (error.name === 'TokenExpiredError') {
 | 
					 | 
				
			||||||
          Logger.info(`[Auth] Refresh token expired, cleaning up session`)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          // Clean up the expired session from database
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            await Database.sessionModel.destroy({
 | 
					 | 
				
			||||||
              where: { refreshToken: refreshToken }
 | 
					 | 
				
			||||||
            })
 | 
					 | 
				
			||||||
            Logger.info(`[Auth] Expired session cleaned up`)
 | 
					 | 
				
			||||||
          } catch (cleanupError) {
 | 
					 | 
				
			||||||
            Logger.error(`[Auth] Error cleaning up expired session: ${cleanupError.message}`)
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          return res.status(401).json({ error: 'Refresh token expired' })
 | 
					 | 
				
			||||||
        } else if (error.name === 'JsonWebTokenError') {
 | 
					 | 
				
			||||||
          Logger.error(`[Auth] Invalid refresh token format: ${error.message}`)
 | 
					 | 
				
			||||||
          return res.status(401).json({ error: 'Invalid refresh token' })
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          Logger.error(`[Auth] Refresh token error: ${error.message}`)
 | 
					 | 
				
			||||||
          return res.status(401).json({ error: 'Invalid refresh token' })
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // openid strategy login route (this redirects to the configured openid login provider)
 | 
					    // openid strategy login route (this redirects to the configured openid login provider)
 | 
				
			||||||
@ -906,255 +833,9 @@ class Auth {
 | 
				
			|||||||
      })
 | 
					      })
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  // #endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  // #region Local Auth
 | 
				
			||||||
   * 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)
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Function to generate a jwt token for a given user
 | 
					 | 
				
			||||||
   * TODO: Old method with no expiration
 | 
					 | 
				
			||||||
   *
 | 
					 | 
				
			||||||
   * @param {{ id:string, username:string }} user
 | 
					 | 
				
			||||||
   * @returns {string} token
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  generateAccessToken(user) {
 | 
					 | 
				
			||||||
    return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Generate access token for a given user
 | 
					 | 
				
			||||||
   *
 | 
					 | 
				
			||||||
   * @param {{ id:string, username:string }} user
 | 
					 | 
				
			||||||
   * @returns {Promise<string>}
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  generateTempAccessToken(user) {
 | 
					 | 
				
			||||||
    return new Promise((resolve) => {
 | 
					 | 
				
			||||||
      jwt.sign({ userId: user.id, username: user.username, type: 'access' }, global.ServerSettings.tokenSecret, { expiresIn: this.AccessTokenExpiry }, (err, token) => {
 | 
					 | 
				
			||||||
        if (err) {
 | 
					 | 
				
			||||||
          Logger.error(`[Auth] Error generating access token for user ${user.id}: ${err}`)
 | 
					 | 
				
			||||||
          resolve(null)
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          resolve(token)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Generate refresh token for a given user
 | 
					 | 
				
			||||||
   *
 | 
					 | 
				
			||||||
   * @param {{ id:string, username:string }} user
 | 
					 | 
				
			||||||
   * @returns {Promise<string>}
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  generateRefreshToken(user) {
 | 
					 | 
				
			||||||
    return new Promise((resolve) => {
 | 
					 | 
				
			||||||
      jwt.sign({ userId: user.id, username: user.username, type: 'refresh' }, global.ServerSettings.tokenSecret, { expiresIn: this.RefreshTokenExpiry }, (err, token) => {
 | 
					 | 
				
			||||||
        if (err) {
 | 
					 | 
				
			||||||
          Logger.error(`[Auth] Error generating refresh token for user ${user.id}: ${err}`)
 | 
					 | 
				
			||||||
          resolve(null)
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          resolve(token)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Create tokens and session for a given user
 | 
					 | 
				
			||||||
   *
 | 
					 | 
				
			||||||
   * @param {{ id:string, username:string }} user
 | 
					 | 
				
			||||||
   * @param {Request} req
 | 
					 | 
				
			||||||
   * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('./models/Session') }>}
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  async createTokensAndSession(user, req) {
 | 
					 | 
				
			||||||
    const ipAddress = requestIp.getClientIp(req)
 | 
					 | 
				
			||||||
    const userAgent = req.headers['user-agent']
 | 
					 | 
				
			||||||
    const [accessToken, refreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Calculate expiration time for the refresh token
 | 
					 | 
				
			||||||
    const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
 | 
					 | 
				
			||||||
    user.accessToken = accessToken
 | 
					 | 
				
			||||||
    // Store refresh token on user object for cookie setting
 | 
					 | 
				
			||||||
    user.refreshToken = refreshToken
 | 
					 | 
				
			||||||
    return { accessToken, refreshToken, session }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Rotate tokens for a given session
 | 
					 | 
				
			||||||
   *
 | 
					 | 
				
			||||||
   * @param {import('./models/Session')} session
 | 
					 | 
				
			||||||
   * @param {import('./models/User')} user
 | 
					 | 
				
			||||||
   * @param {Request} req
 | 
					 | 
				
			||||||
   * @param {Response} res
 | 
					 | 
				
			||||||
   * @returns {Promise<{ accessToken:string, refreshToken:string }>}
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  async rotateTokensForSession(session, user, req, res) {
 | 
					 | 
				
			||||||
    // Generate new tokens
 | 
					 | 
				
			||||||
    const [newAccessToken, newRefreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Calculate new expiration time
 | 
					 | 
				
			||||||
    const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Update the session with the new refresh token and expiration
 | 
					 | 
				
			||||||
    session.refreshToken = newRefreshToken
 | 
					 | 
				
			||||||
    session.expiresAt = newExpiresAt
 | 
					 | 
				
			||||||
    await session.save()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Set new refresh token cookie
 | 
					 | 
				
			||||||
    this.setRefreshTokenCookie(req, res, newRefreshToken)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      accessToken: newAccessToken,
 | 
					 | 
				
			||||||
      refreshToken: newRefreshToken
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * 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 {Request} req
 | 
					 | 
				
			||||||
   * @param {Response} res
 | 
					 | 
				
			||||||
   * @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  async invalidateJwtSessionsForUser(user, req, res) {
 | 
					 | 
				
			||||||
    const currentRefreshToken = req.cookies.refresh_token
 | 
					 | 
				
			||||||
    if (req.user.id === user.id && currentRefreshToken) {
 | 
					 | 
				
			||||||
      // Current user is the same as the user to invalidate sessions for
 | 
					 | 
				
			||||||
      // So rotate token for current session
 | 
					 | 
				
			||||||
      const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
 | 
					 | 
				
			||||||
      if (currentSession) {
 | 
					 | 
				
			||||||
        const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Invalidate all sessions for the user except the current one
 | 
					 | 
				
			||||||
        await Database.sessionModel.destroy({
 | 
					 | 
				
			||||||
          where: {
 | 
					 | 
				
			||||||
            id: {
 | 
					 | 
				
			||||||
              [Op.ne]: currentSession.id
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            userId: user.id
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return newTokens.accessToken
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Current user is not the same as the user to invalidate sessions for (or no refresh token)
 | 
					 | 
				
			||||||
    // So invalidate all sessions for the user
 | 
					 | 
				
			||||||
    await Database.sessionModel.destroy({ where: { userId: user.id } })
 | 
					 | 
				
			||||||
    return null
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Function to validate a jwt token for a given user
 | 
					 | 
				
			||||||
   * Used to authenticate socket connections
 | 
					 | 
				
			||||||
   * TODO: Support API keys for web socket connections
 | 
					 | 
				
			||||||
   *
 | 
					 | 
				
			||||||
   * @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()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // TODO: Old method of non-expiring tokens
 | 
					 | 
				
			||||||
    // 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 or api key in the validated jwt_payload exists and is active.
 | 
					 | 
				
			||||||
   * @param {Object} jwt_payload
 | 
					 | 
				
			||||||
   * @param {function} done
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  async jwtAuthCheck(jwt_payload, done) {
 | 
					 | 
				
			||||||
    if (jwt_payload.type === 'api') {
 | 
					 | 
				
			||||||
      const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!apiKey?.isActive) {
 | 
					 | 
				
			||||||
        done(null, null)
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Check if the api key is expired and deactivate it
 | 
					 | 
				
			||||||
      if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
 | 
					 | 
				
			||||||
        done(null, null)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        apiKey.isActive = false
 | 
					 | 
				
			||||||
        await apiKey.save()
 | 
					 | 
				
			||||||
        Logger.info(`[Auth] API key ${apiKey.id} is expired - deactivated`)
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const user = await Database.userModel.getUserById(apiKey.userId)
 | 
					 | 
				
			||||||
      done(null, user)
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      // Check if the jwt is expired
 | 
					 | 
				
			||||||
      if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
 | 
					 | 
				
			||||||
        done(null, null)
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // 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
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // TODO: Temporary flag to report old tokens to users
 | 
					 | 
				
			||||||
      // May be a better place for this but here means we dont have to decode the token again
 | 
					 | 
				
			||||||
      if (!jwt_payload.exp && !user.isOldToken) {
 | 
					 | 
				
			||||||
        Logger.debug(`[Auth] User ${user.username} is using an access token without an expiration`)
 | 
					 | 
				
			||||||
        user.isOldToken = true
 | 
					 | 
				
			||||||
      } else if (jwt_payload.exp && user.isOldToken !== undefined) {
 | 
					 | 
				
			||||||
        delete user.isOldToken
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // approve login
 | 
					 | 
				
			||||||
      done(null, user)
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Checks if a username and password tuple is valid and the user active.
 | 
					   * Checks if a username and password tuple is valid and the user active.
 | 
				
			||||||
   * @param {Request} req
 | 
					   * @param {Request} req
 | 
				
			||||||
@ -1187,9 +868,6 @@ class Auth {
 | 
				
			|||||||
      // approve login
 | 
					      // approve login
 | 
				
			||||||
      Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
 | 
					      Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Create tokens and session, updates user.accessToken and user.refreshToken
 | 
					 | 
				
			||||||
      await this.createTokensAndSession(user, req)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      done(null, user)
 | 
					      done(null, user)
 | 
				
			||||||
      return
 | 
					      return
 | 
				
			||||||
    } else if (!user.pash) {
 | 
					    } else if (!user.pash) {
 | 
				
			||||||
@ -1204,9 +882,6 @@ class Auth {
 | 
				
			|||||||
      // approve login
 | 
					      // approve login
 | 
				
			||||||
      Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
 | 
					      Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Create tokens and session, updates user.accessToken and user.refreshToken
 | 
					 | 
				
			||||||
      await this.createTokensAndSession(user, req)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      done(null, user)
 | 
					      done(null, user)
 | 
				
			||||||
      return
 | 
					      return
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -1244,23 +919,6 @@ class Auth {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Return the login info payload for a user
 | 
					 | 
				
			||||||
   *
 | 
					 | 
				
			||||||
   * @param {import('./models/User')} user
 | 
					 | 
				
			||||||
   * @returns {Promise<Object>} 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 {string} password
 | 
				
			||||||
@ -1322,6 +980,7 @@ class Auth {
 | 
				
			|||||||
      })
 | 
					      })
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  // #endregion
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = Auth
 | 
					module.exports = Auth
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,7 @@
 | 
				
			|||||||
const SocketIO = require('socket.io')
 | 
					const SocketIO = require('socket.io')
 | 
				
			||||||
const Logger = require('./Logger')
 | 
					const Logger = require('./Logger')
 | 
				
			||||||
const Database = require('./Database')
 | 
					const Database = require('./Database')
 | 
				
			||||||
const Auth = require('./Auth')
 | 
					const TokenManager = require('./auth/TokenManager')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * @typedef SocketClient
 | 
					 * @typedef SocketClient
 | 
				
			||||||
@ -240,7 +240,8 @@ class SocketAuthority {
 | 
				
			|||||||
  async authenticateSocket(socket, token) {
 | 
					  async authenticateSocket(socket, token) {
 | 
				
			||||||
    // we don't use passport to authenticate the jwt we get over the socket connection.
 | 
					    // we don't use passport to authenticate the jwt we get over the socket connection.
 | 
				
			||||||
    // it's easier to directly verify/decode it.
 | 
					    // it's easier to directly verify/decode it.
 | 
				
			||||||
    const token_data = Auth.validateAccessToken(token)
 | 
					    // TODO: Support API keys for web socket connections
 | 
				
			||||||
 | 
					    const token_data = TokenManager.validateAccessToken(token)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!token_data?.userId) {
 | 
					    if (!token_data?.userId) {
 | 
				
			||||||
      // Token invalid
 | 
					      // Token invalid
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										379
									
								
								server/auth/TokenManager.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										379
									
								
								server/auth/TokenManager.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,379 @@
 | 
				
			|||||||
 | 
					const { Op } = require('sequelize')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Database = require('../Database')
 | 
				
			||||||
 | 
					const Logger = require('../Logger')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const requestIp = require('../libs/requestIp')
 | 
				
			||||||
 | 
					const jwt = require('../libs/jsonwebtoken')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TokenManager {
 | 
				
			||||||
 | 
					  constructor() {
 | 
				
			||||||
 | 
					    this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
 | 
				
			||||||
 | 
					    this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
 | 
				
			||||||
 | 
					      Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
 | 
				
			||||||
 | 
					      Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Generate a token which is used to encrypt/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()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // TODO: Old method of non-expiring tokens
 | 
				
			||||||
 | 
					    // 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 = this.generateAccessToken(user)
 | 
				
			||||||
 | 
					        await user.save({ hooks: false })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Sets the refresh token cookie
 | 
				
			||||||
 | 
					   * @param {import('express').Request} req
 | 
				
			||||||
 | 
					   * @param {import('express').Response} res
 | 
				
			||||||
 | 
					   * @param {string} refreshToken
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  setRefreshTokenCookie(req, res, refreshToken) {
 | 
				
			||||||
 | 
					    res.cookie('refresh_token', refreshToken, {
 | 
				
			||||||
 | 
					      httpOnly: true,
 | 
				
			||||||
 | 
					      secure: req.secure || req.get('x-forwarded-proto') === 'https',
 | 
				
			||||||
 | 
					      sameSite: 'lax',
 | 
				
			||||||
 | 
					      maxAge: this.RefreshTokenExpiry * 1000,
 | 
				
			||||||
 | 
					      path: '/'
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Function to validate a jwt token for a given user
 | 
				
			||||||
 | 
					   * Used to authenticate socket connections
 | 
				
			||||||
 | 
					   * TODO: Support API keys for web socket connections
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {string} token
 | 
				
			||||||
 | 
					   * @returns {Object} tokens data
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static validateAccessToken(token) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      return jwt.verify(token, global.ServerSettings.tokenSecret)
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      return null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * 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 jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Generate access token for a given user
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {{ id:string, username:string }} user
 | 
				
			||||||
 | 
					   * @returns {string}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  generateTempAccessToken(user) {
 | 
				
			||||||
 | 
					    const payload = {
 | 
				
			||||||
 | 
					      userId: user.id,
 | 
				
			||||||
 | 
					      username: user.username,
 | 
				
			||||||
 | 
					      type: 'access'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const options = {
 | 
				
			||||||
 | 
					      expiresIn: this.AccessTokenExpiry
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      return jwt.sign(payload, global.ServerSettings.tokenSecret, options)
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`)
 | 
				
			||||||
 | 
					      return null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Generate refresh token for a given user
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {{ id:string, username:string }} user
 | 
				
			||||||
 | 
					   * @returns {string}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  generateRefreshToken(user) {
 | 
				
			||||||
 | 
					    const payload = {
 | 
				
			||||||
 | 
					      userId: user.id,
 | 
				
			||||||
 | 
					      username: user.username,
 | 
				
			||||||
 | 
					      type: 'refresh'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const options = {
 | 
				
			||||||
 | 
					      expiresIn: this.RefreshTokenExpiry
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      return jwt.sign(payload, global.ServerSettings.tokenSecret, options)
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`)
 | 
				
			||||||
 | 
					      return null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Create tokens and session for a given user
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {{ id:string, username:string }} user
 | 
				
			||||||
 | 
					   * @param {import('express').Request} req
 | 
				
			||||||
 | 
					   * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async createTokensAndSession(user, req) {
 | 
				
			||||||
 | 
					    const ipAddress = requestIp.getClientIp(req)
 | 
				
			||||||
 | 
					    const userAgent = req.headers['user-agent']
 | 
				
			||||||
 | 
					    const accessToken = this.generateTempAccessToken(user)
 | 
				
			||||||
 | 
					    const refreshToken = this.generateRefreshToken(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Calculate expiration time for the refresh token
 | 
				
			||||||
 | 
					    const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      accessToken,
 | 
				
			||||||
 | 
					      refreshToken,
 | 
				
			||||||
 | 
					      session
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Rotate tokens for a given session
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {import('../models/Session')} session
 | 
				
			||||||
 | 
					   * @param {import('../models/User')} user
 | 
				
			||||||
 | 
					   * @param {import('express').Request} req
 | 
				
			||||||
 | 
					   * @param {import('express').Response} res
 | 
				
			||||||
 | 
					   * @returns {Promise<{ accessToken:string, refreshToken:string }>}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async rotateTokensForSession(session, user, req, res) {
 | 
				
			||||||
 | 
					    // Generate new tokens
 | 
				
			||||||
 | 
					    const newAccessToken = this.generateTempAccessToken(user)
 | 
				
			||||||
 | 
					    const newRefreshToken = this.generateRefreshToken(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Calculate new expiration time
 | 
				
			||||||
 | 
					    const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Update the session with the new refresh token and expiration
 | 
				
			||||||
 | 
					    session.refreshToken = newRefreshToken
 | 
				
			||||||
 | 
					    session.expiresAt = newExpiresAt
 | 
				
			||||||
 | 
					    await session.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Set new refresh token cookie
 | 
				
			||||||
 | 
					    this.setRefreshTokenCookie(req, res, newRefreshToken)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      accessToken: newAccessToken,
 | 
				
			||||||
 | 
					      refreshToken: newRefreshToken
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Check if the jwt is valid
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {Object} jwt_payload
 | 
				
			||||||
 | 
					   * @param {Function} done - passportjs callback
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async jwtAuthCheck(jwt_payload, done) {
 | 
				
			||||||
 | 
					    if (jwt_payload.type === 'api') {
 | 
				
			||||||
 | 
					      // Api key based authentication
 | 
				
			||||||
 | 
					      const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!apiKey?.isActive) {
 | 
				
			||||||
 | 
					        done(null, null)
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Check if the api key is expired and deactivate it
 | 
				
			||||||
 | 
					      if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
 | 
				
			||||||
 | 
					        done(null, null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        apiKey.isActive = false
 | 
				
			||||||
 | 
					        await apiKey.save()
 | 
				
			||||||
 | 
					        Logger.info(`[TokenManager] API key ${apiKey.id} is expired - deactivated`)
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const user = await Database.userModel.getUserById(apiKey.userId)
 | 
				
			||||||
 | 
					      done(null, user)
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // JWT based authentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Check if the jwt is expired
 | 
				
			||||||
 | 
					      if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
 | 
				
			||||||
 | 
					        done(null, null)
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // 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
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // TODO: Temporary flag to report old tokens to users
 | 
				
			||||||
 | 
					      // May be a better place for this but here means we dont have to decode the token again
 | 
				
			||||||
 | 
					      if (!jwt_payload.exp && !user.isOldToken) {
 | 
				
			||||||
 | 
					        Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)
 | 
				
			||||||
 | 
					        user.isOldToken = true
 | 
				
			||||||
 | 
					      } else if (jwt_payload.exp && user.isOldToken !== undefined) {
 | 
				
			||||||
 | 
					        delete user.isOldToken
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // approve login
 | 
				
			||||||
 | 
					      done(null, user)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Handle refresh token
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {string} refreshToken
 | 
				
			||||||
 | 
					   * @param {import('express').Request} req
 | 
				
			||||||
 | 
					   * @param {import('express').Response} res
 | 
				
			||||||
 | 
					   * @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async handleRefreshToken(refreshToken, req, res) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      // Verify the refresh token
 | 
				
			||||||
 | 
					      const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (decoded.type !== 'refresh') {
 | 
				
			||||||
 | 
					        Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`)
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          error: 'Invalid token type'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const session = await Database.sessionModel.findOne({
 | 
				
			||||||
 | 
					        where: { refreshToken: refreshToken }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!session) {
 | 
				
			||||||
 | 
					        Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          error: 'Invalid refresh token'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Check if session is expired in database
 | 
				
			||||||
 | 
					      if (session.expiresAt < new Date()) {
 | 
				
			||||||
 | 
					        Logger.info(`[TokenManager] Session expired in database, cleaning up`)
 | 
				
			||||||
 | 
					        await session.destroy()
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          error: 'Refresh token expired'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const user = await Database.userModel.getUserById(decoded.userId)
 | 
				
			||||||
 | 
					      if (!user?.isActive) {
 | 
				
			||||||
 | 
					        Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`)
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          error: 'User not found or inactive'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const newTokens = await this.rotateTokensForSession(session, user, req, res)
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        accessToken: newTokens.accessToken,
 | 
				
			||||||
 | 
					        refreshToken: newTokens.refreshToken,
 | 
				
			||||||
 | 
					        user
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      if (error.name === 'TokenExpiredError') {
 | 
				
			||||||
 | 
					        Logger.info(`[TokenManager] Refresh token expired, cleaning up session`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Clean up the expired session from database
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          await Database.sessionModel.destroy({
 | 
				
			||||||
 | 
					            where: { refreshToken: refreshToken }
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					          Logger.info(`[TokenManager] Expired session cleaned up`)
 | 
				
			||||||
 | 
					        } catch (cleanupError) {
 | 
				
			||||||
 | 
					          Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          error: 'Refresh token expired'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } else if (error.name === 'JsonWebTokenError') {
 | 
				
			||||||
 | 
					        Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`)
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          error: 'Invalid refresh token'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        Logger.error(`[TokenManager] Refresh token error: ${error.message}`)
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          error: 'Invalid refresh token'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * 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 {import('express').Request} req
 | 
				
			||||||
 | 
					   * @param {import('express').Response} res
 | 
				
			||||||
 | 
					   * @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async invalidateJwtSessionsForUser(user, req, res) {
 | 
				
			||||||
 | 
					    const currentRefreshToken = req.cookies.refresh_token
 | 
				
			||||||
 | 
					    if (req.user.id === user.id && currentRefreshToken) {
 | 
				
			||||||
 | 
					      // Current user is the same as the user to invalidate sessions for
 | 
				
			||||||
 | 
					      // So rotate token for current session
 | 
				
			||||||
 | 
					      const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
 | 
				
			||||||
 | 
					      if (currentSession) {
 | 
				
			||||||
 | 
					        const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Invalidate all sessions for the user except the current one
 | 
				
			||||||
 | 
					        await Database.sessionModel.destroy({
 | 
				
			||||||
 | 
					          where: {
 | 
				
			||||||
 | 
					            id: {
 | 
				
			||||||
 | 
					              [Op.ne]: currentSession.id
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            userId: user.id
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return newTokens.accessToken
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Current user is not the same as the user to invalidate sessions for (or no refresh token)
 | 
				
			||||||
 | 
					    // So invalidate all sessions for the user
 | 
				
			||||||
 | 
					    await Database.sessionModel.destroy({ where: { userId: user.id } })
 | 
				
			||||||
 | 
					    return null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = TokenManager
 | 
				
			||||||
@ -128,7 +128,7 @@ class UserController {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const userId = uuidv4()
 | 
					    const userId = uuidv4()
 | 
				
			||||||
    const pash = await this.auth.hashPass(req.body.password)
 | 
					    const pash = await this.auth.hashPass(req.body.password)
 | 
				
			||||||
    const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username })
 | 
					    const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
 | 
				
			||||||
    const userType = req.body.type || 'user'
 | 
					    const userType = req.body.type || 'user'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
 | 
					    // librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
 | 
				
			||||||
@ -327,7 +327,7 @@ class UserController {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    if (hasUpdates) {
 | 
					    if (hasUpdates) {
 | 
				
			||||||
      if (shouldUpdateToken) {
 | 
					      if (shouldUpdateToken) {
 | 
				
			||||||
        user.token = await this.auth.generateAccessToken(user)
 | 
					        user.token = this.auth.generateAccessToken(user)
 | 
				
			||||||
        Logger.info(`[UserController] User ${user.username} has generated a new api token`)
 | 
					        Logger.info(`[UserController] User ${user.username} has generated a new api token`)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -190,7 +190,7 @@ class User extends Model {
 | 
				
			|||||||
  static async createRootUser(username, pash, auth) {
 | 
					  static async createRootUser(username, pash, auth) {
 | 
				
			||||||
    const userId = uuidv4()
 | 
					    const userId = uuidv4()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const token = await auth.generateAccessToken({ id: userId, username })
 | 
					    const token = auth.generateAccessToken({ id: userId, username })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const newUser = {
 | 
					    const newUser = {
 | 
				
			||||||
      id: userId,
 | 
					      id: userId,
 | 
				
			||||||
@ -208,6 +208,96 @@ class User extends Model {
 | 
				
			|||||||
    return this.create(newUser)
 | 
					    return this.create(newUser)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * 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.
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * @param {Object} userinfo
 | 
				
			||||||
 | 
					   * @param {import('../Auth')} auth
 | 
				
			||||||
 | 
					   * @returns {Promise<User>}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async findOrCreateUserFromOpenIdUserInfo(userinfo, auth) {
 | 
				
			||||||
 | 
					    let user = await this.getUserByOpenIDSub(userinfo.sub)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Matched by sub
 | 
				
			||||||
 | 
					    if (user) {
 | 
				
			||||||
 | 
					      Logger.debug(`[User] openid: User found by sub`)
 | 
				
			||||||
 | 
					      return user
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Match existing user by email
 | 
				
			||||||
 | 
					    if (global.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(`[User] openid: User not found and email "${userinfo.email}" is not verified`)
 | 
				
			||||||
 | 
					          return null
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          Logger.info(`[User] openid: User not found, checking existing with email "${userinfo.email}"`)
 | 
				
			||||||
 | 
					          user = await this.getUserByEmail(userinfo.email)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          if (user?.authOpenIDSub) {
 | 
				
			||||||
 | 
					            Logger.warn(`[User] 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(`[User] 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 (global.ServerSettings.authOpenIDMatchExistingBy === 'username') {
 | 
				
			||||||
 | 
					      let username
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (userinfo.preferred_username) {
 | 
				
			||||||
 | 
					        Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`)
 | 
				
			||||||
 | 
					        username = userinfo.preferred_username
 | 
				
			||||||
 | 
					      } else if (userinfo.username) {
 | 
				
			||||||
 | 
					        Logger.info(`[User] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`)
 | 
				
			||||||
 | 
					        username = userinfo.username
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`)
 | 
				
			||||||
 | 
					        return null
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      user = await this.getUserByUsername(username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (user?.authOpenIDSub) {
 | 
				
			||||||
 | 
					        Logger.warn(`[User] 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(`[User] 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(`[User] openid: User found by email/username`)
 | 
				
			||||||
 | 
					      return user
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // If no existing user was matched, auto-register if configured
 | 
				
			||||||
 | 
					    if (global.ServerSettings.authOpenIDAutoRegister) {
 | 
				
			||||||
 | 
					      Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
 | 
				
			||||||
 | 
					      user = await this.createUserFromOpenIdUserInfo(userinfo, auth)
 | 
				
			||||||
 | 
					      return user
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Logger.warn(`[User] openid: User not found and auto-register is disabled`)
 | 
				
			||||||
 | 
					    return null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Create user from openid userinfo
 | 
					   * Create user from openid userinfo
 | 
				
			||||||
   * @param {Object} userinfo
 | 
					   * @param {Object} userinfo
 | 
				
			||||||
@ -220,7 +310,7 @@ class User extends Model {
 | 
				
			|||||||
    const username = userinfo.preferred_username || userinfo.name || userinfo.sub
 | 
					    const username = userinfo.preferred_username || userinfo.name || userinfo.sub
 | 
				
			||||||
    const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
 | 
					    const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const token = await auth.generateAccessToken({ id: userId, username })
 | 
					    const token = auth.generateAccessToken({ id: userId, username })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const newUser = {
 | 
					    const newUser = {
 | 
				
			||||||
      id: userId,
 | 
					      id: userId,
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user