Switch mailgun webhooks to the new Mailgun webhook api (#17919)
* Switch mailgun webhooks to the webhook api * Change mailgun strings to indicate application/json is in use * Lint * Revert Changes to .translations. * Don't fail if the API key isn't setpull/18005/head
parent
3de822a0e2
commit
f0693f6f91
|
@ -4,6 +4,10 @@ Support for Mailgun.
|
||||||
For more details about this component, please refer to the documentation at
|
For more details about this component, please refer to the documentation at
|
||||||
https://home-assistant.io/components/mailgun/
|
https://home-assistant.io/components/mailgun/
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
@ -12,7 +16,7 @@ from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID
|
||||||
from homeassistant.helpers import config_entry_flow
|
from homeassistant.helpers import config_entry_flow
|
||||||
|
|
||||||
DOMAIN = 'mailgun'
|
DOMAIN = 'mailgun'
|
||||||
API_PATH = '/api/{}'.format(DOMAIN)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
DEPENDENCIES = ['webhook']
|
DEPENDENCIES = ['webhook']
|
||||||
MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN)
|
MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN)
|
||||||
CONF_SANDBOX = 'sandbox'
|
CONF_SANDBOX = 'sandbox'
|
||||||
|
@ -38,9 +42,40 @@ async def async_setup(hass, config):
|
||||||
|
|
||||||
async def handle_webhook(hass, webhook_id, request):
|
async def handle_webhook(hass, webhook_id, request):
|
||||||
"""Handle incoming webhook with Mailgun inbound messages."""
|
"""Handle incoming webhook with Mailgun inbound messages."""
|
||||||
data = dict(await request.post())
|
body = await request.text()
|
||||||
data['webhook_id'] = webhook_id
|
try:
|
||||||
hass.bus.async_fire(MESSAGE_RECEIVED, data)
|
data = json.loads(body) if body else {}
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(data, dict) and 'signature' in data.keys():
|
||||||
|
if await verify_webhook(hass, **data['signature']):
|
||||||
|
data['webhook_id'] = webhook_id
|
||||||
|
hass.bus.async_fire(MESSAGE_RECEIVED, data)
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.warning(
|
||||||
|
'Mailgun webhook received an unauthenticated message - webhook_id: %s',
|
||||||
|
webhook_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_webhook(hass, token=None, timestamp=None, signature=None):
|
||||||
|
"""Verify webhook was signed by Mailgun."""
|
||||||
|
if DOMAIN not in hass.data:
|
||||||
|
_LOGGER.warning('Cannot validate Mailgun webhook, missing API Key')
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not (token and timestamp and signature):
|
||||||
|
return False
|
||||||
|
|
||||||
|
hmac_digest = hmac.new(
|
||||||
|
key=bytes(hass.data[DOMAIN][CONF_API_KEY], 'utf-8'),
|
||||||
|
msg=bytes('{}{}'.format(timestamp, token), 'utf-8'),
|
||||||
|
digestmod=hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return hmac.compare_digest(signature, hmac_digest)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass, entry):
|
async def async_setup_entry(hass, entry):
|
||||||
|
@ -59,8 +94,7 @@ config_entry_flow.register_webhook_flow(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
'Mailgun Webhook',
|
'Mailgun Webhook',
|
||||||
{
|
{
|
||||||
'mailgun_url':
|
'mailgun_url': 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', # noqa: E501 pylint: disable=line-too-long
|
||||||
'https://www.mailgun.com/blog/a-guide-to-using-mailguns-webhooks',
|
|
||||||
'docs_url': 'https://www.home-assistant.io/components/mailgun/'
|
'docs_url': 'https://www.home-assistant.io/components/mailgun/'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
"not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages."
|
"not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages."
|
||||||
},
|
},
|
||||||
"create_entry": {
|
"create_entry": {
|
||||||
"default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
|
"default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,231 @@
|
||||||
"""Test the init file of Mailgun."""
|
"""Test the init file of Mailgun."""
|
||||||
from unittest.mock import patch
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from homeassistant import data_entry_flow
|
from homeassistant import data_entry_flow
|
||||||
from homeassistant.components import mailgun
|
from homeassistant.components import mailgun, webhook
|
||||||
|
from homeassistant.const import CONF_API_KEY, CONF_DOMAIN
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
API_KEY = 'abc123'
|
||||||
|
|
||||||
|
|
||||||
async def test_config_flow_registers_webhook(hass, aiohttp_client):
|
@pytest.fixture
|
||||||
"""Test setting up Mailgun and sending webhook."""
|
async def http_client(hass, aiohttp_client):
|
||||||
with patch('homeassistant.util.get_local_ip', return_value='example.com'):
|
"""Initialize a Home Assistant Server for testing this module."""
|
||||||
result = await hass.config_entries.flow.async_init('mailgun', context={
|
await async_setup_component(hass, webhook.DOMAIN, {})
|
||||||
'source': 'user'
|
return await aiohttp_client(hass.http.app)
|
||||||
})
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def webhook_id_with_api_key(hass):
|
||||||
|
"""Initialize the Mailgun component and get the webhook_id."""
|
||||||
|
await async_setup_component(hass, mailgun.DOMAIN, {
|
||||||
|
mailgun.DOMAIN: {
|
||||||
|
CONF_API_KEY: API_KEY,
|
||||||
|
CONF_DOMAIN: 'example.com'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
hass.config.api = Mock(base_url='http://example.com')
|
||||||
|
result = await hass.config_entries.flow.async_init('mailgun', context={
|
||||||
|
'source': 'user'
|
||||||
|
})
|
||||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
|
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
result['flow_id'], {})
|
result['flow_id'], {})
|
||||||
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
webhook_id = result['result'].data['webhook_id']
|
|
||||||
|
|
||||||
mailgun_events = []
|
return result['result'].data['webhook_id']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def webhook_id_without_api_key(hass):
|
||||||
|
"""Initialize the Mailgun component and get the webhook_id w/o API key."""
|
||||||
|
await async_setup_component(hass, mailgun.DOMAIN, {})
|
||||||
|
|
||||||
|
hass.config.api = Mock(base_url='http://example.com')
|
||||||
|
result = await hass.config_entries.flow.async_init('mailgun', context={
|
||||||
|
'source': 'user'
|
||||||
|
})
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result['flow_id'], {})
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
|
||||||
|
return result['result'].data['webhook_id']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mailgun_events(hass):
|
||||||
|
"""Return a list of mailgun_events triggered."""
|
||||||
|
events = []
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def handle_event(event):
|
def handle_event(event):
|
||||||
"""Handle Mailgun event."""
|
"""Handle Mailgun event."""
|
||||||
mailgun_events.append(event)
|
events.append(event)
|
||||||
|
|
||||||
hass.bus.async_listen(mailgun.MESSAGE_RECEIVED, handle_event)
|
hass.bus.async_listen(mailgun.MESSAGE_RECEIVED, handle_event)
|
||||||
|
|
||||||
client = await aiohttp_client(hass.http.app)
|
return events
|
||||||
await client.post('/api/webhook/{}'.format(webhook_id), data={
|
|
||||||
'hello': 'mailgun'
|
|
||||||
})
|
|
||||||
|
|
||||||
assert len(mailgun_events) == 1
|
|
||||||
assert mailgun_events[0].data['webhook_id'] == webhook_id
|
async def test_mailgun_webhook_with_missing_signature(
|
||||||
assert mailgun_events[0].data['hello'] == 'mailgun'
|
http_client,
|
||||||
|
webhook_id_with_api_key,
|
||||||
|
mailgun_events
|
||||||
|
):
|
||||||
|
"""Test that webhook doesn't trigger an event without a signature."""
|
||||||
|
event_count = len(mailgun_events)
|
||||||
|
|
||||||
|
await http_client.post(
|
||||||
|
'/api/webhook/{}'.format(webhook_id_with_api_key),
|
||||||
|
json={
|
||||||
|
'hello': 'mailgun',
|
||||||
|
'signature': {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mailgun_events) == event_count
|
||||||
|
|
||||||
|
await http_client.post(
|
||||||
|
'/api/webhook/{}'.format(webhook_id_with_api_key),
|
||||||
|
json={
|
||||||
|
'hello': 'mailgun',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mailgun_events) == event_count
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mailgun_webhook_with_different_api_key(
|
||||||
|
http_client,
|
||||||
|
webhook_id_with_api_key,
|
||||||
|
mailgun_events
|
||||||
|
):
|
||||||
|
"""Test that webhook doesn't trigger an event with a wrong signature."""
|
||||||
|
timestamp = '1529006854'
|
||||||
|
token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0'
|
||||||
|
|
||||||
|
event_count = len(mailgun_events)
|
||||||
|
|
||||||
|
await http_client.post(
|
||||||
|
'/api/webhook/{}'.format(webhook_id_with_api_key),
|
||||||
|
json={
|
||||||
|
'hello': 'mailgun',
|
||||||
|
'signature': {
|
||||||
|
'signature': hmac.new(
|
||||||
|
key=b'random_api_key',
|
||||||
|
msg=bytes('{}{}'.format(timestamp, token), 'utf-8'),
|
||||||
|
digestmod=hashlib.sha256
|
||||||
|
).hexdigest(),
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'token': token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mailgun_events) == event_count
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mailgun_webhook_event_with_correct_api_key(
|
||||||
|
http_client,
|
||||||
|
webhook_id_with_api_key,
|
||||||
|
mailgun_events
|
||||||
|
):
|
||||||
|
"""Test that webhook triggers an event after validating a signature."""
|
||||||
|
timestamp = '1529006854'
|
||||||
|
token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0'
|
||||||
|
|
||||||
|
event_count = len(mailgun_events)
|
||||||
|
|
||||||
|
await http_client.post(
|
||||||
|
'/api/webhook/{}'.format(webhook_id_with_api_key),
|
||||||
|
json={
|
||||||
|
'hello': 'mailgun',
|
||||||
|
'signature': {
|
||||||
|
'signature': hmac.new(
|
||||||
|
key=bytes(API_KEY, 'utf-8'),
|
||||||
|
msg=bytes('{}{}'.format(timestamp, token), 'utf-8'),
|
||||||
|
digestmod=hashlib.sha256
|
||||||
|
).hexdigest(),
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'token': token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mailgun_events) == event_count + 1
|
||||||
|
assert mailgun_events[-1].data['webhook_id'] == webhook_id_with_api_key
|
||||||
|
assert mailgun_events[-1].data['hello'] == 'mailgun'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mailgun_webhook_with_missing_signature_without_api_key(
|
||||||
|
http_client,
|
||||||
|
webhook_id_without_api_key,
|
||||||
|
mailgun_events
|
||||||
|
):
|
||||||
|
"""Test that webhook triggers an event without a signature w/o API key."""
|
||||||
|
event_count = len(mailgun_events)
|
||||||
|
|
||||||
|
await http_client.post(
|
||||||
|
'/api/webhook/{}'.format(webhook_id_without_api_key),
|
||||||
|
json={
|
||||||
|
'hello': 'mailgun',
|
||||||
|
'signature': {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mailgun_events) == event_count + 1
|
||||||
|
assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key
|
||||||
|
assert mailgun_events[-1].data['hello'] == 'mailgun'
|
||||||
|
|
||||||
|
await http_client.post(
|
||||||
|
'/api/webhook/{}'.format(webhook_id_without_api_key),
|
||||||
|
json={
|
||||||
|
'hello': 'mailgun',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mailgun_events) == event_count + 1
|
||||||
|
assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key
|
||||||
|
assert mailgun_events[-1].data['hello'] == 'mailgun'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mailgun_webhook_event_without_an_api_key(
|
||||||
|
http_client,
|
||||||
|
webhook_id_without_api_key,
|
||||||
|
mailgun_events
|
||||||
|
):
|
||||||
|
"""Test that webhook triggers an event if there is no api key."""
|
||||||
|
timestamp = '1529006854'
|
||||||
|
token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0'
|
||||||
|
|
||||||
|
event_count = len(mailgun_events)
|
||||||
|
|
||||||
|
await http_client.post(
|
||||||
|
'/api/webhook/{}'.format(webhook_id_without_api_key),
|
||||||
|
json={
|
||||||
|
'hello': 'mailgun',
|
||||||
|
'signature': {
|
||||||
|
'signature': hmac.new(
|
||||||
|
key=bytes(API_KEY, 'utf-8'),
|
||||||
|
msg=bytes('{}{}'.format(timestamp, token), 'utf-8'),
|
||||||
|
digestmod=hashlib.sha256
|
||||||
|
).hexdigest(),
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'token': token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mailgun_events) == event_count + 1
|
||||||
|
assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key
|
||||||
|
assert mailgun_events[-1].data['hello'] == 'mailgun'
|
||||||
|
|
Loading…
Reference in New Issue