From 4f5123e84260b8e1e1049d9ce82854c7ca7f6afa Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 29 Jun 2025 17:22:58 -0500 Subject: [PATCH 01/29] Implement new JWT auth --- client/components/cards/AuthorCard.vue | 3 - client/components/covers/AuthorImage.vue | 3 - client/components/modals/AccountModal.vue | 6 +- client/components/modals/item/tabs/Files.vue | 3 - client/components/player/PlayerUi.vue | 3 - client/components/readers/Reader.vue | 3 - client/components/tables/EbookFilesTable.vue | 3 - .../components/tables/LibraryFilesTable.vue | 3 - .../components/ui/MultiSelectQueryInput.vue | 3 - client/nuxt.config.js | 3 +- client/pages/config/users/_id/index.vue | 9 +- client/plugins/axios.js | 95 ++++++- client/store/user.js | 14 +- server/Auth.js | 258 ++++++++++++++++-- server/Database.js | 31 +++ server/controllers/UserController.js | 14 + server/managers/CronManager.js | 4 +- .../v2.26.0-create-sessions-table.js | 153 +++++++++++ server/models/ApiToken.js | 90 ++++++ server/models/Session.js | 88 ++++++ server/models/User.js | 6 + 21 files changed, 739 insertions(+), 56 deletions(-) create mode 100644 server/migrations/v2.26.0-create-sessions-table.js create mode 100644 server/models/ApiToken.js create mode 100644 server/models/Session.js diff --git a/client/components/cards/AuthorCard.vue b/client/components/cards/AuthorCard.vue index 82645c57..05347393 100644 --- a/client/components/cards/AuthorCard.vue +++ b/client/components/cards/AuthorCard.vue @@ -71,9 +71,6 @@ export default { coverHeight() { return this.cardHeight }, - userToken() { - return this.store.getters['user/getToken'] - }, _author() { return this.author || {} }, diff --git a/client/components/covers/AuthorImage.vue b/client/components/covers/AuthorImage.vue index e320e552..084492b0 100644 --- a/client/components/covers/AuthorImage.vue +++ b/client/components/covers/AuthorImage.vue @@ -39,9 +39,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, _author() { return this.author || {} }, diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 71ac8155..7cf46567 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -309,9 +309,9 @@ export default { } else { console.log('Account updated', data.user) - if (data.user.id === this.user.id && data.user.token !== this.user.token) { - console.log('Current user token was updated') - this.$store.commit('user/setUserToken', data.user.token) + if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) { + console.log('Current user access token was updated') + this.$store.commit('user/setUserToken', data.user.accessToken) } this.$toast.success(this.$strings.ToastAccountUpdateSuccess) diff --git a/client/components/modals/item/tabs/Files.vue b/client/components/modals/item/tabs/Files.vue index 7be286fe..15c44261 100644 --- a/client/components/modals/item/tabs/Files.vue +++ b/client/components/modals/item/tabs/Files.vue @@ -29,9 +29,6 @@ export default { media() { return this.libraryItem.media || {} }, - userToken() { - return this.$store.getters['user/getToken'] - }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] }, diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index 82d53552..f929943c 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -129,9 +129,6 @@ export default { return `${hoursRounded}h` } }, - token() { - return this.$store.getters['user/getToken'] - }, timeRemaining() { if (this.useChapterTrack && this.currentChapter) { var currChapTime = this.currentTime - this.currentChapter.start diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index c2e5986e..a7a5ac3d 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -266,9 +266,6 @@ export default { isComic() { return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr' }, - userToken() { - return this.$store.getters['user/getToken'] - }, keepProgress() { return this.$store.state.ereaderKeepProgress }, diff --git a/client/components/tables/EbookFilesTable.vue b/client/components/tables/EbookFilesTable.vue index cc968acd..3ce9d30f 100644 --- a/client/components/tables/EbookFilesTable.vue +++ b/client/components/tables/EbookFilesTable.vue @@ -49,9 +49,6 @@ export default { libraryItemId() { return this.libraryItem.id }, - userToken() { - return this.$store.getters['user/getToken'] - }, userCanDownload() { return this.$store.getters['user/getUserCanDownload'] }, diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue index 9be7e249..6f6e74b8 100644 --- a/client/components/tables/LibraryFilesTable.vue +++ b/client/components/tables/LibraryFilesTable.vue @@ -53,9 +53,6 @@ export default { libraryItemId() { return this.libraryItem.id }, - userToken() { - return this.$store.getters['user/getToken'] - }, userCanDownload() { return this.$store.getters['user/getUserCanDownload'] }, diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index 6de16bf2..18abc66e 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -85,9 +85,6 @@ export default { this.$emit('input', val) } }, - userToken() { - return this.$store.getters['user/getToken'] - }, wrapperClass() { var classes = [] if (this.disabled) classes.push('bg-black-300') diff --git a/client/nuxt.config.js b/client/nuxt.config.js index f54d1cf4..7219c784 100644 --- a/client/nuxt.config.js +++ b/client/nuxt.config.js @@ -73,7 +73,8 @@ module.exports = { // Axios module configuration: https://go.nuxtjs.dev/config-axios axios: { - baseURL: routerBasePath + baseURL: routerBasePath, + progress: false }, // nuxt/pwa https://pwa.nuxtjs.org diff --git a/client/pages/config/users/_id/index.vue b/client/pages/config/users/_id/index.vue index e2f8e208..b87d10c3 100644 --- a/client/pages/config/users/_id/index.vue +++ b/client/pages/config/users/_id/index.vue @@ -13,8 +13,8 @@

{{ username }}

-
- +
+
@@ -100,9 +100,12 @@ export default { } }, computed: { - userToken() { + legacyToken() { return this.user.token }, + userToken() { + return this.user.accessToken + }, bookCoverAspectRatio() { return this.$store.getters['libraries/getBookCoverAspectRatio'] }, diff --git a/client/plugins/axios.js b/client/plugins/axios.js index c2ce8dad..c95067d1 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -1,4 +1,19 @@ -export default function ({ $axios, store, $config }) { +export default function ({ $axios, store, $config, app }) { + // Track if we're currently refreshing to prevent multiple refresh attempts + let isRefreshing = false + let failedQueue = [] + + const processQueue = (error, token = null) => { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error) + } else { + resolve(token) + } + }) + failedQueue = [] + } + $axios.onRequest((config) => { if (!config.url) { console.error('Axios request invalid config', config) @@ -7,7 +22,7 @@ export default function ({ $axios, store, $config }) { if (config.url.startsWith('http:') || config.url.startsWith('https:')) { return } - const bearerToken = store.state.user.user?.token || null + const bearerToken = store.getters['user/getToken'] if (bearerToken) { config.headers.common['Authorization'] = `Bearer ${bearerToken}` } @@ -17,9 +32,83 @@ export default function ({ $axios, store, $config }) { } }) - $axios.onError((error) => { + $axios.onError(async (error) => { + const originalRequest = error.config const code = parseInt(error.response && error.response.status) const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' + console.error('Axios error', code, message) + + // Handle 401 Unauthorized (token expired) + if (code === 401 && !originalRequest._retry) { + // Skip refresh for auth endpoints to prevent infinite loops + if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') { + // Refresh failed or login failed, redirect to login + store.commit('user/setUser', null) + app.router.push('/login') + return Promise.reject(error) + } + + if (isRefreshing) { + // If already refreshing, queue this request + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }) + .then((token) => { + if (!originalRequest.headers) { + originalRequest.headers = {} + } + originalRequest.headers['Authorization'] = `Bearer ${token}` + return $axios(originalRequest) + }) + .catch((err) => { + return Promise.reject(err) + }) + } + + originalRequest._retry = true + isRefreshing = true + + try { + // Attempt to refresh the token + const response = await $axios.$post('/auth/refresh') + const newAccessToken = response.user.accessToken + + if (!newAccessToken) { + console.error('No new access token received') + return Promise.reject(error) + } + + // Update the token in store and localStorage + store.commit('user/setUser', response.user) + + // Update the original request with new token + if (!originalRequest.headers) { + originalRequest.headers = {} + } + originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}` + + // Process any queued requests + processQueue(null, newAccessToken) + + // Retry the original request + return $axios(originalRequest) + } catch (refreshError) { + console.error('Token refresh failed:', refreshError) + + // Process queued requests with error + processQueue(refreshError, null) + + // Clear user data and redirect to login + store.commit('user/setUser', null) + app.router.push('/login') + + return Promise.reject(refreshError) + } finally { + isRefreshing = false + } + } + + return Promise.reject(error) }) } diff --git a/client/store/user.js b/client/store/user.js index 41e12cad..787d67db 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -25,19 +25,19 @@ export const getters = { getIsRoot: (state) => state.user && state.user.type === 'root', getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getToken: (state) => { - return state.user?.token || null + return state.user?.accessToken || null }, getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { - if (!state.user.mediaProgress) return null + if (!state.user?.mediaProgress) return null return state.user.mediaProgress.find((li) => { if (episodeId && li.episodeId !== episodeId) return false return li.libraryItemId == libraryItemId }) }, getUserBookmarksForItem: (state) => (libraryItemId) => { - if (!state.user.bookmarks) return [] + if (!state.user?.bookmarks) return [] return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId) }, getUserSetting: (state) => (key) => { @@ -152,13 +152,17 @@ export const mutations = { setUser(state, user) { state.user = user if (user) { - if (user.token) localStorage.setItem('token', user.token) + if (user.accessToken) localStorage.setItem('token', user.accessToken) + else { + console.error('No access token found for user', user) + } } else { localStorage.removeItem('token') } }, setUserToken(state, token) { - state.user.token = token + if (!state.user) return + state.user.accessToken = token localStorage.setItem('token', token) }, updateMediaProgress(state, { id, data }) { diff --git a/server/Auth.js b/server/Auth.js index 4e76ee33..1839d27f 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,5 +1,6 @@ const axios = require('axios') const passport = require('passport') +const { Op } = require('sequelize') const { Request, Response, NextFunction } = require('express') const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') @@ -21,6 +22,9 @@ class Auth { this.openIdAuthSession = new Map() const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) 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.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours } /** @@ -406,6 +410,22 @@ class Auth { 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 * (clients choise is restored from cookies). @@ -444,17 +464,77 @@ class Auth { // return the user login response json if the login was successfull const userResponse = await this.getUserLoginResponsePayload(req.user) - // Experimental Next.js client uses bearer token in cookies - res.cookie('auth_token', userResponse.user.token, { - httpOnly: true, - secure: req.secure || req.get('x-forwarded-proto') === 'https', - sameSite: 'strict', - maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days - }) + this.setRefreshTokenCookie(req, res, req.user.refreshToken) res.json(userResponse) }) + // Refresh token route + router.post('/auth/refresh', async (req, res) => { + const refreshToken = req.cookies.refresh_token + + if (!refreshToken) { + return res.status(401).json({ error: 'No refresh token provided' }) + } + + try { + // Verify the refresh token + const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) + + if (decoded.type !== 'refresh') { + return res.status(401).json({ error: 'Invalid token type' }) + } + + const session = await Database.sessionModel.findOne({ + where: { refreshToken: refreshToken } + }) + + if (!session) { + 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) { + return res.status(401).json({ error: 'User not found or inactive' }) + } + + const newAccessToken = await this.rotateTokensForSession(session, user, req, res) + + user.accessToken = newAccessToken + const userResponse = await this.getUserLoginResponsePayload(user) + res.json(userResponse) + } catch (error) { + if (error.name === 'TokenExpiredError') { + Logger.info(`[Auth] Refresh token expired, cleaning up session`) + + // Clean up the expired session from database + try { + await Database.sessionModel.destroy({ + where: { refreshToken: refreshToken } + }) + Logger.info(`[Auth] Expired session cleaned up`) + } catch (cleanupError) { + Logger.error(`[Auth] Error cleaning up expired session: ${cleanupError.message}`) + } + + return res.status(401).json({ error: 'Refresh token expired' }) + } else if (error.name === 'JsonWebTokenError') { + Logger.error(`[Auth] Invalid refresh token format: ${error.message}`) + return res.status(401).json({ error: 'Invalid refresh token' }) + } else { + Logger.error(`[Auth] Refresh token error: ${error.message}`) + return res.status(401).json({ error: 'Invalid refresh token' }) + } + } + }) + // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { // Get the OIDC client from the strategy @@ -719,7 +799,24 @@ class Auth { }) // Logout route - router.post('/logout', (req, res) => { + router.post('/logout', async (req, res) => { + const refreshToken = req.cookies.refresh_token + // Clear refresh token cookie + res.clearCookie('refresh_token', { + path: '/' + }) + + // Invalidate the session in database using refresh token + if (refreshToken) { + try { + await Database.sessionModel.destroy({ + where: { refreshToken } + }) + } catch (error) { + Logger.error(`[Auth] Error destroying session: ${error.message}`) + } + } + // TODO: invalidate possible JWTs req.logout((err) => { if (err) { @@ -728,7 +825,6 @@ class Auth { const authMethod = req.cookies.auth_method res.clearCookie('auth_method') - res.clearCookie('auth_token') let logoutUrl = null @@ -776,22 +872,18 @@ 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) { - // check if session cookie says that we are authenticated - if (req.isAuthenticated()) { - next() - } else { - // try JWT to authenticate - passport.authenticate('jwt')(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 @@ -800,6 +892,132 @@ class Auth { 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} + */ + 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} + */ + 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} newAccessToken + */ + 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 newAccessToken + } + + /** + * 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} 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 newAccessToken = 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 newAccessToken + } 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 * @@ -888,6 +1106,10 @@ class Auth { } // approve login 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) return } else if (!user.pash) { @@ -901,6 +1123,10 @@ class Auth { if (compare) { // approve login 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) return } diff --git a/server/Database.js b/server/Database.js index a260e89f..f94b5d19 100644 --- a/server/Database.js +++ b/server/Database.js @@ -42,6 +42,16 @@ class Database { return this.models.user } + /** @type {typeof import('./models/Session')} */ + get sessionModel() { + return this.models.session + } + + /** @type {typeof import('./models/ApiToken')} */ + get apiTokenModel() { + return this.models.apiToken + } + /** @type {typeof import('./models/Library')} */ get libraryModel() { return this.models.library @@ -311,6 +321,8 @@ class Database { buildModels(force = false) { require('./models/User').init(this.sequelize) + require('./models/Session').init(this.sequelize) + require('./models/ApiToken').init(this.sequelize) require('./models/Library').init(this.sequelize) require('./models/LibraryFolder').init(this.sequelize) require('./models/Book').init(this.sequelize) @@ -656,6 +668,8 @@ class Database { * Series should have atleast one Book * Book and Podcast must have an associated LibraryItem (and vice versa) * Remove playback sessions that are 3 seconds or less + * Remove duplicate mediaProgresses + * Remove expired auth sessions */ async cleanDatabase() { // Remove invalid Podcast records @@ -785,6 +799,23 @@ WHERE EXISTS ( where: { id: duplicateMediaProgress.id } }) } + + // Remove expired Session records + await this.cleanupExpiredSessions() + } + + /** + * Clean up expired sessions from the database + */ + async cleanupExpiredSessions() { + try { + const deletedCount = await this.sessionModel.cleanupExpiredSessions() + if (deletedCount > 0) { + Logger.info(`[Database] Cleaned up ${deletedCount} expired sessions`) + } + } catch (error) { + Logger.error(`[Database] Error cleaning up expired sessions: ${error.message}`) + } } async createTextSearchQuery(query) { diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 0fb10513..0a99b84e 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -237,6 +237,7 @@ class UserController { let hasUpdates = false let shouldUpdateToken = false + let shouldInvalidateJwtSessions = false // When changing username create a new API token if (updatePayload.username && updatePayload.username !== user.username) { const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username) @@ -245,6 +246,7 @@ class UserController { } user.username = updatePayload.username shouldUpdateToken = true + shouldInvalidateJwtSessions = true hasUpdates = true } @@ -328,6 +330,18 @@ class UserController { user.token = await this.auth.generateAccessToken(user) Logger.info(`[UserController] User ${user.username} has generated a new api token`) } + + // Handle JWT session invalidation for username changes + if (shouldInvalidateJwtSessions) { + const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res) + if (newAccessToken) { + user.accessToken = newAccessToken + Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`) + } else { + Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`) + } + } + await user.save() SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser()) } diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index 3f948583..adc14177 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -31,10 +31,11 @@ class CronManager { } /** - * Initialize open session cleanup cron + * Initialize open session & auth session cleanup cron * Runs every day at 00:30 * Closes open share sessions that have not been updated in 24 hours * Closes open playback sessions that have not been updated in 36 hours + * Cleans up expired auth sessions * TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner */ initOpenSessionCleanupCron() { @@ -42,6 +43,7 @@ class CronManager { Logger.debug('[CronManager] Open session cleanup cron executing') ShareManager.closeStaleOpenShareSessions() await this.playbackSessionManager.closeStaleOpenSessions() + await Database.cleanupExpiredSessions() }) } diff --git a/server/migrations/v2.26.0-create-sessions-table.js b/server/migrations/v2.26.0-create-sessions-table.js new file mode 100644 index 00000000..aad49f8f --- /dev/null +++ b/server/migrations/v2.26.0-create-sessions-table.js @@ -0,0 +1,153 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.26.0' +const migrationName = `${migrationVersion}-create-sessions-table` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration creates a sessions table and apiTokens table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Check if table exists + if (await queryInterface.tableExists('sessions')) { + logger.info(`${loggerPrefix} table "sessions" already exists`) + } else { + // Create table + logger.info(`${loggerPrefix} creating table "sessions"`) + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('sessions', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + ipAddress: DataTypes.STRING, + userAgent: DataTypes.STRING, + refreshToken: { + type: DataTypes.STRING, + allowNull: false + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + }, + userId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + } + }) + logger.info(`${loggerPrefix} created table "sessions"`) + } + + // Check if table exists + if (await queryInterface.tableExists('apiTokens')) { + logger.info(`${loggerPrefix} table "apiTokens" already exists`) + } else { + // Create table + logger.info(`${loggerPrefix} creating table "apiTokens"`) + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('apiTokens', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + tokenHash: { + type: DataTypes.STRING, + allowNull: false + }, + expiresAt: DataTypes.DATE, + lastUsedAt: DataTypes.DATE, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + permissions: DataTypes.JSON, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + }, + userId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + } + }) + logger.info(`${loggerPrefix} created table "apiTokens"`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the sessions table and apiTokens table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + // Check if table exists + if (await queryInterface.tableExists('sessions')) { + logger.info(`${loggerPrefix} dropping table "sessions"`) + // Drop table + await queryInterface.dropTable('sessions') + logger.info(`${loggerPrefix} dropped table "sessions"`) + } else { + logger.info(`${loggerPrefix} table "sessions" does not exist`) + } + + if (await queryInterface.tableExists('apiTokens')) { + logger.info(`${loggerPrefix} dropping table "apiTokens"`) + await queryInterface.dropTable('apiTokens') + logger.info(`${loggerPrefix} dropped table "apiTokens"`) + } else { + logger.info(`${loggerPrefix} table "apiTokens" does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/ApiToken.js b/server/models/ApiToken.js new file mode 100644 index 00000000..753fba6f --- /dev/null +++ b/server/models/ApiToken.js @@ -0,0 +1,90 @@ +const { DataTypes, Model, Op } = require('sequelize') + +class ApiToken extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {string} */ + this.tokenHash + /** @type {Date} */ + this.expiresAt + /** @type {Date} */ + this.lastUsedAt + /** @type {boolean} */ + this.isActive + /** @type {Object} */ + this.permissions + /** @type {Date} */ + this.createdAt + /** @type {UUIDV4} */ + this.userId + + // Expanded properties + + /** @type {import('./User').User} */ + this.user + } + + /** + * Clean up expired api tokens from the database + * @returns {Promise} Number of api tokens deleted + */ + static async cleanupExpiredApiTokens() { + const deletedCount = await ApiToken.destroy({ + where: { + expiresAt: { + [Op.lt]: new Date() + } + } + }) + return deletedCount + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + tokenHash: { + type: DataTypes.STRING, + allowNull: false + }, + expiresAt: DataTypes.DATE, + lastUsedAt: DataTypes.DATE, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + permissions: DataTypes.JSON + }, + { + sequelize, + modelName: 'apiToken' + } + ) + + const { user } = sequelize.models + user.hasMany(ApiToken, { + onDelete: 'CASCADE', + foreignKey: { + allowNull: false + } + }) + ApiToken.belongsTo(user) + } +} + +module.exports = ApiToken diff --git a/server/models/Session.js b/server/models/Session.js new file mode 100644 index 00000000..fe9dd542 --- /dev/null +++ b/server/models/Session.js @@ -0,0 +1,88 @@ +const { DataTypes, Model, Op } = require('sequelize') + +class Session extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.ipAddress + /** @type {string} */ + this.userAgent + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + /** @type {UUIDV4} */ + this.userId + /** @type {Date} */ + this.expiresAt + + // Expanded properties + + /** @type {import('./User').User} */ + this.user + } + + static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) { + const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt }) + return session + } + + /** + * Clean up expired sessions from the database + * @returns {Promise} Number of sessions deleted + */ + static async cleanupExpiredSessions() { + const deletedCount = await Session.destroy({ + where: { + expiresAt: { + [Op.lt]: new Date() + } + } + }) + return deletedCount + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + ipAddress: DataTypes.STRING, + userAgent: DataTypes.STRING, + refreshToken: { + type: DataTypes.STRING, + allowNull: false + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false + } + }, + { + sequelize, + modelName: 'session' + } + ) + + const { user } = sequelize.models + user.hasMany(Session, { + onDelete: 'CASCADE', + foreignKey: { + allowNull: false + } + }) + Session.belongsTo(user) + } +} + +module.exports = Session diff --git a/server/models/User.js b/server/models/User.js index 999c80b2..9b26b0ff 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -112,6 +112,10 @@ class User extends Model { this.updatedAt /** @type {import('./MediaProgress')[]?} - Only included when extended */ this.mediaProgresses + + // Temporary accessToken, not stored in database + /** @type {string} */ + this.accessToken } // Excludes "root" since their can only be 1 root user @@ -520,7 +524,9 @@ class User extends Model { username: this.username, email: this.email, type: this.type, + // TODO: Old non-expiring token token: this.type === 'root' && hideRootToken ? '' : this.token, + accessToken: this.accessToken || null, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], From d96ed01ce473c60c91b1af5fa0a825a979e23f51 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 10:12:39 -0500 Subject: [PATCH 02/29] Set up ApiKey model and create Api Key endpoint --- server/Database.js | 8 +- server/controllers/ApiKeyController.js | 78 +++++++ ...table.js => v2.26.0-create-auth-tables.js} | 33 ++- server/models/ApiKey.js | 191 ++++++++++++++++++ server/models/ApiToken.js | 90 --------- server/routers/ApiRouter.js | 6 + 6 files changed, 293 insertions(+), 113 deletions(-) create mode 100644 server/controllers/ApiKeyController.js rename server/migrations/{v2.26.0-create-sessions-table.js => v2.26.0-create-auth-tables.js} (80%) create mode 100644 server/models/ApiKey.js delete mode 100644 server/models/ApiToken.js diff --git a/server/Database.js b/server/Database.js index f94b5d19..b632d040 100644 --- a/server/Database.js +++ b/server/Database.js @@ -47,9 +47,9 @@ class Database { return this.models.session } - /** @type {typeof import('./models/ApiToken')} */ - get apiTokenModel() { - return this.models.apiToken + /** @type {typeof import('./models/ApiKey')} */ + get apiKeyModel() { + return this.models.apiKey } /** @type {typeof import('./models/Library')} */ @@ -322,7 +322,7 @@ class Database { buildModels(force = false) { require('./models/User').init(this.sequelize) require('./models/Session').init(this.sequelize) - require('./models/ApiToken').init(this.sequelize) + require('./models/ApiKey').init(this.sequelize) require('./models/Library').init(this.sequelize) require('./models/LibraryFolder').init(this.sequelize) require('./models/Book').init(this.sequelize) diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js new file mode 100644 index 00000000..0f995263 --- /dev/null +++ b/server/controllers/ApiKeyController.js @@ -0,0 +1,78 @@ +const { Request, Response, NextFunction } = require('express') +const uuidv4 = require('uuid').v4 +const Logger = require('../Logger') +const Database = require('../Database') + +/** + * @typedef RequestUserObject + * @property {import('../models/User')} user + * + * @typedef {Request & RequestUserObject} RequestWithUser + */ + +class ApiKeyController { + constructor() {} + + /** + * POST: /api/api-keys + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async create(req, res) { + if (!req.body.name || typeof req.body.name !== 'string') { + Logger.warn(`[ApiKeyController] create: Invalid name: ${req.body.name}`) + return res.sendStatus(400) + } + if (req.body.expiresIn && (typeof req.body.expiresIn !== 'number' || req.body.expiresIn <= 0)) { + Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`) + return res.sendStatus(400) + } + + const keyId = uuidv4() // Generate key id ahead of time to use in JWT + + const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) + const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn) + + if (!apiKey) { + Logger.error(`[ApiKeyController] create: Error generating API key`) + return res.sendStatus(500) + } + + // Calculate expiration time for the api key + const expiresAt = req.body.expiresIn ? new Date(Date.now() + req.body.expiresIn * 1000) : null + + const apiKeyInstance = await Database.apiKeyModel.create({ + id: keyId, + name: req.body.name, + expiresAt, + permissions, + userId: req.user.id + }) + + return res.json({ + id: apiKeyInstance.id, + name: apiKeyInstance.name, + apiKey, + expiresAt: apiKeyInstance.expiresAt, + permissions: apiKeyInstance.permissions + }) + } + + /** + * + * @param {RequestWithUser} req + * @param {Response} res + * @param {NextFunction} next + */ + middleware(req, res, next) { + if (!req.user.isAdminOrUp) { + Logger.error(`[ApiKeyController] Non-admin user "${req.user.username}" attempting to access api keys`) + return res.sendStatus(403) + } + + next() + } +} + +module.exports = new ApiKeyController() diff --git a/server/migrations/v2.26.0-create-sessions-table.js b/server/migrations/v2.26.0-create-auth-tables.js similarity index 80% rename from server/migrations/v2.26.0-create-sessions-table.js rename to server/migrations/v2.26.0-create-auth-tables.js index aad49f8f..2c86411e 100644 --- a/server/migrations/v2.26.0-create-sessions-table.js +++ b/server/migrations/v2.26.0-create-auth-tables.js @@ -8,11 +8,11 @@ */ const migrationVersion = '2.26.0' -const migrationName = `${migrationVersion}-create-sessions-table` +const migrationName = `${migrationVersion}-create-auth-tables` const loggerPrefix = `[${migrationVersion} migration]` /** - * This upward migration creates a sessions table and apiTokens table. + * This upward migration creates a sessions table and apiKeys table. * * @param {MigrationOptions} options - an object containing the migration context. * @returns {Promise} - A promise that resolves when the migration is complete. @@ -68,23 +68,19 @@ async function up({ context: { queryInterface, logger } }) { } // Check if table exists - if (await queryInterface.tableExists('apiTokens')) { - logger.info(`${loggerPrefix} table "apiTokens" already exists`) + if (await queryInterface.tableExists('apiKeys')) { + logger.info(`${loggerPrefix} table "apiKeys" already exists`) } else { // Create table - logger.info(`${loggerPrefix} creating table "apiTokens"`) + logger.info(`${loggerPrefix} creating table "apiKeys"`) const DataTypes = queryInterface.sequelize.Sequelize.DataTypes - await queryInterface.createTable('apiTokens', { + await queryInterface.createTable('apiKeys', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, name: DataTypes.STRING, - tokenHash: { - type: DataTypes.STRING, - allowNull: false - }, expiresAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE, isActive: { @@ -109,18 +105,17 @@ async function up({ context: { queryInterface, logger } }) { }, key: 'id' }, - allowNull: false, - onDelete: 'CASCADE' + onDelete: 'SET NULL' } }) - logger.info(`${loggerPrefix} created table "apiTokens"`) + logger.info(`${loggerPrefix} created table "apiKeys"`) } logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) } /** - * This downward migration script removes the sessions table and apiTokens table. + * This downward migration script removes the sessions table and apiKeys table. * * @param {MigrationOptions} options - an object containing the migration context. * @returns {Promise} - A promise that resolves when the migration is complete. @@ -139,12 +134,12 @@ async function down({ context: { queryInterface, logger } }) { logger.info(`${loggerPrefix} table "sessions" does not exist`) } - if (await queryInterface.tableExists('apiTokens')) { - logger.info(`${loggerPrefix} dropping table "apiTokens"`) - await queryInterface.dropTable('apiTokens') - logger.info(`${loggerPrefix} dropped table "apiTokens"`) + if (await queryInterface.tableExists('apiKeys')) { + logger.info(`${loggerPrefix} dropping table "apiKeys"`) + await queryInterface.dropTable('apiKeys') + logger.info(`${loggerPrefix} dropped table "apiKeys"`) } else { - logger.info(`${loggerPrefix} table "apiTokens" does not exist`) + logger.info(`${loggerPrefix} table "apiKeys" does not exist`) } logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js new file mode 100644 index 00000000..54cc036a --- /dev/null +++ b/server/models/ApiKey.js @@ -0,0 +1,191 @@ +const { DataTypes, Model, Op } = require('sequelize') +const jwt = require('jsonwebtoken') +const Logger = require('../Logger') + +/** + * @typedef {Object} ApiKeyPermissions + * @property {boolean} download + * @property {boolean} update + * @property {boolean} delete + * @property {boolean} upload + * @property {boolean} createEreader + * @property {boolean} accessAllLibraries + * @property {boolean} accessAllTags + * @property {boolean} accessExplicitContent + * @property {boolean} selectedTagsNotAccessible + * @property {string[]} librariesAccessible + * @property {string[]} itemTagsSelected + */ + +class ApiKey extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {Date} */ + this.expiresAt + /** @type {Date} */ + this.lastUsedAt + /** @type {boolean} */ + this.isActive + /** @type {Object} */ + this.permissions + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + /** @type {UUIDV4} */ + this.userId + + // Expanded properties + + /** @type {import('./User').User} */ + this.user + } + + /** + * Same properties as User.getDefaultPermissions + * @returns {ApiKeyPermissions} + */ + static getDefaultPermissions() { + return { + download: true, + update: true, + delete: true, + upload: true, + createEreader: true, + accessAllLibraries: true, + accessAllTags: true, + accessExplicitContent: true, + selectedTagsNotAccessible: false, // Inverts itemTagsSelected + librariesAccessible: [], + itemTagsSelected: [] + } + } + + /** + * Merge permissions from request with default permissions + * @param {ApiKeyPermissions} reqPermissions + * @returns {ApiKeyPermissions} + */ + static mergePermissionsWithDefault(reqPermissions) { + const permissions = this.getDefaultPermissions() + + if (!reqPermissions || typeof reqPermissions !== 'object') { + Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permissions: ${reqPermissions}`) + return permissions + } + + for (const key in reqPermissions) { + if (reqPermissions[key] === undefined) { + Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission key: ${key}`) + continue + } + + if (key === 'librariesAccessible' || key === 'itemTagsSelected') { + if (!Array.isArray(reqPermissions[key]) || reqPermissions[key].some((value) => typeof value !== 'string')) { + Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid ${key} value: ${reqPermissions[key]}`) + continue + } + + permissions[key] = reqPermissions[key] + } else if (typeof reqPermissions[key] !== 'boolean') { + Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission value for key ${key}. Should be boolean`) + continue + } + + permissions[key] = reqPermissions[key] + } + + return permissions + } + + /** + * Clean up expired api keys from the database + * @returns {Promise} Number of api keys deleted + */ + static async cleanupExpiredApiKeys() { + const deletedCount = await ApiKey.destroy({ + where: { + expiresAt: { + [Op.lt]: new Date() + } + } + }) + return deletedCount + } + + /** + * Generate a new api key + * @param {string} keyId + * @param {string} name + * @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration + * @returns {Promise} + */ + static async generateApiKey(keyId, name, expiresIn) { + const options = {} + if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) { + options.expiresIn = expiresIn + } + + return new Promise((resolve) => { + jwt.sign( + { + keyId, + name, + type: 'api' + }, + global.ServerSettings.tokenSecret, + options, + (err, token) => { + if (err) { + Logger.error(`[ApiKey] Error generating API key: ${err}`) + resolve(null) + } else { + resolve(token) + } + } + ) + }) + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + expiresAt: DataTypes.DATE, + lastUsedAt: DataTypes.DATE, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + permissions: DataTypes.JSON + }, + { + sequelize, + modelName: 'apiKey' + } + ) + + const { user } = sequelize.models + user.hasMany(ApiKey, { + onDelete: 'SET NULL' + }) + ApiKey.belongsTo(user) + } +} + +module.exports = ApiKey diff --git a/server/models/ApiToken.js b/server/models/ApiToken.js deleted file mode 100644 index 753fba6f..00000000 --- a/server/models/ApiToken.js +++ /dev/null @@ -1,90 +0,0 @@ -const { DataTypes, Model, Op } = require('sequelize') - -class ApiToken extends Model { - constructor(values, options) { - super(values, options) - - /** @type {UUIDV4} */ - this.id - /** @type {string} */ - this.name - /** @type {string} */ - this.tokenHash - /** @type {Date} */ - this.expiresAt - /** @type {Date} */ - this.lastUsedAt - /** @type {boolean} */ - this.isActive - /** @type {Object} */ - this.permissions - /** @type {Date} */ - this.createdAt - /** @type {UUIDV4} */ - this.userId - - // Expanded properties - - /** @type {import('./User').User} */ - this.user - } - - /** - * Clean up expired api tokens from the database - * @returns {Promise} Number of api tokens deleted - */ - static async cleanupExpiredApiTokens() { - const deletedCount = await ApiToken.destroy({ - where: { - expiresAt: { - [Op.lt]: new Date() - } - } - }) - return deletedCount - } - - /** - * Initialize model - * @param {import('../Database').sequelize} sequelize - */ - static init(sequelize) { - super.init( - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - name: DataTypes.STRING, - tokenHash: { - type: DataTypes.STRING, - allowNull: false - }, - expiresAt: DataTypes.DATE, - lastUsedAt: DataTypes.DATE, - isActive: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false - }, - permissions: DataTypes.JSON - }, - { - sequelize, - modelName: 'apiToken' - } - ) - - const { user } = sequelize.models - user.hasMany(ApiToken, { - onDelete: 'CASCADE', - foreignKey: { - allowNull: false - } - }) - ApiToken.belongsTo(user) - } -} - -module.exports = ApiToken diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index ecb1555f..a4ec7d3c 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -34,6 +34,7 @@ const CustomMetadataProviderController = require('../controllers/CustomMetadataP const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') const StatsController = require('../controllers/StatsController') +const ApiKeyController = require('../controllers/ApiKeyController') class ApiRouter { constructor(Server) { @@ -325,6 +326,11 @@ class ApiRouter { this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this)) this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this)) + // + // API Key Routes + // + this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this)) + // // Misc Routes // From af1ff12dbb95dbbc9321d9f8fb1dfafcbece5f35 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 11:32:02 -0500 Subject: [PATCH 03/29] Add get all, update and delete endpoints. Add api keys config page --- client/components/app/ConfigSideNav.vue | 5 + client/components/modals/AccountModal.vue | 3 - .../components/modals/ApiKeyCreatedModal.vue | 60 ++++ client/components/modals/ApiKeyModal.vue | 317 ++++++++++++++++++ client/components/tables/ApiKeysTable.vue | 167 +++++++++ client/pages/config.vue | 1 + client/pages/config/api-keys/index.vue | 68 ++++ client/strings/en-us.json | 15 + server/controllers/ApiKeyController.js | 80 ++++- server/routers/ApiRouter.js | 3 + 10 files changed, 710 insertions(+), 9 deletions(-) create mode 100644 client/components/modals/ApiKeyCreatedModal.vue create mode 100644 client/components/modals/ApiKeyModal.vue create mode 100644 client/components/tables/ApiKeysTable.vue create mode 100644 client/pages/config/api-keys/index.vue diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50fa7a06..32e7e694 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -70,6 +70,11 @@ export default { title: this.$strings.HeaderUsers, path: '/config/users' }, + { + id: 'config-api-keys', + title: this.$strings.HeaderApiKeys, + path: '/config/api-keys' + }, { id: 'config-sessions', title: this.$strings.HeaderListeningSessions, diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 7cf46567..9293a6d1 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -351,9 +351,6 @@ export default { this.$toast.error(errMsg || 'Failed to create account') }) }, - toggleActive() { - this.newUser.isActive = !this.newUser.isActive - }, userTypeUpdated(type) { this.newUser.permissions = { download: type !== 'guest', diff --git a/client/components/modals/ApiKeyCreatedModal.vue b/client/components/modals/ApiKeyCreatedModal.vue new file mode 100644 index 00000000..96442a17 --- /dev/null +++ b/client/components/modals/ApiKeyCreatedModal.vue @@ -0,0 +1,60 @@ + + + diff --git a/client/components/modals/ApiKeyModal.vue b/client/components/modals/ApiKeyModal.vue new file mode 100644 index 00000000..c00de195 --- /dev/null +++ b/client/components/modals/ApiKeyModal.vue @@ -0,0 +1,317 @@ + + + diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue new file mode 100644 index 00000000..72fbe691 --- /dev/null +++ b/client/components/tables/ApiKeysTable.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/client/pages/config.vue b/client/pages/config.vue index 5fa145e5..c4fe2446 100644 --- a/client/pages/config.vue +++ b/client/pages/config.vue @@ -53,6 +53,7 @@ export default { else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions else if (pageName === 'stats') return this.$strings.HeaderYourStats else if (pageName === 'users') return this.$strings.HeaderUsers + else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'email') return this.$strings.HeaderEmail diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue new file mode 100644 index 00000000..99ae9c52 --- /dev/null +++ b/client/pages/config/api-keys/index.vue @@ -0,0 +1,68 @@ + + + diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f6288912..62443e0b 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -1,5 +1,6 @@ { "ButtonAdd": "Add", + "ButtonAddApiKey": "Add API Key", "ButtonAddChapters": "Add Chapters", "ButtonAddDevice": "Add Device", "ButtonAddLibrary": "Add Library", @@ -20,6 +21,7 @@ "ButtonChooseAFolder": "Choose a folder", "ButtonChooseFiles": "Choose files", "ButtonClearFilter": "Clear Filter", + "ButtonClose": "Close", "ButtonCloseFeed": "Close Feed", "ButtonCloseSession": "Close Open Session", "ButtonCollections": "Collections", @@ -119,6 +121,7 @@ "HeaderAccount": "Account", "HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider", "HeaderAdvanced": "Advanced", + "HeaderApiKeys": "API Keys", "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudioTracks": "Audio Tracks", "HeaderAudiobookTools": "Audiobook File Management Tools", @@ -162,6 +165,7 @@ "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", + "HeaderNewApiKey": "New API Key", "HeaderNewLibrary": "New Library", "HeaderNotificationCreate": "Create Notification", "HeaderNotificationUpdate": "Update Notification", @@ -206,6 +210,7 @@ "HeaderTableOfContents": "Table of Contents", "HeaderTools": "Tools", "HeaderUpdateAccount": "Update Account", + "HeaderUpdateApiKey": "Update API Key", "HeaderUpdateAuthor": "Update Author", "HeaderUpdateDetails": "Update Details", "HeaderUpdateLibrary": "Update Library", @@ -235,6 +240,8 @@ "LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", + "LabelApiKeyCreated": "API Key \"{0}\" created successfully.", + "LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.", "LabelApiToken": "API Token", "LabelAppend": "Append", "LabelAudioBitrate": "Audio Bitrate (e.g. 128k)", @@ -346,6 +353,9 @@ "LabelExample": "Example", "LabelExpandSeries": "Expand Series", "LabelExpandSubSeries": "Expand Sub Series", + "LabelExpiresAt": "Expires At", + "LabelExpiresInSeconds": "Expires in (seconds)", + "LabelExpiresNever": "Never", "LabelExplicit": "Explicit", "LabelExplicitChecked": "Explicit (checked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)", @@ -408,6 +418,7 @@ "LabelLastSeen": "Last Seen", "LabelLastTime": "Last Time", "LabelLastUpdate": "Last Update", + "LabelLastUsed": "Last Used", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Single page", "LabelLayoutSplitPage": "Split page", @@ -455,6 +466,7 @@ "LabelNewestEpisodes": "Newest Episodes", "LabelNextBackupDate": "Next backup date", "LabelNextScheduledRun": "Next scheduled run", + "LabelNoApiKeys": "No API keys", "LabelNoCustomMetadataProviders": "No custom metadata providers", "LabelNoEpisodesSelected": "No episodes selected", "LabelNotFinished": "Not Finished", @@ -730,6 +742,7 @@ "MessageChaptersNotFound": "Chapters not found", "MessageCheckingCron": "Checking cron...", "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", + "MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{0}\"?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", @@ -1000,6 +1013,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared", "ToastEpisodeUpdateSuccess": "{0} episodes updated", "ToastErrorCannotShare": "Cannot share natively on this device", + "ToastFailedToCreate": "Failed to create", + "ToastFailedToDelete": "Failed to delete", "ToastFailedToLoadData": "Failed to load data", "ToastFailedToMatch": "Failed to match", "ToastFailedToShare": "Failed to share", diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index 0f995263..776ddcbe 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -13,6 +13,20 @@ const Database = require('../Database') class ApiKeyController { constructor() {} + /** + * GET: /api/api-keys + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getAll(req, res) { + const apiKeys = await Database.apiKeyModel.findAll() + + return res.json({ + apiKeys: apiKeys.map((a) => a.toJSON()) + }) + } + /** * POST: /api/api-keys * @@ -47,18 +61,72 @@ class ApiKeyController { name: req.body.name, expiresAt, permissions, - userId: req.user.id + userId: req.user.id, + isActive: !!req.body.isActive }) + Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) return res.json({ - id: apiKeyInstance.id, - name: apiKeyInstance.name, - apiKey, - expiresAt: apiKeyInstance.expiresAt, - permissions: apiKeyInstance.permissions + apiKey: { + apiKey, // Actual key only shown to user on creation + ...apiKeyInstance.toJSON() + } }) } + /** + * PATCH: /api/api-keys/:id + * Only isActive and permissions can be updated because name and expiresIn are in the JWT + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async update(req, res) { + const apiKey = await Database.apiKeyModel.findByPk(req.params.id) + if (!apiKey) { + return res.sendStatus(404) + } + + if (req.body.isActive !== undefined) { + if (typeof req.body.isActive !== 'boolean') { + return res.sendStatus(400) + } + + apiKey.isActive = req.body.isActive + } + + if (req.body.permissions && Object.keys(req.body.permissions).length > 0) { + const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) + apiKey.permissions = permissions + } + + await apiKey.save() + + Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) + + return res.json({ + apiKey: apiKey.toJSON() + }) + } + + /** + * DELETE: /api/api-keys/:id + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async delete(req, res) { + const apiKey = await Database.apiKeyModel.findByPk(req.params.id) + if (!apiKey) { + return res.sendStatus(404) + } + + await apiKey.destroy() + Logger.info(`[ApiKeyController] Deleted API key "${apiKey.name}"`) + + return res.sendStatus(200) + } + /** * * @param {RequestWithUser} req diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index a4ec7d3c..8966ff66 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -329,7 +329,10 @@ class ApiRouter { // // API Key Routes // + this.router.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this)) this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this)) + this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this)) + this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this)) // // Misc Routes From 4d32a22de9268fc1ec4e3d3d9f2bfb75fa4dfd6c Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 14:53:11 -0500 Subject: [PATCH 04/29] Update API Keys to be tied to a user, add apikey lru-cache, handle deactivating expired keys --- client/components/modals/ApiKeyModal.vue | 201 ++++-------------- client/components/modals/Modal.vue | 4 +- client/components/tables/ApiKeysTable.vue | 20 +- client/components/ui/SelectInput.vue | 9 +- client/components/ui/TextInputWithLabel.vue | 3 +- client/pages/config/api-keys/index.vue | 29 ++- client/strings/en-us.json | 6 +- server/Auth.js | 52 ++++- server/Database.js | 18 ++ server/controllers/ApiKeyController.js | 87 ++++++-- server/managers/CronManager.js | 2 + .../migrations/v2.26.0-create-auth-tables.js | 17 +- server/models/ApiKey.js | 104 +++++++-- 13 files changed, 335 insertions(+), 217 deletions(-) diff --git a/client/components/modals/ApiKeyModal.vue b/client/components/modals/ApiKeyModal.vue index c00de195..b347abd0 100644 --- a/client/components/modals/ApiKeyModal.vue +++ b/client/components/modals/ApiKeyModal.vue @@ -13,102 +13,23 @@
- +
-
-
+
+

{{ $strings.LabelEnable }}

- + +
+
+

{{ $strings.LabelExpired }}

-
-

{{ $strings.HeaderPermissions }}

-
-
-

{{ $strings.LabelPermissionsDownload }}

-
-
- -
-
- -
-
-

{{ $strings.LabelPermissionsUpdate }}

-
-
- -
-
- -
-
-

{{ $strings.LabelPermissionsDelete }}

-
-
- -
-
- -
-
-

{{ $strings.LabelPermissionsUpload }}

-
-
- -
-
- -
-
-

{{ $strings.LabelPermissionsCreateEreader }}

-
-
- -
-
- -
-
-

{{ $strings.LabelPermissionsAccessExplicitContent }}

-
-
- -
-
- -
-
-

{{ $strings.LabelPermissionsAccessAllLibraries }}

-
-
- -
-
- -
- -
- -
-
-

{{ $strings.LabelPermissionsAccessAllTags }}

-
-
- -
-
-
-
- -
-

{{ $strings.LabelInvert }}

- -
-
-
+
+

{{ $strings.LabelApiKeyUser }}

+

{{ $strings.LabelApiKeyUserDescription }}

+
@@ -128,15 +49,17 @@ export default { apiKey: { type: Object, default: () => null + }, + users: { + type: Array, + default: () => [] } }, data() { return { processing: false, newApiKey: {}, - isNew: true, - tags: [], - loadingTags: false + isNew: true } }, watch: { @@ -160,64 +83,29 @@ export default { title() { return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey }, - libraries() { - return this.$store.state.libraries.libraries + userItems() { + return this.users + .filter((u) => { + // Only show root user if the current user is root + return u.type !== 'root' || this.$store.getters['user/getIsRoot'] + }) + .map((u) => ({ text: u.username, value: u.id, subtext: u.type })) }, - libraryItems() { - return this.libraries.map((lib) => ({ text: lib.name, value: lib.id })) - }, - itemTags() { - return this.tags.map((t) => { - return { - text: t, - value: t - } - }) - }, - tagsSelectionText() { - return this.newApiKey.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser + isExpired() { + if (!this.apiKey || !this.apiKey.expiresAt) return false + + return new Date(this.apiKey.expiresAt).getTime() < Date.now() } }, methods: { - accessAllTagsToggled(val) { - if (val) { - if (this.newApiKey.itemTagsSelected?.length) { - this.newApiKey.itemTagsSelected = [] - } - this.newApiKey.permissions.selectedTagsNotAccessible = false - } - }, - fetchAllTags() { - this.loadingTags = true - this.$axios - .$get(`/api/tags`) - .then((res) => { - this.tags = res.tags - this.loadingTags = false - }) - .catch((error) => { - console.error('Failed to load tags', error) - this.loadingTags = false - }) - }, - accessAllLibrariesToggled(val) { - if (!val && !this.newApiKey.permissions.librariesAccessible.length) { - this.newApiKey.permissions.librariesAccessible = this.libraries.map((l) => l.id) - } else if (val && this.newApiKey.permissions.librariesAccessible.length) { - this.newApiKey.permissions.librariesAccessible = [] - } - }, submitForm() { if (!this.newApiKey.name) { - this.$toast.error(this.$strings.ToastNewApiKeyNameError) + this.$toast.error(this.$strings.ToastNameRequired) return } - if (!this.newApiKey.permissions.accessAllLibraries && !this.newApiKey.permissions.librariesAccessible.length) { - this.$toast.error(this.$strings.ToastNewApiKeyLibraryError) - return - } - if (!this.newApiKey.permissions.accessAllTags && !this.newApiKey.itemTagsSelected.length) { - this.$toast.error(this.$strings.ToastNewApiKeyTagError) + + if (!this.newApiKey.userId) { + this.$toast.error(this.$strings.ToastNewApiKeyUserError) return } @@ -228,9 +116,15 @@ export default { } }, submitUpdateApiKey() { - var apiKey = { + if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) { + this.$toast.info(this.$strings.ToastNoUpdatesNecessary) + this.show = false + return + } + + const apiKey = { isActive: this.newApiKey.isActive, - permissions: this.newApiKey.permissions + userId: this.newApiKey.userId } this.processing = true @@ -281,33 +175,20 @@ export default { }) }, init() { - this.fetchAllTags() this.isNew = !this.apiKey if (this.apiKey) { this.newApiKey = { name: this.apiKey.name, isActive: this.apiKey.isActive, - permissions: { ...this.apiKey.permissions } + userId: this.apiKey.userId } } else { this.newApiKey = { name: null, expiresIn: null, isActive: true, - permissions: { - download: true, - update: false, - delete: false, - upload: false, - accessAllLibraries: true, - accessAllTags: true, - accessExplicitContent: false, - selectedTagsNotAccessible: false, - createEreader: false, - librariesAccessible: [], - itemTagsSelected: [] - } + userId: null } } } diff --git a/client/components/modals/Modal.vue b/client/components/modals/Modal.vue index a7d9c0ae..31ea1e61 100644 --- a/client/components/modals/Modal.vue +++ b/client/components/modals/Modal.vue @@ -23,7 +23,7 @@ export default { processing: Boolean, persistent: { type: Boolean, - default: true + default: false }, width: { type: [String, Number], @@ -99,7 +99,7 @@ export default { this.preventClickoutside = false return } - if (this.processing && this.persistent) return + if (this.processing || this.persistent) return if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) { this.show = false } diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue index 72fbe691..037000b5 100644 --- a/client/components/tables/ApiKeysTable.vue +++ b/client/components/tables/ApiKeysTable.vue @@ -4,8 +4,8 @@ + - @@ -15,11 +15,15 @@

{{ apiKey.name }}

- +
{{ $strings.LabelName }}{{ $strings.LabelApiKeyUser }} {{ $strings.LabelExpiresAt }}{{ $strings.LabelLastUsed }} {{ $strings.LabelCreatedAt }}
{{ apiKey.expiresAt ? $formatJsDatetime(new Date(apiKey.expiresAt), dateFormat, timeFormat) : $strings.LabelExpiresNever }} - - {{ $dateDistanceFromNow(new Date(apiKey.lastUsedAt).getTime()) }} - + + {{ apiKey.user.username }} + +

Error

+
+

{{ getExpiresAtText(apiKey) }}

+

{{ $strings.LabelExpiresNever }}

@@ -60,6 +64,12 @@ export default { } }, methods: { + getExpiresAtText(apiKey) { + if (new Date(apiKey.expiresAt).getTime() < Date.now()) { + return this.$strings.LabelExpired + } + return this.$formatJsDatetime(new Date(apiKey.expiresAt), this.dateFormat, this.timeFormat) + }, deleteApiKeyClick(apiKey) { if (this.isDeletingApiKey) return diff --git a/client/components/ui/SelectInput.vue b/client/components/ui/SelectInput.vue index 9e0961c1..f38414ac 100644 --- a/client/components/ui/SelectInput.vue +++ b/client/components/ui/SelectInput.vue @@ -1,9 +1,9 @@ @@ -21,6 +21,7 @@ export default { type: String, default: 'text' }, + min: [String, Number], readonly: Boolean, disabled: Boolean, inputClass: String, diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue index 99ae9c52..edc4d59f 100644 --- a/client/pages/config/api-keys/index.vue +++ b/client/pages/config/api-keys/index.vue @@ -14,12 +14,12 @@
- {{ $strings.ButtonAddApiKey }} + {{ $strings.ButtonAddApiKey }} - +
@@ -33,10 +33,12 @@ export default { }, data() { return { + loadingUsers: false, selectedApiKey: null, showApiKeyModal: false, showApiKeyCreatedModal: false, - numApiKeys: 0 + numApiKeys: 0, + users: [] } }, methods: { @@ -45,7 +47,6 @@ export default { this.selectedApiKey = apiKey this.showApiKeyCreatedModal = true if (this.$refs.apiKeysTable) { - console.log('apiKeyCreated', apiKey) this.$refs.apiKeysTable.addApiKey(apiKey) } }, @@ -60,9 +61,27 @@ export default { setShowApiKeyModal(selectedApiKey) { this.selectedApiKey = selectedApiKey this.showApiKeyModal = true + }, + loadUsers() { + this.loadingUsers = true + this.$axios + .$get('/api/users') + .then((res) => { + this.users = res.users.sort((a, b) => { + return a.createdAt - b.createdAt + }) + }) + .catch((error) => { + console.error('Failed', error) + }) + .finally(() => { + this.loadingUsers = false + }) } }, - mounted() {}, + mounted() { + this.loadUsers() + }, beforeDestroy() {} } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 62443e0b..0d76f2e7 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -242,6 +242,8 @@ "LabelAlreadyInYourLibrary": "Already in your library", "LabelApiKeyCreated": "API Key \"{0}\" created successfully.", "LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.", + "LabelApiKeyUser": "Act on behalf of user", + "LabelApiKeyUserDescription": "This API key will have the same permissions as the user it is acting on behalf of. This will appear the same in logs as if the user was making the request.", "LabelApiToken": "API Token", "LabelAppend": "Append", "LabelAudioBitrate": "Audio Bitrate (e.g. 128k)", @@ -353,6 +355,7 @@ "LabelExample": "Example", "LabelExpandSeries": "Expand Series", "LabelExpandSubSeries": "Expand Sub Series", + "LabelExpired": "Expired", "LabelExpiresAt": "Expires At", "LabelExpiresInSeconds": "Expires in (seconds)", "LabelExpiresNever": "Never", @@ -418,7 +421,6 @@ "LabelLastSeen": "Last Seen", "LabelLastTime": "Last Time", "LabelLastUpdate": "Last Update", - "LabelLastUsed": "Last Used", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Single page", "LabelLayoutSplitPage": "Split page", @@ -556,6 +558,7 @@ "LabelSelectAll": "Select all", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUser": "Select user", "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", @@ -1046,6 +1049,7 @@ "ToastMustHaveAtLeastOnePath": "Must have at least one path", "ToastNameEmailRequired": "Name and email are required", "ToastNameRequired": "Name is required", + "ToastNewApiKeyUserError": "Must select a user", "ToastNewEpisodesFound": "{0} new episodes found", "ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"", "ToastNewUserCreatedSuccess": "New account created", diff --git a/server/Auth.js b/server/Auth.js index 1839d27f..b1d94d41 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -65,7 +65,9 @@ class Auth { new JwtStrategy( { jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), - secretOrKey: Database.serverSettings.tokenSecret + secretOrKey: Database.serverSettings.tokenSecret, + // Handle expiration manaully in order to disable api keys that are expired + ignoreExpiration: true }, this.jwtAuthCheck.bind(this) ) @@ -1044,6 +1046,7 @@ class Auth { } 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'] @@ -1057,22 +1060,49 @@ class Auth { } /** - * Checks if the user in the validated jwt_payload really exists and is active. + * 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) { - // load user by id from the jwt token - const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) + if (jwt_payload.type === 'api') { + const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId) - if (!user?.isActive) { - // deny login - done(null, null) - return + 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 + } + // approve login + done(null, user) } - // approve login - done(null, user) - return } /** diff --git a/server/Database.js b/server/Database.js index b632d040..213c2c61 100644 --- a/server/Database.js +++ b/server/Database.js @@ -670,6 +670,7 @@ class Database { * Remove playback sessions that are 3 seconds or less * Remove duplicate mediaProgresses * Remove expired auth sessions + * Deactivate expired api keys */ async cleanDatabase() { // Remove invalid Podcast records @@ -802,6 +803,23 @@ WHERE EXISTS ( // Remove expired Session records await this.cleanupExpiredSessions() + + // Deactivate expired api keys + await this.deactivateExpiredApiKeys() + } + + /** + * Deactivate expired api keys + */ + async deactivateExpiredApiKeys() { + try { + const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys() + if (affectedCount > 0) { + Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`) + } + } catch (error) { + Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`) + } } /** diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index 776ddcbe..0166479f 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -20,7 +20,19 @@ class ApiKeyController { * @param {Response} res */ async getAll(req, res) { - const apiKeys = await Database.apiKeyModel.findAll() + const apiKeys = await Database.apiKeyModel.findAll({ + include: [ + { + model: Database.userModel, + attributes: ['id', 'username', 'type'] + }, + { + model: Database.userModel, + as: 'createdByUser', + attributes: ['id', 'username', 'type'] + } + ] + }) return res.json({ apiKeys: apiKeys.map((a) => a.toJSON()) @@ -42,10 +54,21 @@ class ApiKeyController { Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`) return res.sendStatus(400) } + if (!req.body.userId || typeof req.body.userId !== 'string') { + Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`) + return res.sendStatus(400) + } + const user = await Database.userModel.getUserById(req.body.userId) + if (!user) { + Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`) + return res.sendStatus(400) + } + if (user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`) + return res.sendStatus(403) + } const keyId = uuidv4() // Generate key id ahead of time to use in JWT - - const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn) if (!apiKey) { @@ -60,9 +83,9 @@ class ApiKeyController { id: keyId, name: req.body.name, expiresAt, - permissions, - userId: req.user.id, - isActive: !!req.body.isActive + userId: req.body.userId, + isActive: !!req.body.isActive, + createdByUserId: req.user.id }) Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) @@ -76,34 +99,64 @@ class ApiKeyController { /** * PATCH: /api/api-keys/:id - * Only isActive and permissions can be updated because name and expiresIn are in the JWT + * Only isActive and userId can be updated because name and expiresIn are in the JWT * * @param {RequestWithUser} req * @param {Response} res */ async update(req, res) { - const apiKey = await Database.apiKeyModel.findByPk(req.params.id) + const apiKey = await Database.apiKeyModel.findByPk(req.params.id, { + include: { + model: Database.userModel + } + }) if (!apiKey) { return res.sendStatus(404) } + // Only root user can update root user API keys + if (apiKey.user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`) + return res.sendStatus(403) + } + + let hasUpdates = false + if (req.body.userId !== undefined) { + if (typeof req.body.userId !== 'string') { + Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`) + return res.sendStatus(400) + } + const user = await Database.userModel.getUserById(req.body.userId) + if (!user) { + Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`) + return res.sendStatus(400) + } + if (user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`) + return res.sendStatus(403) + } + if (apiKey.userId !== req.body.userId) { + apiKey.userId = req.body.userId + hasUpdates = true + } + } if (req.body.isActive !== undefined) { if (typeof req.body.isActive !== 'boolean') { return res.sendStatus(400) } - - apiKey.isActive = req.body.isActive + if (apiKey.isActive !== req.body.isActive) { + apiKey.isActive = req.body.isActive + hasUpdates = true + } } - if (req.body.permissions && Object.keys(req.body.permissions).length > 0) { - const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) - apiKey.permissions = permissions + if (hasUpdates) { + await apiKey.save() + Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) + } else { + Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`) } - await apiKey.save() - - Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) - return res.json({ apiKey: apiKey.toJSON() }) diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index adc14177..d3e65212 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -36,6 +36,7 @@ class CronManager { * Closes open share sessions that have not been updated in 24 hours * Closes open playback sessions that have not been updated in 36 hours * Cleans up expired auth sessions + * Deactivates expired api keys * TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner */ initOpenSessionCleanupCron() { @@ -44,6 +45,7 @@ class CronManager { ShareManager.closeStaleOpenShareSessions() await this.playbackSessionManager.closeStaleOpenSessions() await Database.cleanupExpiredSessions() + await Database.deactivateExpiredApiKeys() }) } diff --git a/server/migrations/v2.26.0-create-auth-tables.js b/server/migrations/v2.26.0-create-auth-tables.js index 2c86411e..a1480462 100644 --- a/server/migrations/v2.26.0-create-auth-tables.js +++ b/server/migrations/v2.26.0-create-auth-tables.js @@ -80,7 +80,11 @@ async function up({ context: { queryInterface, logger } }) { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING, + name: { + type: DataTypes.STRING, + allowNull: false + }, + description: DataTypes.TEXT, expiresAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE, isActive: { @@ -105,6 +109,17 @@ async function up({ context: { queryInterface, logger } }) { }, key: 'id' }, + onDelete: 'CASCADE' + }, + createdByUserId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users', + as: 'createdByUser' + }, + key: 'id' + }, onDelete: 'SET NULL' } }) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 54cc036a..7b61f731 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -1,5 +1,6 @@ const { DataTypes, Model, Op } = require('sequelize') const jwt = require('jsonwebtoken') +const { LRUCache } = require('lru-cache') const Logger = require('../Logger') /** @@ -17,6 +18,32 @@ const Logger = require('../Logger') * @property {string[]} itemTagsSelected */ +class ApiKeyCache { + constructor() { + this.cache = new LRUCache({ max: 100 }) + } + + getById(id) { + const apiKey = this.cache.get(id) + return apiKey + } + + set(apiKey) { + apiKey.fromCache = true + this.cache.set(apiKey.id, apiKey) + } + + delete(apiKeyId) { + this.cache.delete(apiKeyId) + } + + maybeInvalidate(apiKey) { + if (!apiKey.fromCache) this.delete(apiKey.id) + } +} + +const apiKeyCache = new ApiKeyCache() + class ApiKey extends Model { constructor(values, options) { super(values, options) @@ -25,13 +52,15 @@ class ApiKey extends Model { this.id /** @type {string} */ this.name + /** @type {string} */ + this.description /** @type {Date} */ this.expiresAt /** @type {Date} */ this.lastUsedAt /** @type {boolean} */ this.isActive - /** @type {Object} */ + /** @type {ApiKeyPermissions} */ this.permissions /** @type {Date} */ this.createdAt @@ -39,6 +68,8 @@ class ApiKey extends Model { this.updatedAt /** @type {UUIDV4} */ this.userId + /** @type {UUIDV4} */ + this.createdByUserId // Expanded properties @@ -104,18 +135,24 @@ class ApiKey extends Model { } /** - * Clean up expired api keys from the database - * @returns {Promise} Number of api keys deleted + * Deactivate expired api keys + * @returns {Promise} Number of api keys affected */ - static async cleanupExpiredApiKeys() { - const deletedCount = await ApiKey.destroy({ - where: { - expiresAt: { - [Op.lt]: new Date() + static async deactivateExpiredApiKeys() { + const [affectedCount] = await ApiKey.update( + { + isActive: false + }, + { + where: { + isActive: true, + expiresAt: { + [Op.lt]: new Date() + } } } - }) - return deletedCount + ) + return affectedCount } /** @@ -152,6 +189,24 @@ class ApiKey extends Model { }) } + /** + * Get an api key by id, from cache or database + * @param {string} apiKeyId + * @returns {Promise} + */ + static async getById(apiKeyId) { + if (!apiKeyId) return null + + const cachedApiKey = apiKeyCache.getById(apiKeyId) + if (cachedApiKey) return cachedApiKey + + const apiKey = await ApiKey.findByPk(apiKeyId) + if (!apiKey) return null + + apiKeyCache.set(apiKey) + return apiKey + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize @@ -164,7 +219,11 @@ class ApiKey extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING, + name: { + type: DataTypes.STRING, + allowNull: false + }, + description: DataTypes.TEXT, expiresAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE, isActive: { @@ -182,9 +241,30 @@ class ApiKey extends Model { const { user } = sequelize.models user.hasMany(ApiKey, { - onDelete: 'SET NULL' + onDelete: 'CASCADE' }) ApiKey.belongsTo(user) + + user.hasMany(ApiKey, { + foreignKey: 'createdByUserId', + onDelete: 'SET NULL' + }) + ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' }) + } + + async update(values, options) { + apiKeyCache.maybeInvalidate(this) + return await super.update(values, options) + } + + async save(options) { + apiKeyCache.maybeInvalidate(this) + return await super.save(options) + } + + async destroy(options) { + apiKeyCache.delete(this.id) + await super.destroy(options) } } From 8b995a179ddf52fedcd1275edbe0a091465485e7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 17:31:31 -0500 Subject: [PATCH 05/29] Add support for returning refresh token for mobile clients --- client/store/user.js | 5 +++- server/Auth.js | 36 +++++++++++++++++++++------- server/controllers/UserController.js | 3 +++ server/models/User.js | 5 ---- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/client/store/user.js b/client/store/user.js index 787d67db..e37568f1 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -152,8 +152,11 @@ export const mutations = { setUser(state, user) { state.user = user if (user) { + // Use accessToken from user if included in response (for login) if (user.accessToken) localStorage.setItem('token', user.accessToken) - else { + else if (localStorage.getItem('token')) { + user.accessToken = localStorage.getItem('token') + } else { console.error('No access token found for user', user) } } else { diff --git a/server/Auth.js b/server/Auth.js index b1d94d41..b811a5db 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -466,14 +466,29 @@ class Auth { // return the user login response json if the login was successfull const userResponse = await this.getUserLoginResponsePayload(req.user) - this.setRefreshTokenCookie(req, res, req.user.refreshToken) + // Check if mobile app wants refresh token in response + const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' + + userResponse.user.refreshToken = returnTokens ? req.user.refreshToken : null + userResponse.user.accessToken = req.user.accessToken + + if (!returnTokens) { + this.setRefreshTokenCookie(req, res, req.user.refreshToken) + } res.json(userResponse) }) // Refresh token route router.post('/auth/refresh', async (req, res) => { - const refreshToken = req.cookies.refresh_token + let refreshToken = req.cookies.refresh_token + + // For mobile clients, the refresh token is sent in the authorization header + let shouldReturnRefreshToken = false + if (!refreshToken && req.headers.authorization?.startsWith('Bearer ')) { + refreshToken = req.headers.authorization.split(' ')[1] + shouldReturnRefreshToken = true + } if (!refreshToken) { return res.status(401).json({ error: 'No refresh token provided' }) @@ -507,10 +522,12 @@ class Auth { return res.status(401).json({ error: 'User not found or inactive' }) } - const newAccessToken = await this.rotateTokensForSession(session, user, req, res) + const newTokens = await this.rotateTokensForSession(session, user, req, res) - user.accessToken = newAccessToken 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') { @@ -961,7 +978,7 @@ class Auth { * @param {import('./models/User')} user * @param {Request} req * @param {Response} res - * @returns {Promise} newAccessToken + * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ async rotateTokensForSession(session, user, req, res) { // Generate new tokens @@ -978,7 +995,10 @@ class Auth { // Set new refresh token cookie this.setRefreshTokenCookie(req, res, newRefreshToken) - return newAccessToken + return { + accessToken: newAccessToken, + refreshToken: newRefreshToken + } } /** @@ -996,7 +1016,7 @@ class Auth { // So rotate token for current session const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) if (currentSession) { - const newAccessToken = await this.rotateTokensForSession(currentSession, user, req, res) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ @@ -1008,7 +1028,7 @@ class Auth { } }) - return newAccessToken + return newTokens.accessToken } else { Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`) } diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 0a99b84e..48c98150 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -336,6 +336,9 @@ class UserController { const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res) if (newAccessToken) { user.accessToken = newAccessToken + // Refresh tokens are only returned for mobile clients + // Mobile apps currently do not use this API endpoint so always set to null + user.refreshToken = null Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`) } else { Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`) diff --git a/server/models/User.js b/server/models/User.js index 9b26b0ff..588b53bb 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -112,10 +112,6 @@ class User extends Model { this.updatedAt /** @type {import('./MediaProgress')[]?} - Only included when extended */ this.mediaProgresses - - // Temporary accessToken, not stored in database - /** @type {string} */ - this.accessToken } // Excludes "root" since their can only be 1 root user @@ -526,7 +522,6 @@ class User extends Model { type: this.type, // TODO: Old non-expiring token token: this.type === 'root' && hideRootToken ? '' : this.token, - accessToken: this.accessToken || null, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], From 44ff90a6f2aa354a2cc4c58bd4b7c7ac9d8efe81 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 1 Jul 2025 16:31:26 -0500 Subject: [PATCH 06/29] Update refresh endpoint to support override cookie token --- server/Auth.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index b811a5db..df2d2115 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -484,8 +484,9 @@ class Auth { let refreshToken = req.cookies.refresh_token // For mobile clients, the refresh token is sent in the authorization header + // Force return refresh token if x-return-tokens header is true let shouldReturnRefreshToken = false - if (!refreshToken && req.headers.authorization?.startsWith('Bearer ')) { + if (req.headers.authorization?.startsWith('Bearer ') && (!refreshToken || req.headers['x-return-tokens'] === 'true')) { refreshToken = req.headers.authorization.split(' ')[1] shouldReturnRefreshToken = true } From f127a7beb548e29969873027c6d4389dadb9a13a Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 3 Jul 2025 17:31:38 -0500 Subject: [PATCH 07/29] Update router for internal-api routes --- server/Server.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 22a53a3a..7bf3e048 100644 --- a/server/Server.js +++ b/server/Server.js @@ -309,12 +309,14 @@ class Server { }) ) router.use(express.urlencoded({ extended: true, limit: '5mb' })) - router.use(express.json({ limit: '10mb' })) router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/hls', this.hlsRouter.router) router.use('/public', this.publicRouter.router) + // Skip JSON parsing for internal-api routes + router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) + // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) @@ -404,6 +406,7 @@ class Server { const handle = nextApp.getRequestHandler() await nextApp.prepare() router.get('*', (req, res) => handle(req, res)) + router.post('/internal-api/*', (req, res) => handle(req, res)) } const unixSocketPrefix = 'unix/' From cdc37ddb0f71c74c4caf1f53dbb9cf891b3768a7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 4 Jul 2025 13:54:37 -0500 Subject: [PATCH 08/29] Use x-refresh-token for alt method of passing refresh token, check x-refresh-token for logout --- server/Auth.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index df2d2115..1b3ba601 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -483,11 +483,11 @@ class Auth { router.post('/auth/refresh', async (req, res) => { let refreshToken = req.cookies.refresh_token - // For mobile clients, the refresh token is sent in the authorization header - // Force return refresh token if x-return-tokens header is true + // If x-refresh-token header is present, use it instead of the cookie + // and return the refresh token in the response let shouldReturnRefreshToken = false - if (req.headers.authorization?.startsWith('Bearer ') && (!refreshToken || req.headers['x-return-tokens'] === 'true')) { - refreshToken = req.headers.authorization.split(' ')[1] + if (req.headers['x-refresh-token']) { + refreshToken = req.headers['x-refresh-token'] shouldReturnRefreshToken = true } @@ -495,6 +495,8 @@ class Auth { return res.status(401).json({ error: 'No refresh token provided' }) } + Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`) + try { // Verify the refresh token const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) @@ -820,7 +822,9 @@ class Auth { // Logout route router.post('/logout', async (req, res) => { - const refreshToken = req.cookies.refresh_token + // Refresh token be alternatively be sent in the header + const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token'] + // Clear refresh token cookie res.clearCookie('refresh_token', { path: '/' @@ -829,12 +833,15 @@ class Auth { // Invalidate the session in database using refresh token if (refreshToken) { try { + Logger.info(`[Auth] logout: Invalidating session for refresh token: ${refreshToken}`) await Database.sessionModel.destroy({ where: { refreshToken } }) } catch (error) { Logger.error(`[Auth] Error destroying session: ${error.message}`) } + } else { + Logger.info(`[Auth] logout: No refresh token on request`) } // TODO: invalidate possible JWTs From 8dbe1e4e5d2fac4ed1b20e4cc3dcd52abc14a168 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 4 Jul 2025 16:49:45 -0500 Subject: [PATCH 09/29] Fix express.json position --- server/Server.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Server.js b/server/Server.js index 7bf3e048..639ae210 100644 --- a/server/Server.js +++ b/server/Server.js @@ -310,13 +310,13 @@ class Server { ) router.use(express.urlencoded({ extended: true, limit: '5mb' })) + // Skip JSON parsing for internal-api routes + router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) + router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/hls', this.hlsRouter.router) router.use('/public', this.publicRouter.router) - // Skip JSON parsing for internal-api routes - router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) - // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) From e59babdf24cfa3283b9ea6fd6c8f0e22a76f15ec Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 17:46:18 -0500 Subject: [PATCH 10/29] Force re-login if using old token, show alert if admin user, add isOldToken flag to user --- client/pages/login.vue | 30 +++++++++++++++++++++++++++--- server/Auth.js | 14 ++++++++++++++ server/models/User.js | 3 +++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 3f48509f..71fa8c2b 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -40,6 +40,15 @@

{{ error }}

+
+ +
+

Authentication has been improved for security. All users will be required to re-login.

+ More info +
+
+
+
@@ -85,7 +94,8 @@ export default { MetadataPath: '', login_local: true, login_openid: false, - authFormData: null + authFormData: null, + showNewAuthSystemAdminMessage: false } }, watch: { @@ -184,6 +194,7 @@ export default { }, async submitForm() { this.error = null + this.showNewAuthSystemAdminMessage = false this.processing = true const payload = { @@ -217,15 +228,28 @@ export default { } }) .then((res) => { + // Force re-login if user is using an old token with no expiration + if (res.user.isOldToken) { + if (res.user.type === 'admin' || res.user.type === 'root') { + this.username = res.user.username + // Show message to admin users about new auth system + this.showNewAuthSystemAdminMessage = true + } else { + // Regular users just shown login + this.username = res.user.username + } + return false + } this.setUser(res) - this.processing = false return true }) .catch((error) => { console.error('Authorize error', error) - this.processing = false return false }) + .finally(() => { + this.processing = false + }) }, checkStatus() { this.processing = true diff --git a/server/Auth.js b/server/Auth.js index 1b3ba601..d2250f17 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -492,6 +492,7 @@ class Auth { } if (!refreshToken) { + Logger.error(`[Auth] Failed to refresh token. No refresh token provided`) return res.status(401).json({ error: 'No refresh token provided' }) } @@ -502,6 +503,7 @@ class Auth { const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) 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' }) } @@ -510,6 +512,7 @@ class Auth { }) 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' }) } @@ -522,6 +525,7 @@ class Auth { 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' }) } @@ -1128,6 +1132,16 @@ class Auth { 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) } diff --git a/server/models/User.js b/server/models/User.js index 588b53bb..154587a7 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -522,6 +522,9 @@ class User extends Model { type: this.type, // TODO: Old non-expiring token token: this.type === 'root' && hideRootToken ? '' : this.token, + // TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck + // Necessary to detect apps using old tokens that no longer match the old token stored on the user + isOldToken: this.isOldToken, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], From e201247d69566350739e0eefa5eca767e814cea1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 11:07:01 -0500 Subject: [PATCH 11/29] Handle socket re-authentication, fix socket toast to be re-usable, socket cleanup --- client/components/app/LazyBookshelf.vue | 6 ---- client/layouts/default.vue | 43 +++++++++++++++++++++++-- client/plugins/axios.js | 7 +++- server/Auth.js | 2 ++ server/SocketAuthority.js | 24 ++++++++++---- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 61331fb9..854b61b2 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -778,10 +778,6 @@ export default { windowResize() { this.executeRebuild() }, - socketInit() { - // Server settings are set on socket init - this.executeRebuild() - }, initListeners() { window.addEventListener('resize', this.windowResize) @@ -794,7 +790,6 @@ export default { }) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$on('socket_init', this.socketInit) this.$eventBus.$on('user-settings', this.settingsUpdated) if (this.$root.socket) { @@ -826,7 +821,6 @@ export default { } this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$off('socket_init', this.socketInit) this.$eventBus.$off('user-settings', this.settingsUpdated) if (this.$root.socket) { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 33e7aa15..9f15af67 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -33,6 +33,7 @@ export default { return { socket: null, isSocketConnected: false, + isSocketAuthenticated: false, isFirstSocketConnection: true, socketConnectionToastId: null, currentLang: null, @@ -81,9 +82,28 @@ export default { document.body.classList.add('app-bar') } }, + tokenRefreshed(newAccessToken) { + if (this.isSocketConnected && !this.isSocketAuthenticated) { + console.log('[SOCKET] Re-authenticating socket after token refresh') + this.socket.emit('auth', newAccessToken) + } + }, updateSocketConnectionToast(content, type, timeout) { if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) { - this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false) + const toastUpdateOptions = { + content: content, + options: { + timeout: timeout, + type: type, + closeButton: false, + position: 'bottom-center', + onClose: () => { + this.socketConnectionToastId = null + }, + closeOnClick: timeout !== null + } + } + this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false) } else { this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null }) } @@ -109,7 +129,7 @@ export default { this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null) }, reconnect() { - console.error('[SOCKET] reconnected') + console.log('[SOCKET] reconnected') }, reconnectAttempt(val) { console.log(`[SOCKET] reconnect attempt ${val}`) @@ -120,6 +140,10 @@ export default { reconnectFailed() { console.error('[SOCKET] reconnect failed') }, + authFailed(payload) { + console.error('[SOCKET] auth failed', payload.message) + this.isSocketAuthenticated = false + }, init(payload) { console.log('Init Payload', payload) @@ -127,7 +151,7 @@ export default { this.$store.commit('users/setUsersOnline', payload.usersOnline) } - this.$eventBus.$emit('socket_init') + this.isSocketAuthenticated = true }, streamOpen(stream) { if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream) @@ -354,6 +378,15 @@ export default { this.$store.commit('scanners/removeCustomMetadataProvider', provider) }, initializeSocket() { + if (this.$root.socket) { + // Can happen in dev due to hot reload + console.warn('Socket already initialized') + this.socket = this.$root.socket + this.isSocketConnected = this.$root.socket?.connected + this.isFirstSocketConnection = false + this.socketConnectionToastId = null + return + } this.socket = this.$nuxtSocket({ name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', persist: 'main', @@ -364,6 +397,7 @@ export default { path: `${this.$config.routerBasePath}/socket.io` }) this.$root.socket = this.socket + this.isSocketAuthenticated = false console.log('Socket initialized') // Pre-defined socket events @@ -377,6 +411,7 @@ export default { // Event received after authorizing socket this.socket.on('init', this.init) + this.socket.on('auth_failed', this.authFailed) // Stream Listeners this.socket.on('stream_open', this.streamOpen) @@ -571,6 +606,7 @@ export default { this.updateBodyClass() this.resize() this.$eventBus.$on('change-lang', this.changeLanguage) + this.$eventBus.$on('token_refreshed', this.tokenRefreshed) window.addEventListener('resize', this.resize) window.addEventListener('keydown', this.keyDown) @@ -594,6 +630,7 @@ export default { }, beforeDestroy() { this.$eventBus.$off('change-lang', this.changeLanguage) + this.$eventBus.$off('token_refreshed', this.tokenRefreshed) window.removeEventListener('resize', this.resize) window.removeEventListener('keydown', this.keyDown) } diff --git a/client/plugins/axios.js b/client/plugins/axios.js index c95067d1..2724acb3 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -1,4 +1,4 @@ -export default function ({ $axios, store, $config, app }) { +export default function ({ $axios, store, $root, app }) { // Track if we're currently refreshing to prevent multiple refresh attempts let isRefreshing = false let failedQueue = [] @@ -82,6 +82,11 @@ export default function ({ $axios, store, $config, app }) { // Update the token in store and localStorage store.commit('user/setUser', response.user) + // Emit event used to re-authenticate socket in default.vue since $root is not available here + if (app.$eventBus) { + app.$eventBus.$emit('token_refreshed', newAccessToken) + } + // Update the original request with new token if (!originalRequest.headers) { originalRequest.headers = {} diff --git a/server/Auth.js b/server/Auth.js index d2250f17..b2fcebf9 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1054,6 +1054,8 @@ class Auth { /** * 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 diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 050e7e2f..68b647ff 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -231,6 +231,9 @@ class SocketAuthority { * When setting up a socket connection the user needs to be associated with a socket id * for this the client will send a 'auth' event that includes the users API token * + * Sends event 'init' to the socket. For admins this contains an array of users online. + * For failed authentication it sends event 'auth_failed' with a message + * * @param {SocketIO.Socket} socket * @param {string} token JWT */ @@ -242,7 +245,7 @@ class SocketAuthority { if (!token_data?.userId) { // Token invalid Logger.error('Cannot validate socket - invalid token') - return socket.emit('invalid_token') + return socket.emit('auth_failed', { message: 'Invalid token' }) } // get the user via the id from the decoded jwt. @@ -250,7 +253,11 @@ class SocketAuthority { if (!user) { // user not found Logger.error('Cannot validate socket - invalid token') - return socket.emit('invalid_token') + return socket.emit('auth_failed', { message: 'Invalid token' }) + } + if (!user.isActive) { + Logger.error('Cannot validate socket - user is not active') + return socket.emit('auth_failed', { message: 'Invalid user' }) } const client = this.clients[socket.id] @@ -260,13 +267,18 @@ class SocketAuthority { } if (client.user !== undefined) { - Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username) + if (client.user.id === user.id) { + // Allow re-authentication of a socket to the same user + Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`) + } else { + // Allow re-authentication of a socket to a different user but shouldn't happen + Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`) + } + } else { + Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`) } client.user = user - - Logger.debug(`[SocketAuthority] User Online ${client.user.username}`) - this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) // Update user lastSeen without firing sequelize bulk update hooks From e24eaab3f187eaa8cac3b9bc73d710d1303154f3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 13:10:14 -0500 Subject: [PATCH 12/29] Log when token expiry is set via env var, api-keys create/update returns with user association --- client/components/tables/ApiKeysTable.vue | 2 +- client/pages/config/api-keys/index.vue | 5 +---- server/Auth.js | 6 ++++++ server/controllers/ApiKeyController.js | 6 ++++++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue index 037000b5..feab4e68 100644 --- a/client/components/tables/ApiKeysTable.vue +++ b/client/components/tables/ApiKeysTable.vue @@ -93,7 +93,7 @@ export default { this.$toast.error(data.error) } else { this.removeApiKey(apiKey.id) - this.$emit('deleted', apiKey.id) + this.$emit('numApiKeys', this.apiKeys.length) } }) .catch((error) => { diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue index edc4d59f..2523feed 100644 --- a/client/pages/config/api-keys/index.vue +++ b/client/pages/config/api-keys/index.vue @@ -19,7 +19,7 @@ - + @@ -50,9 +50,6 @@ export default { this.$refs.apiKeysTable.addApiKey(apiKey) } }, - apiKeyDeleted() { - this.numApiKeys-- - }, apiKeyUpdated(apiKey) { if (this.$refs.apiKeysTable) { this.$refs.apiKeysTable.updateApiKey(apiKey) diff --git a/server/Auth.js b/server/Auth.js index b2fcebf9..c445b45e 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -25,6 +25,12 @@ class Auth { 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(`[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`) + } } /** diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index 0166479f..f60480df 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -87,6 +87,9 @@ class ApiKeyController { isActive: !!req.body.isActive, createdByUserId: req.user.id }) + apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({ + attributes: ['id', 'username', 'type'] + }) Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) return res.json({ @@ -152,6 +155,9 @@ class ApiKeyController { if (hasUpdates) { await apiKey.save() + apiKey.dataValues.user = await apiKey.getUser({ + attributes: ['id', 'username', 'type'] + }) Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) } else { Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`) From 97afd22f81d58c3a9c086404bbdd7b8eb3328c1e Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 16:43:03 -0500 Subject: [PATCH 13/29] Refactor Auth to breakout functions in TokenManager, handle token generation for OIDC --- client/pages/login.vue | 1 + server/Auth.js | 593 ++++++--------------------- server/SocketAuthority.js | 5 +- server/auth/TokenManager.js | 379 +++++++++++++++++ server/controllers/UserController.js | 4 +- server/models/User.js | 94 ++++- 6 files changed, 603 insertions(+), 473 deletions(-) create mode 100644 server/auth/TokenManager.js diff --git a/client/pages/login.vue b/client/pages/login.vue index 71fa8c2b..5d447ed9 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -304,6 +304,7 @@ export default { } }, async mounted() { + // Token passed as query parameter after successful oidc login if (this.$route.query?.setToken) { localStorage.setItem('token', this.$route.query.setToken) } diff --git a/server/Auth.js b/server/Auth.js index c445b45e..a4c52781 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,16 +1,17 @@ +const { Request, Response, NextFunction } = require('express') const axios = require('axios') const passport = require('passport') -const { Op } = require('sequelize') -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 OpenIDClient = require('openid-client') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt -const OpenIDClient = require('openid-client') + const Database = require('./Database') const Logger = require('./Logger') +const TokenManager = require('./auth/TokenManager') + +const bcrypt = require('./libs/bcryptjs') +const requestIp = require('./libs/requestIp') +const LocalStrategy = require('./libs/passportLocal') const { escapeRegExp } = require('./utils') /** @@ -23,26 +24,23 @@ class Auth { const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) 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.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`) - } + this.tokenManager = new TokenManager() } /** * Checks if the request should not be authenticated. * @param {Request} req * @returns {boolean} - * @private */ authNotNeeded(req) { return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path)) } + /** + * Middleware to register passport in express-session + * + * @param {function} middleware + */ ifAuthNeeded(middleware) { return (req, res, next) => { if (this.authNotNeeded(req)) { @@ -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} accessToken only if user is current user and refresh token is valid + */ + async invalidateJwtSessionsForUser(user, req, res) { + return this.tokenManager.invalidateJwtSessionsForUser(user, req, res) + } + + /** + * Return the login info payload for a user + * + * @param {import('./models/User')} user + * @returns {Promise} jsonPayload + */ + async getUserLoginResponsePayload(user) { + const libraryIds = await Database.libraryModel.getAllLibraryIds() + return { + user: user.toOldJSONForBrowser(), + userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), + serverSettings: Database.serverSettings.toJSONForBrowser(), + ereaderDevices: Database.emailSettings.getEReaderDevices(user), + Source: global.Source + } + } + + // #region Passport strategies /** * Inializes all passportjs strategies and other passportjs ralated initialization. */ @@ -75,7 +134,7 @@ class Auth { // Handle expiration manaully in order to disable api keys that are expired 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`) } - let user = await this.findOrCreateUser(userinfo) + let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) if (!user?.isActive) { throw new Error('User not active or not found') @@ -183,94 +242,7 @@ class Auth { ) ) } - - /** - * Finds an existing user by OpenID subject identifier, or by email/username based on server settings, - * or creates a new user if configured to do so. - * - * @returns {Promise} - */ - async findOrCreateUser(userinfo) { - let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) - - // Matched by sub - if (user) { - Logger.debug(`[Auth] openid: User found by sub`) - return user - } - - // Match existing user by email - if (Database.serverSettings.authOpenIDMatchExistingBy === 'email') { - if (userinfo.email) { - // Only disallow when email_verified explicitly set to false (allow both if not set or true) - if (userinfo.email_verified === false) { - Logger.warn(`[Auth] openid: User not found and email "${userinfo.email}" is not verified`) - return null - } else { - Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) - user = await Database.userModel.getUserByEmail(userinfo.email) - - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) - return null // User is linked to a different OpenID subject; do not proceed. - } - } - } else { - Logger.warn(`[Auth] openid: User not found and no email in userinfo`) - // We deny login, because if the admin whishes to match email, it makes sense to require it - return null - } - } - // Match existing user by username - else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username') { - let username - - if (userinfo.preferred_username) { - Logger.info(`[Auth] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`) - username = userinfo.preferred_username - } else if (userinfo.username) { - Logger.info(`[Auth] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`) - username = userinfo.username - } else { - Logger.warn(`[Auth] openid: User not found and neither preferred_username nor username in userinfo`) - return null - } - - user = await Database.userModel.getUserByUsername(username) - - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`) - return null // User is linked to a different OpenID subject; do not proceed. - } - } - - // Found existing user via email or username - if (user) { - if (!user.isActive) { - Logger.warn(`[Auth] openid: User found but is not active`) - return null - } - - // Update user with OpenID sub - if (!user.extraData) user.extraData = {} - user.extraData.authOpenIDSub = userinfo.sub - user.changed('extraData', true) - await user.save() - - Logger.debug(`[Auth] openid: User found by email/username`) - return user - } - - // If no existing user was matched, auto-register if configured - if (Database.serverSettings.authOpenIDAutoRegister) { - Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) - user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) - return user - } - - Logger.warn(`[Auth] openid: User not found and auto-register is disabled`) - return null - } + // #endregion /** * 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 }) } - /** - * 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 * (clients choise is restored from cookies). @@ -442,25 +398,56 @@ class Auth { * @param {Response} res */ async handleLoginSuccessBasedOnCookie(req, res) { - // get userLogin json (information about the user, server and the session) - const data_json = await this.getUserLoginResponsePayload(req.user) + // Handle token generation and get userResponse object + // TODO: where to check if refresh tokens should be returned? + const userResponse = await this.handleLoginSuccess(req, res, false) if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { // REST request - send data - res.json(data_json) + res.json(userResponse) } else { // UI request -> check if we have a callback url // TODO: do we want to somehow limit the values for auth_cb? if (req.cookies.auth_cb) { let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter - res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`) + res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.accessToken}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } } } + /** + * After login success from local or oidc + * req.user is set by passport.authenticate + * + * attaches the access token to the user in the response + * if returnTokens is true, also attaches the refresh token to the user in the response + * + * if returnTokens is false, sets the refresh token cookie + * + * @param {Request} req + * @param {Response} res + * @param {boolean} returnTokens + */ + async handleLoginSuccess(req, res, returnTokens = false) { + // Create tokens and session + const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(req.user, req) + + const userResponse = await this.getUserLoginResponsePayload(req.user) + + userResponse.user.refreshToken = returnTokens ? refreshToken : null + userResponse.user.accessToken = accessToken + + if (!returnTokens) { + this.tokenManager.setRefreshTokenCookie(req, res, refreshToken) + } + + return userResponse + } + + // #region Auth routes /** * Creates all (express) routes required for authentication. * @@ -469,19 +456,10 @@ class Auth { async initAuthRoutes(router) { // Local strategy login route (takes username and password) router.post('/login', passport.authenticate('local'), async (req, res) => { - // return the user login response json if the login was successfull - const userResponse = await this.getUserLoginResponsePayload(req.user) - // Check if mobile app wants refresh token in response const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' - userResponse.user.refreshToken = returnTokens ? req.user.refreshToken : null - userResponse.user.accessToken = req.user.accessToken - - if (!returnTokens) { - this.setRefreshTokenCookie(req, res, req.user.refreshToken) - } - + const userResponse = await this.handleLoginSuccess(req, res, returnTokens) res.json(userResponse) }) @@ -504,67 +482,16 @@ class Auth { Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`) - try { - // Verify the refresh token - const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) - - 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 refreshResponse = await this.tokenManager.handleRefreshToken(refreshToken, req, res) + if (refreshResponse.error) { + return res.status(401).json({ error: refreshResponse.error }) } + + const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user) + + userResponse.user.accessToken = refreshResponse.accessToken + userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null + res.json(userResponse) }) // openid strategy login route (this redirects to the configured openid login provider) @@ -906,255 +833,9 @@ class Auth { }) }) } + // #endregion - /** - * 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} - */ - 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} - */ - 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} 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) - } - } - + // #region Local Auth /** * Checks if a username and password tuple is valid and the user active. * @param {Request} req @@ -1187,9 +868,6 @@ class Auth { // approve login 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) return } else if (!user.pash) { @@ -1204,9 +882,6 @@ class Auth { // approve login 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) return } @@ -1244,23 +919,6 @@ class Auth { }) } - /** - * Return the login info payload for a user - * - * @param {import('./models/User')} user - * @returns {Promise} jsonPayload - */ - async getUserLoginResponsePayload(user) { - const libraryIds = await Database.libraryModel.getAllLibraryIds() - return { - user: user.toOldJSONForBrowser(), - userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), - serverSettings: Database.serverSettings.toJSONForBrowser(), - ereaderDevices: Database.emailSettings.getEReaderDevices(user), - Source: global.Source - } - } - /** * * @param {string} password @@ -1322,6 +980,7 @@ class Auth { }) } } + // #endregion } module.exports = Auth diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 68b647ff..da31ba4a 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -1,7 +1,7 @@ const SocketIO = require('socket.io') const Logger = require('./Logger') const Database = require('./Database') -const Auth = require('./Auth') +const TokenManager = require('./auth/TokenManager') /** * @typedef SocketClient @@ -240,7 +240,8 @@ class SocketAuthority { async authenticateSocket(socket, token) { // we don't use passport to authenticate the jwt we get over the socket connection. // 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) { // Token invalid diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js new file mode 100644 index 00000000..cc4783b5 --- /dev/null +++ b/server/auth/TokenManager.js @@ -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} 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 diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 48c98150..2ed92616 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -128,7 +128,7 @@ class UserController { const userId = uuidv4() 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' // librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions @@ -327,7 +327,7 @@ class UserController { if (hasUpdates) { 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`) } diff --git a/server/models/User.js b/server/models/User.js index 154587a7..3f06b238 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -190,7 +190,7 @@ class User extends Model { static async createRootUser(username, pash, auth) { const userId = uuidv4() - const token = await auth.generateAccessToken({ id: userId, username }) + const token = auth.generateAccessToken({ id: userId, username }) const newUser = { id: userId, @@ -208,6 +208,96 @@ class User extends Model { 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} + */ + 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 * @param {Object} userinfo @@ -220,7 +310,7 @@ class User extends Model { const username = userinfo.preferred_username || userinfo.name || userinfo.sub 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 = { id: userId, From d9cfcc86e77971966c556ac19be90cca5c7b3741 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 09:16:07 -0500 Subject: [PATCH 14/29] Update oidc to return refresh token in response body for mobile --- server/Auth.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index a4c52781..6b2f2bd8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -399,10 +399,11 @@ class Auth { */ async handleLoginSuccessBasedOnCookie(req, res) { // Handle token generation and get userResponse object - // TODO: where to check if refresh tokens should be returned? - const userResponse = await this.handleLoginSuccess(req, res, false) + // For API based auth (e.g. mobile), we will return the refresh token in the response + const isApiBased = this.isAuthMethodAPIBased(req.cookies.auth_method) + const userResponse = await this.handleLoginSuccess(req, res, isApiBased) - if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { + if (isApiBased) { // REST request - send data res.json(userResponse) } else { From 9c8900560c8261ce885f138e3b2de5c8f77a0687 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 15:04:40 -0500 Subject: [PATCH 15/29] Seperate out auth strategies, update change password to return error status codes --- client/pages/account.vue | 21 +- server/Auth.js | 539 ++--------------------------- server/auth/LocalAuthStrategy.js | 186 ++++++++++ server/auth/OidcAuthStrategy.js | 488 ++++++++++++++++++++++++++ server/controllers/MeController.js | 18 +- 5 files changed, 729 insertions(+), 523 deletions(-) create mode 100644 server/auth/LocalAuthStrategy.js create mode 100644 server/auth/OidcAuthStrategy.js diff --git a/client/pages/account.vue b/client/pages/account.vue index b157f570..e9b5da3c 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -182,18 +182,19 @@ export default { password: this.password, newPassword: this.newPassword }) - .then((res) => { - if (res.success) { - this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) - this.resetForm() - } else { - this.$toast.error(res.error || this.$strings.ToastUnknownError) - } - this.changingPassword = false + .then(() => { + this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) + this.resetForm() }) .catch((error) => { - console.error(error) - this.$toast.error(this.$strings.ToastUnknownError) + console.error('Failed to change password', error) + let errorMessage = this.$strings.ToastUnknownError + if (error.response?.data && typeof error.response.data === 'string') { + errorMessage = error.response.data + } + this.$toast.error(errorMessage) + }) + .finally(() => { this.changingPassword = false }) }, diff --git a/server/Auth.js b/server/Auth.js index 6b2f2bd8..e62df0b8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,17 +1,14 @@ const { Request, Response, NextFunction } = require('express') -const axios = require('axios') const passport = require('passport') -const OpenIDClient = require('openid-client') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const Database = require('./Database') const Logger = require('./Logger') const TokenManager = require('./auth/TokenManager') +const LocalAuthStrategy = require('./auth/LocalAuthStrategy') +const OidcAuthStrategy = require('./auth/OidcAuthStrategy') -const bcrypt = require('./libs/bcryptjs') -const requestIp = require('./libs/requestIp') -const LocalStrategy = require('./libs/passportLocal') const { escapeRegExp } = require('./utils') /** @@ -19,12 +16,12 @@ const { escapeRegExp } = require('./utils') */ class Auth { constructor() { - // Map of openId sessions indexed by oauth2 state-variable - this.openIdAuthSession = new Map() const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] this.tokenManager = new TokenManager() + this.localAuthStrategy = new LocalAuthStrategy() + this.oidcAuthStrategy = new OidcAuthStrategy() } /** @@ -117,12 +114,12 @@ class Auth { async initPassportJs() { // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes('local')) { - this.initAuthStrategyPassword() + this.localAuthStrategy.init() } // Check if we should load the openid strategy if (global.ServerSettings.authActiveAuthMethods.includes('openid')) { - this.initAuthStrategyOpenID() + this.oidcAuthStrategy.init() } // Load the JwtStrategy (always) -> for bearer token auth @@ -165,169 +162,21 @@ class Auth { }.bind(this) ) } - - /** - * Passport use LocalStrategy - */ - initAuthStrategyPassword() { - passport.use(new LocalStrategy({ passReqToCallback: true }, this.localAuthCheckUserPw.bind(this))) - } - - /** - * Passport use OpenIDClient.Strategy - */ - initAuthStrategyOpenID() { - if (!Database.serverSettings.isOpenIDAuthSettingsValid) { - Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`) - return - } - - // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing - OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) - - const openIdIssuerClient = new OpenIDClient.Issuer({ - issuer: global.ServerSettings.authOpenIDIssuerURL, - authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, - token_endpoint: global.ServerSettings.authOpenIDTokenURL, - userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, - jwks_uri: global.ServerSettings.authOpenIDJwksURL, - end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL - }).Client - const openIdClient = new openIdIssuerClient({ - client_id: global.ServerSettings.authOpenIDClientID, - client_secret: global.ServerSettings.authOpenIDClientSecret, - id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm - }) - passport.use( - 'openid-client', - new OpenIDClient.Strategy( - { - client: openIdClient, - params: { - redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, - scope: 'openid profile email' - } - }, - async (tokenset, userinfo, done) => { - try { - Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) - - if (!userinfo.sub) { - throw new Error('Invalid userinfo, no sub') - } - - if (!this.validateGroupClaim(userinfo)) { - throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) - } - - let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) - - if (!user?.isActive) { - throw new Error('User not active or not found') - } - - await this.setUserGroup(user, userinfo) - await this.updateUserPermissions(user, userinfo) - - // We also have to save the id_token for later (used for logout) because we cannot set cookies here - user.openid_id_token = tokenset.id_token - - return done(null, user) - } catch (error) { - Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`) - - return done(null, null, 'Unauthorized') - } - } - ) - ) - } // #endregion - /** - * Validates the presence and content of the group claim in userinfo. - */ - validateGroupClaim(userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) - // Allow no group claim when configured like this - return true - - // If configured it must exist in userinfo - if (!userinfo[groupClaimName]) { - return false - } - return true - } - - /** - * Sets the user group based on group claim in userinfo. - * - * @param {import('./models/User')} user - * @param {Object} userinfo - */ - async setUserGroup(user, userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) - // No group claim configured, don't set anything - return - - if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) - - const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) - const rolesInOrderOfPriority = ['admin', 'user', 'guest'] - - let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) - if (userType) { - if (user.type === 'root') { - // Check OpenID Group - if (userType !== 'admin') { - throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) - } else { - // If root user is logging in via OpenID, we will not change the type - return - } - } - - if (user.type !== userType) { - Logger.info(`[Auth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) - user.type = userType - await user.save() - } - } else { - throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) - } - } - - /** - * Updates user permissions based on the advanced permissions claim. - * - * @param {import('./models/User')} user - * @param {Object} userinfo - */ - async updateUserPermissions(user, userinfo) { - const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim - if (!absPermissionsClaim) - // No advanced permissions claim configured, don't set anything - return - - if (user.type === 'admin' || user.type === 'root') return - - const absPermissions = userinfo[absPermissionsClaim] - if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) - - if (await user.updatePermissionsFromExternalJSON(absPermissions)) { - Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) - } - } - /** * Unuse strategy * * @param {string} name */ unuseAuthStrategy(name) { - passport.unuse(name) + if (name === 'openid') { + this.oidcAuthStrategy.unuse() + } else if (name === 'local') { + this.localAuthStrategy.unuse() + } else { + Logger.error('[Auth] Invalid auth strategy ' + name) + } } /** @@ -337,9 +186,9 @@ class Auth { */ useAuthStrategy(name) { if (name === 'openid') { - this.initAuthStrategyOpenID() + this.oidcAuthStrategy.init() } else if (name === 'local') { - this.initAuthStrategyPassword() + this.localAuthStrategy.init() } else { Logger.error('[Auth] Invalid auth strategy ' + name) } @@ -496,153 +345,27 @@ class Auth { }) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', (req, res, next) => { - // Get the OIDC client from the strategy - // We need to call the client manually, because the strategy does not support forwarding the code challenge - // for API or mobile clients - const oidcStrategy = passport._strategy('openid-client') - const client = oidcStrategy._client - const sessionKey = oidcStrategy._key + router.get('/auth/openid', (req, res) => { + const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) - try { - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const hostUrl = new URL(`${protocol}://${req.get('host')}`) - const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge - - // Only allow code flow (for mobile clients) - if (req.query.response_type && req.query.response_type !== 'code') { - Logger.debug(`[Auth] OIDC Invalid response_type=${req.query.response_type}`) - return res.status(400).send('Invalid response_type, only code supported') - } - - // Generate a state on web flow or if no state supplied - const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state - - // Redirect URL for the SSO provider - let redirectUri - if (isMobileFlow) { - // Mobile required redirect uri - // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect - // where we will handle the redirect to it - if (!req.query.redirect_uri || !isValidRedirectUri(req.query.redirect_uri)) { - Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri}`) - return res.status(400).send('Invalid redirect_uri') - } - // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API - // for the request to mobile-redirect and as such the session is not shared - this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) - - redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() - } else { - redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() - - if (req.query.state) { - Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) - return res.status(400).send('Invalid state, not allowed on web flow') - } - } - oidcStrategy._params.redirect_uri = redirectUri - Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`) - - let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow) - - req.session[sessionKey] = { - ...req.session[sessionKey], - state: state, - max_age: oidcStrategy._params.max_age, - response_type: 'code', - code_verifier: code_verifier, // not null if web flow - mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out - sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback - } - - var scope = 'openid profile email' - if (global.ServerSettings.authOpenIDGroupClaim) { - scope += ' ' + global.ServerSettings.authOpenIDGroupClaim - } - if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { - scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim - } - - const authorizationUrl = client.authorizationUrl({ - ...oidcStrategy._params, - state: state, - response_type: 'code', - scope: scope, - code_challenge, - code_challenge_method - }) - - this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid') - - res.redirect(authorizationUrl) - } catch (error) { - Logger.error(`[Auth] Error in /auth/openid route: ${error}\n${error?.stack}`) - res.status(500).send('Internal Server Error') + if (authorizationUrlResponse.error) { + return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error) } - function generatePkce(req, isMobileFlow) { - if (isMobileFlow) { - if (!req.query.code_challenge) { - throw new Error('code_challenge required for mobile flow (PKCE)') - } - if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { - throw new Error('Only S256 code_challenge_method method supported') - } - return { - code_challenge: req.query.code_challenge, - code_challenge_method: req.query.code_challenge_method || 'S256' - } - } else { - const code_verifier = OpenIDClient.generators.codeVerifier() - const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) - return { code_challenge, code_challenge_method: 'S256', code_verifier } - } - } + this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid') - function isValidRedirectUri(uri) { - // Check if the redirect_uri is in the whitelist - return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') - } + res.redirect(authorizationUrlResponse.authorizationUrl) }) // This will be the oauth2 callback route for mobile clients // It will redirect to an app-link like audiobookshelf://oauth - router.get('/auth/openid/mobile-redirect', (req, res) => { - try { - // Extract the state parameter from the request - const { state, code } = req.query - - // Check if the state provided is in our list - if (!state || !this.openIdAuthSession.has(state)) { - Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch') - return res.status(400).send('State parameter mismatch') - } - - let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri - - if (!mobile_redirect_uri) { - Logger.error('[Auth] No redirect URI') - return res.status(400).send('No redirect URI') - } - - this.openIdAuthSession.delete(state) - - const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` - // Redirect to the overwrite URI saved in the map - res.redirect(redirectUri) - } catch (error) { - Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) - res.status(500).send('Internal Server Error') - } - }) + router.get('/auth/openid/mobile-redirect', (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) // openid strategy callback route (this receives the token from the configured openid login provider) router.get( '/auth/openid/callback', (req, res, next) => { - const oidcStrategy = passport._strategy('openid-client') - const sessionKey = oidcStrategy._key + const sessionKey = this.oidcAuthStrategy.getStrategy()._key if (!req.session[sessionKey]) { return res.status(400).send('No session') @@ -719,43 +442,16 @@ class Auth { return res.sendStatus(403) } - if (!req.query.issuer) { + if (!req.query.issuer || typeof req.query.issuer !== 'string') { return res.status(400).send("Invalid request. Query param 'issuer' is required") } - // Strip trailing slash - let issuerUrl = req.query.issuer - if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) - - // Append config pathname and validate URL - let configUrl = null - try { - configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) - if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { - throw new Error('Invalid pathname') - } - } catch (error) { - Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) - return res.status(400).send("Invalid request. Query param 'issuer' is invalid") + const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer) + if (openIdIssuerConfig.error) { + return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error) } - axios - .get(configUrl.toString()) - .then(({ data }) => { - res.json({ - issuer: data.issuer, - authorization_endpoint: data.authorization_endpoint, - token_endpoint: data.token_endpoint, - userinfo_endpoint: data.userinfo_endpoint, - end_session_endpoint: data.end_session_endpoint, - jwks_uri: data.jwks_uri, - id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported - }) - }) - .catch((error) => { - Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error) - res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`) - }) + res.json(openIdIssuerConfig) }) // Logout route @@ -782,7 +478,6 @@ class Auth { Logger.info(`[Auth] logout: No refresh token on request`) } - // TODO: invalidate possible JWTs req.logout((err) => { if (err) { res.sendStatus(500) @@ -794,36 +489,7 @@ class Auth { let logoutUrl = null if (authMethod === 'openid' || authMethod === 'openid-mobile') { - // If we are using openid, we need to redirect to the logout endpoint - // node-openid-client does not support doing it over passport - const oidcStrategy = passport._strategy('openid-client') - const client = oidcStrategy._client - - if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { - let postLogoutRedirectUri = null - - if (authMethod === 'openid') { - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const host = req.get('host') - // TODO: ABS does currently not support subfolders for installation - // If we want to support it we need to include a config for the serverurl - postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` - } - // else for openid-mobile we keep postLogoutRedirectUri on null - // nice would be to redirect to the app here, but for example Authentik does not implement - // the post_logout_redirect_uri parameter at all and for other providers - // we would also need again to implement (and even before get to know somehow for 3rd party apps) - // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). - // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like - // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution - // (The URL needs to be whitelisted in the config of the SSO/ID provider) - - logoutUrl = client.endSessionUrl({ - id_token_hint: req.cookies.openid_id_token, - post_logout_redirect_uri: postLogoutRedirectUri - }) - } - + logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod) res.clearCookie('openid_id_token') } @@ -835,153 +501,6 @@ class Auth { }) } // #endregion - - // #region Local Auth - /** - * Checks if a username and password tuple is valid and the user active. - * @param {Request} req - * @param {string} username - * @param {string} password - * @param {Promise} done - */ - async localAuthCheckUserPw(req, username, password, done) { - // Load the user given it's username - const user = await Database.userModel.getUserByUsername(username.toLowerCase()) - - if (!user?.isActive) { - if (user) { - this.logFailedLocalAuthLoginAttempt(req, user.username, 'User is not active') - } else { - this.logFailedLocalAuthLoginAttempt(req, username, 'User not found') - } - done(null, null) - return - } - - // Check passwordless root user - if (user.type === 'root' && !user.pash) { - if (password) { - // deny login - this.logFailedLocalAuthLoginAttempt(req, user.username, 'Root user has no password set') - done(null, null) - return - } - // approve login - Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - - done(null, user) - return - } else if (!user.pash) { - this.logFailedLocalAuthLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID') - done(null, null) - return - } - - // Check password match - const compare = await bcrypt.compare(password, user.pash) - if (compare) { - // approve login - Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - - done(null, user) - return - } - // deny login - this.logFailedLocalAuthLoginAttempt(req, user.username, 'Invalid password') - done(null, null) - return - } - - /** - * - * @param {Request} req - * @param {string} username - * @param {string} message - */ - logFailedLocalAuthLoginAttempt(req, username, message) { - if (!req || !username || !message) return - Logger.error(`[Auth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`) - } - - /** - * Hashes a password with bcrypt. - * @param {string} password - * @returns {Promise} hash - */ - hashPass(password) { - return new Promise((resolve) => { - bcrypt.hash(password, 8, (err, hash) => { - if (err) { - resolve(null) - } else { - resolve(hash) - } - }) - }) - } - - /** - * - * @param {string} password - * @param {import('./models/User')} user - * @returns {Promise} - */ - comparePassword(password, user) { - if (user.type === 'root' && !password && !user.pash) return true - if (!password || !user.pash) return false - return bcrypt.compare(password, user.pash) - } - - /** - * User changes their password from request - * TODO: Update responses to use error status codes - * - * @param {import('./controllers/MeController').RequestWithUser} req - * @param {Response} res - */ - async userChangePassword(req, res) { - let { password, newPassword } = req.body - newPassword = newPassword || '' - const matchingUser = req.user - - // Only root can have an empty password - if (matchingUser.type !== 'root' && !newPassword) { - return res.json({ - error: 'Invalid new password - Only root can have an empty password' - }) - } - - // Check password match - const compare = await this.comparePassword(password, matchingUser) - if (!compare) { - return res.json({ - error: 'Invalid password' - }) - } - - let pw = '' - if (newPassword) { - pw = await this.hashPass(newPassword) - if (!pw) { - return res.json({ - error: 'Hash failed' - }) - } - } - try { - await matchingUser.update({ pash: pw }) - Logger.info(`[Auth] User "${matchingUser.username}" changed password`) - res.json({ - success: true - }) - } catch (error) { - Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) - res.json({ - error: 'Unknown error' - }) - } - } - // #endregion } module.exports = Auth diff --git a/server/auth/LocalAuthStrategy.js b/server/auth/LocalAuthStrategy.js new file mode 100644 index 00000000..e9499d13 --- /dev/null +++ b/server/auth/LocalAuthStrategy.js @@ -0,0 +1,186 @@ +const passport = require('passport') +const LocalStrategy = require('../libs/passportLocal') +const Database = require('../Database') +const Logger = require('../Logger') + +const bcrypt = require('../libs/bcryptjs') +const requestIp = require('../libs/requestIp') + +/** + * Local authentication strategy using username/password + */ +class LocalAuthStrategy { + constructor() { + this.name = 'local' + this.strategy = null + } + + /** + * Get the passport strategy instance + * @returns {LocalStrategy} + */ + getStrategy() { + if (!this.strategy) { + this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this)) + } + return this.strategy + } + + /** + * Initialize the strategy with passport + */ + init() { + passport.use(this.name, this.getStrategy()) + } + + /** + * Remove the strategy from passport + */ + unuse() { + passport.unuse(this.name) + this.strategy = null + } + + /** + * Verify user credentials + * @param {import('express').Request} req + * @param {string} username + * @param {string} password + * @param {Function} done - Passport callback + */ + async verifyCredentials(req, username, password, done) { + // Load the user given it's username + const user = await Database.userModel.getUserByUsername(username.toLowerCase()) + + if (!user?.isActive) { + if (user) { + this.logFailedLoginAttempt(req, user.username, 'User is not active') + } else { + this.logFailedLoginAttempt(req, username, 'User not found') + } + done(null, null) + return + } + + // Check passwordless root user + if (user.type === 'root' && !user.pash) { + if (password) { + // deny login + this.logFailedLoginAttempt(req, user.username, 'Root user has no password set') + done(null, null) + return + } + // approve login + Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) + + done(null, user) + return + } else if (!user.pash) { + this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID') + done(null, null) + return + } + + // Check password match + const compare = await bcrypt.compare(password, user.pash) + if (compare) { + // approve login + Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) + + done(null, user) + return + } + + // deny login + this.logFailedLoginAttempt(req, user.username, 'Invalid password') + done(null, null) + } + + /** + * Log failed login attempts + * @param {import('express').Request} req + * @param {string} username + * @param {string} message + */ + logFailedLoginAttempt(req, username, message) { + if (!req || !username || !message) return + Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`) + } + + /** + * Hash a password with bcrypt + * @param {string} password + * @returns {Promise} hash + */ + hashPassword(password) { + return new Promise((resolve) => { + bcrypt.hash(password, 8, (err, hash) => { + if (err) { + resolve(null) + } else { + resolve(hash) + } + }) + }) + } + + /** + * Compare password with user's hashed password + * @param {string} password + * @param {import('../models/User')} user + * @returns {Promise} + */ + comparePassword(password, user) { + if (user.type === 'root' && !password && !user.pash) return true + if (!password || !user.pash) return false + return bcrypt.compare(password, user.pash) + } + + /** + * Change user password + * @param {import('../models/User')} user + * @param {string} password + * @param {string} newPassword + */ + async changePassword(user, password, newPassword) { + // Only root can have an empty password + if (user.type !== 'root' && !newPassword) { + return { + error: 'Invalid new password - Only root can have an empty password' + } + } + + // Check password match + const compare = await this.comparePassword(password, user) + if (!compare) { + return { + error: 'Invalid password' + } + } + + let pw = '' + if (newPassword) { + pw = await this.hashPassword(newPassword) + if (!pw) { + return { + error: 'Hash failed' + } + } + } + + try { + await user.update({ pash: pw }) + Logger.info(`[LocalAuth] User "${user.username}" changed password`) + return { + success: true + } + } catch (error) { + Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error) + return { + error: 'Unknown error' + } + } + } +} + +module.exports = LocalAuthStrategy diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js new file mode 100644 index 00000000..c3f6cfb2 --- /dev/null +++ b/server/auth/OidcAuthStrategy.js @@ -0,0 +1,488 @@ +const { Request, Response } = require('express') +const passport = require('passport') +const OpenIDClient = require('openid-client') +const axios = require('axios') +const Database = require('../Database') +const Logger = require('../Logger') + +/** + * OpenID Connect authentication strategy + */ +class OidcAuthStrategy { + constructor() { + this.name = 'openid-client' + this.strategy = null + this.client = null + // Map of openId sessions indexed by oauth2 state-variable + this.openIdAuthSession = new Map() + } + + /** + * Get the passport strategy instance + * @returns {OpenIDClient.Strategy} + */ + getStrategy() { + if (!this.strategy) { + this.strategy = new OpenIDClient.Strategy( + { + client: this.getClient(), + params: { + redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, + scope: this.getScope() + } + }, + this.verifyCallback.bind(this) + ) + } + return this.strategy + } + + /** + * Get the OpenID Connect client + * @returns {OpenIDClient.Client} + */ + getClient() { + if (!this.client) { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + throw new Error('OpenID Connect settings are not valid') + } + + // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing + OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) + + const openIdIssuerClient = new OpenIDClient.Issuer({ + issuer: global.ServerSettings.authOpenIDIssuerURL, + authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, + token_endpoint: global.ServerSettings.authOpenIDTokenURL, + userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, + jwks_uri: global.ServerSettings.authOpenIDJwksURL, + end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL + }).Client + + this.client = new openIdIssuerClient({ + client_id: global.ServerSettings.authOpenIDClientID, + client_secret: global.ServerSettings.authOpenIDClientSecret, + id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm + }) + } + return this.client + } + + /** + * Get the scope string for the OpenID Connect request + * @returns {string} + */ + getScope() { + let scope = 'openid profile email' + if (global.ServerSettings.authOpenIDGroupClaim) { + scope += ' ' + global.ServerSettings.authOpenIDGroupClaim + } + if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { + scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim + } + return scope + } + + /** + * Initialize the strategy with passport + */ + init() { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`) + return + } + passport.use(this.name, this.getStrategy()) + } + + /** + * Remove the strategy from passport + */ + unuse() { + passport.unuse(this.name) + this.strategy = null + this.client = null + } + + /** + * Verify callback for OpenID Connect authentication + * @param {Object} tokenset + * @param {Object} userinfo + * @param {Function} done - Passport callback + */ + async verifyCallback(tokenset, userinfo, done) { + try { + Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) + + if (!userinfo.sub) { + throw new Error('Invalid userinfo, no sub') + } + + if (!this.validateGroupClaim(userinfo)) { + throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) + } + + let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) + + if (!user?.isActive) { + throw new Error('User not active or not found') + } + + await this.setUserGroup(user, userinfo) + await this.updateUserPermissions(user, userinfo) + + // We also have to save the id_token for later (used for logout) because we cannot set cookies here + user.openid_id_token = tokenset.id_token + + return done(null, user) + } catch (error) { + Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`) + return done(null, null, 'Unauthorized') + } + } + + /** + * Validates the presence and content of the group claim in userinfo. + * @param {Object} userinfo + * @returns {boolean} + */ + validateGroupClaim(userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) + // Allow no group claim when configured like this + return true + + // If configured it must exist in userinfo + if (!userinfo[groupClaimName]) { + return false + } + return true + } + + /** + * Sets the user group based on group claim in userinfo. + * @param {import('../models/User')} user + * @param {Object} userinfo + */ + async setUserGroup(user, userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) + // No group claim configured, don't set anything + return + + if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) + + const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + + let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) + if (userType) { + if (user.type === 'root') { + // Check OpenID Group + if (userType !== 'admin') { + throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) + } else { + // If root user is logging in via OpenID, we will not change the type + return + } + } + + if (user.type !== userType) { + Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) + user.type = userType + await user.save() + } + } else { + throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) + } + } + + /** + * Updates user permissions based on the advanced permissions claim. + * @param {import('../models/User')} user + * @param {Object} userinfo + */ + async updateUserPermissions(user, userinfo) { + const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (!absPermissionsClaim) + // No advanced permissions claim configured, don't set anything + return + + if (user.type === 'admin' || user.type === 'root') return + + const absPermissions = userinfo[absPermissionsClaim] + if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) + + if (await user.updatePermissionsFromExternalJSON(absPermissions)) { + Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) + } + } + + /** + * Generate PKCE parameters for the authorization request + * @param {Request} req + * @param {boolean} isMobileFlow + * @returns {Object|{error: string}} + */ + generatePkce(req, isMobileFlow) { + if (isMobileFlow) { + if (!req.query.code_challenge) { + return { + error: 'code_challenge required for mobile flow (PKCE)' + } + } + if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { + return { + error: 'Only S256 code_challenge_method method supported' + } + } + return { + code_challenge: req.query.code_challenge, + code_challenge_method: req.query.code_challenge_method || 'S256' + } + } else { + const code_verifier = OpenIDClient.generators.codeVerifier() + const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) + return { code_challenge, code_challenge_method: 'S256', code_verifier } + } + } + + /** + * Check if a redirect URI is valid + * @param {string} uri + * @returns {boolean} + */ + isValidRedirectUri(uri) { + // Check if the redirect_uri is in the whitelist + return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') + } + + /** + * Get the authorization URL for OpenID Connect + * Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow + * @param {Request} req + * @returns {{ authorizationUrl: string }|{status: number, error: string}} + */ + getAuthorizationUrl(req) { + const client = this.getClient() + const strategy = this.getStrategy() + const sessionKey = strategy._key + + try { + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const hostUrl = new URL(`${protocol}://${req.get('host')}`) + const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge + + // Only allow code flow (for mobile clients) + if (req.query.response_type && req.query.response_type !== 'code') { + Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`) + return { + status: 400, + error: 'Invalid response_type, only code supported' + } + } + + // Generate a state on web flow or if no state supplied + const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state + + // Redirect URL for the SSO provider + let redirectUri + if (isMobileFlow) { + // Mobile required redirect uri + // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect + // where we will handle the redirect to it + if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) { + Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`) + return { + status: 400, + error: 'Invalid redirect_uri' + } + } + // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API + // for the request to mobile-redirect and as such the session is not shared + this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) + + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() + } else { + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() + + if (req.query.state) { + Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`) + return { + status: 400, + error: 'Invalid state, not allowed on web flow' + } + } + } + + // Update the strategy's redirect_uri for this request + strategy._params.redirect_uri = redirectUri + Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`) + + const pkceData = this.generatePkce(req, isMobileFlow) + if (pkceData.error) { + return { + status: 400, + error: pkceData.error + } + } + + req.session[sessionKey] = { + ...req.session[sessionKey], + state: state, + max_age: strategy._params.max_age, + response_type: 'code', + code_verifier: pkceData.code_verifier, // not null if web flow + mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out + sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback + } + + const authorizationUrl = client.authorizationUrl({ + ...strategy._params, + redirect_uri: redirectUri, + state: state, + response_type: 'code', + scope: this.getScope(), + code_challenge: pkceData.code_challenge, + code_challenge_method: pkceData.code_challenge_method + }) + + return { + authorizationUrl, + isMobileFlow + } + } catch (error) { + Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`) + return { + status: 500, + error: error.message || 'Unknown error' + } + } + } + + /** + * Get the end session URL for logout + * @param {Request} req + * @param {string} idToken + * @param {string} authMethod + * @returns {string|null} + */ + getEndSessionUrl(req, idToken, authMethod) { + const client = this.getClient() + + if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { + let postLogoutRedirectUri = null + + if (authMethod === 'openid') { + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const host = req.get('host') + // TODO: ABS does currently not support subfolders for installation + // If we want to support it we need to include a config for the serverurl + postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` + } + // else for openid-mobile we keep postLogoutRedirectUri on null + // nice would be to redirect to the app here, but for example Authentik does not implement + // the post_logout_redirect_uri parameter at all and for other providers + // we would also need again to implement (and even before get to know somehow for 3rd party apps) + // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). + // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like + // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution + // (The URL needs to be whitelisted in the config of the SSO/ID provider) + + return client.endSessionUrl({ + id_token_hint: idToken, + post_logout_redirect_uri: postLogoutRedirectUri + }) + } + + return null + } + + /** + * @typedef {Object} OpenIdIssuerConfig + * @property {string} issuer + * @property {string} authorization_endpoint + * @property {string} token_endpoint + * @property {string} userinfo_endpoint + * @property {string} end_session_endpoint + * @property {string} jwks_uri + * @property {string} id_token_signing_alg_values_supported + * + * Get OpenID Connect configuration from an issuer URL + * @param {string} issuerUrl + * @returns {Promise} + */ + async getIssuerConfig(issuerUrl) { + // Strip trailing slash + if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) + + // Append config pathname and validate URL + let configUrl = null + try { + configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) + if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { + throw new Error('Invalid pathname') + } + } catch (error) { + Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) + return { + status: 400, + error: "Invalid request. Query param 'issuer' is invalid" + } + } + + try { + const { data } = await axios.get(configUrl.toString()) + return { + issuer: data.issuer, + authorization_endpoint: data.authorization_endpoint, + token_endpoint: data.token_endpoint, + userinfo_endpoint: data.userinfo_endpoint, + end_session_endpoint: data.end_session_endpoint, + jwks_uri: data.jwks_uri, + id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported + } + } catch (error) { + Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error) + return { + status: 400, + error: 'Failed to get openid configuration' + } + } + } + + /** + * Handle mobile redirect for OAuth2 callback + * @param {Request} req + * @param {Response} res + */ + handleMobileRedirect(req, res) { + try { + // Extract the state parameter from the request + const { state, code } = req.query + + // Check if the state provided is in our list + if (!state || !this.openIdAuthSession.has(state)) { + Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch') + return res.status(400).send('State parameter mismatch') + } + + let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri + + if (!mobile_redirect_uri) { + Logger.error('[OidcAuth] No redirect URI') + return res.status(400).send('No redirect URI') + } + + this.openIdAuthSession.delete(state) + + const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + // Redirect to the overwrite URI saved in the map + res.redirect(redirectUri) + } catch (error) { + Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) + res.status(500).send('Internal Server Error') + } + } +} + +module.exports = OidcAuthStrategy diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 87acd221..9451a765 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -273,12 +273,24 @@ class MeController { * @param {RequestWithUser} req * @param {Response} res */ - updatePassword(req, res) { + async updatePassword(req, res) { if (req.user.isGuest) { Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`) - return res.sendStatus(500) + return res.sendStatus(403) } - this.auth.userChangePassword(req, res) + + const { password, newPassword } = req.body + if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') { + return res.status(400).send('Missing or invalid password or new password') + } + + const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword) + + if (result.error) { + return res.status(400).send(result.error) + } + + res.sendStatus(200) } /** From ac381854e56b736be5b629e46bafb26fd62131cf Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 16:23:15 -0500 Subject: [PATCH 16/29] Add rate limiter for auth endpoints --- package-lock.json | 16 ++++++++ package.json | 1 + server/Auth.js | 16 +++++--- server/routers/ApiRouter.js | 2 +- server/utils/rateLimiterFactory.js | 61 ++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 server/utils/rateLimiterFactory.js diff --git a/package-lock.json b/package-lock.json index d44ea79b..1be14fa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", @@ -1893,6 +1894,21 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-session": { "version": "1.17.3", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", diff --git a/package.json b/package.json index 2fd1a87e..3fdbf768 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", diff --git a/server/Auth.js b/server/Auth.js index e62df0b8..601fe8f2 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,4 +1,5 @@ const { Request, Response, NextFunction } = require('express') +const { rateLimit } = require('express-rate-limit') const passport = require('passport') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt @@ -9,6 +10,7 @@ const TokenManager = require('./auth/TokenManager') const LocalAuthStrategy = require('./auth/LocalAuthStrategy') const OidcAuthStrategy = require('./auth/OidcAuthStrategy') +const RateLimiterFactory = require('./utils/rateLimiterFactory') const { escapeRegExp } = require('./utils') /** @@ -19,6 +21,9 @@ class Auth { const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] + /** @type {import('express-rate-limit').RateLimitRequestHandler} */ + this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter() + this.tokenManager = new TokenManager() this.localAuthStrategy = new LocalAuthStrategy() this.oidcAuthStrategy = new OidcAuthStrategy() @@ -305,7 +310,7 @@ class Auth { */ async initAuthRoutes(router) { // Local strategy login route (takes username and password) - router.post('/login', passport.authenticate('local'), async (req, res) => { + router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { // Check if mobile app wants refresh token in response const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' @@ -314,7 +319,7 @@ class Auth { }) // Refresh token route - router.post('/auth/refresh', async (req, res) => { + router.post('/auth/refresh', this.authRateLimiter, async (req, res) => { let refreshToken = req.cookies.refresh_token // If x-refresh-token header is present, use it instead of the cookie @@ -345,7 +350,7 @@ class Auth { }) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', (req, res) => { + router.get('/auth/openid', this.authRateLimiter, (req, res) => { const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) if (authorizationUrlResponse.error) { @@ -359,11 +364,12 @@ class Auth { // This will be the oauth2 callback route for mobile clients // It will redirect to an app-link like audiobookshelf://oauth - router.get('/auth/openid/mobile-redirect', (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) + router.get('/auth/openid/mobile-redirect', this.authRateLimiter, (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) // openid strategy callback route (this receives the token from the configured openid login provider) router.get( '/auth/openid/callback', + this.authRateLimiter, (req, res, next) => { const sessionKey = this.oidcAuthStrategy.getStrategy()._key @@ -436,7 +442,7 @@ class Auth { * * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ - router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { + router.get('/auth/openid/config', this.authRateLimiter, this.isAuthenticated, async (req, res) => { if (!req.user.isAdminOrUp) { Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`) return res.sendStatus(403) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8966ff66..6446ecc8 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -182,7 +182,7 @@ class ApiRouter { this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this)) this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) - this.router.patch('/me/password', MeController.updatePassword.bind(this)) + this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js new file mode 100644 index 00000000..6f04d5ac --- /dev/null +++ b/server/utils/rateLimiterFactory.js @@ -0,0 +1,61 @@ +const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit') +const Logger = require('../Logger') + +/** + * Factory for creating authentication rate limiters + */ +class RateLimiterFactory { + constructor() { + this.authRateLimiter = null + } + + /** + * Get the authentication rate limiter + * @returns {RateLimitRequestHandler} + */ + getAuthRateLimiter() { + if (this.authRateLimiter) { + return this.authRateLimiter + } + + let windowMs = 10 * 60 * 1000 // 10 minutes default + if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { + windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + } + + let max = 20 // 20 attempts default + if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { + max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) + } + + let message = 'Too many requests, please try again later.' + if (process.env.RATE_LIMIT_AUTH_MESSAGE) { + message = process.env.RATE_LIMIT_AUTH_MESSAGE + } + + this.authRateLimiter = rateLimit({ + windowMs, + max, + message, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + const userAgent = req.get('User-Agent') || 'Unknown' + const endpoint = req.path + const method = req.method + + Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${req.ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) + + res.status(429).json({ + error: 'Too many authentication attempts, please try again later.' + }) + } + }) + + Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`) + + return this.authRateLimiter + } +} + +module.exports = new RateLimiterFactory() From 691f291843e8b09fbf607d66915990be6c4f0470 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 16:26:17 -0500 Subject: [PATCH 17/29] Update LibraryItemController unit test --- test/server/controllers/LibraryItemController.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 9972bd90..5a042239 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -6,6 +6,7 @@ const Database = require('../../../server/Database') const ApiRouter = require('../../../server/routers/ApiRouter') const LibraryItemController = require('../../../server/controllers/LibraryItemController') const ApiCacheManager = require('../../../server/managers/ApiCacheManager') +const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') describe('LibraryItemController', () => { @@ -19,6 +20,7 @@ describe('LibraryItemController', () => { await Database.buildModels() apiRouter = new ApiRouter({ + auth: new Auth(), apiCacheManager: new ApiCacheManager() }) From 6cc7a44a22c917184177b886a8f4918e3ac344aa Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 17:21:25 -0500 Subject: [PATCH 18/29] Update oidc redirect to pass both new and old token in url --- client/pages/login.vue | 4 ++-- server/Auth.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 5d447ed9..a9d44561 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -305,8 +305,8 @@ export default { }, async mounted() { // Token passed as query parameter after successful oidc login - if (this.$route.query?.setToken) { - localStorage.setItem('token', this.$route.query.setToken) + if (this.$route.query?.accessToken) { + localStorage.setItem('token', this.$route.query.accessToken) } if (localStorage.getItem('token')) { if (await this.checkAuth()) return // if valid user no need to check status diff --git a/server/Auth.js b/server/Auth.js index 601fe8f2..1d229ceb 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -266,7 +266,8 @@ class Auth { if (req.cookies.auth_cb) { let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter - res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.accessToken}${stateQuery}`) + // TODO: Temporarily continue sending the old token as setToken + res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } From 4ff735526229f9d4172071395edb82b3651bd01c Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 09:14:07 -0500 Subject: [PATCH 19/29] Fix hashPassword --- server/Server.js | 2 +- server/controllers/UserController.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Server.js b/server/Server.js index 639ae210..cf0ad5c7 100644 --- a/server/Server.js +++ b/server/Server.js @@ -431,7 +431,7 @@ class Server { Logger.info(`[Server] Initializing new server`) const newRoot = req.body.newRoot const rootUsername = newRoot.username || 'root' - const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : '' + const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : '' if (!rootPash) Logger.warn(`[Server] Creating root user with no password`) await Database.createRootUser(rootUsername, rootPash, this.auth) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 2ed92616..e72293cb 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -127,7 +127,7 @@ class UserController { } const userId = uuidv4() - const pash = await this.auth.hashPass(req.body.password) + const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password) const token = this.auth.generateAccessToken({ id: userId, username: req.body.username }) const userType = req.body.type || 'user' @@ -252,7 +252,7 @@ class UserController { // Updating password if (updatePayload.password) { - user.pash = await this.auth.hashPass(updatePayload.password) + user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password) hasUpdates = true } From d0d152c20d3db90ccefbc4a79d21c1b889e09af9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 09:45:24 -0500 Subject: [PATCH 20/29] Seperate setUserToken from setUser in store --- client/pages/login.vue | 1 + client/plugins/axios.js | 3 +++ client/store/user.js | 23 +++++++++-------------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index a9d44561..51f60600 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -189,6 +189,7 @@ export default { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('user/setUser', user) + this.$store.commit('user/setUserToken', user.accessToken) this.$store.dispatch('user/loadUserSettings') }, diff --git a/client/plugins/axios.js b/client/plugins/axios.js index 2724acb3..87eedca2 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -45,6 +45,7 @@ export default function ({ $axios, store, $root, app }) { if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') { // Refresh failed or login failed, redirect to login store.commit('user/setUser', null) + store.commit('user/setUserToken', null) app.router.push('/login') return Promise.reject(error) } @@ -81,6 +82,7 @@ export default function ({ $axios, store, $root, app }) { // Update the token in store and localStorage store.commit('user/setUser', response.user) + store.commit('user/setUserToken', newAccessToken) // Emit event used to re-authenticate socket in default.vue since $root is not available here if (app.$eventBus) { @@ -106,6 +108,7 @@ export default function ({ $axios, store, $root, app }) { // Clear user data and redirect to login store.commit('user/setUser', null) + store.commit('user/setUserToken', null) app.router.push('/login') return Promise.reject(refreshError) diff --git a/client/store/user.js b/client/store/user.js index e37568f1..04dc8447 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -151,22 +151,17 @@ export const actions = { export const mutations = { setUser(state, user) { state.user = user - if (user) { - // Use accessToken from user if included in response (for login) - if (user.accessToken) localStorage.setItem('token', user.accessToken) - else if (localStorage.getItem('token')) { - user.accessToken = localStorage.getItem('token') - } else { - console.error('No access token found for user', user) - } - } else { - localStorage.removeItem('token') - } }, setUserToken(state, token) { - if (!state.user) return - state.user.accessToken = token - localStorage.setItem('token', token) + if (!token) { + localStorage.removeItem('token') + if (state.user) { + state.user.accessToken = null + } + } else if (state.user) { + state.user.accessToken = token + localStorage.setItem('token', token) + } }, updateMediaProgress(state, { id, data }) { if (!state.user) return From 8775e55762de9494bd66fe413b7072572bd00d8d Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 16:39:50 -0500 Subject: [PATCH 21/29] Update jwt secret handling --- server/Auth.js | 9 +--- server/Server.js | 9 ++-- server/auth/TokenManager.js | 51 +++++++++++++---------- server/controllers/ApiKeyController.js | 4 +- server/models/ApiKey.js | 5 ++- server/objects/settings/ServerSettings.js | 1 + 6 files changed, 39 insertions(+), 40 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 1d229ceb..55eb334a 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -63,13 +63,6 @@ class Auth { 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 @@ -132,7 +125,7 @@ class Auth { new JwtStrategy( { jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), - secretOrKey: Database.serverSettings.tokenSecret, + secretOrKey: TokenManager.TokenSecret, // Handle expiration manaully in order to disable api keys that are expired ignoreExpiration: true }, diff --git a/server/Server.js b/server/Server.js index cf0ad5c7..99e72d0c 100644 --- a/server/Server.js +++ b/server/Server.js @@ -156,14 +156,11 @@ class Server { } await Database.init(false) + // Create or set JWT secret in token manager + await this.auth.tokenManager.initTokenSecret() await Logger.logManager.init() - // Create token secret if does not exist (Added v2.1.0) - if (!Database.serverSettings.tokenSecret) { - await this.auth.initTokenSecret() - } - await this.cleanUserData() // Remove invalid user item progress await CacheManager.ensureCachePaths() @@ -264,7 +261,7 @@ class Server { // enable express-session app.use( expressSession({ - secret: global.ServerSettings.tokenSecret, + secret: this.auth.tokenManager.TokenSecret, resave: false, saveUninitialized: false, cookie: { diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index cc4783b5..3f5cc836 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -7,8 +7,13 @@ const requestIp = require('../libs/requestIp') const jwt = require('../libs/jsonwebtoken') class TokenManager { + /** @type {string} JWT secret key */ + static TokenSecret = null + constructor() { + /** @type {number} Refresh token expiry in seconds */ this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days + /** @type {number} Access token expiry in seconds */ this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { @@ -19,28 +24,28 @@ class TokenManager { } } + get TokenSecret() { + return TokenManager.TokenSecret + } + /** - * Generate a token which is used to encrypt/protect the jwts. + * Token secret is used to sign and verify JWTs + * Set by ENV variable "JWT_SECRET_KEY" or generated and stored on server settings if not set */ async initTokenSecret() { - if (process.env.TOKEN_SECRET) { - // User can supply their own token secret - Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET + if (process.env.JWT_SECRET_KEY) { + // Use user supplied token secret + Logger.info('[TokenManager] JWT secret key set from ENV variable') + TokenManager.TokenSecret = process.env.JWT_SECRET_KEY + } else if (!Database.serverSettings.tokenSecret) { + // Generate new token secret and store it on server settings + Logger.info('[TokenManager] JWT secret key not found, generating one') + TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64') + Database.serverSettings.tokenSecret = TokenManager.TokenSecret + await Database.updateServerSettings() } 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 }) - } + // Use existing token secret from server settings + TokenManager.TokenSecret = Database.serverSettings.tokenSecret } } @@ -70,7 +75,7 @@ class TokenManager { */ static validateAccessToken(token) { try { - return jwt.verify(token, global.ServerSettings.tokenSecret) + return jwt.verify(token, TokenManager.TokenSecret) } catch (err) { return null } @@ -85,7 +90,7 @@ class TokenManager { * @returns {string} */ generateAccessToken(user) { - return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) + return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret) } /** @@ -104,7 +109,7 @@ class TokenManager { expiresIn: this.AccessTokenExpiry } try { - return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + return jwt.sign(payload, TokenManager.TokenSecret, options) } catch (error) { Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`) return null @@ -127,7 +132,7 @@ class TokenManager { expiresIn: this.RefreshTokenExpiry } try { - return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + return jwt.sign(payload, TokenManager.TokenSecret, options) } catch (error) { Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`) return null @@ -261,7 +266,7 @@ class TokenManager { async handleRefreshToken(refreshToken, req, res) { try { // Verify the refresh token - const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) + const decoded = jwt.verify(refreshToken, TokenManager.TokenSecret) if (decoded.type !== 'refresh') { Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`) diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index f60480df..c09d0736 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -42,6 +42,8 @@ class ApiKeyController { /** * POST: /api/api-keys * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -69,7 +71,7 @@ class ApiKeyController { } const keyId = uuidv4() // Generate key id ahead of time to use in JWT - const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn) + const apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, keyId, req.body.name, req.body.expiresIn) if (!apiKey) { Logger.error(`[ApiKeyController] create: Error generating API key`) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 7b61f731..7c61611d 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -157,12 +157,13 @@ class ApiKey extends Model { /** * Generate a new api key + * @param {string} tokenSecret * @param {string} keyId * @param {string} name * @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration * @returns {Promise} */ - static async generateApiKey(keyId, name, expiresIn) { + static async generateApiKey(tokenSecret, keyId, name, expiresIn) { const options = {} if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) { options.expiresIn = expiresIn @@ -175,7 +176,7 @@ class ApiKey extends Model { name, type: 'api' }, - global.ServerSettings.tokenSecret, + tokenSecret, options, (err, token) => { if (err) { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 29913e44..4f0aa97b 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -7,6 +7,7 @@ const User = require('../../models/User') class ServerSettings { constructor(settings) { this.id = 'server-settings' + /** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */ this.tokenSecret = null // Scanner From 25fe4dee3a0aa2ab7cb46f018505bbddd3695b56 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 9 Jul 2025 17:03:10 -0500 Subject: [PATCH 22/29] Update epub reader to use axios for handling refresh tokens --- client/components/readers/EpubReader.vue | 66 ++++++++++++++---------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 350d8596..795fcd2b 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -97,9 +97,9 @@ export default { }, ebookUrl() { if (this.fileId) { - return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}` + return `/api/items/${this.libraryItemId}/ebook/${this.fileId}` } - return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook` + return `/api/items/${this.libraryItemId}/ebook` }, themeRules() { const isDark = this.ereaderSettings.theme === 'dark' @@ -309,14 +309,24 @@ export default { /** @type {EpubReader} */ const reader = this + // Use axios to make request because we have token refresh logic in interceptor + const customRequest = async (url) => { + try { + return this.$axios.$get(url, { + responseType: 'arraybuffer' + }) + } catch (error) { + console.error('EpubReader.initEpub customRequest failed:', error) + throw error + } + } + /** @type {ePub.Book} */ reader.book = new ePub(reader.ebookUrl, { width: this.readerWidth, height: this.readerHeight - 50, openAs: 'epub', - requestHeaders: { - Authorization: `Bearer ${this.userToken}` - } + requestMethod: customRequest }) /** @type {ePub.Rendition} */ @@ -337,29 +347,33 @@ export default { this.applyTheme() }) - reader.book.ready.then(() => { - // set up event listeners - reader.rendition.on('relocated', reader.relocated) - reader.rendition.on('keydown', reader.keyUp) + reader.book.ready + .then(() => { + // set up event listeners + reader.rendition.on('relocated', reader.relocated) + reader.rendition.on('keydown', reader.keyUp) - reader.rendition.on('touchstart', (event) => { - this.$emit('touchstart', event) - }) - reader.rendition.on('touchend', (event) => { - this.$emit('touchend', event) - }) - - // load ebook cfi locations - const savedLocations = this.loadLocations() - if (savedLocations) { - reader.book.locations.load(savedLocations) - } else { - reader.book.locations.generate().then(() => { - this.checkSaveLocations(reader.book.locations.save()) + reader.rendition.on('touchstart', (event) => { + this.$emit('touchstart', event) }) - } - this.getChapters() - }) + reader.rendition.on('touchend', (event) => { + this.$emit('touchend', event) + }) + + // load ebook cfi locations + const savedLocations = this.loadLocations() + if (savedLocations) { + reader.book.locations.load(savedLocations) + } else { + reader.book.locations.generate().then(() => { + this.checkSaveLocations(reader.book.locations.save()) + }) + } + this.getChapters() + }) + .catch((error) => { + console.error('EpubReader.initEpub failed:', error) + }) }, getChapters() { // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759 From d3402e30c252a9acacee63fc3530805d127e354c Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 10 Jul 2025 16:54:28 -0500 Subject: [PATCH 23/29] Update ereaders to handle refreshing, epubjs to use custom request method, separate accessToken in store --- client/components/modals/AccountModal.vue | 2 +- client/components/readers/ComicReader.vue | 8 +----- client/components/readers/EpubReader.vue | 3 -- client/components/readers/MobiReader.vue | 10 ++----- client/components/readers/PdfReader.vue | 32 +++++++++++++++++++-- client/pages/login.vue | 2 +- client/plugins/axios.js | 20 +++---------- client/store/user.js | 34 ++++++++++++++++++----- server/utils/rateLimiterFactory.js | 2 +- 9 files changed, 67 insertions(+), 46 deletions(-) diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 9293a6d1..6f4b7b67 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -311,7 +311,7 @@ export default { if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) { console.log('Current user access token was updated') - this.$store.commit('user/setUserToken', data.user.accessToken) + this.$store.commit('user/setAccessToken', data.user.accessToken) } this.$toast.success(this.$strings.ToastAccountUpdateSuccess) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index 28d79bf2..fce26939 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -104,9 +104,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -234,10 +231,7 @@ export default { async extract() { this.loading = true var buff = await this.$axios.$get(this.ebookUrl, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) const archive = await Archive.open(buff) const originalFilesObject = await archive.getFilesObject() diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 795fcd2b..ac8e3397 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -57,9 +57,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, /** @returns {string} */ libraryItemId() { return this.libraryItem?.id diff --git a/client/components/readers/MobiReader.vue b/client/components/readers/MobiReader.vue index 3e784f77..459ae55b 100644 --- a/client/components/readers/MobiReader.vue +++ b/client/components/readers/MobiReader.vue @@ -26,9 +26,6 @@ export default { return {} }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -96,11 +93,8 @@ export default { }, async initMobi() { // Fetch mobi file as blob - var buff = await this.$axios.$get(this.ebookUrl, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + const buff = await this.$axios.$get(this.ebookUrl, { + responseType: 'blob' }) var reader = new FileReader() reader.onload = async (event) => { diff --git a/client/components/readers/PdfReader.vue b/client/components/readers/PdfReader.vue index c05f459c..d9459d76 100644 --- a/client/components/readers/PdfReader.vue +++ b/client/components/readers/PdfReader.vue @@ -55,7 +55,8 @@ export default { loadedRatio: 0, page: 1, numPages: 0, - pdfDocInitParams: null + pdfDocInitParams: null, + isRefreshing: false } }, computed: { @@ -152,7 +153,34 @@ export default { this.page++ this.updateProgress() }, - error(err) { + async refreshToken() { + if (this.isRefreshing) return + this.isRefreshing = true + const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => { + console.error('Failed to refresh token', error) + return null + }) + if (!newAccessToken) { + // Redirect to login on failed refresh + this.$router.push('/login') + return + } + + // Force Vue to re-render the PDF component by creating a new object + this.pdfDocInitParams = { + url: this.ebookUrl, + httpHeaders: { + Authorization: `Bearer ${newAccessToken}` + } + } + this.isRefreshing = false + }, + async error(err) { + if (err && err.status === 401) { + console.log('Received 401 error, refreshing token') + await this.refreshToken() + return + } console.error(err) }, resize() { diff --git a/client/pages/login.vue b/client/pages/login.vue index 51f60600..242eb93a 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -189,7 +189,7 @@ export default { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('user/setUser', user) - this.$store.commit('user/setUserToken', user.accessToken) + this.$store.commit('user/setAccessToken', user.accessToken) this.$store.dispatch('user/loadUserSettings') }, diff --git a/client/plugins/axios.js b/client/plugins/axios.js index 87eedca2..66a9fa85 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -45,7 +45,7 @@ export default function ({ $axios, store, $root, app }) { if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') { // Refresh failed or login failed, redirect to login store.commit('user/setUser', null) - store.commit('user/setUserToken', null) + store.commit('user/setAccessToken', null) app.router.push('/login') return Promise.reject(error) } @@ -72,23 +72,13 @@ export default function ({ $axios, store, $root, app }) { try { // Attempt to refresh the token - const response = await $axios.$post('/auth/refresh') - const newAccessToken = response.user.accessToken - + // Updates store if successful, otherwise clears store and throw error + const newAccessToken = await store.dispatch('user/refreshToken') if (!newAccessToken) { console.error('No new access token received') return Promise.reject(error) } - // Update the token in store and localStorage - store.commit('user/setUser', response.user) - store.commit('user/setUserToken', newAccessToken) - - // Emit event used to re-authenticate socket in default.vue since $root is not available here - if (app.$eventBus) { - app.$eventBus.$emit('token_refreshed', newAccessToken) - } - // Update the original request with new token if (!originalRequest.headers) { originalRequest.headers = {} @@ -106,9 +96,7 @@ export default function ({ $axios, store, $root, app }) { // Process queued requests with error processQueue(refreshError, null) - // Clear user data and redirect to login - store.commit('user/setUser', null) - store.commit('user/setUserToken', null) + // Redirect to login app.router.push('/login') return Promise.reject(refreshError) diff --git a/client/store/user.js b/client/store/user.js index 04dc8447..a67eae34 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -1,5 +1,6 @@ export const state = () => ({ user: null, + accessToken: null, settings: { orderBy: 'media.metadata.title', orderDesc: false, @@ -25,7 +26,7 @@ export const getters = { getIsRoot: (state) => state.user && state.user.type === 'root', getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getToken: (state) => { - return state.user?.accessToken || null + return state.accessToken || null }, getUserMediaProgress: (state) => @@ -145,6 +146,27 @@ export const actions = { } catch (error) { console.error('Failed to load userSettings from local storage', error) } + }, + refreshToken({ state, commit }) { + return this.$axios + .$post('/auth/refresh') + .then(async (response) => { + const newAccessToken = response.user.accessToken + commit('setUser', response.user) + commit('setAccessToken', newAccessToken) + // Emit event used to re-authenticate socket in default.vue since $root is not available here + if (this.$eventBus) { + this.$eventBus.$emit('token_refreshed', newAccessToken) + } + return newAccessToken + }) + .catch((error) => { + console.error('Failed to refresh token', error) + commit('setUser', null) + commit('setAccessToken', null) + // Calling function handles redirect to login + throw error + }) } } @@ -152,14 +174,12 @@ export const mutations = { setUser(state, user) { state.user = user }, - setUserToken(state, token) { + setAccessToken(state, token) { if (!token) { localStorage.removeItem('token') - if (state.user) { - state.user.accessToken = null - } - } else if (state.user) { - state.user.accessToken = token + state.accessToken = null + } else { + state.accessToken = token localStorage.setItem('token', token) } }, diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index 6f04d5ac..b5199662 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -23,7 +23,7 @@ class RateLimiterFactory { windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) } - let max = 20 // 20 attempts default + let max = 40 // 40 attempts default if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) } From 7d6d3e668788a2e37a5d6a27a939516d3e24d165 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 11 Jul 2025 14:43:07 -0500 Subject: [PATCH 24/29] Move invalidate refresh token to TokenManager --- server/Auth.js | 10 +--------- server/auth/TokenManager.js | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 55eb334a..571472a7 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,5 +1,4 @@ const { Request, Response, NextFunction } = require('express') -const { rateLimit } = require('express-rate-limit') const passport = require('passport') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt @@ -466,14 +465,7 @@ class Auth { // Invalidate the session in database using refresh token if (refreshToken) { - try { - Logger.info(`[Auth] logout: Invalidating session for refresh token: ${refreshToken}`) - await Database.sessionModel.destroy({ - where: { refreshToken } - }) - } catch (error) { - Logger.error(`[Auth] Error destroying session: ${error.message}`) - } + await this.tokenManager.invalidateRefreshToken(refreshToken) } else { Logger.info(`[Auth] logout: No refresh token on request`) } diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 3f5cc836..65ae32b1 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -379,6 +379,28 @@ class TokenManager { await Database.sessionModel.destroy({ where: { userId: user.id } }) return null } + + /** + * Invalidate a refresh token - used for logout + * + * @param {string} refreshToken + * @returns {Promise} + */ + async invalidateRefreshToken(refreshToken) { + if (!refreshToken) { + Logger.error(`[TokenManager] No refresh token provided to invalidate`) + return false + } + + try { + const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } }) + Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`) + return true + } catch (error) { + Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`) + return false + } + } } module.exports = TokenManager From 806c0a2991d236a219d8df7aa75d3d751b0efc51 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 11 Jul 2025 16:01:45 -0500 Subject: [PATCH 25/29] Remove return_tokens query param for login --- server/Auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index 571472a7..9c0e2fdb 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -305,7 +305,7 @@ class Auth { // Local strategy login route (takes username and password) router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { // Check if mobile app wants refresh token in response - const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' + const returnTokens = req.headers['x-return-tokens'] === 'true' const userResponse = await this.handleLoginSuccess(req, res, returnTokens) res.json(userResponse) From f081a7fdc1dfd47795b5d838b2d4ec64f7bc4bd9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 10:32:35 -0500 Subject: [PATCH 26/29] Update rate limiter to use requestIp as key, pass in configurable error message --- server/utils/rateLimiterFactory.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index b5199662..0ad77406 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -1,5 +1,6 @@ const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit') const Logger = require('../Logger') +const requestIp = require('../libs/requestIp') /** * Factory for creating authentication rate limiters @@ -28,7 +29,7 @@ class RateLimiterFactory { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) } - let message = 'Too many requests, please try again later.' + let message = 'Too many authentication requests' if (process.env.RATE_LIMIT_AUTH_MESSAGE) { message = process.env.RATE_LIMIT_AUTH_MESSAGE } @@ -36,18 +37,22 @@ class RateLimiterFactory { this.authRateLimiter = rateLimit({ windowMs, max, - message, standardHeaders: true, legacyHeaders: false, + keyGenerator: (req) => { + // Override keyGenerator to handle proxy IPs + return requestIp.getClientIp(req) || req.ip + }, handler: (req, res) => { const userAgent = req.get('User-Agent') || 'Unknown' const endpoint = req.path const method = req.method + const ip = requestIp.getClientIp(req) || req.ip - Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${req.ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) + Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) res.status(429).json({ - error: 'Too many authentication attempts, please try again later.' + error: message }) } }) From 030e43f38218f5fe9f013940fba9a63d27ed57ce Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 10:51:07 -0500 Subject: [PATCH 27/29] Support disabled rate limiter by setting max to 0, add logs when rate limit is changed from default --- server/utils/rateLimiterFactory.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index 0ad77406..e639c51c 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -6,6 +6,9 @@ const requestIp = require('../libs/requestIp') * Factory for creating authentication rate limiters */ class RateLimiterFactory { + static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes + static DEFAULT_MAX = 40 // 40 attempts + constructor() { this.authRateLimiter = null } @@ -19,14 +22,27 @@ class RateLimiterFactory { return this.authRateLimiter } - let windowMs = 10 * 60 * 1000 // 10 minutes default - if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { - windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + // Disable by setting max to 0 + if (process.env.RATE_LIMIT_AUTH_MAX === '0') { + this.authRateLimiter = (req, res, next) => next() + Logger.info(`[RateLimiterFactory] Authentication rate limiting disabled by ENV variable`) + return this.authRateLimiter } - let max = 40 // 40 attempts default + let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS + if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { + windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) { + Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`) + } + } + + let max = RateLimiterFactory.DEFAULT_MAX if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) + if (max !== RateLimiterFactory.DEFAULT_MAX) { + Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`) + } } let message = 'Too many authentication requests' From d09db19cd5fd6133a9f49c3d8a8f709fdff26a49 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 11:21:52 -0500 Subject: [PATCH 28/29] Update re-login message to show for users without github discussion link, add message to i18n strings --- client/pages/login.vue | 21 ++++++++++----------- client/strings/en-us.json | 1 + 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 242eb93a..01adadcd 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -40,11 +40,11 @@

{{ error }}

-
+
-

Authentication has been improved for security. All users will be required to re-login.

- More info +

{{ $strings.MessageAuthenticationSecurityMessage }}

+ {{ $strings.LabelMoreInfo }}
@@ -95,6 +95,8 @@ export default { login_local: true, login_openid: false, authFormData: null, + // New JWT auth system re-login flags + showNewAuthSystemMessage: false, showNewAuthSystemAdminMessage: false } }, @@ -195,6 +197,7 @@ export default { }, async submitForm() { this.error = null + this.showNewAuthSystemMessage = false this.showNewAuthSystemAdminMessage = false this.processing = true @@ -231,14 +234,10 @@ export default { .then((res) => { // Force re-login if user is using an old token with no expiration if (res.user.isOldToken) { - if (res.user.type === 'admin' || res.user.type === 'root') { - this.username = res.user.username - // Show message to admin users about new auth system - this.showNewAuthSystemAdminMessage = true - } else { - // Regular users just shown login - this.username = res.user.username - } + this.username = res.user.username + this.showNewAuthSystemMessage = true + // Admin user sees link to github discussion + this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root' return false } this.setUser(res) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index a25f02cf..84127708 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -724,6 +724,7 @@ "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", + "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users will be required to re-login.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", "MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups", "MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.", From 4f7831611f9a4a99158ca638a1ce3f1361200ca3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 11:23:08 -0500 Subject: [PATCH 29/29] Update auth re-login i18n string --- client/strings/en-us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 84127708..56c29ec1 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -724,7 +724,7 @@ "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", - "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users will be required to re-login.", + "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", "MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups", "MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.",