mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-13 10:55:05 +02:00
Implement new JWT auth
This commit is contained in:
parent
e384863148
commit
4f5123e842
21 changed files with 739 additions and 56 deletions
|
@ -71,9 +71,6 @@ export default {
|
||||||
coverHeight() {
|
coverHeight() {
|
||||||
return this.cardHeight
|
return this.cardHeight
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
|
|
|
@ -39,9 +39,6 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
|
|
|
@ -309,9 +309,9 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
console.log('Account updated', data.user)
|
console.log('Account updated', data.user)
|
||||||
|
|
||||||
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {
|
||||||
console.log('Current user token was updated')
|
console.log('Current user access token was updated')
|
||||||
this.$store.commit('user/setUserToken', data.user.token)
|
this.$store.commit('user/setUserToken', data.user.accessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||||
|
|
|
@ -29,9 +29,6 @@ export default {
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
|
|
|
@ -129,9 +129,6 @@ export default {
|
||||||
return `${hoursRounded}h`
|
return `${hoursRounded}h`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
token() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
timeRemaining() {
|
timeRemaining() {
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.useChapterTrack && this.currentChapter) {
|
||||||
var currChapTime = this.currentTime - this.currentChapter.start
|
var currChapTime = this.currentTime - this.currentChapter.start
|
||||||
|
|
|
@ -266,9 +266,6 @@ export default {
|
||||||
isComic() {
|
isComic() {
|
||||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
keepProgress() {
|
keepProgress() {
|
||||||
return this.$store.state.ereaderKeepProgress
|
return this.$store.state.ereaderKeepProgress
|
||||||
},
|
},
|
||||||
|
|
|
@ -49,9 +49,6 @@ export default {
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem.id
|
return this.libraryItem.id
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
|
|
|
@ -53,9 +53,6 @@ export default {
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem.id
|
return this.libraryItem.id
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
|
|
|
@ -85,9 +85,6 @@ export default {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
if (this.disabled) classes.push('bg-black-300')
|
if (this.disabled) classes.push('bg-black-300')
|
||||||
|
|
|
@ -73,7 +73,8 @@ module.exports = {
|
||||||
|
|
||||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||||
axios: {
|
axios: {
|
||||||
baseURL: routerBasePath
|
baseURL: routerBasePath,
|
||||||
|
progress: false
|
||||||
},
|
},
|
||||||
|
|
||||||
// nuxt/pwa https://pwa.nuxtjs.org
|
// nuxt/pwa https://pwa.nuxtjs.org
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
<widgets-online-indicator :value="!!userOnline" />
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userToken" class="flex text-xs mt-4">
|
<div v-if="legacyToken" class="flex text-xs mt-4">
|
||||||
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy />
|
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-px bg-white/10 my-2" />
|
<div class="w-full h-px bg-white/10 my-2" />
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
|
@ -100,9 +100,12 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
legacyToken() {
|
||||||
return this.user.token
|
return this.user.token
|
||||||
},
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.user.accessToken
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
|
|
|
@ -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) => {
|
$axios.onRequest((config) => {
|
||||||
if (!config.url) {
|
if (!config.url) {
|
||||||
console.error('Axios request invalid config', config)
|
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:')) {
|
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const bearerToken = store.state.user.user?.token || null
|
const bearerToken = store.getters['user/getToken']
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
config.headers.common['Authorization'] = `Bearer ${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 code = parseInt(error.response && error.response.status)
|
||||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
|
||||||
console.error('Axios error', code, message)
|
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,19 +25,19 @@ export const getters = {
|
||||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||||
getToken: (state) => {
|
getToken: (state) => {
|
||||||
return state.user?.token || null
|
return state.user?.accessToken || null
|
||||||
},
|
},
|
||||||
getUserMediaProgress:
|
getUserMediaProgress:
|
||||||
(state) =>
|
(state) =>
|
||||||
(libraryItemId, episodeId = null) => {
|
(libraryItemId, episodeId = null) => {
|
||||||
if (!state.user.mediaProgress) return null
|
if (!state.user?.mediaProgress) return null
|
||||||
return state.user.mediaProgress.find((li) => {
|
return state.user.mediaProgress.find((li) => {
|
||||||
if (episodeId && li.episodeId !== episodeId) return false
|
if (episodeId && li.episodeId !== episodeId) return false
|
||||||
return li.libraryItemId == libraryItemId
|
return li.libraryItemId == libraryItemId
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
||||||
if (!state.user.bookmarks) return []
|
if (!state.user?.bookmarks) return []
|
||||||
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||||
},
|
},
|
||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
|
@ -152,13 +152,17 @@ export const mutations = {
|
||||||
setUser(state, user) {
|
setUser(state, user) {
|
||||||
state.user = user
|
state.user = user
|
||||||
if (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 {
|
} else {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setUserToken(state, token) {
|
setUserToken(state, token) {
|
||||||
state.user.token = token
|
if (!state.user) return
|
||||||
|
state.user.accessToken = token
|
||||||
localStorage.setItem('token', token)
|
localStorage.setItem('token', token)
|
||||||
},
|
},
|
||||||
updateMediaProgress(state, { id, data }) {
|
updateMediaProgress(state, { id, data }) {
|
||||||
|
|
258
server/Auth.js
258
server/Auth.js
|
@ -1,5 +1,6 @@
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const passport = require('passport')
|
const passport = require('passport')
|
||||||
|
const { Op } = require('sequelize')
|
||||||
const { Request, Response, NextFunction } = require('express')
|
const { Request, Response, NextFunction } = require('express')
|
||||||
const bcrypt = require('./libs/bcryptjs')
|
const bcrypt = require('./libs/bcryptjs')
|
||||||
const jwt = require('./libs/jsonwebtoken')
|
const jwt = require('./libs/jsonwebtoken')
|
||||||
|
@ -21,6 +22,9 @@ class Auth {
|
||||||
this.openIdAuthSession = new Map()
|
this.openIdAuthSession = new Map()
|
||||||
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
|
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
|
||||||
this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
|
this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
|
||||||
|
|
||||||
|
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
|
||||||
|
this.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 })
|
res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the refresh token cookie
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {string} refreshToken
|
||||||
|
*/
|
||||||
|
setRefreshTokenCookie(req, res, refreshToken) {
|
||||||
|
res.cookie('refresh_token', refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: this.RefreshTokenExpiry * 1000,
|
||||||
|
path: '/'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Informs the client in the right mode about a successfull login and the token
|
* Informs the client in the right mode about a successfull login and the token
|
||||||
* (clients choise is restored from cookies).
|
* (clients choise is restored from cookies).
|
||||||
|
@ -444,17 +464,77 @@ class Auth {
|
||||||
// return the user login response json if the login was successfull
|
// return the user login response json if the login was successfull
|
||||||
const userResponse = await this.getUserLoginResponsePayload(req.user)
|
const userResponse = await this.getUserLoginResponsePayload(req.user)
|
||||||
|
|
||||||
// Experimental Next.js client uses bearer token in cookies
|
this.setRefreshTokenCookie(req, res, req.user.refreshToken)
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
res.json(userResponse)
|
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)
|
// openid strategy login route (this redirects to the configured openid login provider)
|
||||||
router.get('/auth/openid', (req, res, next) => {
|
router.get('/auth/openid', (req, res, next) => {
|
||||||
// Get the OIDC client from the strategy
|
// Get the OIDC client from the strategy
|
||||||
|
@ -719,7 +799,24 @@ class Auth {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Logout route
|
// 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
|
// TODO: invalidate possible JWTs
|
||||||
req.logout((err) => {
|
req.logout((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
@ -728,7 +825,6 @@ class Auth {
|
||||||
const authMethod = req.cookies.auth_method
|
const authMethod = req.cookies.auth_method
|
||||||
|
|
||||||
res.clearCookie('auth_method')
|
res.clearCookie('auth_method')
|
||||||
res.clearCookie('auth_token')
|
|
||||||
|
|
||||||
let logoutUrl = null
|
let logoutUrl = null
|
||||||
|
|
||||||
|
@ -776,22 +872,18 @@ class Auth {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* middleware to use in express to only allow authenticated users.
|
* middleware to use in express to only allow authenticated users.
|
||||||
|
*
|
||||||
* @param {Request} req
|
* @param {Request} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
* @param {NextFunction} next
|
* @param {NextFunction} next
|
||||||
*/
|
*/
|
||||||
isAuthenticated(req, res, next) {
|
isAuthenticated(req, res, next) {
|
||||||
// check if session cookie says that we are authenticated
|
return passport.authenticate('jwt', { session: false })(req, res, next)
|
||||||
if (req.isAuthenticated()) {
|
|
||||||
next()
|
|
||||||
} else {
|
|
||||||
// try JWT to authenticate
|
|
||||||
passport.authenticate('jwt')(req, res, next)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to generate a jwt token for a given user
|
* Function to generate a jwt token for a given user
|
||||||
|
* TODO: Old method with no expiration
|
||||||
*
|
*
|
||||||
* @param {{ id:string, username:string }} user
|
* @param {{ id:string, username:string }} user
|
||||||
* @returns {string} token
|
* @returns {string} token
|
||||||
|
@ -800,6 +892,132 @@ class Auth {
|
||||||
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
|
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token for a given user
|
||||||
|
*
|
||||||
|
* @param {{ id:string, username:string }} user
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
generateTempAccessToken(user) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
jwt.sign({ userId: user.id, username: user.username, type: 'access' }, global.ServerSettings.tokenSecret, { expiresIn: this.AccessTokenExpiry }, (err, token) => {
|
||||||
|
if (err) {
|
||||||
|
Logger.error(`[Auth] Error generating access token for user ${user.id}: ${err}`)
|
||||||
|
resolve(null)
|
||||||
|
} else {
|
||||||
|
resolve(token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate refresh token for a given user
|
||||||
|
*
|
||||||
|
* @param {{ id:string, username:string }} user
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
generateRefreshToken(user) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
jwt.sign({ userId: user.id, username: user.username, type: 'refresh' }, global.ServerSettings.tokenSecret, { expiresIn: this.RefreshTokenExpiry }, (err, token) => {
|
||||||
|
if (err) {
|
||||||
|
Logger.error(`[Auth] Error generating refresh token for user ${user.id}: ${err}`)
|
||||||
|
resolve(null)
|
||||||
|
} else {
|
||||||
|
resolve(token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tokens and session for a given user
|
||||||
|
*
|
||||||
|
* @param {{ id:string, username:string }} user
|
||||||
|
* @param {Request} req
|
||||||
|
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('./models/Session') }>}
|
||||||
|
*/
|
||||||
|
async createTokensAndSession(user, req) {
|
||||||
|
const ipAddress = requestIp.getClientIp(req)
|
||||||
|
const userAgent = req.headers['user-agent']
|
||||||
|
const [accessToken, refreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)])
|
||||||
|
|
||||||
|
// Calculate expiration time for the refresh token
|
||||||
|
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||||
|
|
||||||
|
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
|
||||||
|
user.accessToken = accessToken
|
||||||
|
// Store refresh token on user object for cookie setting
|
||||||
|
user.refreshToken = refreshToken
|
||||||
|
return { accessToken, refreshToken, session }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate tokens for a given session
|
||||||
|
*
|
||||||
|
* @param {import('./models/Session')} session
|
||||||
|
* @param {import('./models/User')} user
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @returns {Promise<string>} 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<string>} accessToken only if user is current user and refresh token is valid
|
||||||
|
*/
|
||||||
|
async invalidateJwtSessionsForUser(user, req, res) {
|
||||||
|
const currentRefreshToken = req.cookies.refresh_token
|
||||||
|
if (req.user.id === user.id && currentRefreshToken) {
|
||||||
|
// Current user is the same as the user to invalidate sessions for
|
||||||
|
// So rotate token for current session
|
||||||
|
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
|
||||||
|
if (currentSession) {
|
||||||
|
const 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
|
* Function to validate a jwt token for a given user
|
||||||
*
|
*
|
||||||
|
@ -888,6 +1106,10 @@ class Auth {
|
||||||
}
|
}
|
||||||
// approve login
|
// approve login
|
||||||
Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||||
|
|
||||||
|
// Create tokens and session, updates user.accessToken and user.refreshToken
|
||||||
|
await this.createTokensAndSession(user, req)
|
||||||
|
|
||||||
done(null, user)
|
done(null, user)
|
||||||
return
|
return
|
||||||
} else if (!user.pash) {
|
} else if (!user.pash) {
|
||||||
|
@ -901,6 +1123,10 @@ class Auth {
|
||||||
if (compare) {
|
if (compare) {
|
||||||
// approve login
|
// approve login
|
||||||
Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||||
|
|
||||||
|
// Create tokens and session, updates user.accessToken and user.refreshToken
|
||||||
|
await this.createTokensAndSession(user, req)
|
||||||
|
|
||||||
done(null, user)
|
done(null, user)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,16 @@ class Database {
|
||||||
return this.models.user
|
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')} */
|
/** @type {typeof import('./models/Library')} */
|
||||||
get libraryModel() {
|
get libraryModel() {
|
||||||
return this.models.library
|
return this.models.library
|
||||||
|
@ -311,6 +321,8 @@ class Database {
|
||||||
|
|
||||||
buildModels(force = false) {
|
buildModels(force = false) {
|
||||||
require('./models/User').init(this.sequelize)
|
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/Library').init(this.sequelize)
|
||||||
require('./models/LibraryFolder').init(this.sequelize)
|
require('./models/LibraryFolder').init(this.sequelize)
|
||||||
require('./models/Book').init(this.sequelize)
|
require('./models/Book').init(this.sequelize)
|
||||||
|
@ -656,6 +668,8 @@ class Database {
|
||||||
* Series should have atleast one Book
|
* Series should have atleast one Book
|
||||||
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
||||||
* Remove playback sessions that are 3 seconds or less
|
* Remove playback sessions that are 3 seconds or less
|
||||||
|
* Remove duplicate mediaProgresses
|
||||||
|
* Remove expired auth sessions
|
||||||
*/
|
*/
|
||||||
async cleanDatabase() {
|
async cleanDatabase() {
|
||||||
// Remove invalid Podcast records
|
// Remove invalid Podcast records
|
||||||
|
@ -785,6 +799,23 @@ WHERE EXISTS (
|
||||||
where: { id: duplicateMediaProgress.id }
|
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) {
|
async createTextSearchQuery(query) {
|
||||||
|
|
|
@ -237,6 +237,7 @@ class UserController {
|
||||||
|
|
||||||
let hasUpdates = false
|
let hasUpdates = false
|
||||||
let shouldUpdateToken = false
|
let shouldUpdateToken = false
|
||||||
|
let shouldInvalidateJwtSessions = false
|
||||||
// When changing username create a new API token
|
// When changing username create a new API token
|
||||||
if (updatePayload.username && updatePayload.username !== user.username) {
|
if (updatePayload.username && updatePayload.username !== user.username) {
|
||||||
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
|
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
|
||||||
|
@ -245,6 +246,7 @@ class UserController {
|
||||||
}
|
}
|
||||||
user.username = updatePayload.username
|
user.username = updatePayload.username
|
||||||
shouldUpdateToken = true
|
shouldUpdateToken = true
|
||||||
|
shouldInvalidateJwtSessions = true
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -328,6 +330,18 @@ class UserController {
|
||||||
user.token = await this.auth.generateAccessToken(user)
|
user.token = await this.auth.generateAccessToken(user)
|
||||||
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
await user.save()
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,10 +31,11 @@ class CronManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize open session cleanup cron
|
* Initialize open session & auth session cleanup cron
|
||||||
* Runs every day at 00:30
|
* Runs every day at 00:30
|
||||||
* Closes open share sessions that have not been updated in 24 hours
|
* Closes open share sessions that have not been updated in 24 hours
|
||||||
* Closes open playback sessions that have not been updated in 36 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
|
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
|
||||||
*/
|
*/
|
||||||
initOpenSessionCleanupCron() {
|
initOpenSessionCleanupCron() {
|
||||||
|
@ -42,6 +43,7 @@ class CronManager {
|
||||||
Logger.debug('[CronManager] Open session cleanup cron executing')
|
Logger.debug('[CronManager] Open session cleanup cron executing')
|
||||||
ShareManager.closeStaleOpenShareSessions()
|
ShareManager.closeStaleOpenShareSessions()
|
||||||
await this.playbackSessionManager.closeStaleOpenSessions()
|
await this.playbackSessionManager.closeStaleOpenSessions()
|
||||||
|
await Database.cleanupExpiredSessions()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
153
server/migrations/v2.26.0-create-sessions-table.js
Normal file
153
server/migrations/v2.26.0-create-sessions-table.js
Normal file
|
@ -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<void>} - 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<void>} - 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 }
|
90
server/models/ApiToken.js
Normal file
90
server/models/ApiToken.js
Normal file
|
@ -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>} 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
|
88
server/models/Session.js
Normal file
88
server/models/Session.js
Normal file
|
@ -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>} 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
|
|
@ -112,6 +112,10 @@ class User extends Model {
|
||||||
this.updatedAt
|
this.updatedAt
|
||||||
/** @type {import('./MediaProgress')[]?} - Only included when extended */
|
/** @type {import('./MediaProgress')[]?} - Only included when extended */
|
||||||
this.mediaProgresses
|
this.mediaProgresses
|
||||||
|
|
||||||
|
// Temporary accessToken, not stored in database
|
||||||
|
/** @type {string} */
|
||||||
|
this.accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// Excludes "root" since their can only be 1 root user
|
// Excludes "root" since their can only be 1 root user
|
||||||
|
@ -520,7 +524,9 @@ class User extends Model {
|
||||||
username: this.username,
|
username: this.username,
|
||||||
email: this.email,
|
email: this.email,
|
||||||
type: this.type,
|
type: this.type,
|
||||||
|
// TODO: Old non-expiring token
|
||||||
token: this.type === 'root' && hideRootToken ? '' : this.token,
|
token: this.type === 'root' && hideRootToken ? '' : this.token,
|
||||||
|
accessToken: this.accessToken || null,
|
||||||
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
|
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
|
||||||
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
|
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
|
||||||
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
|
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue