diff --git a/analytical/templatetags/intercom.py b/analytical/templatetags/intercom.py index f0f50d1..888a0ab 100644 --- a/analytical/templatetags/intercom.py +++ b/analytical/templatetags/intercom.py @@ -3,10 +3,15 @@ intercom.io template tags and filters. """ from __future__ import absolute_import + +import hashlib +import hmac import json +import sys 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 +29,42 @@ 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. + """ + 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): """ @@ -66,11 +107,22 @@ class IntercomNode(Node): if 'email' not in params and user.email: params['email'] = user.email - params['created_at'] = int(time.mktime( - user.date_joined.timetuple())) + params.setdefault('user_id', user.pk) + + params['created_at'] = int(_timestamp(user.date_joined)) 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 bc24739..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 +from analytical.templatetags.intercom import IntercomNode, intercom_user_hash, _timestamp from analytical.tests.utils import TagTestCase from analytical.utils import AnalyticalException @@ -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({ @@ -100,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(_timestamp(user.date_joined)), + '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 700fc1e..f458d28 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,12 +132,29 @@ 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`. .. _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 ---------------------