Refactor Auth to breakout functions in TokenManager, handle token generation for OIDC
Some checks failed
Run Component Tests / Run Component Tests (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
Verify all i18n files are alphabetized / update_translations (push) Has been cancelled

This commit is contained in:
advplyr 2025-07-06 16:43:03 -05:00
parent e24eaab3f1
commit 97afd22f81
6 changed files with 603 additions and 473 deletions

View file

@ -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)
} }

View file

@ -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({
where: { refreshToken: refreshToken }
})
if (!session) {
Logger.error(`[Auth] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
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)
} 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' })
}
} }
const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user)
userResponse.user.accessToken = refreshResponse.accessToken
userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null
res.json(userResponse)
}) })
// openid strategy login route (this redirects to the configured openid login provider) // 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

View file

@ -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
View 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

View file

@ -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`)
} }

View file

@ -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,