-
Notifications
You must be signed in to change notification settings - Fork 6.5k
Cloud Pub/Sub: authenticated push in GAE Standard for Python 3 #2097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
b720ebe
2443e43
b0d0de4
fd3beea
5ac047b
1d5080e
8ab38a1
ef91df7
83b44ee
052bc86
e25c285
f1f76b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
# This file specifies files that are *not* uploaded to Google Cloud Platform | ||
# using gcloud. It follows the same syntax as .gitignore, with the addition of | ||
# "#!include" directives (which insert the entries of the given .gitignore-style | ||
# file at that point). | ||
# | ||
# For more information, run: | ||
# $ gcloud topic gcloudignore | ||
# | ||
.gcloudignore | ||
# If you would like to upload your .git directory, .gitignore file or files | ||
# from your .gitignore file, remove the corresponding line | ||
# below: | ||
.git | ||
.gitignore | ||
|
||
# Python pycache: | ||
__pycache__/ | ||
# Ignored by the build system | ||
/setup.cfg |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
# Python 3 Google Cloud Pub/Sub sample for Google App Engine Standard Environment | ||
anguillanneuf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
[![Open in Cloud Shell][shell_img]][shell_link] | ||
|
||
[shell_img]: http://gstatic.com/cloudssh/images/open-btn.png | ||
[shell_link]: https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/GoogleCloudPlatform/python-docs-samples&page=editor&open_in_editor=appengine/standard/pubsub/README.md | ||
|
||
This demonstrates how to send and receive messages using [Google Cloud Pub/Sub](https://cloud.google.com/pubsub) on [Google App Engine Standard Environment](https://cloud.google.com/appengine/docs/standard/). | ||
|
||
## Setup | ||
|
||
Before you can run or deploy the sample, you will need to do the following: | ||
|
||
1. Enable the Cloud Pub/Sub API in the [Google Developers Console](https://console.developers.google.com/project/_/apiui/apiview/pubsub/overview). | ||
|
||
2. Create a topic and subscription. The push auth service account must have Service Account Token Creator Role assigned, which can be done in the Cloud Console [IAM & admin](https://console.cloud.google.com/iam-admin/iam) UI. | ||
|
||
$ gcloud pubsub topics create [your-topic-name] | ||
$ gcloud beta pubsub subscriptions create [your-subscription-name] \ | ||
--topic [your-topic-name] \ | ||
--push-endpoint \ | ||
https://[your-app-id].appspot.com/_ah/push-handlers/receive_messages/token=[your-token] \ | ||
--ack-deadline 30 | ||
--push-auth-service-account=[your-service-account-email] | ||
|
||
3. Update the environment variables in ``app.yaml``. | ||
|
||
## Running locally | ||
|
||
Refer to the [top-level README](../README.md) for instructions on running and deploying. | ||
anguillanneuf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
When running locally, you can use the [Google Cloud SDK](https://cloud.google.com/sdk) to provide authentication to use Google Cloud APIs: | ||
|
||
$ gcloud init | ||
|
||
Install dependencies, preferably with a virtualenv: | ||
|
||
$ virtualenv env | ||
$ source env/bin/activate | ||
$ pip install -r requirements.txt | ||
|
||
Then set environment variables before starting your application: | ||
|
||
$ export GOOGLE_CLOUD_PROJECT=[your-project-name] | ||
$ export PUBSUB_VERIFICATION_TOKEN=[your-verification-token] | ||
$ export PUBSUB_TOPIC=[your-topic] | ||
$ python main.py | ||
|
||
### Simulating push notifications | ||
|
||
The application can send messages locally, but it is not able to receive push messages locally. You can, however, simulate a push message by making an HTTP request to the local push notification endpoint. There is an included ``sample_message.json``. You can use | ||
``curl`` or [httpie](https://github.com/jkbrzt/httpie) to POST this: | ||
|
||
$ curl -i --data @sample_message.json "localhost:8080/_ah/push-handlers/receive_messages?token=[your-token]" | ||
|
||
Or | ||
|
||
$ http POST ":8080/_ah/push-handlers/receive_messages?token=[your-token]" < sample_message.json | ||
|
||
Response: | ||
|
||
HTTP/1.0 400 BAD REQUEST | ||
Content-Type: text/html; charset=utf-8 | ||
Content-Length: 58 | ||
Server: Werkzeug/0.15.2 Python/3.7.3 | ||
Date: Sat, 06 Apr 2019 04:56:12 GMT | ||
|
||
Invalid token: 'NoneType' object has no attribute 'split' | ||
|
||
The simulated push request fails because it does not have a Cloud Pub/Sub-generated JWT in the "Authorization" header. | ||
|
||
## Running on App Engine | ||
|
||
Deploy using `gcloud`: | ||
|
||
gcloud app deploy app.yaml | ||
anguillanneuf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
You can now access the application at `https://[your-app-id].appspot.com`. You can use the form to submit messages, but it's non-deterministic which instance of your application will receive the notification. You can send multiple messages and refresh the page to see the received message. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
runtime: python37 | ||
|
||
#[START env] | ||
env_variables: | ||
PUBSUB_TOPIC: your-topic | ||
# This token is used to verify that requests originate from your | ||
# application. It can be any sufficiently random string. | ||
PUBSUB_VERIFICATION_TOKEN: 1234abc | ||
#[END env] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
-----BEGIN RSA PRIVATE KEY----- | ||
anguillanneuf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
MIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj | ||
7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/ | ||
xmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs | ||
SliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18 | ||
pe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk | ||
SBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk | ||
nQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq | ||
HD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y | ||
nHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9 | ||
IisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2 | ||
YCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU | ||
Z422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ | ||
vzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP | ||
B8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl | ||
aLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2 | ||
eCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI | ||
aqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk | ||
klORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ | ||
CFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu | ||
UqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg | ||
soBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28 | ||
bvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH | ||
504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL | ||
YXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx | ||
BeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg== | ||
-----END RSA PRIVATE KEY----- |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
-----BEGIN CERTIFICATE----- | ||
anguillanneuf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV | ||
BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV | ||
MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB | ||
CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM | ||
7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer | ||
uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp | ||
gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 | ||
+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 | ||
ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O | ||
gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh | ||
GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD | ||
AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr | ||
odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk | ||
+JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 | ||
ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql | ||
ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT | ||
cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB | ||
-----END CERTIFICATE----- |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
# Copyright 2019 Google, LLC. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
# [START app] | ||
import base64 | ||
from flask import current_app, Flask, render_template, request | ||
import json | ||
import logging | ||
import os | ||
|
||
from google.auth import jwt | ||
from google.auth.transport import requests | ||
from google.cloud import pubsub_v1 | ||
from google.oauth2 import id_token | ||
|
||
|
||
app = Flask(__name__) | ||
|
||
# Configure the following environment variables via app.yaml | ||
# This is used in the push request handler to verify that the request came from | ||
# pubsub and originated from a trusted source. | ||
app.config['PUBSUB_VERIFICATION_TOKEN'] = \ | ||
os.environ['PUBSUB_VERIFICATION_TOKEN'] | ||
app.config['PUBSUB_TOPIC'] = os.environ['PUBSUB_TOPIC'] | ||
app.config['GCLOUD_PROJECT'] = os.environ['GOOGLE_CLOUD_PROJECT'] | ||
|
||
# Global list to store messages, tokens, etc. received by this instance. | ||
MESSAGES = [] | ||
TOKENS = [] | ||
HEADERS = [] | ||
CLAIMS = [] | ||
|
||
# [START index] | ||
@app.route('/', methods=['GET', 'POST']) | ||
def index(): | ||
if request.method == 'GET': | ||
return render_template('index.html', messages=MESSAGES, tokens=TOKENS, | ||
headers=HEADERS, claims=CLAIMS) | ||
|
||
data = request.form.get('payload', 'Example payload').encode('utf-8') | ||
|
||
publisher = pubsub_v1.PublisherClient() | ||
topic_path = publisher.topic_path(app.config['GCLOUD_PROJECT'], | ||
app.config['PUBSUB_TOPIC']) | ||
future = publisher.publish(topic_path, data) | ||
future.result() | ||
anguillanneuf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return 'OK', 200 | ||
# [END index] | ||
|
||
|
||
# [START push] | ||
@app.route('/_ah/push-handlers/receive_messages', methods=['POST']) | ||
def receive_messages_handler(): | ||
# Verify that the request originates from the application. | ||
if (request.args.get('token', '') != | ||
current_app.config['PUBSUB_VERIFICATION_TOKEN']): | ||
return 'Invalid request', 400 | ||
|
||
# Verify that the push request originates from Cloud Pub/Sub. | ||
try: | ||
# Get the Cloud Pub/Sub-generated JWT in the "Authorization" header. | ||
bearer_token = request.headers.get('Authorization') | ||
token = bearer_token.split(' ')[1] | ||
TOKENS.append(token) | ||
|
||
header = jwt.decode_header(token) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this verifying the audience ('aud') field? Does it need to? This makes sure it is from pub/sub, but it could possibly be relayed from a push notification to a different app. Or do all push notifications use the same aud value? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, it is not verifying the I don't have code that specifically checks the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, I don't think so. This all looks very good to me. Go ahead and merge when you're ready, or let me know if you want me to do it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @anguillanneuf I'm just getting started reading this, so apologies if you are doing this, but the rule of thumb on these things is to verify the signature before touching any of the fields, then look at the expires and then the audience. (in that order). Then everything else. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @lesv 1. verifying signature and 2. checking expiration time are both done by the Google Auth Python library. I added 3. checking the audience field in the claim. Thanks for catching this! |
||
HEADERS.append(header) | ||
|
||
# Verify and decode the JWT. Underneath it checks the signature against | ||
# Google's public certs at https://www.googleapis.com/oauth2/v1/certs | ||
claim = id_token.verify_oauth2_token(token, requests.Request()) | ||
CLAIMS.append(claim) | ||
except Exception as e: | ||
return 'Invalid token: {}\n'.format(e), 400 | ||
|
||
envelope = json.loads(request.data.decode('utf-8')) | ||
payload = base64.b64decode(envelope['message']['data']) | ||
MESSAGES.append(payload) | ||
# Returning any 2xx status indicates successful receipt of the message. | ||
return 'OK', 200 | ||
# [END push] | ||
|
||
|
||
@app.errorhandler(500) | ||
def server_error(e): | ||
logging.exception('An error occurred during a request.') | ||
return """ | ||
An internal error occurred: <pre>{}</pre> | ||
See logs for full stacktrace. | ||
""".format(e), 500 | ||
|
||
|
||
if __name__ == '__main__': | ||
# This is used when running locally. Gunicorn is used to run the | ||
# application on Google App Engine. See entrypoint in app.yaml. | ||
app.run(host='127.0.0.1', port=8080, debug=True) | ||
# [END app] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
# Copyright 2019 Google, LLC. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
import base64 | ||
import calendar | ||
import datetime | ||
import json | ||
import os | ||
import pytest | ||
|
||
from google.auth import crypt | ||
from google.auth import jwt | ||
from google.oauth2 import id_token | ||
|
||
import main | ||
|
||
|
||
DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') | ||
|
||
with open(os.path.join(DATA_DIR, 'privatekey.pem'), 'rb') as fh: | ||
PRIVATE_KEY_BYTES = fh.read() | ||
|
||
with open(os.path.join(DATA_DIR, 'public_cert.pem'), 'rb') as fh: | ||
PUBLIC_CERT_BYTES = fh.read() | ||
|
||
|
||
@pytest.fixture | ||
def client(): | ||
main.app.testing = True | ||
return main.app.test_client() | ||
|
||
|
||
@pytest.fixture | ||
def signer(): | ||
return crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1') | ||
|
||
|
||
@pytest.fixture | ||
def fake_token(signer): | ||
now = calendar.timegm(datetime.datetime.utcnow().utctimetuple()) | ||
payload = { | ||
'aud': 'https://e.io/_ah/push-handlers/receive_messages?token=1234abc', | ||
'azp': '1234567890', | ||
'email': '[email protected]', | ||
'email_verified': True, | ||
'iat': now, | ||
'exp': now + 3600, | ||
'iss': 'https://accounts.google.com', | ||
'sub': '1234567890' | ||
} | ||
header = { | ||
'alg': 'RS256', | ||
'kid': signer.key_id, | ||
'typ': 'JWT' | ||
} | ||
yield jwt.encode(signer, payload, header=header) | ||
|
||
|
||
def _verify_mocked_oauth2_token(token, request): | ||
claims = jwt.decode(token, certs=PUBLIC_CERT_BYTES, verify=True) | ||
return claims | ||
|
||
|
||
def test_index(client): | ||
r = client.get('/') | ||
assert r.status_code == 200 | ||
|
||
|
||
def test_post_index(client): | ||
r = client.post('/', data={'payload': 'Test payload'}) | ||
assert r.status_code == 200 | ||
|
||
|
||
def test_push_endpoint(monkeypatch, client, fake_token): | ||
monkeypatch.setattr(id_token, 'verify_oauth2_token', | ||
_verify_mocked_oauth2_token) | ||
|
||
url = '/_ah/push-handlers/receive_messages?token=' + \ | ||
os.environ['PUBSUB_VERIFICATION_TOKEN'] | ||
# "".join(chr(x) for x in fake_token) | ||
anguillanneuf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
r = client.post( | ||
url, | ||
data=json.dumps({ | ||
"message": { | ||
"data": base64.b64encode( | ||
u'Test message'.encode('utf-8') | ||
).decode('utf-8') | ||
} | ||
}), | ||
headers=dict( | ||
Authorization="Bearer " + fake_token.decode('utf-8') | ||
) | ||
) | ||
assert r.status_code == 200 | ||
|
||
# Make sure the message is visible on the home page. | ||
r = client.get('/') | ||
assert r.status_code == 200 | ||
assert 'Test message' in r.data.decode('utf-8') | ||
|
||
|
||
def test_push_endpoint_errors(client): | ||
# no token | ||
r = client.post('/_ah/push-handlers/receive_messages') | ||
assert r.status_code == 400 | ||
|
||
# invalid token | ||
r = client.post('/_ah/push-handlers/receive_messages?token=bad') | ||
assert r.status_code == 400 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
Flask==1.0.2 | ||
google-api-python-client==1.7.8 | ||
google-auth==1.6.3 | ||
google-cloud-pubsub==0.40.0 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"message": { | ||
"data": "SGVsbG8sIFdvcmxkIQ==" | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.