From 4e2863faa7e4fe50155b0a01d668b5e2141de1e9 Mon Sep 17 00:00:00 2001 From: Maksim Eltyshev Date: Tue, 9 Apr 2024 15:12:46 +0200 Subject: [PATCH] feat: Automatic logout when session expires Closes #693 --- client/src/actions/core.js | 6 ++- client/src/entry-actions/cards.js | 8 ++-- client/src/entry-actions/core.js | 6 ++- client/src/sagas/core/request.js | 6 +-- client/src/sagas/core/services/cards.js | 4 +- client/src/sagas/core/services/core.js | 5 +-- client/src/sagas/core/services/router.js | 9 +++++ client/src/sagas/core/services/users.js | 1 + client/src/sagas/core/watchers/core.js | 6 ++- client/src/sagas/core/watchers/socket.js | 8 ++++ .../api/controllers/access-tokens/delete.js | 4 ++ server/api/hooks/current-user/index.js | 1 + server/api/hooks/oidc/index.js | 33 ++++++++++------ server/api/hooks/smtp/index.js | 37 +++++++++++------- server/api/hooks/watcher/index.js | 38 +++++++++++++++++++ 15 files changed, 130 insertions(+), 42 deletions(-) create mode 100644 server/api/hooks/watcher/index.js diff --git a/client/src/actions/core.js b/client/src/actions/core.js index 2d585874..92f081a1 100644 --- a/client/src/actions/core.js +++ b/client/src/actions/core.js @@ -47,9 +47,11 @@ initializeCore.fetchConfig = (config) => ({ }, }); -const logout = () => ({ +const logout = (invalidateAccessToken) => ({ type: ActionTypes.LOGOUT, - payload: {}, + payload: { + invalidateAccessToken, + }, }); logout.invalidateAccessToken = () => ({ diff --git a/client/src/entry-actions/cards.js b/client/src/entry-actions/cards.js index 346fc8d8..d3dd8495 100755 --- a/client/src/entry-actions/cards.js +++ b/client/src/entry-actions/cards.js @@ -38,7 +38,7 @@ const handleCardUpdate = (card) => ({ }, }); -const moveCard = (id, listId, index = 0) => ({ +const moveCard = (id, listId, index) => ({ type: EntryActionTypes.CARD_MOVE, payload: { id, @@ -47,7 +47,7 @@ const moveCard = (id, listId, index = 0) => ({ }, }); -const moveCurrentCard = (listId, index = 0) => ({ +const moveCurrentCard = (listId, index) => ({ type: EntryActionTypes.CURRENT_CARD_MOVE, payload: { listId, @@ -55,7 +55,7 @@ const moveCurrentCard = (listId, index = 0) => ({ }, }); -const transferCard = (id, boardId, listId, index = 0) => ({ +const transferCard = (id, boardId, listId, index) => ({ type: EntryActionTypes.CARD_TRANSFER, payload: { id, @@ -65,7 +65,7 @@ const transferCard = (id, boardId, listId, index = 0) => ({ }, }); -const transferCurrentCard = (boardId, listId, index = 0) => ({ +const transferCurrentCard = (boardId, listId, index) => ({ type: EntryActionTypes.CURRENT_CARD_TRANSFER, payload: { boardId, diff --git a/client/src/entry-actions/core.js b/client/src/entry-actions/core.js index c1e03fff..b5a3002b 100644 --- a/client/src/entry-actions/core.js +++ b/client/src/entry-actions/core.js @@ -1,8 +1,10 @@ import EntryActionTypes from '../constants/EntryActionTypes'; -const logout = () => ({ +const logout = (invalidateAccessToken) => ({ type: EntryActionTypes.LOGOUT, - payload: {}, + payload: { + invalidateAccessToken, + }, }); export default { diff --git a/client/src/sagas/core/request.js b/client/src/sagas/core/request.js index 2cfd9942..f905c7a0 100755 --- a/client/src/sagas/core/request.js +++ b/client/src/sagas/core/request.js @@ -1,8 +1,7 @@ import { call, fork, join, put, select, take } from 'redux-saga/effects'; import selectors from '../../selectors'; -import actions from '../../actions'; -import { removeAccessToken } from '../../utils/access-token-storage'; +import entryActions from '../../entry-actions'; import ErrorCodes from '../../constants/ErrorCodes'; let lastRequestTask; @@ -22,8 +21,7 @@ function* queueRequest(method, ...args) { }); } catch (error) { if (error.code === ErrorCodes.UNAUTHORIZED) { - yield call(removeAccessToken); - yield put(actions.logout()); // TODO: next url + yield put(entryActions.logout(false)); yield take(); } diff --git a/client/src/sagas/core/services/cards.js b/client/src/sagas/core/services/cards.js index eb2d1da4..7232c3f8 100644 --- a/client/src/sagas/core/services/cards.js +++ b/client/src/sagas/core/services/cards.js @@ -86,7 +86,7 @@ export function* handleCardUpdate(card) { yield put(actions.handleCardUpdate(card)); } -export function* moveCard(id, listId, index) { +export function* moveCard(id, listId, index = 0) { const position = yield select(selectors.selectNextCardPosition, listId, index, id); yield call(updateCard, id, { @@ -101,7 +101,7 @@ export function* moveCurrentCard(listId, index) { yield call(moveCard, cardId, listId, index); } -export function* transferCard(id, boardId, listId, index) { +export function* transferCard(id, boardId, listId, index = 0) { const { cardId: currentCardId, boardId: currentBoardId } = yield select(selectors.selectPath); const position = yield select(selectors.selectNextCardPosition, listId, index, id); diff --git a/client/src/sagas/core/services/core.js b/client/src/sagas/core/services/core.js index cd694136..9296c1cc 100644 --- a/client/src/sagas/core/services/core.js +++ b/client/src/sagas/core/services/core.js @@ -1,4 +1,4 @@ -import { call, put, select, take } from 'redux-saga/effects'; +import { call, put, select } from 'redux-saga/effects'; import request from '../request'; import requests from '../requests'; @@ -84,8 +84,7 @@ export function* logout(invalidateAccessToken = true) { } catch (error) {} // eslint-disable-line no-empty } - yield put(actions.logout()); - yield take(); + yield put(actions.logout()); // TODO: next url } export default { diff --git a/client/src/sagas/core/services/router.js b/client/src/sagas/core/services/router.js index 7b981e49..3f6ab578 100644 --- a/client/src/sagas/core/services/router.js +++ b/client/src/sagas/core/services/router.js @@ -1,10 +1,12 @@ import { call, put, select, take } from 'redux-saga/effects'; import { push } from '../../../lib/redux-router'; +import { logout } from './core'; import request from '../request'; import selectors from '../../../selectors'; import actions from '../../../actions'; import api from '../../../api'; +import { getAccessToken } from '../../../utils/access-token-storage'; import ActionTypes from '../../../constants/ActionTypes'; import Paths from '../../../constants/Paths'; @@ -25,6 +27,13 @@ export function* goToCard(cardId) { } export function* handleLocationChange() { + const accessToken = yield call(getAccessToken); + + if (!accessToken) { + yield call(logout, false); + return; + } + const pathsMatch = yield select(selectors.selectPathsMatch); if (!pathsMatch) { diff --git a/client/src/sagas/core/services/users.js b/client/src/sagas/core/services/users.js index 8b6b865c..2f6008c5 100644 --- a/client/src/sagas/core/services/users.js +++ b/client/src/sagas/core/services/users.js @@ -218,6 +218,7 @@ export function* handleUserDelete(user) { if (user.id === currentUserId) { yield call(logout, false); + return; } yield put(actions.handleUserDelete(user)); diff --git a/client/src/sagas/core/watchers/core.js b/client/src/sagas/core/watchers/core.js index cab47046..dd586687 100644 --- a/client/src/sagas/core/watchers/core.js +++ b/client/src/sagas/core/watchers/core.js @@ -4,5 +4,9 @@ import services from '../services'; import EntryActionTypes from '../../../constants/EntryActionTypes'; export default function* coreWatchers() { - yield all([takeEvery(EntryActionTypes.LOGOUT, () => services.logout())]); + yield all([ + takeEvery(EntryActionTypes.LOGOUT, ({ payload: { invalidateAccessToken } }) => + services.logout(invalidateAccessToken), + ), + ]); } diff --git a/client/src/sagas/core/watchers/socket.js b/client/src/sagas/core/watchers/socket.js index 169de93f..ddb65cf0 100644 --- a/client/src/sagas/core/watchers/socket.js +++ b/client/src/sagas/core/watchers/socket.js @@ -16,6 +16,10 @@ const createSocketEventsChannel = () => emit(entryActions.handleSocketReconnect()); }; + const handleLogout = () => { + emit(entryActions.logout(false)); + }; + const handleUserCreate = api.makeHandleUserCreate(({ item }) => { emit(entryActions.handleUserCreate(item)); }); @@ -171,6 +175,8 @@ const createSocketEventsChannel = () => socket.on('disconnect', handleDisconnect); socket.on('reconnect', handleReconnect); + socket.on('logout', handleLogout); + socket.on('userCreate', handleUserCreate); socket.on('userUpdate', handleUserUpdate); socket.on('userDelete', handleUserDelete); @@ -227,6 +233,8 @@ const createSocketEventsChannel = () => socket.off('disconnect', handleDisconnect); socket.off('reconnect', handleReconnect); + socket.off('logout', handleLogout); + socket.off('userCreate', handleUserCreate); socket.off('userUpdate', handleUserUpdate); socket.off('userDelete', handleUserDelete); diff --git a/server/api/controllers/access-tokens/delete.js b/server/api/controllers/access-tokens/delete.js index e3889e2e..b252a0c0 100644 --- a/server/api/controllers/access-tokens/delete.js +++ b/server/api/controllers/access-tokens/delete.js @@ -9,6 +9,10 @@ module.exports = { deletedAt: new Date().toISOString(), }); + if (this.req.isSocket) { + sails.sockets.leaveAll(`@accessToken:${accessToken}`); + } + return { item: accessToken, }; diff --git a/server/api/hooks/current-user/index.js b/server/api/hooks/current-user/index.js index dd295954..1d3214f6 100644 --- a/server/api/hooks/current-user/index.js +++ b/server/api/hooks/current-user/index.js @@ -61,6 +61,7 @@ module.exports = function defineCurrentUserHook(sails) { }); if (req.isSocket) { + sails.sockets.join(req, `@accessToken:${accessToken}`); sails.sockets.join(req, `@user:${currentUser.id}`); } } diff --git a/server/api/hooks/oidc/index.js b/server/api/hooks/oidc/index.js index 05d9d0bb..6dbeddd1 100644 --- a/server/api/hooks/oidc/index.js +++ b/server/api/hooks/oidc/index.js @@ -1,6 +1,14 @@ const openidClient = require('openid-client'); -module.exports = function oidcServiceHook(sails) { +/** + * oidc hook + * + * @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions, + * and/or initialization logic. + * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks + */ + +module.exports = function defineOidcHook(sails) { let client = null; return { @@ -9,17 +17,20 @@ module.exports = function oidcServiceHook(sails) { */ async initialize() { - if (sails.config.custom.oidcIssuer) { - const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer); - - client = new issuer.Client({ - client_id: sails.config.custom.oidcClientId, - client_secret: sails.config.custom.oidcClientSecret, - redirect_uris: [sails.config.custom.oidcRedirectUri], - response_types: ['code'], - }); - sails.log.info('OIDC hook has been loaded successfully'); + if (!sails.config.custom.oidcIssuer) { + return; } + + sails.log.info('Initializing custom hook (`oidc`)'); + + const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer); + + client = new issuer.Client({ + client_id: sails.config.custom.oidcClientId, + client_secret: sails.config.custom.oidcClientSecret, + redirect_uris: [sails.config.custom.oidcRedirectUri], + response_types: ['code'], + }); }, getClient() { diff --git a/server/api/hooks/smtp/index.js b/server/api/hooks/smtp/index.js index 4cd05038..d6ce84f7 100644 --- a/server/api/hooks/smtp/index.js +++ b/server/api/hooks/smtp/index.js @@ -1,6 +1,14 @@ const nodemailer = require('nodemailer'); -module.exports = function smtpServiceHook(sails) { +/** + * smtp hook + * + * @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions, + * and/or initialization logic. + * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks + */ + +module.exports = function defineSmtpHook(sails) { let transporter = null; return { @@ -9,19 +17,22 @@ module.exports = function smtpServiceHook(sails) { */ async initialize() { - if (sails.config.custom.smtpHost) { - transporter = nodemailer.createTransport({ - pool: true, - host: sails.config.custom.smtpHost, - port: sails.config.custom.smtpPort, - secure: sails.config.custom.smtpSecure, - auth: sails.config.custom.smtpUser && { - user: sails.config.custom.smtpUser, - pass: sails.config.custom.smtpPassword, - }, - }); - sails.log.info('SMTP hook has been loaded successfully'); + if (!sails.config.custom.smtpHost) { + return; } + + sails.log.info('Initializing custom hook (`smtp`)'); + + transporter = nodemailer.createTransport({ + pool: true, + host: sails.config.custom.smtpHost, + port: sails.config.custom.smtpPort, + secure: sails.config.custom.smtpSecure, + auth: sails.config.custom.smtpUser && { + user: sails.config.custom.smtpUser, + pass: sails.config.custom.smtpPassword, + }, + }); }, getTransporter() { diff --git a/server/api/hooks/watcher/index.js b/server/api/hooks/watcher/index.js new file mode 100644 index 00000000..835b1e8d --- /dev/null +++ b/server/api/hooks/watcher/index.js @@ -0,0 +1,38 @@ +/** + * watcher hook + * + * @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions, + * and/or initialization logic. + * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks + */ + +module.exports = function defineWatcherHook(sails) { + const checkSocketConnectionsToLogout = () => { + Object.keys(sails.io.sockets.adapter.rooms).forEach((room) => { + if (!room.startsWith('@accessToken:')) { + return; + } + + const accessToken = room.split(':')[1]; + + try { + sails.helpers.utils.verifyToken(accessToken); + } catch (error) { + sails.sockets.broadcast(room, 'logout'); + sails.sockets.leaveAll(room); + } + }); + }; + + return { + /** + * Runs when this Sails app loads/lifts. + */ + + async initialize() { + sails.log.info('Initializing custom hook (`watcher`)'); + + setInterval(checkSocketConnectionsToLogout, 60 * 1000); + }, + }; +};