From e5139c0b89bedbfab9fe6f9795efe2c1e65ee8e0 Mon Sep 17 00:00:00 2001 From: Iuri de Silvio Date: Wed, 10 Dec 2014 23:57:51 -0200 Subject: [PATCH 1/2] Webhooks --- README.md | 30 +++++++++++++++++++++ flask_pusher.py | 67 +++++++++++++++++++++++++++++++++++++++++---- setup.py | 2 +- tests.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5d62ce4..650f506 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ The `@pusher.channel_data` gives you a way to set other values. If a `user_id` key is returned, it overrides the default `user_id`. ```python +from flask.ext.login import current_user + @pusher.channel_data def pusher_channel_data(channel_name, socket_id): return { @@ -108,6 +110,34 @@ def pusher_channel_data(channel_name, socket_id): } ``` +Pusher webhooks +--------------- + +Pusher has webhooks to send websocket events to your server. + +Flask-Pusher create the routes to handle these webhooks and validate the headers `X-Pusher-Key` and `X-Pusher-Signature`. + +```python +from flask import request + +@pusher.webhooks.channel_existence +def channel_existence_webhook(): + print request.json + +@pusher.webhooks.presence +def presence_webhook(): + print request.json + +@pusher.webhooks.client +def client_webhook(): + print request.json +``` + +The JSON request is documented in Pusher docs: http://pusher.com/docs/webhooks + +These webhooks routes are mounted in `/pusher/events/channel_existence`, `/pusher/events/presence` and `/pusher/events/client`. Configure your Pusher app to send webhooks to these routes. + + Disclaimer ---------- This project is not affiliated with Pusher or Flask. diff --git a/flask_pusher.py b/flask_pusher.py index b739a3a..67fb1f0 100644 --- a/flask_pusher.py +++ b/flask_pusher.py @@ -1,3 +1,6 @@ +import hashlib +import hmac + from flask import Blueprint, current_app, request, jsonify, abort import pusher as _pusher @@ -9,6 +12,8 @@ def __init__(self, app=None): self.app = app self._auth_handler = None self._channel_data_handler = None + self._blueprint = Blueprint('pusher', __name__, url_prefix="/pusher") + self.webhooks = Webhooks(self) if app is not None: self.init_app(app) @@ -29,8 +34,8 @@ def init_app(self, app): port=app.config["PUSHER_PORT"], encoder=getattr(app, "json_encoder", None)) - bp = self._make_blueprint() - app.register_blueprint(bp) + self._make_blueprint() + app.register_blueprint(self._blueprint) if not hasattr(app, "extensions"): app.extensions = {} @@ -49,9 +54,9 @@ def channel_data(self, handler): return handler def _make_blueprint(self): - bp = Blueprint('pusher', __name__) + bp = self._blueprint - @bp.route("/pusher/auth", methods=["POST"]) + @bp.route("/auth", methods=["POST"]) def auth(): if not self._auth_handler: abort(403) @@ -82,4 +87,56 @@ def pusher_data(): "PUSHER_KEY": self.client.key } - return bp + def _sign(self, message): + return hmac.new(self.client.secret, message, + hashlib.sha256).hexdigest() + + +class Webhooks(object): + + def __init__(self, pusher): + self.pusher = pusher + self._handlers = {} + self._register("channel_existence") + self._register("presence") + self._register("client") + + def channel_existence(self, func): + return self._event("channel_existence", func) + + def presence(self, func): + return self._event("presence", func) + + def client(self, func): + return self._event("client", func) + + def _event(self, event, func): + self._handlers[event] = func + return func + + def _register(self, event): + def route(): + func = self._handlers.get(event) + if not func: + abort(404) + self._validate() + func() + return "OK", 200 + + rule = "/events/%s" % event + name = "%s_event" % event + self.pusher._blueprint.add_url_rule(rule, name, route, + methods=["POST"]) + return route + + def _validate(self): + pusher_key = request.headers.get("X-Pusher-Key") + if pusher_key != self.pusher.client.key: + # invalid pusher key + abort(403) + + webhook_signature = request.headers.get("X-Pusher-Signature") + expected_signature = self.pusher._sign(request.data) + if webhook_signature != expected_signature: + # invalid signature + abort(403) diff --git a/setup.py b/setup.py index b651eda..688bb92 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='Flask-Pusher', - version='0.2', + version='0.3dev', url='https://www.github.com/iurisilvio/Flask-Pusher', license='MIT', author='Iuri de Silvio', diff --git a/tests.py b/tests.py index c4914e6..39d46c7 100644 --- a/tests.py +++ b/tests.py @@ -157,5 +157,77 @@ def test_invalid_channel(self): self.assertEqual(404, response.status_code) +class PusherWebhookTest(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.debug = True + self.app.config["PUSHER_KEY"] = "KEY" + self.app.config["PUSHER_SECRET"] = "SUPERSECRET" + self.pusher = Pusher(self.app) + self.client = self.app.test_client() + self._called = False + + @self.pusher.webhooks.client + def c(): + self._called = True + + def test_no_webhook(self): + with self.app.test_request_context(): + url = url_for("pusher.presence_event") + response = self.client.post(url) + self.assertEqual(404, response.status_code) + self.assertFalse(self._called) + + def test_without_key_forbidden(self): + with self.app.test_request_context(): + url = url_for("pusher.client_event") + response = self.client.post(url) + self.assertEqual(403, response.status_code) + self.assertFalse(self._called) + + def test_invalid_key_forbidden(self): + with self.app.test_request_context(): + url = url_for("pusher.client_event") + response = self.client.post(url, headers={ + "Content-Type": "application/json", + "X-Pusher-Key": "meh" + }) + self.assertEqual(403, response.status_code) + self.assertFalse(self._called) + + def test_valid_key_forbidden_without_signature(self): + with self.app.test_request_context(): + url = url_for("pusher.client_event") + response = self.client.post(url, headers={ + "Content-Type": "application/json", + "X-Pusher-Key": "KEY" + }) + self.assertEqual(403, response.status_code) + self.assertFalse(self._called) + + def test_invalid_signature(self): + with self.app.test_request_context(): + url = url_for("pusher.client_event") + response = self.client.post(url, headers={ + "Content-Type": "application/json", + "X-Pusher-Key": "KEY", + "X-Pusher-Signature": "x" + }) + self.assertEqual(403, response.status_code) + self.assertFalse(self._called) + + def test_valid_signature(self): + data = '{"a": "b"}' + with self.app.test_request_context(): + url = url_for("pusher.client_event") + signature = self.pusher._sign(data) + response = self.client.post(url, data=data, headers={ + "Content-Type": "application/json", + "X-Pusher-Key": "KEY", + "X-Pusher-Signature": signature + }) + self.assertEqual(200, response.status_code) + self.assertTrue(self._called) + if __name__ == '__main__': unittest.main() From ed9f361784dcbaa88ce683e0f52ef412ca781bef Mon Sep 17 00:00:00 2001 From: Iuri de Silvio Date: Thu, 11 Dec 2014 09:32:55 -0200 Subject: [PATCH 2/2] Extracting event names to variables to avoid typos. --- flask_pusher.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/flask_pusher.py b/flask_pusher.py index 67fb1f0..0800bba 100644 --- a/flask_pusher.py +++ b/flask_pusher.py @@ -94,24 +94,27 @@ def _sign(self, message): class Webhooks(object): + CHANNEL_EXISTENCE_EVENT = "channel_existence" + PRESENCE_EVENT = "presence" + CLIENT_EVENT = "client" + def __init__(self, pusher): self.pusher = pusher self._handlers = {} - self._register("channel_existence") - self._register("presence") - self._register("client") + self._register(self.CHANNEL_EXISTENCE_EVENT) + self._register(self.PRESENCE_EVENT) + self._register(self.CLIENT_EVENT) def channel_existence(self, func): - return self._event("channel_existence", func) + self._handlers[self.CHANNEL_EXISTENCE_EVENT] = func + return func def presence(self, func): - return self._event("presence", func) + self._handlers[self.PRESENCE_EVENT] = func + return func def client(self, func): - return self._event("client", func) - - def _event(self, event, func): - self._handlers[event] = func + self._handlers[self.CLIENT_EVENT] = func return func def _register(self, event): @@ -127,7 +130,6 @@ def route(): name = "%s_event" % event self.pusher._blueprint.add_url_rule(rule, name, route, methods=["POST"]) - return route def _validate(self): pusher_key = request.headers.get("X-Pusher-Key")