From 4f5123e84260b8e1e1049d9ce82854c7ca7f6afa Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 29 Jun 2025 17:22:58 -0500 Subject: [PATCH] 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 })) || [],