From 47cf9aac3e1289173140e10aad3fad4d84706282 Mon Sep 17 00:00:00 2001 From: Pi Delport Date: Wed, 22 Aug 2018 18:05:06 +0200 Subject: [PATCH 1/3] Intercom: Set user_id field for authenticated users This is one of Intercom's core user detail fields. --- analytical/templatetags/intercom.py | 2 ++ analytical/tests/test_tag_intercom.py | 39 ++++++++++++++------------- docs/services/intercom.rst | 4 ++- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/analytical/templatetags/intercom.py b/analytical/templatetags/intercom.py index f0f50d1..27e9047 100644 --- a/analytical/templatetags/intercom.py +++ b/analytical/templatetags/intercom.py @@ -66,6 +66,8 @@ class IntercomNode(Node): if 'email' not in params and user.email: params['email'] = user.email + params.setdefault('user_id', user.pk) + params['created_at'] = int(time.mktime( user.date_joined.timetuple())) else: diff --git a/analytical/tests/test_tag_intercom.py b/analytical/tests/test_tag_intercom.py index bc24739..754081c 100644 --- a/analytical/tests/test_tag_intercom.py +++ b/analytical/tests/test_tag_intercom.py @@ -26,21 +26,21 @@ class IntercomTagTestCase(TagTestCase): def test_node(self): now = datetime.datetime(2014, 4, 9, 15, 15, 0) - rendered_tag = IntercomNode().render(Context({ - 'user': User( - username='test', - first_name='Firstname', - last_name='Lastname', - email="test@example.com", - date_joined=now), - })) + user = User.objects.create( + username='test', + first_name='Firstname', + last_name='Lastname', + email="test@example.com", + date_joined=now, + ) + rendered_tag = IntercomNode().render(Context({'user': user})) # Because the json isn't predictably ordered, we can't just test the whole thing verbatim. self.assertEqual(""" -""", rendered_tag) # noqa +""" % {'user_id': user.pk}, rendered_tag) # noqa @override_settings(INTERCOM_APP_ID=None) def test_no_account_number(self): @@ -52,18 +52,21 @@ class IntercomTagTestCase(TagTestCase): def test_identify_name_email_and_created_at(self): now = datetime.datetime(2014, 4, 9, 15, 15, 0) + user = User.objects.create( + username='test', + first_name='Firstname', + last_name='Lastname', + email="test@example.com", + date_joined=now, + ) r = IntercomNode().render(Context({ - 'user': User( - username='test', - first_name='Firstname', - last_name='Lastname', - email="test@example.com", - date_joined=now), + 'user': user, })) self.assertTrue('window.intercomSettings = {' '"app_id": "abc123xyz", "created_at": 1397074500, ' - '"email": "test@example.com", "name": "Firstname Lastname"' - '};' in r) + '"email": "test@example.com", "name": "Firstname Lastname", ' + '"user_id": %(user_id)s' + '};' % {'user_id': user.pk} in r, msg=r) def test_custom(self): r = IntercomNode().render(Context({ diff --git a/docs/services/intercom.rst b/docs/services/intercom.rst index 700fc1e..5c9f250 100644 --- a/docs/services/intercom.rst +++ b/docs/services/intercom.rst @@ -120,6 +120,8 @@ Context variable Description -------------------- ------------------------------------------- ``intercom_email`` The visitor's email address. -------------------- ------------------------------------------- +``intercom_user_id`` The visitor's user id. +-------------------- ------------------------------------------- ``created_at`` The date the visitor created an account ==================== =========================================== @@ -130,7 +132,7 @@ Context variable Description Identifying authenticated users ------------------------------- -If you have not set the ``intercom_name`` or ``intercom_email`` variables +If you have not set the ``intercom_name``, ``intercom_email``, or ``intercom_user_id`` variables explicitly, the username and email address of an authenticated user are passed to Intercom automatically. See :ref:`identifying-visitors`. From 4b4f26f54e896fd3f447da33262795fbcc68f6ff Mon Sep 17 00:00:00 2001 From: Pi Delport Date: Wed, 22 Aug 2018 18:43:07 +0200 Subject: [PATCH 2/3] Intercom: Add support for HMAC authentication of identified users Documentation: https://www.intercom.com/help/configure-intercom-for-your-product-or-site/staying-secure/enable-identity-verification-on-your-web-product --- analytical/templatetags/intercom.py | 42 ++++++++++++++++++ analytical/tests/test_tag_intercom.py | 61 ++++++++++++++++++++++++++- docs/services/intercom.rst | 17 ++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/analytical/templatetags/intercom.py b/analytical/templatetags/intercom.py index 27e9047..92badec 100644 --- a/analytical/templatetags/intercom.py +++ b/analytical/templatetags/intercom.py @@ -3,10 +3,14 @@ intercom.io template tags and filters. """ from __future__ import absolute_import + +import hashlib +import hmac import json import time import re +from django.conf import settings from django.template import Library, Node, TemplateSyntaxError from analytical.utils import disable_html, get_required_setting, \ @@ -24,6 +28,34 @@ TRACKING_CODE = """ register = Library() +def _hashable_bytes(data): # type: (AnyStr) -> bytes + """ + Coerce strings to hashable bytes. + """ + if isinstance(data, bytes): + return data + elif isinstance(data, str): + return data.encode('ascii') # Fail on anything non-ASCII. + else: + raise TypeError(data) + + +def intercom_user_hash(data): # type: (AnyStr) -> Optional[str] + """ + Return a SHA-256 HMAC `user_hash` as expected by Intercom, if configured. + + Return None if the `INTERCOM_HMAC_SECRET_KEY` setting is not configured. + """ + if getattr(settings, 'INTERCOM_HMAC_SECRET_KEY', None): + return hmac.new( + key=_hashable_bytes(settings.INTERCOM_HMAC_SECRET_KEY), + msg=_hashable_bytes(data), + digestmod=hashlib.sha256, + ).hexdigest() + else: + return None + + @register.tag def intercom(parser, token): """ @@ -73,6 +105,16 @@ class IntercomNode(Node): else: params['created_at'] = None + # Generate a user_hash HMAC to verify the user's identity, if configured. + # (If both user_id and email are present, the user_id field takes precedence.) + # See: + # https://www.intercom.com/help/configure-intercom-for-your-product-or-site/staying-secure/enable-identity-verification-on-your-web-product + user_hash_data = params.get('user_id', params.get('email')) # type: Optional[str] + if user_hash_data: + user_hash = intercom_user_hash(str(user_hash_data)) # type: Optional[str] + if user_hash is not None: + params.setdefault('user_hash', user_hash) + return params def render(self, context): diff --git a/analytical/tests/test_tag_intercom.py b/analytical/tests/test_tag_intercom.py index 754081c..03cc47f 100644 --- a/analytical/tests/test_tag_intercom.py +++ b/analytical/tests/test_tag_intercom.py @@ -9,7 +9,7 @@ from django.http import HttpRequest from django.template import Context from django.test.utils import override_settings -from analytical.templatetags.intercom import IntercomNode +from analytical.templatetags.intercom import IntercomNode, intercom_user_hash from analytical.tests.utils import TagTestCase from analytical.utils import AnalyticalException @@ -103,6 +103,65 @@ class IntercomTagTestCase(TagTestCase): })) self.assertTrue('"email": "explicit"' in r, r) + @override_settings(INTERCOM_HMAC_SECRET_KEY='secret') + def test_user_hash__without_user_details(self): + """ + No `user_hash` without `user_id` or `email`. + """ + attrs = IntercomNode()._get_custom_attrs(Context()) + self.assertEqual({ + 'created_at': None, + }, attrs) + + @override_settings(INTERCOM_HMAC_SECRET_KEY='secret') + def test_user_hash__with_user(self): + """ + 'user_hash' of default `user_id`. + """ + user = User.objects.create( + email='test@example.com', + ) # type: User + attrs = IntercomNode()._get_custom_attrs(Context({'user': user})) + self.assertEqual({ + 'created_at': int(user.date_joined.timestamp()), + 'email': 'test@example.com', + 'name': '', + 'user_hash': intercom_user_hash(str(user.pk)), + 'user_id': user.pk, + }, attrs) + + @override_settings(INTERCOM_HMAC_SECRET_KEY='secret') + def test_user_hash__with_explicit_user_id(self): + """ + 'user_hash' of context-provided `user_id`. + """ + attrs = IntercomNode()._get_custom_attrs(Context({ + 'intercom_email': 'test@example.com', + 'intercom_user_id': '5', + })) + self.assertEqual({ + 'created_at': None, + 'email': 'test@example.com', + # HMAC for user_id: + 'user_hash': 'd3123a7052b42272d9b520235008c248a5aff3221cc0c530b754702ad91ab102', + 'user_id': '5', + }, attrs) + + @override_settings(INTERCOM_HMAC_SECRET_KEY='secret') + def test_user_hash__with_explicit_email(self): + """ + 'user_hash' of context-provided `email`. + """ + attrs = IntercomNode()._get_custom_attrs(Context({ + 'intercom_email': 'test@example.com', + })) + self.assertEqual({ + 'created_at': None, + 'email': 'test@example.com', + # HMAC for email: + 'user_hash': '49e43229ee99dca2565241719b8341b04e71dd4de0628f991b5bea30a526e153', + }, attrs) + @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) def test_render_internal_ip(self): req = HttpRequest() diff --git a/docs/services/intercom.rst b/docs/services/intercom.rst index 5c9f250..f458d28 100644 --- a/docs/services/intercom.rst +++ b/docs/services/intercom.rst @@ -138,6 +138,23 @@ passed to Intercom automatically. See :ref:`identifying-visitors`. .. _intercom-internal-ips: + +Verifying identified users +-------------------------- + +Intercom supports HMAC authentication of users identified by user ID or email, in order to prevent impersonation. +For more information, see `Enable identity verification on your web product`_ in the Intercom documentation. + +To enable this, configure your Intercom account's HMAC secret key:: + + INTERCOM_HMAC_SECRET_KEY = 'XXXXXXXXXXXXXXXXXXXXXXX' + +(You can find this secret key under the "Identity verification" section of your Intercom account settings page.) + +.. _`Enable identity verification on your web product`: https://www.intercom.com/help/configure-intercom-for-your-product-or-site/staying-secure/enable-identity-verification-on-your-web-product + + + Internal IP addresses --------------------- From 1b7429c3e1711da08be379b646097d7421ecdbc5 Mon Sep 17 00:00:00 2001 From: Pi Delport Date: Wed, 22 Aug 2018 18:51:43 +0200 Subject: [PATCH 3/3] (Python 2 compatibility for datetime.timestamp) --- analytical/templatetags/intercom.py | 12 ++++++++++-- analytical/tests/test_tag_intercom.py | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/analytical/templatetags/intercom.py b/analytical/templatetags/intercom.py index 92badec..888a0ab 100644 --- a/analytical/templatetags/intercom.py +++ b/analytical/templatetags/intercom.py @@ -7,6 +7,7 @@ from __future__ import absolute_import import hashlib import hmac import json +import sys import time import re @@ -28,6 +29,14 @@ TRACKING_CODE = """ register = Library() +def _timestamp(when): # type: (datetime) -> float + """ + Python 2 compatibility for `datetime.timestamp()`. + """ + return (time.mktime(when.timetuple()) if sys.version_info < (3,) else + when.timestamp()) + + def _hashable_bytes(data): # type: (AnyStr) -> bytes """ Coerce strings to hashable bytes. @@ -100,8 +109,7 @@ class IntercomNode(Node): params.setdefault('user_id', user.pk) - params['created_at'] = int(time.mktime( - user.date_joined.timetuple())) + params['created_at'] = int(_timestamp(user.date_joined)) else: params['created_at'] = None diff --git a/analytical/tests/test_tag_intercom.py b/analytical/tests/test_tag_intercom.py index 03cc47f..2085fcb 100644 --- a/analytical/tests/test_tag_intercom.py +++ b/analytical/tests/test_tag_intercom.py @@ -9,7 +9,7 @@ from django.http import HttpRequest from django.template import Context from django.test.utils import override_settings -from analytical.templatetags.intercom import IntercomNode, intercom_user_hash +from analytical.templatetags.intercom import IntercomNode, intercom_user_hash, _timestamp from analytical.tests.utils import TagTestCase from analytical.utils import AnalyticalException @@ -123,7 +123,7 @@ class IntercomTagTestCase(TagTestCase): ) # type: User attrs = IntercomNode()._get_custom_attrs(Context({'user': user})) self.assertEqual({ - 'created_at': int(user.date_joined.timestamp()), + 'created_at': int(_timestamp(user.date_joined)), 'email': 'test@example.com', 'name': '', 'user_hash': intercom_user_hash(str(user.pk)),