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
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
def conversation

View file

@ -1,5 +1,6 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
def update
process_hmac
contact_identify_action = ContactIdentifyAction.new(
contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys
@ -9,7 +10,22 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
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
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

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({});
});
});
});

View file

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

View file

@ -2,12 +2,13 @@
#
# Table name: contact_inboxes
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# contact_id :bigint
# inbox_id :bigint
# source_id :string not null
# id :bigint not null, primary key
# hmac_verified :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# contact_id :bigint
# inbox_id :bigint
# source_id :string not null
#
# 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.reply_time resource.channel.try(:reply_time)
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" />
<%
user_id = 1
user_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
user_id.to_s
)
%>
<script>
window.chatwootSettings = {
@ -24,6 +34,11 @@ window.chatwootSettings = {
})(document,"script");
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>

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.integer "feature_flags", default: 3, null: false
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
end
@ -191,6 +193,7 @@ ActiveRecord::Schema.define(version: 2021_01_13_045116) do
t.string "source_id", null: false
t.datetime "created_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 ["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"

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.
### 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
Inorder to set additional information about the customer you can use customer attributes field.