diff --git a/client/src/actions/core.js b/client/src/actions/core.js index e144bd94..bed09c3d 100644 --- a/client/src/actions/core.js +++ b/client/src/actions/core.js @@ -44,6 +44,11 @@ const logout = () => ({ payload: {}, }); +logout.invalidateAccessToken = () => ({ + type: ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE, + payload: {}, +}); + export default { initializeCore, logout, diff --git a/client/src/actions/users.js b/client/src/actions/users.js index a1e8648a..ad1aaa5d 100644 --- a/client/src/actions/users.js +++ b/client/src/actions/users.js @@ -103,10 +103,11 @@ const updateUserPassword = (id, data) => ({ }, }); -updateUserPassword.success = (user) => ({ +updateUserPassword.success = (user, accessToken) => ({ type: ActionTypes.USER_PASSWORD_UPDATE__SUCCESS, payload: { user, + accessToken, }, }); diff --git a/client/src/api/access-tokens.js b/client/src/api/access-tokens.js index 6cd10639..1778618a 100755 --- a/client/src/api/access-tokens.js +++ b/client/src/api/access-tokens.js @@ -1,9 +1,14 @@ import http from './http'; +import socket from './socket'; /* Actions */ const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers); +const deleteCurrentAccessToken = (headers) => + socket.delete('/access-tokens/me', undefined, headers); + export default { createAccessToken, + deleteCurrentAccessToken, }; diff --git a/client/src/components/Header/Header.jsx b/client/src/components/Header/Header.jsx index a6db9219..462ab97a 100755 --- a/client/src/components/Header/Header.jsx +++ b/client/src/components/Header/Header.jsx @@ -15,6 +15,7 @@ const Header = React.memo( project, user, notifications, + isLogouting, canEditProject, canEditUsers, onProjectSettingsClick, @@ -75,7 +76,11 @@ const Header = React.memo( )} - + {user.name} @@ -93,6 +98,7 @@ Header.propTypes = { user: PropTypes.object.isRequired, notifications: PropTypes.array.isRequired, /* eslint-enable react/forbid-prop-types */ + isLogouting: PropTypes.bool.isRequired, canEditProject: PropTypes.bool.isRequired, canEditUsers: PropTypes.bool.isRequired, onProjectSettingsClick: PropTypes.func.isRequired, diff --git a/client/src/components/UserPopup/UserPopup.jsx b/client/src/components/UserPopup/UserPopup.jsx index 1cbb69f0..1a84e30f 100755 --- a/client/src/components/UserPopup/UserPopup.jsx +++ b/client/src/components/UserPopup/UserPopup.jsx @@ -1,13 +1,13 @@ import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; -import { Menu } from 'semantic-ui-react'; +import { Button, Menu } from 'semantic-ui-react'; import { withPopup } from '../../lib/popup'; import { Popup } from '../../lib/custom-ui'; import styles from './UserPopup.module.scss'; -const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => { +const UserStep = React.memo(({ isLogouting, onSettingsClick, onLogout, onClose }) => { const [t] = useTranslation(); const handleSettingsClick = useCallback(() => { @@ -15,6 +15,17 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => { onClose(); }, [onSettingsClick, onClose]); + let logoutMenuItemProps; + if (isLogouting) { + logoutMenuItemProps = { + as: Button, + fluid: true, + basic: true, + loading: true, + disabled: true, + }; + } + return ( <> @@ -29,7 +40,11 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => { context: 'title', })} - + {t('action.logOut', { context: 'title', })} @@ -41,6 +56,7 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => { }); UserStep.propTypes = { + isLogouting: PropTypes.bool.isRequired, onSettingsClick: PropTypes.func.isRequired, onLogout: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, diff --git a/client/src/constants/ActionTypes.js b/client/src/constants/ActionTypes.js index 998c8ccd..4b075304 100644 --- a/client/src/constants/ActionTypes.js +++ b/client/src/constants/ActionTypes.js @@ -21,6 +21,7 @@ export default { CORE_INITIALIZE: 'CORE_INITIALIZE', LOGOUT: 'LOGOUT', + LOGOUT__ACCESS_TOKEN_INVALIDATE: 'LOGOUT__ACCESS_TOKEN_INVALIDATE', /* Modals */ diff --git a/client/src/containers/HeaderContainer.js b/client/src/containers/HeaderContainer.js index 0ceb24a8..daeb607a 100755 --- a/client/src/containers/HeaderContainer.js +++ b/client/src/containers/HeaderContainer.js @@ -6,6 +6,7 @@ import entryActions from '../entry-actions'; import Header from '../components/Header'; const mapStateToProps = (state) => { + const isLogouting = selectors.selectIsLogouting(state); const currentUser = selectors.selectCurrentUser(state); const currentProject = selectors.selectCurrentProject(state); const notifications = selectors.selectNotificationsForCurrentUser(state); @@ -13,6 +14,7 @@ const mapStateToProps = (state) => { return { notifications, + isLogouting, project: currentProject, user: currentUser, canEditProject: isCurrentUserManager, diff --git a/client/src/reducers/auth.js b/client/src/reducers/auth.js index d5b3c3c4..9ca6ae52 100755 --- a/client/src/reducers/auth.js +++ b/client/src/reducers/auth.js @@ -1,18 +1,34 @@ +import { getAccessToken } from '../utils/access-token-storage'; import ActionTypes from '../constants/ActionTypes'; const initialState = { + accessToken: getAccessToken(), userId: null, }; // eslint-disable-next-line default-param-last export default (state = initialState, { type, payload }) => { switch (type) { + case ActionTypes.AUTHENTICATE__SUCCESS: + return { + ...state, + accessToken: payload.accessToken, + }; case ActionTypes.SOCKET_RECONNECT_HANDLE: case ActionTypes.CORE_INITIALIZE: return { ...state, userId: payload.user.id, }; + case ActionTypes.USER_PASSWORD_UPDATE__SUCCESS: + if (payload.accessToken) { + return { + ...state, + accessToken: payload.accessToken, + }; + } + + return state; default: return state; } diff --git a/client/src/reducers/core.js b/client/src/reducers/core.js index b6f0d45f..d2d0fa83 100755 --- a/client/src/reducers/core.js +++ b/client/src/reducers/core.js @@ -5,6 +5,7 @@ import ModalTypes from '../constants/ModalTypes'; const initialState = { isInitializing: true, + isLogouting: false, currentModal: null, }; @@ -22,6 +23,11 @@ export default (state = initialState, { type, payload }) => { ...state, isInitializing: false, }; + case ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE: + return { + ...state, + isLogouting: true, + }; case ActionTypes.MODAL_OPEN: return { ...state, diff --git a/client/src/sagas/core/index.js b/client/src/sagas/core/index.js index 9b4aeb58..e841af98 100755 --- a/client/src/sagas/core/index.js +++ b/client/src/sagas/core/index.js @@ -1,9 +1,8 @@ -import { all, apply, call, fork, take } from 'redux-saga/effects'; +import { all, apply, fork, take } from 'redux-saga/effects'; import watchers from './watchers'; import services from './services'; import { socket } from '../../api'; -import { removeAccessToken } from '../../utils/access-token-storage'; import ActionTypes from '../../constants/ActionTypes'; import Paths from '../../constants/Paths'; @@ -15,6 +14,5 @@ export default function* coreSaga() { yield take(ActionTypes.LOGOUT); - yield call(removeAccessToken); window.location.href = Paths.LOGIN; } diff --git a/client/src/sagas/core/request.js b/client/src/sagas/core/request.js index a918862e..2cfd9942 100755 --- a/client/src/sagas/core/request.js +++ b/client/src/sagas/core/request.js @@ -1,7 +1,8 @@ -import { call, fork, join, put, take } from 'redux-saga/effects'; +import { call, fork, join, put, select, take } from 'redux-saga/effects'; +import selectors from '../../selectors'; import actions from '../../actions'; -import { getAccessToken } from '../../utils/access-token-storage'; +import { removeAccessToken } from '../../utils/access-token-storage'; import ErrorCodes from '../../constants/ErrorCodes'; let lastRequestTask; @@ -13,7 +14,7 @@ function* queueRequest(method, ...args) { } catch {} // eslint-disable-line no-empty } - const accessToken = yield call(getAccessToken); + const accessToken = yield select(selectors.selectAccessToken); try { return yield call(method, ...args, { @@ -21,6 +22,7 @@ function* queueRequest(method, ...args) { }); } catch (error) { if (error.code === ErrorCodes.UNAUTHORIZED) { + yield call(removeAccessToken); yield put(actions.logout()); // TODO: next url yield take(); } diff --git a/client/src/sagas/core/services/core.js b/client/src/sagas/core/services/core.js index 5420e415..edf4c01a 100644 --- a/client/src/sagas/core/services/core.js +++ b/client/src/sagas/core/services/core.js @@ -1,8 +1,11 @@ import { call, put, take } from 'redux-saga/effects'; +import request from '../request'; import requests from '../requests'; import actions from '../../../actions'; +import api from '../../../api'; import i18n from '../../../i18n'; +import { removeAccessToken } from '../../../utils/access-token-storage'; export function* initializeCore() { const { @@ -60,7 +63,17 @@ export function* changeCoreLanguage(language) { } } -export function* logout() { +export function* logout(invalidateAccessToken = true) { + yield call(removeAccessToken); + + if (invalidateAccessToken) { + yield put(actions.logout.invalidateAccessToken()); + + try { + yield call(request, api.deleteCurrentAccessToken); + } catch (error) {} // eslint-disable-line no-empty + } + yield put(actions.logout()); yield take(); } diff --git a/client/src/sagas/core/services/users.js b/client/src/sagas/core/services/users.js index b950e2f0..8b6b865c 100644 --- a/client/src/sagas/core/services/users.js +++ b/client/src/sagas/core/services/users.js @@ -124,11 +124,13 @@ export function* updateUserPassword(id, data) { return; } - if (accessTokens && accessTokens[0]) { - yield call(setAccessToken, accessTokens[0]); + const accessToken = accessTokens && accessTokens[0]; + + if (accessToken) { + yield call(setAccessToken, accessToken); } - yield put(actions.updateUserPassword.success(user)); + yield put(actions.updateUserPassword.success(user, accessToken)); } export function* updateCurrentUserPassword(data) { @@ -215,7 +217,7 @@ export function* handleUserDelete(user) { const currentUserId = yield select(selectors.selectCurrentUserId); if (user.id === currentUserId) { - yield call(logout); + yield call(logout, false); } yield put(actions.handleUserDelete(user)); diff --git a/client/src/sagas/login/index.js b/client/src/sagas/login/index.js index 785de29d..d47b352d 100755 --- a/client/src/sagas/login/index.js +++ b/client/src/sagas/login/index.js @@ -2,18 +2,13 @@ import { all, call, cancel, fork, take } from 'redux-saga/effects'; import watchers from './watchers'; import services from './services'; -import { setAccessToken } from '../../utils/access-token-storage'; import ActionTypes from '../../constants/ActionTypes'; export default function* loginSaga() { const watcherTasks = yield all(watchers.map((watcher) => fork(watcher))); - const { - payload: { accessToken }, - } = yield take(ActionTypes.AUTHENTICATE__SUCCESS); + yield take(ActionTypes.AUTHENTICATE__SUCCESS); yield cancel(watcherTasks); - - yield call(setAccessToken, accessToken); yield call(services.goToRoot); } diff --git a/client/src/sagas/login/services/login.js b/client/src/sagas/login/services/login.js index f4a6d39a..7bdf7f2d 100644 --- a/client/src/sagas/login/services/login.js +++ b/client/src/sagas/login/services/login.js @@ -2,6 +2,7 @@ import { call, put } from 'redux-saga/effects'; import actions from '../../../actions'; import api from '../../../api'; +import { setAccessToken } from '../../../utils/access-token-storage'; export function* authenticate(data) { yield put(actions.authenticate(data)); @@ -14,6 +15,7 @@ export function* authenticate(data) { return; } + yield call(setAccessToken, accessToken); yield put(actions.authenticate.success(accessToken)); } diff --git a/client/src/selectors/auth.js b/client/src/selectors/auth.js deleted file mode 100644 index 5f86ff36..00000000 --- a/client/src/selectors/auth.js +++ /dev/null @@ -1,5 +0,0 @@ -export const selectAccessToken = ({ auth: { accessToken } }) => accessToken; - -export default { - selectAccessToken, -}; diff --git a/client/src/selectors/core.js b/client/src/selectors/core.js index 0bd9edfe..6133ac77 100755 --- a/client/src/selectors/core.js +++ b/client/src/selectors/core.js @@ -4,8 +4,12 @@ import isUndefined from 'lodash/isUndefined'; import orm from '../orm'; import Config from '../constants/Config'; +export const selectAccessToken = ({ auth: { accessToken } }) => accessToken; + export const selectIsCoreInitializing = ({ core: { isInitializing } }) => isInitializing; +export const selectIsLogouting = ({ core: { isLogouting } }) => isLogouting; + const nextPosition = (items, index, excludedId) => { const filteredItems = isUndefined(excludedId) ? items @@ -94,7 +98,9 @@ export const selectNextTaskPosition = createSelector( ); export default { + selectAccessToken, selectIsCoreInitializing, + selectIsLogouting, selectNextBoardPosition, selectNextListPosition, selectNextCardPosition, diff --git a/client/src/selectors/index.js b/client/src/selectors/index.js index c3da383f..617b2b2c 100755 --- a/client/src/selectors/index.js +++ b/client/src/selectors/index.js @@ -1,5 +1,4 @@ import router from './router'; -import auth from './auth'; import core from './core'; import modals from './modals'; import users from './users'; @@ -14,7 +13,6 @@ import attachments from './attachments'; export default { ...router, - ...auth, ...core, ...modals, ...users, diff --git a/server/api/controllers/access-tokens/create.js b/server/api/controllers/access-tokens/create.js index 5453d726..cdb76243 100755 --- a/server/api/controllers/access-tokens/create.js +++ b/server/api/controllers/access-tokens/create.js @@ -40,24 +40,33 @@ module.exports = { }, async fn(inputs) { + const remoteAddress = getRemoteAddress(this.req); + const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername); if (!user) { sails.log.warn( - `Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${getRemoteAddress( - this.req, - )})`, + `Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${remoteAddress})`, ); throw Errors.INVALID_EMAIL_OR_USERNAME; } if (!bcrypt.compareSync(inputs.password, user.password)) { - sails.log.warn(`Invalid password! (IP: ${getRemoteAddress(this.req)})`); + sails.log.warn(`Invalid password! (IP: ${remoteAddress})`); throw Errors.INVALID_PASSWORD; } + const accessToken = sails.helpers.utils.createToken(user.id); + + await Session.create({ + accessToken, + remoteAddress, + userId: user.id, + userAgent: this.req.headers['user-agent'], + }); + return { - item: sails.helpers.utils.createToken(user.id), + item: accessToken, }; }, }; diff --git a/server/api/controllers/access-tokens/delete.js b/server/api/controllers/access-tokens/delete.js new file mode 100644 index 00000000..a42b739e --- /dev/null +++ b/server/api/controllers/access-tokens/delete.js @@ -0,0 +1,16 @@ +module.exports = { + async fn() { + const { accessToken } = this.req; + + await Session.updateOne({ + accessToken, + deletedAt: null, + }).set({ + deletedAt: new Date().toUTCString(), + }); + + return { + item: accessToken, + }; + }, +}; diff --git a/server/api/controllers/users/update-password.js b/server/api/controllers/users/update-password.js index a99258b7..157ea16c 100644 --- a/server/api/controllers/users/update-password.js +++ b/server/api/controllers/users/update-password.js @@ -1,6 +1,8 @@ const bcrypt = require('bcrypt'); const zxcvbn = require('zxcvbn'); +const { getRemoteAddress } = require('../../../utils/remoteAddress'); + const Errors = { USER_NOT_FOUND: { userNotFound: 'User not found', @@ -71,6 +73,13 @@ module.exports = { if (user.id === currentUser.id) { const accessToken = sails.helpers.utils.createToken(user.id, user.passwordUpdatedAt); + await Session.create({ + accessToken, + userId: user.id, + remoteAddress: getRemoteAddress(this.req), + userAgent: this.req.headers['user-agent'], + }); + return { item: user, included: { diff --git a/server/api/helpers/utils/create-token.js b/server/api/helpers/utils/create-token.js index 01d6ed63..b0db071a 100644 --- a/server/api/helpers/utils/create-token.js +++ b/server/api/helpers/utils/create-token.js @@ -1,3 +1,4 @@ +const { v4: uuid } = require('uuid'); const jwt = require('jsonwebtoken'); module.exports = { @@ -24,6 +25,9 @@ module.exports = { exp: iat + sails.config.custom.tokenExpiresIn * 24 * 60 * 60, }, sails.config.session.secret, + { + keyid: uuid(), + }, ); }, }; diff --git a/server/api/hooks/current-user/index.js b/server/api/hooks/current-user/index.js index ee374031..9531b7b0 100644 --- a/server/api/hooks/current-user/index.js +++ b/server/api/hooks/current-user/index.js @@ -17,6 +17,15 @@ module.exports = function defineCurrentUserHook(sails) { return null; } + const session = await Session.findOne({ + accessToken, + deletedAt: null, + }); + + if (!session) { + return null; + } + const user = await sails.helpers.users.getOne(payload.subject); if (user && user.passwordChangedAt > payload.issuedAt) { @@ -43,8 +52,14 @@ module.exports = function defineCurrentUserHook(sails) { if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) { const accessToken = authorizationHeader.replace(TOKEN_PATTERN, ''); + const currentUser = await getUser(accessToken); - req.currentUser = await getUser(accessToken); + if (currentUser) { + Object.assign(req, { + accessToken, + currentUser, + }); + } } return next(); @@ -52,8 +67,17 @@ module.exports = function defineCurrentUserHook(sails) { }, '/attachments/*': { async fn(req, res, next) { - if (req.cookies.accessToken) { - req.currentUser = await getUser(req.cookies.accessToken); + const { accessToken } = req.cookies; + + if (accessToken) { + const currentUser = await getUser(accessToken); + + if (currentUser) { + Object.assign(req, { + accessToken, + currentUser, + }); + } } return next(); diff --git a/server/api/models/Session.js b/server/api/models/Session.js new file mode 100755 index 00000000..61a9df40 --- /dev/null +++ b/server/api/models/Session.js @@ -0,0 +1,49 @@ +/** + * Session.js + * + * @description :: A model definition represents a database table/collection. + * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models + */ + +module.exports = { + attributes: { + // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ + // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ + // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ + + accessToken: { + type: 'string', + required: true, + columnName: 'access_token', + }, + remoteAddress: { + type: 'string', + required: true, + columnName: 'remote_address', + }, + userAgent: { + type: 'string', + isNotEmptyString: true, + allowNull: true, + columnName: 'user_agent', + }, + deletedAt: { + type: 'ref', + columnName: 'deleted_at', + }, + + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ + // ║╣ ║║║╠╩╗║╣ ║║╚═╗ + // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ + + // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ + // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + + userId: { + model: 'User', + required: true, + columnName: 'user_id', + }, + }, +}; diff --git a/server/config/routes.js b/server/config/routes.js index 31e5a1e4..05450eac 100644 --- a/server/config/routes.js +++ b/server/config/routes.js @@ -10,6 +10,7 @@ module.exports.routes = { 'POST /api/access-tokens': 'access-tokens/create', + 'DELETE /api/access-tokens/me': 'access-tokens/delete', 'GET /api/users': 'users/index', 'POST /api/users': 'users/create', diff --git a/server/db/migrations/20220906094517_create_session_table.js b/server/db/migrations/20220906094517_create_session_table.js new file mode 100644 index 00000000..7f12651a --- /dev/null +++ b/server/db/migrations/20220906094517_create_session_table.js @@ -0,0 +1,24 @@ +module.exports.up = (knex) => + knex.schema.createTable('session', (table) => { + /* Columns */ + + table.bigInteger('id').primary().defaultTo(knex.raw('next_id()')); + + table.bigInteger('user_id').notNullable(); + + table.text('access_token').notNullable(); + table.text('remote_address').notNullable(); + table.text('user_agent'); + + table.timestamp('created_at', true); + table.timestamp('updated_at', true); + table.timestamp('deleted_at', true); + + /* Indexes */ + + table.index('user_id'); + table.unique('access_token'); + table.index('remote_address'); + }); + +module.exports.down = (knex) => knex.schema.dropTable('session');