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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 517 additions and 311 deletions

View file

@ -7,7 +7,12 @@ class Api::V1::Widget::BaseController < ApplicationController
private private
def conversations def conversations
@conversations = @contact_inbox.conversations.where(inbox_id: auth_token_params[:inbox_id]) if @contact_inbox.hmac_verified?
verified_contact_inbox_ids = @contact.contact_inboxes.where(inbox_id: auth_token_params[:inbox_id], hmac_verified: true).map(&:id)
@conversations = @contact.conversations.where(contact_inbox_id: verified_contact_inbox_ids)
else
@conversations = @contact_inbox.conversations.where(inbox_id: auth_token_params[:inbox_id])
end
end end
def conversation def conversation

View file

@ -1,5 +1,6 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
def update def update
process_hmac
contact_identify_action = ContactIdentifyAction.new( contact_identify_action = ContactIdentifyAction.new(
contact: @contact, contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys params: permitted_params.to_h.deep_symbolize_keys
@ -9,7 +10,22 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
private private
def process_hmac
return if params[:identifier_hash].blank?
raise StandardError, 'HMAC failed: Invalid Identifer Hash Provided' unless valid_hmac?
@contact_inbox.update(hmac_verified: true)
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
end
def permitted_params def permitted_params
params.permit(:website_token, :identifier, :email, :name, :avatar_url, custom_attributes: {}) params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {})
end end
end end

View file

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

View file

@ -241,7 +241,9 @@
"AUTO_ASSIGNMENT": "Enable auto assignment", "AUTO_ASSIGNMENT": "Enable auto assignment",
"INBOX_UPDATE_TITLE": "Inbox Settings", "INBOX_UPDATE_TITLE": "Inbox Settings",
"INBOX_UPDATE_SUB_TEXT": "Update your 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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize", "TITLE": "Reauthorize",

View file

@ -241,6 +241,13 @@
> >
<woot-code :script="inbox.web_widget_script"></woot-code> <woot-code :script="inbox.web_widget_script"></woot-code>
</settings-section> </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> </div>
</div> </div>

View file

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

View file

@ -15,11 +15,12 @@ describe('#getUserString', () => {
name: 'Pranav', name: 'Pranav',
email: 'pranav@example.com', email: 'pranav@example.com',
avatar_url: 'https://images.chatwoot.com/placeholder', avatar_url: 'https://images.chatwoot.com/placeholder',
identifier_hash: '12345',
}, },
identifier: '12345', identifier: '12345',
}) })
).toBe( ).toBe(
'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnamePranavidentifier12345' 'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnamePranavidentifier_hash12345identifier12345'
); );
expect( expect(
@ -30,7 +31,7 @@ describe('#getUserString', () => {
}, },
}) })
).toBe( ).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'; import { refreshActionCableConnector } from '../../helpers/actionCable';
export const actions = { export const actions = {
update: async (_, { identifier, user: userObject }) => { update: async ({ dispatch }, { identifier, user: userObject }) => {
try { try {
const user = { const user = {
email: userObject.email, email: userObject.email,
name: userObject.name, name: userObject.name,
avatar_url: userObject.avatar_url, avatar_url: userObject.avatar_url,
identifier_hash: userObject.identifier_hash,
}; };
const { const {
data: { pubsub_token: pubsubToken }, data: { pubsub_token: pubsubToken },
} = await ContactsAPI.update(identifier, user); } = await ContactsAPI.update(identifier, user);
if (userObject.identifier_hash) {
dispatch('conversation/clearConversations', {}, { root: true });
dispatch('conversation/fetchOldConversations', {}, { root: true });
}
refreshActionCableConnector(pubsubToken); refreshActionCableConnector(pubsubToken);
} catch (error) { } catch (error) {
// Ingore 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 { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
import { actions } from '../../conversation'; import { actions } from '../../conversation/actions';
import getUuid from '../../../../helpers/uuid'; import getUuid from '../../../../helpers/uuid';
import { API } from 'widget/helpers/axios'; import { API } from 'widget/helpers/axios';
@ -121,4 +121,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([]); 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', () => { describe('#getters', () => {
it('getConversation', () => { it('getConversation', () => {

View file

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

View file

@ -1,4 +1,4 @@
import { mutations } from '../../conversation'; import { mutations } from '../../conversation/mutations';
const temporaryMessagePayload = { const temporaryMessagePayload = {
content: 'hello', 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({});
});
});
}); });

View file

@ -4,6 +4,7 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# feature_flags :integer default(3), not null # feature_flags :integer default(3), not null
# hmac_token :string
# reply_time :integer default("in_a_few_minutes") # reply_time :integer default("in_a_few_minutes")
# website_token :string # website_token :string
# website_url :string # website_url :string
@ -16,6 +17,7 @@
# #
# Indexes # Indexes
# #
# index_channel_web_widgets_on_hmac_token (hmac_token) UNIQUE
# index_channel_web_widgets_on_website_token (website_token) UNIQUE # index_channel_web_widgets_on_website_token (website_token) UNIQUE
# #
@ -30,6 +32,8 @@ class Channel::WebWidget < ApplicationRecord
belongs_to :account belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy has_one :inbox, as: :channel, dependent: :destroy
has_secure_token :website_token has_secure_token :website_token
has_secure_token :hmac_token
has_flags 1 => :attachments, has_flags 1 => :attachments,
2 => :emoji_picker, 2 => :emoji_picker,
:column => 'feature_flags' :column => 'feature_flags'

View file

@ -2,12 +2,13 @@
# #
# Table name: contact_inboxes # Table name: contact_inboxes
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# created_at :datetime not null # hmac_verified :boolean default(FALSE)
# updated_at :datetime not null # created_at :datetime not null
# contact_id :bigint # updated_at :datetime not null
# inbox_id :bigint # contact_id :bigint
# source_id :string not null # inbox_id :bigint
# source_id :string not null
# #
# Indexes # Indexes
# #

View file

@ -17,3 +17,4 @@ json.phone_number resource.channel.try(:phone_number)
json.selected_feature_flags resource.channel.try(:selected_feature_flags) json.selected_feature_flags resource.channel.try(:selected_feature_flags)
json.reply_time resource.channel.try(:reply_time) json.reply_time resource.channel.try(:reply_time)
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.facebook? json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.facebook?
json.hmac_token resource.channel.try(:hmac_token) if resource.web_widget?

View file

@ -1,5 +1,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<%
user_id = 1
user_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
user_id.to_s
)
%>
<script> <script>
window.chatwootSettings = { window.chatwootSettings = {
@ -24,6 +34,11 @@ window.chatwootSettings = {
})(document,"script"); })(document,"script");
window.addEventListener('chatwoot:ready', function() { window.addEventListener('chatwoot:ready', function() {
console.log(window.$chatwoot) console.log(window.$chatwoot);
window.$chatwoot.setUser('<%= user_id %>', {
identifier_hash: '<%= user_hash %>',
email: 'jane@acme.inc',
name: 'Jane Doe'
});
}) })
</script> </script>

View file

@ -0,0 +1,15 @@
class AddHmacTokenToInbox < ActiveRecord::Migration[6.0]
def change
add_column :channel_web_widgets, :hmac_token, :string
add_index :channel_web_widgets, :hmac_token, unique: true
set_up_existing_webwidgets
add_column :contact_inboxes, :hmac_verified, :boolean, default: false
end
def set_up_existing_webwidgets
::Channel::WebWidget.find_in_batches do |webwidgets_batch|
Rails.logger.info "migrated till #{webwidgets_batch.first.id}\n"
webwidgets_batch.map(&:regenerate_hmac_token)
end
end
end

View file

@ -182,6 +182,8 @@ ActiveRecord::Schema.define(version: 2021_01_13_045116) do
t.string "welcome_tagline" t.string "welcome_tagline"
t.integer "feature_flags", default: 3, null: false t.integer "feature_flags", default: 3, null: false
t.integer "reply_time", default: 0 t.integer "reply_time", default: 0
t.string "hmac_token"
t.index ["hmac_token"], name: "index_channel_web_widgets_on_hmac_token", unique: true
t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true
end end
@ -191,6 +193,7 @@ ActiveRecord::Schema.define(version: 2021_01_13_045116) do
t.string "source_id", null: false t.string "source_id", null: false
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.boolean "hmac_verified", default: false
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id" t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id" t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"

View file

@ -0,0 +1,84 @@
---
path: '/docs/website-sdk/identity-validation'
title: 'Identity validation in Chatwoot'
---
To make sure the conversations between the customers and the support agents are private and to disallow impersonation, you can setup identity validation Chatwoot.
Identity validation can be enabled by generating an HMAC. The key used to generate HMAC for each webwidget is different and can be copied from Inboxes -> Settings -> Configuration -> Identity Validation -> Copy the token shown there
You can generate HMAC in different languages as shown below.
```php
<?php
$key = 'webwidget.hmac_token';
$message = 'identifier';
$identifier_hash = hash_hmac('sha256', $message, $key);
?>
```
```js
const crypto = require('crypto');
const key = 'webwidget.hmac_token';
const message = 'identifier';
const hash = crypto.createHmac('sha256', key).update(message);
hash.digest('hex');
```
```rb
require 'openssl'
require 'base64'
key = 'webwidget.hmac_token'
message = 'identifier'
OpenSSL::HMAC.hexdigest('sha256', key, message)
```
```elixir
key = 'webwidget.hmac_token'
message = 'identifier'
signature = :crypto.hmac(:sha256, key, message)
Base.encode16(signature, case: :lower)
```
```go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
func main() {
secret := []byte("webwidget.hmac_token")
message := []byte("identifier")
hash := hmac.New(sha256.New, secret)
hash.Write(message)
hex.EncodeToString(hash.Sum(nil))
}
```
```py
import hashlib
import hmac
import base64
message = bytes('webwidget.hmac_token', 'utf-8')
secret = bytes('identifier', 'utf-8')
hash = hmac.new(secret, message, hashlib.sha256)
hash.hexdigest()
```

View file

@ -77,6 +77,21 @@ window.$chatwoot.setUser('<unique-identifier-key-of-the-user>', {
Make sure that you reset the session when the user logs out of your app. Make sure that you reset the session when the user logs out of your app.
### Identity validation
To disallow impersonation and to keep the conversation with your customers private, we recommend setting up the identity validation in Chatwoot. Identity validation is enabled by generating an HMAC(hash based message authentication code) based on the `identifier` attribute, using SHA256. Along with the `identifier` you can pass `identifier_hash` also as shown below to make sure that the user is correct one.
```js
window.$chatwoot.setUser(`identifier-hash`, {
name: '', // Name of the user
avatar_url: '', // Avatar URL
email: '', // Email of the user
identifier_hash: '' // Identifier Hash generated based on the webwidget hmac_token
})
```
To generate HMAC, read [identity validation](/website-sdk/identity-validation)
### Set custom attributes ### Set custom attributes
Inorder to set additional information about the customer you can use customer attributes field. Inorder to set additional information about the customer you can use customer attributes field.