1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

feat: Improve security of access tokens (#279)

Closes #275
This commit is contained in:
SimonTagne 2022-08-09 18:03:21 +02:00 committed by GitHub
parent 77ac2cf1b1
commit 2b4c2b0f49
40 changed files with 273 additions and 133 deletions

View file

@ -15,6 +15,7 @@
"i18next-browser-languagedetector": "^6.1.4", "i18next-browser-languagedetector": "^6.1.4",
"initials": "^3.1.2", "initials": "^3.1.2",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-sass": "^7.0.1", "node-sass": "^7.0.1",
"photoswipe": "^5.3.0", "photoswipe": "^5.3.0",
@ -14333,6 +14334,11 @@
"node": ">=4.0" "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": { "node_modules/keyboard-key": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz", "resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz",
@ -35776,6 +35782,11 @@
"object.assign": "^4.1.2" "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": { "keyboard-key": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz", "resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz",

View file

@ -72,6 +72,7 @@
"i18next-browser-languagedetector": "^6.1.4", "i18next-browser-languagedetector": "^6.1.4",
"initials": "^3.1.2", "initials": "^3.1.2",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-sass": "^7.0.1", "node-sass": "^7.0.1",
"photoswipe": "^5.3.0", "photoswipe": "^5.3.0",

View file

@ -2,7 +2,7 @@ import http from './http';
/* Actions */ /* Actions */
const createAccessToken = (data) => http.post('/access-tokens', data); const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
export default { export default {
createAccessToken, createAccessToken,

View file

@ -9,8 +9,8 @@ export const transformActivity = (activity) => ({
/* Actions */ /* Actions */
const getActivities = (cardId, data) => const getActivities = (cardId, data, headers) =>
socket.get(`/cards/${cardId}/actions`, data).then((body) => ({ socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({
...body, ...body,
items: body.items.map(transformActivity), items: body.items.map(transformActivity),
})); }));

View file

@ -10,20 +10,20 @@ export const transformAttachment = (attachment) => ({
/* Actions */ /* Actions */
const createAttachment = (cardId, data, requestId) => const createAttachment = (cardId, data, requestId, headers) =>
http.post(`/cards/${cardId}/attachments?requestId=${requestId}`, data).then((body) => ({ http.post(`/cards/${cardId}/attachments?requestId=${requestId}`, data, headers).then((body) => ({
...body, ...body,
item: transformAttachment(body.item), item: transformAttachment(body.item),
})); }));
const updateAttachment = (id, data) => const updateAttachment = (id, data, headers) =>
socket.patch(`/attachments/${id}`, data).then((body) => ({ socket.patch(`/attachments/${id}`, data, headers).then((body) => ({
...body, ...body,
item: transformAttachment(body.item), item: transformAttachment(body.item),
})); }));
const deleteAttachment = (id) => const deleteAttachment = (id, headers) =>
socket.delete(`/attachments/${id}`).then((body) => ({ socket.delete(`/attachments/${id}`, undefined, headers).then((body) => ({
...body, ...body,
item: transformAttachment(body.item), item: transformAttachment(body.item),
})); }));

View file

@ -2,10 +2,11 @@ import socket from './socket';
/* Actions */ /* Actions */
const createBoardMembership = (boardId, data) => const createBoardMembership = (boardId, data, headers) =>
socket.post(`/boards/${boardId}/memberships`, data); 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 { export default {
createBoardMembership, createBoardMembership,

View file

@ -4,10 +4,11 @@ import { transformAttachment } from './attachments';
/* Actions */ /* 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) => const getBoard = (id, headers) =>
socket.get(`/boards/${id}`).then((body) => ({ socket.get(`/boards/${id}`, undefined, headers).then((body) => ({
...body, ...body,
included: { included: {
...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 { export default {
createBoard, createBoard,

View file

@ -2,9 +2,11 @@ import socket from './socket';
/* Actions */ /* 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 { export default {
createCardLabel, createCardLabel,

View file

@ -2,10 +2,11 @@ import socket from './socket';
/* Actions */ /* 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) => const deleteCardMembership = (cardId, userId, headers) =>
socket.delete(`/cards/${cardId}/memberships?userId=${userId}`); socket.delete(`/cards/${cardId}/memberships?userId=${userId}`, undefined, headers);
export default { export default {
createCardMembership, createCardMembership,

View file

@ -35,8 +35,8 @@ export const transformCardData = (data) => ({
/* Actions */ /* Actions */
const getCards = (boardId, data) => const getCards = (boardId, data, headers) =>
socket.get(`/board/${boardId}/cards`, data).then((body) => ({ socket.get(`/board/${boardId}/cards`, data, headers).then((body) => ({
...body, ...body,
items: body.items.map(transformCard), items: body.items.map(transformCard),
included: { included: {
@ -45,26 +45,26 @@ const getCards = (boardId, data) =>
}, },
})); }));
const createCard = (boardId, data) => const createCard = (boardId, data, headers) =>
socket.post(`/boards/${boardId}/cards`, transformCardData(data)).then((body) => ({ socket.post(`/boards/${boardId}/cards`, transformCardData(data), headers).then((body) => ({
...body, ...body,
item: transformCard(body.item), item: transformCard(body.item),
})); }));
const getCard = (id) => const getCard = (id, headers) =>
socket.get(`/cards/${id}`).then((body) => ({ socket.get(`/cards/${id}`, undefined, headers).then((body) => ({
...body, ...body,
item: transformCard(body.item), item: transformCard(body.item),
})); }));
const updateCard = (id, data) => const updateCard = (id, data, headers) =>
socket.patch(`/cards/${id}`, transformCardData(data)).then((body) => ({ socket.patch(`/cards/${id}`, transformCardData(data), headers).then((body) => ({
...body, ...body,
item: transformCard(body.item), item: transformCard(body.item),
})); }));
const deleteCard = (id) => const deleteCard = (id, headers) =>
socket.delete(`/cards/${id}`).then((body) => ({ socket.delete(`/cards/${id}`, undefined, headers).then((body) => ({
...body, ...body,
item: transformCard(body.item), item: transformCard(body.item),
})); }));

View file

@ -3,20 +3,20 @@ import { transformActivity } from './activities';
/* Actions */ /* Actions */
const createCommentActivity = (cardId, data) => const createCommentActivity = (cardId, data, headers) =>
socket.post(`/cards/${cardId}/comment-actions`, data).then((body) => ({ socket.post(`/cards/${cardId}/comment-actions`, data, headers).then((body) => ({
...body, ...body,
item: transformActivity(body.item), item: transformActivity(body.item),
})); }));
const updateCommentActivity = (id, data) => const updateCommentActivity = (id, data, headers) =>
socket.patch(`/comment-actions/${id}`, data).then((body) => ({ socket.patch(`/comment-actions/${id}`, data, headers).then((body) => ({
...body, ...body,
item: transformActivity(body.item), item: transformActivity(body.item),
})); }));
const deleteCommentActivity = (id) => const deleteCommentActivity = (id, headers) =>
socket.delete(`/comment-actions/${id}`).then((body) => ({ socket.delete(`/comment-actions/${id}`, undefined, headers).then((body) => ({
...body, ...body,
item: transformActivity(body.item), item: transformActivity(body.item),
})); }));

View file

@ -6,7 +6,7 @@ const http = {};
// TODO: add all methods // TODO: add all methods
['POST'].forEach((method) => { ['POST'].forEach((method) => {
http[method.toLowerCase()] = (url, data) => { http[method.toLowerCase()] = (url, data, headers) => {
const formData = Object.keys(data).reduce((result, key) => { const formData = Object.keys(data).reduce((result, key) => {
result.append(key, data[key]); result.append(key, data[key]);
@ -15,8 +15,8 @@ const http = {};
return fetch(`${Config.SERVER_BASE_URL}/api${url}`, { return fetch(`${Config.SERVER_BASE_URL}/api${url}`, {
method, method,
headers,
body: formData, body: formData,
...Config.FETCH_OPTIONS,
}) })
.then((response) => .then((response) =>
response.json().then((body) => ({ response.json().then((body) => ({

View file

@ -2,11 +2,12 @@ import socket from './socket';
/* Actions */ /* 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 { export default {
createLabel, createLabel,

View file

@ -2,11 +2,12 @@ import socket from './socket';
/* Actions */ /* 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 { export default {
createList, createList,

View file

@ -13,8 +13,8 @@ export const transformNotification = (notification) => ({
/* Actions */ /* Actions */
const getNotifications = () => const getNotifications = (headers) =>
socket.get('/notifications').then((body) => ({ socket.get('/notifications', undefined, headers).then((body) => ({
...body, ...body,
items: body.items.map(transformNotification), items: body.items.map(transformNotification),
included: { included: {
@ -24,8 +24,8 @@ const getNotifications = () =>
}, },
})); }));
const getNotification = (id) => const getNotification = (id, headers) =>
socket.get(`/notifications/${id}`).then((body) => ({ socket.get(`/notifications/${id}`, undefined, headers).then((body) => ({
...body, ...body,
item: transformNotification(body.item), item: transformNotification(body.item),
included: { included: {
@ -35,8 +35,8 @@ const getNotification = (id) =>
}, },
})); }));
const updateNotifications = (ids, data) => const updateNotifications = (ids, data, headers) =>
socket.patch(`/notifications/${ids.join(',')}`, data).then((body) => ({ socket.patch(`/notifications/${ids.join(',')}`, data, headers).then((body) => ({
...body, ...body,
items: body.items.map(transformNotification), items: body.items.map(transformNotification),
})); }));

View file

@ -2,10 +2,11 @@ import socket from './socket';
/* Actions */ /* Actions */
const createProjectManager = (projectId, data) => const createProjectManager = (projectId, data, headers) =>
socket.post(`/projects/${projectId}/managers`, data); 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 { export default {
createProjectManager, createProjectManager,

View file

@ -3,18 +3,18 @@ import socket from './socket';
/* Actions */ /* 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) => const updateProjectBackgroundImage = (id, data, headers) =>
http.post(`/projects/${id}/background-image`, data); 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 { export default {
getProjects, getProjects,

View file

@ -16,12 +16,13 @@ const { socket } = io;
socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => { ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => {
socket[method.toLowerCase()] = (url, data) => socket[method.toLowerCase()] = (url, data, headers) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
socket.request( socket.request(
{ {
method, method,
data, data,
headers,
url: `/api${url}`, url: `/api${url}`,
}, },
(_, { body, error }) => { (_, { body, error }) => {

View file

@ -2,11 +2,11 @@ import socket from './socket';
/* Actions */ /* 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 { export default {
createTask, createTask,

View file

@ -3,25 +3,27 @@ import socket from './socket';
/* Actions */ /* 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 { export default {
getUsers, getUsers,

View file

@ -2,24 +2,18 @@ const SERVER_BASE_URL =
process.env.REACT_APP_SERVER_BASE_URL || process.env.REACT_APP_SERVER_BASE_URL ||
(process.env.NODE_ENV === 'production' ? '' : 'http://localhost:1337'); (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_KEY = 'accessToken';
const ACCESS_TOKEN_EXPIRES = 365; const ACCESS_TOKEN_VERSION_KEY = 'accessTokenVersion';
const ACCESS_TOKEN_VERSION = '1';
const POSITION_GAP = 65535; const POSITION_GAP = 65535;
const ACTIVITIES_LIMIT = 50; const ACTIVITIES_LIMIT = 50;
export default { export default {
SERVER_BASE_URL, SERVER_BASE_URL,
FETCH_OPTIONS,
ACCESS_TOKEN_KEY, ACCESS_TOKEN_KEY,
ACCESS_TOKEN_EXPIRES, ACCESS_TOKEN_VERSION_KEY,
ACCESS_TOKEN_VERSION,
POSITION_GAP, POSITION_GAP,
ACTIVITIES_LIMIT, ACTIVITIES_LIMIT,
}; };

View file

@ -1,6 +1,7 @@
import { call, fork, join, put, take } from 'redux-saga/effects'; import { call, fork, join, put, take } from 'redux-saga/effects';
import actions from '../../actions'; import actions from '../../actions';
import { getAccessToken } from '../../utils/access-token-storage';
import ErrorCodes from '../../constants/ErrorCodes'; import ErrorCodes from '../../constants/ErrorCodes';
let lastRequestTask; let lastRequestTask;
@ -12,8 +13,12 @@ function* queueRequest(method, ...args) {
} catch {} // eslint-disable-line no-empty } catch {} // eslint-disable-line no-empty
} }
const accessToken = yield call(getAccessToken);
try { try {
return yield call(method, ...args); return yield call(method, ...args, {
Authorization: `Bearer ${accessToken}`,
});
} catch (error) { } catch (error) {
if (error.code === ErrorCodes.UNAUTHORIZED) { if (error.code === ErrorCodes.UNAUTHORIZED) {
yield put(actions.logout()); // TODO: next url yield put(actions.logout()); // TODO: next url

View file

@ -5,6 +5,7 @@ import request from '../request';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import actions from '../../../actions'; import actions from '../../../actions';
import api from '../../../api'; import api from '../../../api';
import { setAccessToken } from '../../../utils/access-token-storage';
export function* createUser(data) { export function* createUser(data) {
yield put(actions.createUser(data)); yield put(actions.createUser(data));
@ -109,13 +110,19 @@ export function* updateUserPassword(id, data) {
yield put(actions.updateUserPassword(id, data)); yield put(actions.updateUserPassword(id, data));
let user; let user;
let accessToken;
try { try {
({ item: user } = yield call(request, api.updateUserPassword, id, data)); ({ item: user, accessToken } = yield call(request, api.updateUserPassword, id, data));
} catch (error) { } catch (error) {
yield put(actions.updateUserPassword.failure(id, error)); yield put(actions.updateUserPassword.failure(id, error));
return; return;
} }
if (accessToken) {
yield call(setAccessToken, accessToken);
}
yield put(actions.updateUserPassword.success(user)); yield put(actions.updateUserPassword.success(user));
} }

View file

@ -1,15 +1,36 @@
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import jwtDecode from 'jwt-decode';
import Config from '../constants/Config'; import Config from '../constants/Config';
export const setAccessToken = (accessToken) => { export const setAccessToken = (accessToken) => {
const { exp } = jwtDecode(accessToken);
const expires = new Date(exp * 1000);
Cookies.set(Config.ACCESS_TOKEN_KEY, accessToken, { 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 = () => { export const removeAccessToken = () => {
Cookies.remove(Config.ACCESS_TOKEN_KEY); 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;
}; };

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "planka", "name": "planka",
"version": "1.5.0", "version": "1.5.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planka", "name": "planka",
"version": "1.5.0", "version": "1.5.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View file

@ -1,4 +1,9 @@
TZ=UTC
BASE_URL=http://localhost:1337 BASE_URL=http://localhost:1337
DATABASE_URL=postgresql://postgres@localhost/planka DATABASE_URL=postgresql://postgres@localhost/planka
SECRET_KEY=notsecretkey SECRET_KEY=notsecretkey
# In days
TOKEN_EXPIRES_IN=365
# Do not edit this
TZ=UTC

View file

@ -49,7 +49,7 @@ module.exports = {
} }
return { return {
item: sails.helpers.utils.signToken(user.id), item: sails.helpers.utils.createToken(user.id),
}; };
}, },
}; };

View file

@ -54,6 +54,7 @@ module.exports = {
{ {
avatarDirname: files[0].extra.dirname, avatarDirname: files[0].extra.dirname,
}, },
currentUser,
this.req, this.req,
); );

View file

@ -69,7 +69,7 @@ module.exports = {
const values = _.pick(inputs, ['email']); const values = _.pick(inputs, ['email']);
user = await sails.helpers.users user = await sails.helpers.users
.updateOne(user, values, this.req) .updateOne(user, values, currentUser, this.req)
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE); .intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE);
if (!user) { if (!user) {

View file

@ -60,12 +60,21 @@ module.exports = {
} }
const values = _.pick(inputs, ['password']); 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) { if (!user) {
throw Errors.USER_NOT_FOUND; 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 { return {
item: user, item: user,
}; };

View file

@ -71,7 +71,7 @@ module.exports = {
const values = _.pick(inputs, ['username']); const values = _.pick(inputs, ['username']);
user = await sails.helpers.users user = await sails.helpers.users
.updateOne(user, values, this.req) .updateOne(user, values, currentUser, this.req)
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE); .intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE);
if (!user) { if (!user) {

View file

@ -75,7 +75,7 @@ module.exports = {
'subscribeToOwnCards', 'subscribeToOwnCards',
]); ]);
user = await sails.helpers.users.updateOne(user, values, this.req); user = await sails.helpers.users.updateOne(user, values, currentUser, this.req);
if (!user) { if (!user) {
throw Errors.USER_NOT_FOUND; throw Errors.USER_NOT_FOUND;

View file

@ -1,6 +1,7 @@
const path = require('path'); const path = require('path');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const rimraf = require('rimraf'); const rimraf = require('rimraf');
const { v4: uuid } = require('uuid');
module.exports = { module.exports = {
inputs: { inputs: {
@ -35,6 +36,10 @@ module.exports = {
}, },
required: true, required: true,
}, },
user: {
type: 'ref',
required: true,
},
request: { request: {
type: 'ref', type: 'ref',
}, },
@ -54,8 +59,10 @@ module.exports = {
let isOnlyPasswordChange = false; let isOnlyPasswordChange = false;
if (!_.isUndefined(inputs.values.password)) { if (!_.isUndefined(inputs.values.password)) {
// eslint-disable-next-line no-param-reassign Object.assign(inputs.values, {
inputs.values.password = bcrypt.hashSync(inputs.values.password, 10); password: bcrypt.hashSync(inputs.values.password, 10),
passwordChangedAt: new Date().toUTCString(),
});
if (Object.keys(inputs.values).length === 1) { if (Object.keys(inputs.values).length === 1) {
isOnlyPasswordChange = true; 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) { if (!isOnlyPasswordChange) {
/* const projectIds = await sails.helpers.users.getManagerProjectIds(user.id); /* const projectIds = await sails.helpers.users.getManagerProjectIds(user.id);

View file

@ -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,
);
},
};

View file

@ -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);
},
};

View file

@ -15,10 +15,16 @@ module.exports = {
}, },
fn(inputs) { fn(inputs) {
let payload;
try { try {
return jwt.verify(inputs.token, sails.config.session.secret); payload = jwt.verify(inputs.token, sails.config.session.secret);
} catch (error) { } catch (error) {
throw 'invalidToken'; throw 'invalidToken';
} }
return {
subject: payload.sub,
issuedAt: new Date(payload.iat * 1000),
};
}, },
}; };

View file

@ -10,15 +10,20 @@ module.exports = function defineCurrentUserHook(sails) {
const TOKEN_PATTERN = /^Bearer /; const TOKEN_PATTERN = /^Bearer /;
const getUser = async (accessToken) => { const getUser = async (accessToken) => {
let id; let payload;
try { try {
id = sails.helpers.utils.verifyToken(accessToken); payload = sails.helpers.utils.verifyToken(accessToken);
} catch (error) { } catch (error) {
return null; 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 { return {
@ -32,19 +37,23 @@ module.exports = function defineCurrentUserHook(sails) {
routes: { routes: {
before: { before: {
'/*': { '/api/*': {
async fn(req, res, next) { async fn(req, res, next) {
let accessToken; const { authorization: authorizationHeader } = req.headers;
if (req.headers.authorization) {
if (TOKEN_PATTERN.test(req.headers.authorization)) { if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) {
accessToken = req.headers.authorization.replace(TOKEN_PATTERN, ''); const accessToken = authorizationHeader.replace(TOKEN_PATTERN, '');
}
} else if (req.cookies.accessToken) { req.currentUser = await getUser(accessToken);
accessToken = req.cookies.accessToken;
} }
if (accessToken) { return next();
req.currentUser = await getUser(accessToken); },
},
'/attachments/*': {
async fn(req, res, next) {
if (req.cookies.accessToken) {
req.currentUser = await getUser(req.cookies.accessToken);
} }
return next(); return next();

View file

@ -67,6 +67,10 @@ module.exports = {
type: 'ref', type: 'ref',
columnName: 'deleted_at', columnName: 'deleted_at',
}, },
passwordChangedAt: {
type: 'ref',
columnName: 'password_changed_at',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗
@ -102,7 +106,7 @@ module.exports = {
customToJSON() { customToJSON() {
return { return {
..._.omit(this, ['password', 'avatarDirname']), ..._.omit(this, ['password', 'avatarDirname', 'passwordChangedAt']),
avatarUrl: avatarUrl:
this.avatarDirname && this.avatarDirname &&
`${sails.config.custom.userAvatarsUrl}/${this.avatarDirname}/square-100.jpg`, `${sails.config.custom.userAvatarsUrl}/${this.avatarDirname}/square-100.jpg`,

View file

@ -20,6 +20,8 @@ module.exports.custom = {
baseUrl: process.env.BASE_URL, baseUrl: process.env.BASE_URL,
tokenExpiresIn: process.env.TOKEN_EXPIRES_IN,
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'), userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`, userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,

View file

@ -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');
});