feat: HMAC verification for web widget (#1643)

* feat: HMAC verification for web widget. Let you verify the authenticated contact via HMAC on the web widget to prevent data tampering.
* Add docs for identity-validation

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Sojan Jose
2021-01-17 22:44:03 +05:30
committed by GitHub
parent d758df8807
commit b6e8173b24
26 changed files with 517 additions and 311 deletions

View File

@@ -42,5 +42,9 @@ export default {
font-weight: $font-weight-medium;
margin-bottom: 0;
}
.title--section {
padding-right: var(--space-large);
}
}
</style>

View File

@@ -241,7 +241,9 @@
"AUTO_ASSIGNMENT": "Enable auto assignment",
"INBOX_UPDATE_TITLE": "Inbox Settings",
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox."
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
"HMAC_VERIFICATION": "User Identity Validation",
"HMAC_DESCRIPTION": "Inorder validate the users identity, the SDK allows you to pass an `identity_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",

View File

@@ -241,6 +241,13 @@
>
<woot-code :script="inbox.web_widget_script"></woot-code>
</settings-section>
<settings-section
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION')"
>
<woot-code :script="inbox.hmac_token"></woot-code>
</settings-section>
</div>
</div>
</div>

View File

@@ -3,7 +3,9 @@ import { IFrameHelper } from '../sdk/IFrameHelper';
import { getBubbleView } from '../sdk/bubbleHelpers';
import md5 from 'md5';
const ALLOWED_LIST_OF_SET_USER_ATTRIBUTES = ['avatar_url', 'email', 'name'];
const REQUIRED_USER_KEYS = ['avatar_url', 'email', 'name'];
const ALLOWED_USER_ATTRIBUTES = [...REQUIRED_USER_KEYS, 'identifier_hash'];
export const getUserCookieName = () => {
const SET_USER_COOKIE_PREFIX = 'cw_user_';
@@ -12,7 +14,7 @@ export const getUserCookieName = () => {
};
export const getUserString = ({ identifier = '', user }) => {
const userStringWithSortedKeys = ALLOWED_LIST_OF_SET_USER_ATTRIBUTES.reduce(
const userStringWithSortedKeys = ALLOWED_USER_ATTRIBUTES.reduce(
(acc, key) => `${acc}${key}${user[key] || ''}`,
''
);
@@ -22,10 +24,7 @@ export const getUserString = ({ identifier = '', user }) => {
const computeHashForUserData = (...args) => md5(getUserString(...args));
export const hasUserKeys = user =>
ALLOWED_LIST_OF_SET_USER_ATTRIBUTES.reduce(
(acc, key) => acc || !!user[key],
false
);
REQUIRED_USER_KEYS.reduce((acc, key) => acc || !!user[key], false);
const runSDK = ({ baseUrl, websiteToken }) => {
const chatwootSettings = window.chatwootSettings || {};

View File

@@ -15,11 +15,12 @@ describe('#getUserString', () => {
name: 'Pranav',
email: 'pranav@example.com',
avatar_url: 'https://images.chatwoot.com/placeholder',
identifier_hash: '12345',
},
identifier: '12345',
})
).toBe(
'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnamePranavidentifier12345'
'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnamePranavidentifier_hash12345identifier12345'
);
expect(
@@ -30,7 +31,7 @@ describe('#getUserString', () => {
},
})
).toBe(
'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnameidentifier'
'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnameidentifier_hashidentifier'
);
});
});

View File

@@ -2,16 +2,23 @@ import ContactsAPI from '../../api/contacts';
import { refreshActionCableConnector } from '../../helpers/actionCable';
export const actions = {
update: async (_, { identifier, user: userObject }) => {
update: async ({ dispatch }, { identifier, user: userObject }) => {
try {
const user = {
email: userObject.email,
name: userObject.name,
avatar_url: userObject.avatar_url,
identifier_hash: userObject.identifier_hash,
};
const {
data: { pubsub_token: pubsubToken },
} = await ContactsAPI.update(identifier, user);
if (userObject.identifier_hash) {
dispatch('conversation/clearConversations', {}, { root: true });
dispatch('conversation/fetchOldConversations', {}, { root: true });
}
refreshActionCableConnector(pubsubToken);
} catch (error) {
// Ingore error

View File

@@ -1,288 +0,0 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import {
sendMessageAPI,
getMessagesAPI,
sendAttachmentAPI,
toggleTyping,
setUserLastSeenAt,
} from 'widget/api/conversation';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
import { formatUnixDate } from 'shared/helpers/DateHelper';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import getUuid from '../../helpers/uuid';
const groupBy = require('lodash.groupby');
export const createTemporaryMessage = ({ attachments, content }) => {
const timestamp = new Date().getTime() / 1000;
return {
id: getUuid(),
content,
attachments,
status: 'in_progress',
created_at: timestamp,
message_type: MESSAGE_TYPE.INCOMING,
};
};
const getSenderName = message => (message.sender ? message.sender.name : '');
const shouldShowAvatar = (message, nextMessage) => {
const currentSender = getSenderName(message);
const nextSender = getSenderName(nextMessage);
return (
currentSender !== nextSender ||
message.message_type !== nextMessage.message_type ||
isASubmittedFormMessage(nextMessage)
);
};
const groupConversationBySender = conversationsForADate =>
conversationsForADate.map((message, index) => {
let showAvatar = false;
const isLastMessage = index === conversationsForADate.length - 1;
if (isASubmittedFormMessage(message)) {
showAvatar = false;
} else if (isLastMessage) {
showAvatar = true;
} else {
const nextMessage = conversationsForADate[index + 1];
showAvatar = shouldShowAvatar(message, nextMessage);
}
return { showAvatar, ...message };
});
export const findUndeliveredMessage = (messageInbox, { content }) =>
Object.values(messageInbox).filter(
message => message.content === content && message.status === 'in_progress'
);
export const onNewMessageCreated = data => {
const { message_type: messageType } = data;
const isIncomingMessage = messageType === MESSAGE_TYPE.OUTGOING;
if (isIncomingMessage) {
playNotificationAudio();
}
};
export const DEFAULT_CONVERSATION = 'default';
const state = {
conversations: {},
meta: {
userLastSeenAt: undefined,
},
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
isAgentTyping: false,
},
};
export const getters = {
getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
getConversation: _state => _state.conversations,
getConversationSize: _state => Object.keys(_state.conversations).length,
getEarliestMessage: _state => {
const conversation = Object.values(_state.conversations);
if (conversation.length) {
return conversation[0];
}
return {};
},
getGroupedConversation: _state => {
const conversationGroupedByDate = groupBy(
Object.values(_state.conversations),
message => formatUnixDate(message.created_at)
);
return Object.keys(conversationGroupedByDate).map(date => ({
date,
messages: groupConversationBySender(conversationGroupedByDate[date]),
}));
},
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
getUnreadMessageCount: _state => {
const { userLastSeenAt } = _state.meta;
const count = Object.values(_state.conversations).filter(chat => {
const { created_at: createdAt, message_type: messageType } = chat;
const isOutGoing = messageType === MESSAGE_TYPE.OUTGOING;
const hasNotSeen = userLastSeenAt
? createdAt * 1000 > userLastSeenAt * 1000
: true;
return hasNotSeen && isOutGoing;
}).length;
return count;
},
getUnreadTextMessages: (_state, _getters) => {
const unreadCount = _getters.getUnreadMessageCount;
const allMessages = [...Object.values(_state.conversations)];
const unreadAgentMessages = allMessages.filter(message => {
const { message_type: messageType } = message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const maxUnreadCount = Math.min(unreadCount, 3);
const allUnreadMessages = unreadAgentMessages.splice(-maxUnreadCount);
return allUnreadMessages;
},
};
export const actions = {
sendMessage: async ({ commit }, params) => {
const { content } = params;
commit('pushMessageToConversation', createTemporaryMessage({ content }));
await sendMessageAPI(content);
},
sendAttachment: async ({ commit }, params) => {
const {
attachment: { thumbUrl, fileType },
} = params;
const attachment = {
thumb_url: thumbUrl,
data_url: thumbUrl,
file_type: fileType,
status: 'in_progress',
};
const tempMessage = createTemporaryMessage({
attachments: [attachment],
});
commit('pushMessageToConversation', tempMessage);
try {
const { data } = await sendAttachmentAPI(params);
commit('updateAttachmentMessageStatus', {
message: data,
tempId: tempMessage.id,
});
} catch (error) {
// Show error
}
},
fetchOldConversations: async ({ commit }, { before } = {}) => {
try {
commit('setConversationListLoading', true);
const { data } = await getMessagesAPI({ before });
commit('setMessagesInConversation', data);
commit('setConversationListLoading', false);
} catch (error) {
commit('setConversationListLoading', false);
}
},
addMessage: async ({ commit }, data) => {
commit('pushMessageToConversation', data);
onNewMessageCreated(data);
},
updateMessage({ commit }, data) {
commit('pushMessageToConversation', data);
},
toggleAgentTyping({ commit }, data) {
commit('toggleAgentTypingStatus', data);
},
toggleUserTyping: async (_, data) => {
try {
await toggleTyping(data);
} catch (error) {
// IgnoreError
}
},
setUserLastSeen: async ({ commit, getters: appGetters }) => {
if (!appGetters.getConversationSize) {
return;
}
const lastSeen = Date.now() / 1000;
try {
commit('setMetaUserLastSeenAt', lastSeen);
await setUserLastSeenAt({ lastSeen });
} catch (error) {
// IgnoreError
}
},
};
export const mutations = {
pushMessageToConversation($state, message) {
const { id, status, message_type: type } = message;
const messagesInbox = $state.conversations;
const isMessageIncoming = type === MESSAGE_TYPE.INCOMING;
const isTemporaryMessage = status === 'in_progress';
if (!isMessageIncoming || isTemporaryMessage) {
Vue.set(messagesInbox, id, message);
return;
}
const [messageInConversation] = findUndeliveredMessage(
messagesInbox,
message
);
if (!messageInConversation) {
Vue.set(messagesInbox, id, message);
} else {
Vue.delete(messagesInbox, messageInConversation.id);
Vue.set(messagesInbox, id, message);
}
},
updateAttachmentMessageStatus($state, { message, tempId }) {
const { id } = message;
const messagesInbox = $state.conversations;
const messageInConversation = messagesInbox[tempId];
if (messageInConversation) {
Vue.delete(messagesInbox, tempId);
Vue.set(messagesInbox, id, { ...message });
}
},
setConversationListLoading($state, status) {
$state.uiFlags.isFetchingList = status;
},
setMessagesInConversation($state, payload) {
if (!payload.length) {
$state.uiFlags.allMessagesLoaded = true;
return;
}
payload.map(message => Vue.set($state.conversations, message.id, message));
},
updateMessage($state, { id, content_attributes }) {
$state.conversations[id] = {
...$state.conversations[id],
content_attributes: {
...($state.conversations[id].content_attributes || {}),
...content_attributes,
},
};
},
toggleAgentTypingStatus($state, { status }) {
const isTyping = status === 'on';
$state.uiFlags.isAgentTyping = isTyping;
},
setMetaUserLastSeenAt($state, lastSeen) {
$state.meta.userLastSeenAt = lastSeen;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,92 @@
import {
sendMessageAPI,
getMessagesAPI,
sendAttachmentAPI,
toggleTyping,
setUserLastSeenAt,
} from 'widget/api/conversation';
import { createTemporaryMessage, onNewMessageCreated } from './helpers';
export const actions = {
sendMessage: async ({ commit }, params) => {
const { content } = params;
commit('pushMessageToConversation', createTemporaryMessage({ content }));
await sendMessageAPI(content);
},
sendAttachment: async ({ commit }, params) => {
const {
attachment: { thumbUrl, fileType },
} = params;
const attachment = {
thumb_url: thumbUrl,
data_url: thumbUrl,
file_type: fileType,
status: 'in_progress',
};
const tempMessage = createTemporaryMessage({
attachments: [attachment],
});
commit('pushMessageToConversation', tempMessage);
try {
const { data } = await sendAttachmentAPI(params);
commit('updateAttachmentMessageStatus', {
message: data,
tempId: tempMessage.id,
});
} catch (error) {
// Show error
}
},
fetchOldConversations: async ({ commit }, { before } = {}) => {
try {
commit('setConversationListLoading', true);
const { data } = await getMessagesAPI({ before });
commit('setMessagesInConversation', data);
commit('setConversationListLoading', false);
} catch (error) {
commit('setConversationListLoading', false);
}
},
clearConversations: ({ commit }) => {
commit('clearConversations');
},
addMessage: async ({ commit }, data) => {
commit('pushMessageToConversation', data);
onNewMessageCreated(data);
},
updateMessage({ commit }, data) {
commit('pushMessageToConversation', data);
},
toggleAgentTyping({ commit }, data) {
commit('toggleAgentTypingStatus', data);
},
toggleUserTyping: async (_, data) => {
try {
await toggleTyping(data);
} catch (error) {
// IgnoreError
}
},
setUserLastSeen: async ({ commit, getters: appGetters }) => {
if (!appGetters.getConversationSize) {
return;
}
const lastSeen = Date.now() / 1000;
try {
commit('setMetaUserLastSeenAt', lastSeen);
await setUserLastSeenAt({ lastSeen });
} catch (error) {
// IgnoreError
}
},
};

View File

@@ -0,0 +1,52 @@
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import groupBy from 'lodash.groupby';
import { groupConversationBySender } from './helpers';
import { formatUnixDate } from 'shared/helpers/DateHelper';
export const getters = {
getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
getConversation: _state => _state.conversations,
getConversationSize: _state => Object.keys(_state.conversations).length,
getEarliestMessage: _state => {
const conversation = Object.values(_state.conversations);
if (conversation.length) {
return conversation[0];
}
return {};
},
getGroupedConversation: _state => {
const conversationGroupedByDate = groupBy(
Object.values(_state.conversations),
message => formatUnixDate(message.created_at)
);
return Object.keys(conversationGroupedByDate).map(date => ({
date,
messages: groupConversationBySender(conversationGroupedByDate[date]),
}));
},
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
getUnreadMessageCount: _state => {
const { userLastSeenAt } = _state.meta;
const count = Object.values(_state.conversations).filter(chat => {
const { created_at: createdAt, message_type: messageType } = chat;
const isOutGoing = messageType === MESSAGE_TYPE.OUTGOING;
const hasNotSeen = userLastSeenAt
? createdAt * 1000 > userLastSeenAt * 1000
: true;
return hasNotSeen && isOutGoing;
}).length;
return count;
},
getUnreadTextMessages: (_state, _getters) => {
const unreadCount = _getters.getUnreadMessageCount;
const allMessages = [...Object.values(_state.conversations)];
const unreadAgentMessages = allMessages.filter(message => {
const { message_type: messageType } = message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const maxUnreadCount = Math.min(unreadCount, 3);
const allUnreadMessages = unreadAgentMessages.splice(-maxUnreadCount);
return allUnreadMessages;
},
};

View File

@@ -0,0 +1,58 @@
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import getUuid from '../../../helpers/uuid';
export const createTemporaryMessage = ({ attachments, content }) => {
const timestamp = new Date().getTime() / 1000;
return {
id: getUuid(),
content,
attachments,
status: 'in_progress',
created_at: timestamp,
message_type: MESSAGE_TYPE.INCOMING,
};
};
const getSenderName = message => (message.sender ? message.sender.name : '');
const shouldShowAvatar = (message, nextMessage) => {
const currentSender = getSenderName(message);
const nextSender = getSenderName(nextMessage);
return (
currentSender !== nextSender ||
message.message_type !== nextMessage.message_type ||
isASubmittedFormMessage(nextMessage)
);
};
export const groupConversationBySender = conversationsForADate =>
conversationsForADate.map((message, index) => {
let showAvatar = false;
const isLastMessage = index === conversationsForADate.length - 1;
if (isASubmittedFormMessage(message)) {
showAvatar = false;
} else if (isLastMessage) {
showAvatar = true;
} else {
const nextMessage = conversationsForADate[index + 1];
showAvatar = shouldShowAvatar(message, nextMessage);
}
return { showAvatar, ...message };
});
export const findUndeliveredMessage = (messageInbox, { content }) =>
Object.values(messageInbox).filter(
message => message.content === content && message.status === 'in_progress'
);
export const onNewMessageCreated = data => {
const { message_type: messageType } = data;
const isIncomingMessage = messageType === MESSAGE_TYPE.OUTGOING;
if (isIncomingMessage) {
playNotificationAudio();
}
};

View File

@@ -0,0 +1,23 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
conversations: {},
meta: {
userLastSeenAt: undefined,
},
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
isAgentTyping: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,75 @@
import Vue from 'vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { findUndeliveredMessage } from './helpers';
export const mutations = {
clearConversations($state) {
Vue.set($state, 'conversations', {});
},
pushMessageToConversation($state, message) {
const { id, status, message_type: type } = message;
const messagesInbox = $state.conversations;
const isMessageIncoming = type === MESSAGE_TYPE.INCOMING;
const isTemporaryMessage = status === 'in_progress';
if (!isMessageIncoming || isTemporaryMessage) {
Vue.set(messagesInbox, id, message);
return;
}
const [messageInConversation] = findUndeliveredMessage(
messagesInbox,
message
);
if (!messageInConversation) {
Vue.set(messagesInbox, id, message);
} else {
Vue.delete(messagesInbox, messageInConversation.id);
Vue.set(messagesInbox, id, message);
}
},
updateAttachmentMessageStatus($state, { message, tempId }) {
const { id } = message;
const messagesInbox = $state.conversations;
const messageInConversation = messagesInbox[tempId];
if (messageInConversation) {
Vue.delete(messagesInbox, tempId);
Vue.set(messagesInbox, id, { ...message });
}
},
setConversationListLoading($state, status) {
$state.uiFlags.isFetchingList = status;
},
setMessagesInConversation($state, payload) {
if (!payload.length) {
$state.uiFlags.allMessagesLoaded = true;
return;
}
payload.map(message => Vue.set($state.conversations, message.id, message));
},
updateMessage($state, { id, content_attributes }) {
$state.conversations[id] = {
...$state.conversations[id],
content_attributes: {
...($state.conversations[id].content_attributes || {}),
...content_attributes,
},
};
},
toggleAgentTypingStatus($state, { status }) {
const isTyping = status === 'on';
$state.uiFlags.isAgentTyping = isTyping;
},
setMetaUserLastSeenAt($state, lastSeen) {
$state.meta.userLastSeenAt = lastSeen;
},
};

View File

@@ -1,5 +1,5 @@
import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
import { actions } from '../../conversation';
import { actions } from '../../conversation/actions';
import getUuid from '../../../../helpers/uuid';
import { API } from 'widget/helpers/axios';
@@ -121,4 +121,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([]);
});
});
describe('#clearConversations', () => {
it('sends correct mutations', () => {
actions.clearConversations({ commit });
expect(commit).toBeCalledWith('clearConversations');
});
});
});

View File

@@ -1,4 +1,4 @@
import { getters } from '../../conversation';
import { getters } from '../../conversation/getters';
describe('#getters', () => {
it('getConversation', () => {

View File

@@ -1,7 +1,7 @@
import {
findUndeliveredMessage,
createTemporaryMessage,
} from '../../conversation';
} from '../../conversation/helpers';
describe('#findUndeliveredMessage', () => {
it('returns message objects if exist', () => {

View File

@@ -1,4 +1,4 @@
import { mutations } from '../../conversation';
import { mutations } from '../../conversation/mutations';
const temporaryMessagePayload = {
content: 'hello',
@@ -156,4 +156,12 @@ describe('#mutations', () => {
});
});
});
describe('#clearConversations', () => {
it('clears the state', () => {
const state = { conversations: { 1: { id: 1 } } };
mutations.clearConversations(state);
expect(state.conversations).toEqual({});
});
});
});