diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 61331fb9..854b61b2 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -778,10 +778,6 @@ export default { windowResize() { this.executeRebuild() }, - socketInit() { - // Server settings are set on socket init - this.executeRebuild() - }, initListeners() { window.addEventListener('resize', this.windowResize) @@ -794,7 +790,6 @@ export default { }) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$on('socket_init', this.socketInit) this.$eventBus.$on('user-settings', this.settingsUpdated) if (this.$root.socket) { @@ -826,7 +821,6 @@ export default { } this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$off('socket_init', this.socketInit) this.$eventBus.$off('user-settings', this.settingsUpdated) if (this.$root.socket) { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 33e7aa15..9f15af67 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -33,6 +33,7 @@ export default { return { socket: null, isSocketConnected: false, + isSocketAuthenticated: false, isFirstSocketConnection: true, socketConnectionToastId: null, currentLang: null, @@ -81,9 +82,28 @@ export default { document.body.classList.add('app-bar') } }, + tokenRefreshed(newAccessToken) { + if (this.isSocketConnected && !this.isSocketAuthenticated) { + console.log('[SOCKET] Re-authenticating socket after token refresh') + this.socket.emit('auth', newAccessToken) + } + }, updateSocketConnectionToast(content, type, timeout) { if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) { - this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false) + const toastUpdateOptions = { + content: content, + options: { + timeout: timeout, + type: type, + closeButton: false, + position: 'bottom-center', + onClose: () => { + this.socketConnectionToastId = null + }, + closeOnClick: timeout !== null + } + } + this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false) } else { this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null }) } @@ -109,7 +129,7 @@ export default { this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null) }, reconnect() { - console.error('[SOCKET] reconnected') + console.log('[SOCKET] reconnected') }, reconnectAttempt(val) { console.log(`[SOCKET] reconnect attempt ${val}`) @@ -120,6 +140,10 @@ export default { reconnectFailed() { console.error('[SOCKET] reconnect failed') }, + authFailed(payload) { + console.error('[SOCKET] auth failed', payload.message) + this.isSocketAuthenticated = false + }, init(payload) { console.log('Init Payload', payload) @@ -127,7 +151,7 @@ export default { this.$store.commit('users/setUsersOnline', payload.usersOnline) } - this.$eventBus.$emit('socket_init') + this.isSocketAuthenticated = true }, streamOpen(stream) { if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream) @@ -354,6 +378,15 @@ export default { this.$store.commit('scanners/removeCustomMetadataProvider', provider) }, initializeSocket() { + if (this.$root.socket) { + // Can happen in dev due to hot reload + console.warn('Socket already initialized') + this.socket = this.$root.socket + this.isSocketConnected = this.$root.socket?.connected + this.isFirstSocketConnection = false + this.socketConnectionToastId = null + return + } this.socket = this.$nuxtSocket({ name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', persist: 'main', @@ -364,6 +397,7 @@ export default { path: `${this.$config.routerBasePath}/socket.io` }) this.$root.socket = this.socket + this.isSocketAuthenticated = false console.log('Socket initialized') // Pre-defined socket events @@ -377,6 +411,7 @@ export default { // Event received after authorizing socket this.socket.on('init', this.init) + this.socket.on('auth_failed', this.authFailed) // Stream Listeners this.socket.on('stream_open', this.streamOpen) @@ -571,6 +606,7 @@ export default { this.updateBodyClass() this.resize() this.$eventBus.$on('change-lang', this.changeLanguage) + this.$eventBus.$on('token_refreshed', this.tokenRefreshed) window.addEventListener('resize', this.resize) window.addEventListener('keydown', this.keyDown) @@ -594,6 +630,7 @@ export default { }, beforeDestroy() { this.$eventBus.$off('change-lang', this.changeLanguage) + this.$eventBus.$off('token_refreshed', this.tokenRefreshed) window.removeEventListener('resize', this.resize) window.removeEventListener('keydown', this.keyDown) } diff --git a/client/plugins/axios.js b/client/plugins/axios.js index c95067d1..2724acb3 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -1,4 +1,4 @@ -export default function ({ $axios, store, $config, app }) { +export default function ({ $axios, store, $root, app }) { // Track if we're currently refreshing to prevent multiple refresh attempts let isRefreshing = false let failedQueue = [] @@ -82,6 +82,11 @@ export default function ({ $axios, store, $config, app }) { // Update the token in store and localStorage store.commit('user/setUser', response.user) + // Emit event used to re-authenticate socket in default.vue since $root is not available here + if (app.$eventBus) { + app.$eventBus.$emit('token_refreshed', newAccessToken) + } + // Update the original request with new token if (!originalRequest.headers) { originalRequest.headers = {} diff --git a/server/Auth.js b/server/Auth.js index d2250f17..b2fcebf9 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1054,6 +1054,8 @@ class Auth { /** * Function to validate a jwt token for a given user + * Used to authenticate socket connections + * TODO: Support API keys for web socket connections * * @param {string} token * @returns {Object} tokens data diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 050e7e2f..68b647ff 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -231,6 +231,9 @@ class SocketAuthority { * When setting up a socket connection the user needs to be associated with a socket id * for this the client will send a 'auth' event that includes the users API token * + * Sends event 'init' to the socket. For admins this contains an array of users online. + * For failed authentication it sends event 'auth_failed' with a message + * * @param {SocketIO.Socket} socket * @param {string} token JWT */ @@ -242,7 +245,7 @@ class SocketAuthority { if (!token_data?.userId) { // Token invalid Logger.error('Cannot validate socket - invalid token') - return socket.emit('invalid_token') + return socket.emit('auth_failed', { message: 'Invalid token' }) } // get the user via the id from the decoded jwt. @@ -250,7 +253,11 @@ class SocketAuthority { if (!user) { // user not found Logger.error('Cannot validate socket - invalid token') - return socket.emit('invalid_token') + return socket.emit('auth_failed', { message: 'Invalid token' }) + } + if (!user.isActive) { + Logger.error('Cannot validate socket - user is not active') + return socket.emit('auth_failed', { message: 'Invalid user' }) } const client = this.clients[socket.id] @@ -260,13 +267,18 @@ class SocketAuthority { } if (client.user !== undefined) { - Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username) + if (client.user.id === user.id) { + // Allow re-authentication of a socket to the same user + Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`) + } else { + // Allow re-authentication of a socket to a different user but shouldn't happen + Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`) + } + } else { + Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`) } client.user = user - - Logger.debug(`[SocketAuthority] User Online ${client.user.username}`) - this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) // Update user lastSeen without firing sequelize bulk update hooks