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 @@
-
+
+
@@ -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 })) || [],