0001-sms-sign-request-to-SMS-gateway-if-it-s-a-known-serv.patch
corbo/utils.py | ||
---|---|---|
14 | 14 |
# You should have received a copy of the GNU Affero General Public License |
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | |
17 | ||
18 |
import base64 |
|
19 |
import datetime |
|
17 | 20 |
import os |
18 | 21 |
import re |
19 | 22 |
import logging |
20 |
import requests |
|
21 | 23 |
import urlparse |
22 | 24 |
import hashlib |
25 |
import hmac |
|
26 |
import random |
|
27 |
import requests |
|
23 | 28 |
from html2text import HTML2Text |
24 | 29 |
from emails.django import Message |
25 | 30 |
from lxml import etree |
26 | 31 | |
32 |
from requests import RequestException |
|
33 | ||
27 | 34 |
from django.conf import settings |
28 | 35 |
from django.template import loader, Context |
29 | 36 |
from django.utils.translation import activate |
37 |
from django.utils.http import urlencode, quote |
|
30 | 38 |
from django.core.files.storage import DefaultStorage |
31 | 39 |
from django.core.urlresolvers import reverse |
32 | 40 |
from django.core import signing |
33 | 41 | |
34 | 42 | |
43 |
# Simple signature scheme for query strings |
|
44 | ||
45 |
def sign_url(url, key, algo='sha256', timestamp=None, nonce=None): |
|
46 |
parsed = urlparse.urlparse(url) |
|
47 |
new_query = sign_query(parsed.query, key, algo, timestamp, nonce) |
|
48 |
return urlparse.urlunparse(parsed[:4] + (new_query,) + parsed[5:]) |
|
49 | ||
50 |
def sign_query(query, key, algo='sha256', timestamp=None, nonce=None): |
|
51 |
if timestamp is None: |
|
52 |
timestamp = datetime.datetime.utcnow() |
|
53 |
timestamp = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ') |
|
54 |
if nonce is None: |
|
55 |
nonce = hex(random.getrandbits(128))[2:] |
|
56 |
new_query = query |
|
57 |
if new_query: |
|
58 |
new_query += '&' |
|
59 |
new_query += urlencode(( |
|
60 |
('algo', algo), |
|
61 |
('timestamp', timestamp), |
|
62 |
('nonce', nonce))) |
|
63 |
signature = base64.b64encode(sign_string(new_query, key, algo=algo)) |
|
64 |
new_query += '&signature=' + quote(signature) |
|
65 |
return new_query |
|
66 | ||
67 |
def sign_string(s, key, algo='sha256', timedelta=30): |
|
68 |
digestmod = getattr(hashlib, algo) |
|
69 |
hash = hmac.HMAC(str(key), digestmod=digestmod, msg=s) |
|
70 |
return hash.digest() |
|
71 | ||
72 | ||
73 |
class Requests(requests.Session): |
|
74 |
def request(self, method, url, **kwargs): |
|
75 |
logger = logging.getLogger(__name__) |
|
76 |
log_errors = kwargs.pop('log_errors', True) |
|
77 | ||
78 |
remote_service = None |
|
79 |
scheme, netloc, path, params, query, fragment = urlparse.urlparse(url) |
|
80 |
for services in getattr(settings, 'KNOWN_SERVICES', {}).values(): |
|
81 |
for service in services.values(): |
|
82 |
remote_url = service.get('url') |
|
83 |
remote_scheme, remote_netloc, r_path, r_params, r_query, r_fragment = \ |
|
84 |
urlparse.urlparse(remote_url) |
|
85 |
if remote_scheme == scheme and remote_netloc == netloc: |
|
86 |
remote_service = service |
|
87 |
break |
|
88 |
else: |
|
89 |
continue |
|
90 |
break |
|
91 |
if remote_service: |
|
92 |
# only keeps the path (URI) in url parameter, scheme and netloc are |
|
93 |
# in remote_service |
|
94 |
url = urlparse.urlunparse(('', '', path, params, query, fragment)) |
|
95 |
else: |
|
96 |
logger.warning('service not found in settings.KNOWN_SERVICES for %s', url) |
|
97 | ||
98 |
if remote_service: |
|
99 |
query_params = {'orig': remote_service.get('orig')} |
|
100 | ||
101 |
remote_service_base_url = remote_service.get('url') |
|
102 |
scheme, netloc, old_path, params, old_query, fragment = urlparse.urlparse( |
|
103 |
remote_service_base_url) |
|
104 | ||
105 |
query = urlencode(query_params) |
|
106 |
if '?' in url: |
|
107 |
path, old_query = url.split('?') |
|
108 |
query += '&' + old_query |
|
109 |
else: |
|
110 |
path = url |
|
111 | ||
112 |
url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment)) |
|
113 | ||
114 |
if remote_service: # sign |
|
115 |
url = sign_url(url, remote_service.get('secret')) |
|
116 | ||
117 |
response = super(Requests, self).request(method, url, **kwargs) |
|
118 |
if log_errors and (response.status_code // 100 != 2): |
|
119 |
logger.error('failed to %s %s (%s)', method, url, response.status_code) |
|
120 |
return response |
|
121 | ||
122 |
requests = Requests() |
|
123 | ||
124 | ||
35 | 125 |
UNSUBSCRIBE_LINK_PLACEHOLDER = '%%UNSUBSCRIBE_LINK_PLACEHOLDER%%' |
36 | 126 | |
37 | 127 | |
... | ... | |
98 | 188 |
sent = len(destinations) |
99 | 189 |
else: |
100 | 190 |
logger.warning('Error occured while sending sms: %s', response.json()['err_desc']) |
101 |
except requests.RequestException as e:
|
|
191 |
except RequestException as e: |
|
102 | 192 |
logger.warning('Failed to reach SMS gateway: %s', e) |
103 | 193 |
return sent |
104 | 194 |
else: |
tests/test_broadcasting.py | ||
---|---|---|
241 | 241 |
assert mocked_post.call_args[1]['json']['from'] == 'Corbo' |
242 | 242 |
assert isinstance(mocked_post.call_args[1]['json']['to'], list) |
243 | 243 |
assert broadcast.delivery_count == 3 |
244 | ||
245 |
@mock.patch('requests.Session.request') |
|
246 |
def test_sms_send_with_signed_webservice_call(mocked_post, app, categories, announces, caplog): |
|
247 |
services = { |
|
248 |
'passerelle': { |
|
249 |
'default': {'title': 'test', 'url': 'http://passerelle.example.org', |
|
250 |
'secret': 'corbo', 'orig': 'corbo'} |
|
251 |
}, |
|
252 |
'corbo': { |
|
253 |
'announces': { |
|
254 |
'title': 'announces', 'url': 'http://corbo.example.org', |
|
255 |
'secret': 'corbo', 'orig': 'corbo', |
|
256 |
} |
|
257 |
} |
|
258 |
} |
|
259 |
for category in categories: |
|
260 |
for i in range(3): |
|
261 |
uuid = uuid4() |
|
262 |
Subscription.objects.create(category=category, |
|
263 |
identifier='sms:%s' % get_random_number(), uuid=uuid) |
|
264 |
for announce in announces: |
|
265 |
broadcast = Broadcast.objects.get(announce=announce) |
|
266 |
with override_settings(SMS_GATEWAY_URL='http://passerelle.example.org', KNOWN_SERVICES=services): |
|
267 |
mocked_response = mock.Mock(status_code=200) |
|
268 |
mocked_response.json.return_value = {'err': 0, 'err_desc': None, 'data': 'gateway response'} |
|
269 |
mocked_post.return_value = mocked_response |
|
270 |
broadcast.send() |
|
271 |
assert 'http://passerelle.example.org' in mocked_post.call_args[0][1] |
|
272 |
assert 'orig=corbo' in mocked_post.call_args[0][1] |
|
273 |
assert 'signature=' in mocked_post.call_args[0][1] |
|
274 |
assert 'nonce=' in mocked_post.call_args[0][1] |
|
275 | ||
276 |
mocked_response.raise_for_status.side_effect = requests.exceptions.HTTPError('Error 500') |
|
277 |
broadcast.send() |
|
278 |
for record in caplog.records: |
|
279 |
assert record.name == 'corbo.utils' |
|
280 |
assert record.levelno == logging.WARNING |
|
281 |
assert record.getMessage() == 'Failed to reach SMS gateway: Error 500' |
tests/test_manager.py | ||
---|---|---|
291 | 291 |
# add mellon attribute to web session |
292 | 292 |
session = app.session |
293 | 293 |
session['mellon_session'] = {'mobile': ['00000000']} |
294 |
app.set_cookie(str(settings.SESSION_COOKIE_NAME), session.session_key) |
|
294 | 295 |
session.save() |
295 | 296 |
app.set_cookie(settings.SESSION_COOKIE_NAME, session.session_key) |
296 | 297 |
resp = resp.click('First announce') |
... | ... | |
347 | 348 |
form['mobile'] = '0607080900' |
348 | 349 |
resp = form.submit() |
349 | 350 |
records = caplog.records |
350 |
assert len(records) == 1
|
|
351 |
assert len(records) == 2
|
|
351 | 352 |
for record in records: |
352 | 353 |
assert record.name == 'corbo.utils' |
353 | 354 |
assert record.levelno == logging.WARNING |
354 |
assert 'Invalid URL' in record.getMessage() |
|
355 |
- |