From 2b4c2b0f497b8fe59511db01d74078c5222750e4 Mon Sep 17 00:00:00 2001 From: SimonTagne <40598597+SimonTagne@users.noreply.github.com> Date: Tue, 9 Aug 2022 18:03:21 +0200 Subject: [PATCH] feat: Improve security of access tokens (#279) Closes #275 --- client/package-lock.json | 11 ++++++ client/package.json | 1 + client/src/api/access-tokens.js | 2 +- client/src/api/activities.js | 4 +- client/src/api/attachments.js | 12 +++--- client/src/api/board-memberships.js | 7 ++-- client/src/api/boards.js | 11 +++--- client/src/api/card-labels.js | 6 ++- client/src/api/card-memberships.js | 7 ++-- client/src/api/cards.js | 20 +++++----- client/src/api/comment-activities.js | 12 +++--- client/src/api/http.js | 4 +- client/src/api/labels.js | 7 ++-- client/src/api/lists.js | 7 ++-- client/src/api/notifications.js | 12 +++--- client/src/api/project-managers.js | 7 ++-- client/src/api/projects.js | 14 +++---- client/src/api/socket.js | 3 +- client/src/api/tasks.js | 6 +-- client/src/api/users.js | 22 ++++++----- client/src/constants/Config.js | 14 ++----- client/src/sagas/core/request.js | 7 +++- client/src/sagas/core/services/users.js | 9 ++++- client/src/utils/access-token-storage.js | 27 ++++++++++++-- package-lock.json | 4 +- server/.env.sample | 7 +++- .../api/controllers/access-tokens/create.js | 2 +- server/api/controllers/users/update-avatar.js | 1 + server/api/controllers/users/update-email.js | 2 +- .../api/controllers/users/update-password.js | 11 +++++- .../api/controllers/users/update-username.js | 2 +- server/api/controllers/users/update.js | 2 +- server/api/helpers/users/update-one.js | 34 ++++++++++++++++- server/api/helpers/utils/create-token.js | 29 +++++++++++++++ server/api/helpers/utils/sign-token.js | 16 -------- server/api/helpers/utils/verify-token.js | 8 +++- server/api/hooks/current-user/index.js | 37 ++++++++++++------- server/api/models/User.js | 6 ++- server/config/custom.js | 2 + ...ssword_changed_at_to_user_account_table.js | 11 ++++++ 40 files changed, 273 insertions(+), 133 deletions(-) create mode 100644 server/api/helpers/utils/create-token.js delete mode 100644 server/api/helpers/utils/sign-token.js create mode 100644 server/db/migrations/20220803221221_add_password_changed_at_to_user_account_table.js diff --git a/client/package-lock.json b/client/package-lock.json index 935151ee..2605b6be 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,6 +15,7 @@ "i18next-browser-languagedetector": "^6.1.4", "initials": "^3.1.2", "js-cookie": "^3.0.1", + "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "node-sass": "^7.0.1", "photoswipe": "^5.3.0", @@ -14333,6 +14334,11 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "node_modules/keyboard-key": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz", @@ -35776,6 +35782,11 @@ "object.assign": "^4.1.2" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "keyboard-key": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz", diff --git a/client/package.json b/client/package.json index 9cb0fe07..c6e4e9ec 100755 --- a/client/package.json +++ b/client/package.json @@ -72,6 +72,7 @@ "i18next-browser-languagedetector": "^6.1.4", "initials": "^3.1.2", "js-cookie": "^3.0.1", + "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "node-sass": "^7.0.1", "photoswipe": "^5.3.0", diff --git a/client/src/api/access-tokens.js b/client/src/api/access-tokens.js index 0e5c9883..6cd10639 100755 --- a/client/src/api/access-tokens.js +++ b/client/src/api/access-tokens.js @@ -2,7 +2,7 @@ import http from './http'; /* Actions */ -const createAccessToken = (data) => http.post('/access-tokens', data); +const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers); export default { createAccessToken, diff --git a/client/src/api/activities.js b/client/src/api/activities.js index b99999bf..a404eeec 100755 --- a/client/src/api/activities.js +++ b/client/src/api/activities.js @@ -9,8 +9,8 @@ export const transformActivity = (activity) => ({ /* Actions */ -const getActivities = (cardId, data) => - socket.get(`/cards/${cardId}/actions`, data).then((body) => ({ +const getActivities = (cardId, data, headers) => + socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({ ...body, items: body.items.map(transformActivity), })); diff --git a/client/src/api/attachments.js b/client/src/api/attachments.js index 8ed64726..f3edba43 100755 --- a/client/src/api/attachments.js +++ b/client/src/api/attachments.js @@ -10,20 +10,20 @@ export const transformAttachment = (attachment) => ({ /* Actions */ -const createAttachment = (cardId, data, requestId) => - http.post(`/cards/${cardId}/attachments?requestId=${requestId}`, data).then((body) => ({ +const createAttachment = (cardId, data, requestId, headers) => + http.post(`/cards/${cardId}/attachments?requestId=${requestId}`, data, headers).then((body) => ({ ...body, item: transformAttachment(body.item), })); -const updateAttachment = (id, data) => - socket.patch(`/attachments/${id}`, data).then((body) => ({ +const updateAttachment = (id, data, headers) => + socket.patch(`/attachments/${id}`, data, headers).then((body) => ({ ...body, item: transformAttachment(body.item), })); -const deleteAttachment = (id) => - socket.delete(`/attachments/${id}`).then((body) => ({ +const deleteAttachment = (id, headers) => + socket.delete(`/attachments/${id}`, undefined, headers).then((body) => ({ ...body, item: transformAttachment(body.item), })); diff --git a/client/src/api/board-memberships.js b/client/src/api/board-memberships.js index 0fa087ba..3ef1ce87 100644 --- a/client/src/api/board-memberships.js +++ b/client/src/api/board-memberships.js @@ -2,10 +2,11 @@ import socket from './socket'; /* Actions */ -const createBoardMembership = (boardId, data) => - socket.post(`/boards/${boardId}/memberships`, data); +const createBoardMembership = (boardId, data, headers) => + socket.post(`/boards/${boardId}/memberships`, data, headers); -const deleteBoardMembership = (id) => socket.delete(`/board-memberships/${id}`); +const deleteBoardMembership = (id, headers) => + socket.delete(`/board-memberships/${id}`, undefined, headers); export default { createBoardMembership, diff --git a/client/src/api/boards.js b/client/src/api/boards.js index 4e618cea..b605ed2e 100755 --- a/client/src/api/boards.js +++ b/client/src/api/boards.js @@ -4,10 +4,11 @@ import { transformAttachment } from './attachments'; /* Actions */ -const createBoard = (projectId, data) => socket.post(`/projects/${projectId}/boards`, data); +const createBoard = (projectId, data, headers) => + socket.post(`/projects/${projectId}/boards`, data, headers); -const getBoard = (id) => - socket.get(`/boards/${id}`).then((body) => ({ +const getBoard = (id, headers) => + socket.get(`/boards/${id}`, undefined, headers).then((body) => ({ ...body, included: { ...body.included, @@ -16,9 +17,9 @@ const getBoard = (id) => }, })); -const updateBoard = (id, data) => socket.patch(`/boards/${id}`, data); +const updateBoard = (id, data, headers) => socket.patch(`/boards/${id}`, data, headers); -const deleteBoard = (id) => socket.delete(`/boards/${id}`); +const deleteBoard = (id, headers) => socket.delete(`/boards/${id}`, undefined, headers); export default { createBoard, diff --git a/client/src/api/card-labels.js b/client/src/api/card-labels.js index d4fc0fa9..92715d64 100644 --- a/client/src/api/card-labels.js +++ b/client/src/api/card-labels.js @@ -2,9 +2,11 @@ import socket from './socket'; /* Actions */ -const createCardLabel = (cardId, data) => socket.post(`/cards/${cardId}/labels`, data); +const createCardLabel = (cardId, data, headers) => + socket.post(`/cards/${cardId}/labels`, data, headers); -const deleteCardLabel = (cardId, labelId) => socket.delete(`/cards/${cardId}/labels/${labelId}`); +const deleteCardLabel = (cardId, labelId, headers) => + socket.delete(`/cards/${cardId}/labels/${labelId}`, undefined, headers); export default { createCardLabel, diff --git a/client/src/api/card-memberships.js b/client/src/api/card-memberships.js index 23330301..2deb9d92 100644 --- a/client/src/api/card-memberships.js +++ b/client/src/api/card-memberships.js @@ -2,10 +2,11 @@ import socket from './socket'; /* Actions */ -const createCardMembership = (cardId, data) => socket.post(`/cards/${cardId}/memberships`, data); +const createCardMembership = (cardId, data, headers) => + socket.post(`/cards/${cardId}/memberships`, data, headers); -const deleteCardMembership = (cardId, userId) => - socket.delete(`/cards/${cardId}/memberships?userId=${userId}`); +const deleteCardMembership = (cardId, userId, headers) => + socket.delete(`/cards/${cardId}/memberships?userId=${userId}`, undefined, headers); export default { createCardMembership, diff --git a/client/src/api/cards.js b/client/src/api/cards.js index b8e26244..c7e34353 100755 --- a/client/src/api/cards.js +++ b/client/src/api/cards.js @@ -35,8 +35,8 @@ export const transformCardData = (data) => ({ /* Actions */ -const getCards = (boardId, data) => - socket.get(`/board/${boardId}/cards`, data).then((body) => ({ +const getCards = (boardId, data, headers) => + socket.get(`/board/${boardId}/cards`, data, headers).then((body) => ({ ...body, items: body.items.map(transformCard), included: { @@ -45,26 +45,26 @@ const getCards = (boardId, data) => }, })); -const createCard = (boardId, data) => - socket.post(`/boards/${boardId}/cards`, transformCardData(data)).then((body) => ({ +const createCard = (boardId, data, headers) => + socket.post(`/boards/${boardId}/cards`, transformCardData(data), headers).then((body) => ({ ...body, item: transformCard(body.item), })); -const getCard = (id) => - socket.get(`/cards/${id}`).then((body) => ({ +const getCard = (id, headers) => + socket.get(`/cards/${id}`, undefined, headers).then((body) => ({ ...body, item: transformCard(body.item), })); -const updateCard = (id, data) => - socket.patch(`/cards/${id}`, transformCardData(data)).then((body) => ({ +const updateCard = (id, data, headers) => + socket.patch(`/cards/${id}`, transformCardData(data), headers).then((body) => ({ ...body, item: transformCard(body.item), })); -const deleteCard = (id) => - socket.delete(`/cards/${id}`).then((body) => ({ +const deleteCard = (id, headers) => + socket.delete(`/cards/${id}`, undefined, headers).then((body) => ({ ...body, item: transformCard(body.item), })); diff --git a/client/src/api/comment-activities.js b/client/src/api/comment-activities.js index 3d378639..814f9dec 100755 --- a/client/src/api/comment-activities.js +++ b/client/src/api/comment-activities.js @@ -3,20 +3,20 @@ import { transformActivity } from './activities'; /* Actions */ -const createCommentActivity = (cardId, data) => - socket.post(`/cards/${cardId}/comment-actions`, data).then((body) => ({ +const createCommentActivity = (cardId, data, headers) => + socket.post(`/cards/${cardId}/comment-actions`, data, headers).then((body) => ({ ...body, item: transformActivity(body.item), })); -const updateCommentActivity = (id, data) => - socket.patch(`/comment-actions/${id}`, data).then((body) => ({ +const updateCommentActivity = (id, data, headers) => + socket.patch(`/comment-actions/${id}`, data, headers).then((body) => ({ ...body, item: transformActivity(body.item), })); -const deleteCommentActivity = (id) => - socket.delete(`/comment-actions/${id}`).then((body) => ({ +const deleteCommentActivity = (id, headers) => + socket.delete(`/comment-actions/${id}`, undefined, headers).then((body) => ({ ...body, item: transformActivity(body.item), })); diff --git a/client/src/api/http.js b/client/src/api/http.js index 9776807b..646528da 100755 --- a/client/src/api/http.js +++ b/client/src/api/http.js @@ -6,7 +6,7 @@ const http = {}; // TODO: add all methods ['POST'].forEach((method) => { - http[method.toLowerCase()] = (url, data) => { + http[method.toLowerCase()] = (url, data, headers) => { const formData = Object.keys(data).reduce((result, key) => { result.append(key, data[key]); @@ -15,8 +15,8 @@ const http = {}; return fetch(`${Config.SERVER_BASE_URL}/api${url}`, { method, + headers, body: formData, - ...Config.FETCH_OPTIONS, }) .then((response) => response.json().then((body) => ({ diff --git a/client/src/api/labels.js b/client/src/api/labels.js index 80d92014..e1101ef3 100755 --- a/client/src/api/labels.js +++ b/client/src/api/labels.js @@ -2,11 +2,12 @@ import socket from './socket'; /* Actions */ -const createLabel = (boardId, data) => socket.post(`/boards/${boardId}/labels`, data); +const createLabel = (boardId, data, headers) => + socket.post(`/boards/${boardId}/labels`, data, headers); -const updateLabel = (id, data) => socket.patch(`/labels/${id}`, data); +const updateLabel = (id, data, headers) => socket.patch(`/labels/${id}`, data, headers); -const deleteLabel = (id) => socket.delete(`/labels/${id}`); +const deleteLabel = (id, headers) => socket.delete(`/labels/${id}`, undefined, headers); export default { createLabel, diff --git a/client/src/api/lists.js b/client/src/api/lists.js index 59722edf..6c1ee6a2 100755 --- a/client/src/api/lists.js +++ b/client/src/api/lists.js @@ -2,11 +2,12 @@ import socket from './socket'; /* Actions */ -const createList = (boardId, data) => socket.post(`/boards/${boardId}/lists`, data); +const createList = (boardId, data, headers) => + socket.post(`/boards/${boardId}/lists`, data, headers); -const updateList = (id, data) => socket.patch(`/lists/${id}`, data); +const updateList = (id, data, headers) => socket.patch(`/lists/${id}`, data, headers); -const deleteList = (id) => socket.delete(`/lists/${id}`); +const deleteList = (id, headers) => socket.delete(`/lists/${id}`, undefined, headers); export default { createList, diff --git a/client/src/api/notifications.js b/client/src/api/notifications.js index 3fd664fd..f415e240 100755 --- a/client/src/api/notifications.js +++ b/client/src/api/notifications.js @@ -13,8 +13,8 @@ export const transformNotification = (notification) => ({ /* Actions */ -const getNotifications = () => - socket.get('/notifications').then((body) => ({ +const getNotifications = (headers) => + socket.get('/notifications', undefined, headers).then((body) => ({ ...body, items: body.items.map(transformNotification), included: { @@ -24,8 +24,8 @@ const getNotifications = () => }, })); -const getNotification = (id) => - socket.get(`/notifications/${id}`).then((body) => ({ +const getNotification = (id, headers) => + socket.get(`/notifications/${id}`, undefined, headers).then((body) => ({ ...body, item: transformNotification(body.item), included: { @@ -35,8 +35,8 @@ const getNotification = (id) => }, })); -const updateNotifications = (ids, data) => - socket.patch(`/notifications/${ids.join(',')}`, data).then((body) => ({ +const updateNotifications = (ids, data, headers) => + socket.patch(`/notifications/${ids.join(',')}`, data, headers).then((body) => ({ ...body, items: body.items.map(transformNotification), })); diff --git a/client/src/api/project-managers.js b/client/src/api/project-managers.js index 6701a764..a835bf2d 100755 --- a/client/src/api/project-managers.js +++ b/client/src/api/project-managers.js @@ -2,10 +2,11 @@ import socket from './socket'; /* Actions */ -const createProjectManager = (projectId, data) => - socket.post(`/projects/${projectId}/managers`, data); +const createProjectManager = (projectId, data, headers) => + socket.post(`/projects/${projectId}/managers`, data, headers); -const deleteProjectManager = (id) => socket.delete(`/project-managers/${id}`); +const deleteProjectManager = (id, headers) => + socket.delete(`/project-managers/${id}`, undefined, headers); export default { createProjectManager, diff --git a/client/src/api/projects.js b/client/src/api/projects.js index 863d3f5e..5a22beec 100755 --- a/client/src/api/projects.js +++ b/client/src/api/projects.js @@ -3,18 +3,18 @@ import socket from './socket'; /* Actions */ -const getProjects = () => socket.get('/projects'); +const getProjects = (headers) => socket.get('/projects', undefined, headers); -const createProject = (data) => socket.post('/projects', data); +const createProject = (data, headers) => socket.post('/projects', data, headers); -const getProject = (id) => socket.get(`/projects/${id}`); +const getProject = (id, headers) => socket.get(`/projects/${id}`, undefined, headers); -const updateProject = (id, data) => socket.patch(`/projects/${id}`, data); +const updateProject = (id, data, headers) => socket.patch(`/projects/${id}`, data, headers); -const updateProjectBackgroundImage = (id, data) => - http.post(`/projects/${id}/background-image`, data); +const updateProjectBackgroundImage = (id, data, headers) => + http.post(`/projects/${id}/background-image`, data, headers); -const deleteProject = (id) => socket.delete(`/projects/${id}`); +const deleteProject = (id, headers) => socket.delete(`/projects/${id}`, undefined, headers); export default { getProjects, diff --git a/client/src/api/socket.js b/client/src/api/socket.js index 8e4aa7af..2902661b 100755 --- a/client/src/api/socket.js +++ b/client/src/api/socket.js @@ -16,12 +16,13 @@ const { socket } = io; socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => { - socket[method.toLowerCase()] = (url, data) => + socket[method.toLowerCase()] = (url, data, headers) => new Promise((resolve, reject) => { socket.request( { method, data, + headers, url: `/api${url}`, }, (_, { body, error }) => { diff --git a/client/src/api/tasks.js b/client/src/api/tasks.js index 2b078967..584ec3e1 100755 --- a/client/src/api/tasks.js +++ b/client/src/api/tasks.js @@ -2,11 +2,11 @@ import socket from './socket'; /* Actions */ -const createTask = (cardId, data) => socket.post(`/cards/${cardId}/tasks`, data); +const createTask = (cardId, data, headers) => socket.post(`/cards/${cardId}/tasks`, data, headers); -const updateTask = (id, data) => socket.patch(`/tasks/${id}`, data); +const updateTask = (id, data, headers) => socket.patch(`/tasks/${id}`, data, headers); -const deleteTask = (id) => socket.delete(`/tasks/${id}`); +const deleteTask = (id, headers) => socket.delete(`/tasks/${id}`, undefined, headers); export default { createTask, diff --git a/client/src/api/users.js b/client/src/api/users.js index cb286e8f..ca77bd9d 100755 --- a/client/src/api/users.js +++ b/client/src/api/users.js @@ -3,25 +3,27 @@ import socket from './socket'; /* Actions */ -const getUsers = () => socket.get('/users'); +const getUsers = (headers) => socket.get('/users', undefined, headers); -const createUser = (data) => socket.post('/users', data); +const createUser = (data, headers) => socket.post('/users', data, headers); -const getUser = (id) => socket.get(`/users/${id}`); +const getUser = (id, headers) => socket.get(`/users/${id}`, undefined, headers); -const getCurrentUser = () => socket.get('/users/me'); +const getCurrentUser = (headers) => socket.get('/users/me', undefined, headers); -const updateUser = (id, data) => socket.patch(`/users/${id}`, data); +const updateUser = (id, data, headers) => socket.patch(`/users/${id}`, data, headers); -const updateUserEmail = (id, data) => socket.patch(`/users/${id}/email`, data); +const updateUserEmail = (id, data, headers) => socket.patch(`/users/${id}/email`, data, headers); -const updateUserPassword = (id, data) => socket.patch(`/users/${id}/password`, data); +const updateUserPassword = (id, data, headers) => + socket.patch(`/users/${id}/password`, data, headers); -const updateUserUsername = (id, data) => socket.patch(`/users/${id}/username`, data); +const updateUserUsername = (id, data, headers) => + socket.patch(`/users/${id}/username`, data, headers); -const updateUserAvatar = (id, data) => http.post(`/users/${id}/avatar`, data); +const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`, data, headers); -const deleteUser = (id) => socket.delete(`/users/${id}`); +const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers); export default { getUsers, diff --git a/client/src/constants/Config.js b/client/src/constants/Config.js index 6b6ff98b..28fb3747 100755 --- a/client/src/constants/Config.js +++ b/client/src/constants/Config.js @@ -2,24 +2,18 @@ const SERVER_BASE_URL = process.env.REACT_APP_SERVER_BASE_URL || (process.env.NODE_ENV === 'production' ? '' : 'http://localhost:1337'); -const FETCH_OPTIONS = - process.env.NODE_ENV === 'production' - ? undefined - : { - credentials: 'include', - }; - const ACCESS_TOKEN_KEY = 'accessToken'; -const ACCESS_TOKEN_EXPIRES = 365; +const ACCESS_TOKEN_VERSION_KEY = 'accessTokenVersion'; +const ACCESS_TOKEN_VERSION = '1'; const POSITION_GAP = 65535; const ACTIVITIES_LIMIT = 50; export default { SERVER_BASE_URL, - FETCH_OPTIONS, ACCESS_TOKEN_KEY, - ACCESS_TOKEN_EXPIRES, + ACCESS_TOKEN_VERSION_KEY, + ACCESS_TOKEN_VERSION, POSITION_GAP, ACTIVITIES_LIMIT, }; diff --git a/client/src/sagas/core/request.js b/client/src/sagas/core/request.js index 83184529..a918862e 100755 --- a/client/src/sagas/core/request.js +++ b/client/src/sagas/core/request.js @@ -1,6 +1,7 @@ import { call, fork, join, put, take } from 'redux-saga/effects'; import actions from '../../actions'; +import { getAccessToken } from '../../utils/access-token-storage'; import ErrorCodes from '../../constants/ErrorCodes'; let lastRequestTask; @@ -12,8 +13,12 @@ function* queueRequest(method, ...args) { } catch {} // eslint-disable-line no-empty } + const accessToken = yield call(getAccessToken); + try { - return yield call(method, ...args); + return yield call(method, ...args, { + Authorization: `Bearer ${accessToken}`, + }); } catch (error) { if (error.code === ErrorCodes.UNAUTHORIZED) { yield put(actions.logout()); // TODO: next url diff --git a/client/src/sagas/core/services/users.js b/client/src/sagas/core/services/users.js index 12f1f965..b2a9ea4b 100644 --- a/client/src/sagas/core/services/users.js +++ b/client/src/sagas/core/services/users.js @@ -5,6 +5,7 @@ import request from '../request'; import selectors from '../../../selectors'; import actions from '../../../actions'; import api from '../../../api'; +import { setAccessToken } from '../../../utils/access-token-storage'; export function* createUser(data) { yield put(actions.createUser(data)); @@ -109,13 +110,19 @@ export function* updateUserPassword(id, data) { yield put(actions.updateUserPassword(id, data)); let user; + let accessToken; + try { - ({ item: user } = yield call(request, api.updateUserPassword, id, data)); + ({ item: user, accessToken } = yield call(request, api.updateUserPassword, id, data)); } catch (error) { yield put(actions.updateUserPassword.failure(id, error)); return; } + if (accessToken) { + yield call(setAccessToken, accessToken); + } + yield put(actions.updateUserPassword.success(user)); } diff --git a/client/src/utils/access-token-storage.js b/client/src/utils/access-token-storage.js index cde5b2e2..c735a9c8 100755 --- a/client/src/utils/access-token-storage.js +++ b/client/src/utils/access-token-storage.js @@ -1,15 +1,36 @@ import Cookies from 'js-cookie'; +import jwtDecode from 'jwt-decode'; import Config from '../constants/Config'; export const setAccessToken = (accessToken) => { + const { exp } = jwtDecode(accessToken); + const expires = new Date(exp * 1000); + Cookies.set(Config.ACCESS_TOKEN_KEY, accessToken, { - expires: Config.ACCESS_TOKEN_EXPIRES, + expires, + secure: window.location.protocol === 'https:', + sameSite: 'strict', + }); + + Cookies.set(Config.ACCESS_TOKEN_VERSION_KEY, Config.ACCESS_TOKEN_VERSION, { + expires, }); }; -export const getAccessToken = () => Cookies.get(Config.ACCESS_TOKEN_KEY); - export const removeAccessToken = () => { Cookies.remove(Config.ACCESS_TOKEN_KEY); + Cookies.remove(Config.ACCESS_TOKEN_VERSION_KEY); +}; + +export const getAccessToken = () => { + let accessToken = Cookies.get(Config.ACCESS_TOKEN_KEY); + const accessTokenVersion = Cookies.get(Config.ACCESS_TOKEN_VERSION_KEY); + + if (accessToken && accessTokenVersion !== Config.ACCESS_TOKEN_VERSION) { + removeAccessToken(); + accessToken = undefined; + } + + return accessToken; }; diff --git a/package-lock.json b/package-lock.json index efc547b3..52b132ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "planka", - "version": "1.5.0", + "version": "1.5.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "planka", - "version": "1.5.0", + "version": "1.5.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/server/.env.sample b/server/.env.sample index cca5da54..ac5696c8 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -1,4 +1,9 @@ -TZ=UTC BASE_URL=http://localhost:1337 DATABASE_URL=postgresql://postgres@localhost/planka SECRET_KEY=notsecretkey + +# In days +TOKEN_EXPIRES_IN=365 + +# Do not edit this +TZ=UTC diff --git a/server/api/controllers/access-tokens/create.js b/server/api/controllers/access-tokens/create.js index 1ca6eead..5345b484 100755 --- a/server/api/controllers/access-tokens/create.js +++ b/server/api/controllers/access-tokens/create.js @@ -49,7 +49,7 @@ module.exports = { } return { - item: sails.helpers.utils.signToken(user.id), + item: sails.helpers.utils.createToken(user.id), }; }, }; diff --git a/server/api/controllers/users/update-avatar.js b/server/api/controllers/users/update-avatar.js index 455ed178..66635b7f 100755 --- a/server/api/controllers/users/update-avatar.js +++ b/server/api/controllers/users/update-avatar.js @@ -54,6 +54,7 @@ module.exports = { { avatarDirname: files[0].extra.dirname, }, + currentUser, this.req, ); diff --git a/server/api/controllers/users/update-email.js b/server/api/controllers/users/update-email.js index f1ccb1b7..2d2cd522 100644 --- a/server/api/controllers/users/update-email.js +++ b/server/api/controllers/users/update-email.js @@ -69,7 +69,7 @@ module.exports = { const values = _.pick(inputs, ['email']); user = await sails.helpers.users - .updateOne(user, values, this.req) + .updateOne(user, values, currentUser, this.req) .intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE); if (!user) { diff --git a/server/api/controllers/users/update-password.js b/server/api/controllers/users/update-password.js index 699af366..f64d01f0 100644 --- a/server/api/controllers/users/update-password.js +++ b/server/api/controllers/users/update-password.js @@ -60,12 +60,21 @@ module.exports = { } const values = _.pick(inputs, ['password']); - user = await sails.helpers.users.updateOne(user, values, this.req); + user = await sails.helpers.users.updateOne(user, values, currentUser, this.req); if (!user) { throw Errors.USER_NOT_FOUND; } + if (user.id === currentUser.id) { + const accessToken = sails.helpers.utils.createToken(user.id, user.passwordUpdatedAt); + + return { + accessToken, + item: user, + }; + } + return { item: user, }; diff --git a/server/api/controllers/users/update-username.js b/server/api/controllers/users/update-username.js index 60699b55..f9961b3e 100644 --- a/server/api/controllers/users/update-username.js +++ b/server/api/controllers/users/update-username.js @@ -71,7 +71,7 @@ module.exports = { const values = _.pick(inputs, ['username']); user = await sails.helpers.users - .updateOne(user, values, this.req) + .updateOne(user, values, currentUser, this.req) .intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE); if (!user) { diff --git a/server/api/controllers/users/update.js b/server/api/controllers/users/update.js index 10abb6c6..0057c913 100755 --- a/server/api/controllers/users/update.js +++ b/server/api/controllers/users/update.js @@ -75,7 +75,7 @@ module.exports = { 'subscribeToOwnCards', ]); - user = await sails.helpers.users.updateOne(user, values, this.req); + user = await sails.helpers.users.updateOne(user, values, currentUser, this.req); if (!user) { throw Errors.USER_NOT_FOUND; diff --git a/server/api/helpers/users/update-one.js b/server/api/helpers/users/update-one.js index 8ceb2d14..1be079a2 100644 --- a/server/api/helpers/users/update-one.js +++ b/server/api/helpers/users/update-one.js @@ -1,6 +1,7 @@ const path = require('path'); const bcrypt = require('bcrypt'); const rimraf = require('rimraf'); +const { v4: uuid } = require('uuid'); module.exports = { inputs: { @@ -35,6 +36,10 @@ module.exports = { }, required: true, }, + user: { + type: 'ref', + required: true, + }, request: { type: 'ref', }, @@ -54,8 +59,10 @@ module.exports = { let isOnlyPasswordChange = false; if (!_.isUndefined(inputs.values.password)) { - // eslint-disable-next-line no-param-reassign - inputs.values.password = bcrypt.hashSync(inputs.values.password, 10); + Object.assign(inputs.values, { + password: bcrypt.hashSync(inputs.values.password, 10), + passwordChangedAt: new Date().toUTCString(), + }); if (Object.keys(inputs.values).length === 1) { isOnlyPasswordChange = true; @@ -103,6 +110,29 @@ module.exports = { } } + if (!_.isUndefined(inputs.values.password)) { + sails.sockets.broadcast( + `user:${user.id}`, + 'userDelete', // TODO: introduce separate event + { + item: user, + }, + inputs.request, + ); + + if (user.id === inputs.user.id && inputs.request && inputs.request.isSocket) { + const tempRoom = uuid(); + + sails.sockets.addRoomMembersToRooms(`user:${user.id}`, tempRoom, () => { + sails.sockets.leave(inputs.request, tempRoom, () => { + sails.sockets.leaveAll(tempRoom); + }); + }); + } else { + sails.sockets.leaveAll(`user:${user.id}`); + } + } + if (!isOnlyPasswordChange) { /* const projectIds = await sails.helpers.users.getManagerProjectIds(user.id); diff --git a/server/api/helpers/utils/create-token.js b/server/api/helpers/utils/create-token.js new file mode 100644 index 00000000..01d6ed63 --- /dev/null +++ b/server/api/helpers/utils/create-token.js @@ -0,0 +1,29 @@ +const jwt = require('jsonwebtoken'); + +module.exports = { + sync: true, + + inputs: { + subject: { + type: 'json', + required: true, + }, + issuedAt: { + type: 'ref', + }, + }, + + fn(inputs) { + const { issuedAt = new Date() } = inputs; + const iat = Math.floor(issuedAt / 1000); + + return jwt.sign( + { + iat, + sub: inputs.subject, + exp: iat + sails.config.custom.tokenExpiresIn * 24 * 60 * 60, + }, + sails.config.session.secret, + ); + }, +}; diff --git a/server/api/helpers/utils/sign-token.js b/server/api/helpers/utils/sign-token.js deleted file mode 100644 index 90fc792d..00000000 --- a/server/api/helpers/utils/sign-token.js +++ /dev/null @@ -1,16 +0,0 @@ -const jwt = require('jsonwebtoken'); - -module.exports = { - sync: true, - - inputs: { - payload: { - type: 'json', - required: true, - }, - }, - - fn(inputs) { - return jwt.sign(inputs.payload, sails.config.session.secret); - }, -}; diff --git a/server/api/helpers/utils/verify-token.js b/server/api/helpers/utils/verify-token.js index a7d71bb9..585287c4 100644 --- a/server/api/helpers/utils/verify-token.js +++ b/server/api/helpers/utils/verify-token.js @@ -15,10 +15,16 @@ module.exports = { }, fn(inputs) { + let payload; try { - return jwt.verify(inputs.token, sails.config.session.secret); + payload = jwt.verify(inputs.token, sails.config.session.secret); } catch (error) { throw 'invalidToken'; } + + return { + subject: payload.sub, + issuedAt: new Date(payload.iat * 1000), + }; }, }; diff --git a/server/api/hooks/current-user/index.js b/server/api/hooks/current-user/index.js index 3268ca42..ee374031 100644 --- a/server/api/hooks/current-user/index.js +++ b/server/api/hooks/current-user/index.js @@ -10,15 +10,20 @@ module.exports = function defineCurrentUserHook(sails) { const TOKEN_PATTERN = /^Bearer /; const getUser = async (accessToken) => { - let id; - + let payload; try { - id = sails.helpers.utils.verifyToken(accessToken); + payload = sails.helpers.utils.verifyToken(accessToken); } catch (error) { return null; } - return sails.helpers.users.getOne(id); + const user = await sails.helpers.users.getOne(payload.subject); + + if (user && user.passwordChangedAt > payload.issuedAt) { + return null; + } + + return user; }; return { @@ -32,19 +37,23 @@ module.exports = function defineCurrentUserHook(sails) { routes: { before: { - '/*': { + '/api/*': { async fn(req, res, next) { - let accessToken; - if (req.headers.authorization) { - if (TOKEN_PATTERN.test(req.headers.authorization)) { - accessToken = req.headers.authorization.replace(TOKEN_PATTERN, ''); - } - } else if (req.cookies.accessToken) { - accessToken = req.cookies.accessToken; + const { authorization: authorizationHeader } = req.headers; + + if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) { + const accessToken = authorizationHeader.replace(TOKEN_PATTERN, ''); + + req.currentUser = await getUser(accessToken); } - if (accessToken) { - req.currentUser = await getUser(accessToken); + return next(); + }, + }, + '/attachments/*': { + async fn(req, res, next) { + if (req.cookies.accessToken) { + req.currentUser = await getUser(req.cookies.accessToken); } return next(); diff --git a/server/api/models/User.js b/server/api/models/User.js index b545fbb4..4d03e0e4 100755 --- a/server/api/models/User.js +++ b/server/api/models/User.js @@ -67,6 +67,10 @@ module.exports = { type: 'ref', columnName: 'deleted_at', }, + passwordChangedAt: { + type: 'ref', + columnName: 'password_changed_at', + }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ @@ -102,7 +106,7 @@ module.exports = { customToJSON() { return { - ..._.omit(this, ['password', 'avatarDirname']), + ..._.omit(this, ['password', 'avatarDirname', 'passwordChangedAt']), avatarUrl: this.avatarDirname && `${sails.config.custom.userAvatarsUrl}/${this.avatarDirname}/square-100.jpg`, diff --git a/server/config/custom.js b/server/config/custom.js index 8d47ae74..9b3d86ef 100644 --- a/server/config/custom.js +++ b/server/config/custom.js @@ -20,6 +20,8 @@ module.exports.custom = { baseUrl: process.env.BASE_URL, + tokenExpiresIn: process.env.TOKEN_EXPIRES_IN, + userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'), userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`, diff --git a/server/db/migrations/20220803221221_add_password_changed_at_to_user_account_table.js b/server/db/migrations/20220803221221_add_password_changed_at_to_user_account_table.js new file mode 100644 index 00000000..5d636967 --- /dev/null +++ b/server/db/migrations/20220803221221_add_password_changed_at_to_user_account_table.js @@ -0,0 +1,11 @@ +module.exports.up = async (knex) => + knex.schema.table('user_account', (table) => { + /* Columns */ + + table.timestamp('password_changed_at', true); + }); + +module.exports.down = async (knex) => + knex.schema.table('user_account', (table) => { + table.dropColumn('password_changed_at'); + });