mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-13 10:55:05 +02:00
Refactor Auth to breakout functions in TokenManager, handle token generation for OIDC
Some checks failed
Some checks failed
This commit is contained in:
parent
e24eaab3f1
commit
97afd22f81
6 changed files with 603 additions and 473 deletions
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
593
server/Auth.js
593
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({
|
|
||||||
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
|
||||||
|
|
|
@ -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…
Add table
Add a link
Reference in a new issue