Skip to content

Commit 3d1f403

Browse files
Cloud Pub/Sub: authenticated push in GAE Standard for Python 3 (GoogleCloudPlatform#2097)
Showcasing Cloud Pub/Sub new feature authenticated push in GAE Standard for Python 3.
1 parent 334822b commit 3d1f403

File tree

9 files changed

+432
-0
lines changed

9 files changed

+432
-0
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Python 3 Google Cloud Pub/Sub sample for Google App Engine Standard Environment
2+
3+
[![Open in Cloud Shell][shell_img]][shell_link]
4+
5+
[shell_img]: http://gstatic.com/cloudssh/images/open-btn.png
6+
[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
7+
8+
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/).
9+
10+
## Setup
11+
12+
Before you can run or deploy the sample, you will need to do the following:
13+
14+
1. Enable the Cloud Pub/Sub API in the [Google Developers Console](https://console.developers.google.com/project/_/apiui/apiview/pubsub/overview).
15+
16+
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. `--push-auth-token-audience` is optional. If set, remember to modify the audience field check in `main.py` (line 88).
17+
18+
$ gcloud pubsub topics create [your-topic-name]
19+
$ gcloud beta pubsub subscriptions create [your-subscription-name] \
20+
--topic=[your-topic-name] \
21+
--push-endpoint=\
22+
https://[your-app-id].appspot.com/_ah/push-handlers/receive_messages/token=[your-token] \
23+
--ack-deadline=30 \
24+
--push-auth-service-account=[your-service-account-email] \
25+
--push-auth-token-audience=example.com
26+
27+
3. Update the environment variables in ``app.yaml``.
28+
29+
## Running locally
30+
31+
When running locally, you can use the [Google Cloud SDK](https://cloud.google.com/sdk) to provide authentication to use Google Cloud APIs:
32+
33+
$ gcloud init
34+
35+
Install dependencies, preferably with a virtualenv:
36+
37+
$ virtualenv env
38+
$ source env/bin/activate
39+
$ pip install -r requirements.txt
40+
41+
Then set environment variables before starting your application:
42+
43+
$ export GOOGLE_CLOUD_PROJECT=[your-project-name]
44+
$ export PUBSUB_VERIFICATION_TOKEN=[your-verification-token]
45+
$ export PUBSUB_TOPIC=[your-topic]
46+
$ python main.py
47+
48+
### Simulating push notifications
49+
50+
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
51+
``curl`` or [httpie](https://github.com/jkbrzt/httpie) to POST this:
52+
53+
$ curl -i --data @sample_message.json "localhost:8080/_ah/push-handlers/receive_messages?token=[your-token]"
54+
55+
Or
56+
57+
$ http POST ":8080/_ah/push-handlers/receive_messages?token=[your-token]" < sample_message.json
58+
59+
Response:
60+
61+
HTTP/1.0 400 BAD REQUEST
62+
Content-Type: text/html; charset=utf-8
63+
Content-Length: 58
64+
Server: Werkzeug/0.15.2 Python/3.7.3
65+
Date: Sat, 06 Apr 2019 04:56:12 GMT
66+
67+
Invalid token: 'NoneType' object has no attribute 'split'
68+
69+
The simulated push request fails because it does not have a Cloud Pub/Sub-generated JWT in the "Authorization" header.
70+
71+
## Running on App Engine
72+
73+
Note: Not all the files in the current directory are needed to run your code on App Engine. Specifically, `main_test.py` and the `data` directory, which contains a mocked private key file and a mocked public certs file, are for testing purposes only. They SHOULD NOT be included in when deploying your app. When your app is up and running, Cloud Pub/Sub creates tokens using a private key, then the Google Auth Python library takes care of verifying and decoding the token using Google's public certs, to confirm that the push requests indeed come from Cloud Pub/Sub.
74+
75+
In the current directory, deploy using `gcloud`:
76+
77+
$ gcloud app deploy app.yaml
78+
79+
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.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
runtime: python37
2+
3+
#[START env]
4+
env_variables:
5+
PUBSUB_TOPIC: your-topic
6+
# This token is used to verify that requests originate from your
7+
# application. It can be any sufficiently random string.
8+
PUBSUB_VERIFICATION_TOKEN: 1234abc
9+
#[END env]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-----BEGIN RSA PRIVATE KEY-----
2+
MIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj
3+
7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/
4+
xmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs
5+
SliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18
6+
pe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk
7+
SBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk
8+
nQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq
9+
HD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y
10+
nHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9
11+
IisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2
12+
YCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU
13+
Z422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ
14+
vzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP
15+
B8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl
16+
aLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2
17+
eCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI
18+
aqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk
19+
klORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ
20+
CFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu
21+
UqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg
22+
soBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28
23+
bvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH
24+
504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL
25+
YXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx
26+
BeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==
27+
-----END RSA PRIVATE KEY-----
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV
3+
BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV
4+
MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
5+
CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM
6+
7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer
7+
uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp
8+
gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4
9+
+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3
10+
ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O
11+
gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh
12+
GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD
13+
AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr
14+
odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk
15+
+JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9
16+
ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql
17+
ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT
18+
cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB
19+
-----END CERTIFICATE-----
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright 2019 Google, LLC.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# [START app]
16+
import base64
17+
from flask import current_app, Flask, render_template, request
18+
import json
19+
import logging
20+
import os
21+
22+
from google.auth import jwt
23+
from google.auth.transport import requests
24+
from google.cloud import pubsub_v1
25+
from google.oauth2 import id_token
26+
27+
28+
app = Flask(__name__)
29+
30+
# Configure the following environment variables via app.yaml
31+
# This is used in the push request handler to verify that the request came from
32+
# pubsub and originated from a trusted source.
33+
app.config['PUBSUB_VERIFICATION_TOKEN'] = \
34+
os.environ['PUBSUB_VERIFICATION_TOKEN']
35+
app.config['PUBSUB_TOPIC'] = os.environ['PUBSUB_TOPIC']
36+
app.config['GCLOUD_PROJECT'] = os.environ['GOOGLE_CLOUD_PROJECT']
37+
38+
# Global list to store messages, tokens, etc. received by this instance.
39+
MESSAGES = []
40+
TOKENS = []
41+
HEADERS = []
42+
CLAIMS = []
43+
44+
# [START index]
45+
@app.route('/', methods=['GET', 'POST'])
46+
def index():
47+
if request.method == 'GET':
48+
return render_template('index.html', messages=MESSAGES, tokens=TOKENS,
49+
headers=HEADERS, claims=CLAIMS)
50+
51+
data = request.form.get('payload', 'Example payload').encode('utf-8')
52+
53+
publisher = pubsub_v1.PublisherClient()
54+
topic_path = publisher.topic_path(app.config['GCLOUD_PROJECT'],
55+
app.config['PUBSUB_TOPIC'])
56+
future = publisher.publish(topic_path, data)
57+
future.result()
58+
return 'OK', 200
59+
# [END index]
60+
61+
62+
# [START push]
63+
@app.route('/_ah/push-handlers/receive_messages', methods=['POST'])
64+
def receive_messages_handler():
65+
# Verify that the request originates from the application.
66+
if (request.args.get('token', '') !=
67+
current_app.config['PUBSUB_VERIFICATION_TOKEN']):
68+
return 'Invalid request', 400
69+
70+
# Verify that the push request originates from Cloud Pub/Sub.
71+
try:
72+
# Get the Cloud Pub/Sub-generated JWT in the "Authorization" header.
73+
bearer_token = request.headers.get('Authorization')
74+
token = bearer_token.split(' ')[1]
75+
TOKENS.append(token)
76+
77+
header = jwt.decode_header(token)
78+
HEADERS.append(header)
79+
80+
# Verify and decode the JWT. Underneath it checks the signature against
81+
# Google's public certs at https://www.googleapis.com/oauth2/v1/certs.
82+
# It also checks the token expiration time.
83+
claim = id_token.verify_oauth2_token(token, requests.Request())
84+
CLAIMS.append(claim)
85+
86+
# Check the audience field in the claim. It was specified in
87+
# `--push-auth-token-audience` when you created the subscription.
88+
assert claim['aud'] == 'example.com'
89+
except Exception as e:
90+
return 'Invalid token: {}\n'.format(e), 400
91+
92+
envelope = json.loads(request.data.decode('utf-8'))
93+
payload = base64.b64decode(envelope['message']['data'])
94+
MESSAGES.append(payload)
95+
# Returning any 2xx status indicates successful receipt of the message.
96+
return 'OK', 200
97+
# [END push]
98+
99+
100+
@app.errorhandler(500)
101+
def server_error(e):
102+
logging.exception('An error occurred during a request.')
103+
return """
104+
An internal error occurred: <pre>{}</pre>
105+
See logs for full stacktrace.
106+
""".format(e), 500
107+
108+
109+
if __name__ == '__main__':
110+
# This is used when running locally. Gunicorn is used to run the
111+
# application on Google App Engine. See entrypoint in app.yaml.
112+
app.run(host='127.0.0.1', port=8080, debug=True)
113+
# [END app]
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright 2019 Google, LLC.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# This file is for testing purposes only. You SHOULD NOT include it
16+
# or the PEM files when deploying your app.
17+
18+
import base64
19+
import calendar
20+
import datetime
21+
import json
22+
import os
23+
import pytest
24+
25+
from google.auth import crypt
26+
from google.auth import jwt
27+
from google.oauth2 import id_token
28+
29+
import main
30+
31+
32+
DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
33+
34+
with open(os.path.join(DATA_DIR, 'privatekey.pem'), 'rb') as fh:
35+
PRIVATE_KEY_BYTES = fh.read()
36+
37+
with open(os.path.join(DATA_DIR, 'public_cert.pem'), 'rb') as fh:
38+
PUBLIC_CERT_BYTES = fh.read()
39+
40+
41+
@pytest.fixture
42+
def client():
43+
main.app.testing = True
44+
return main.app.test_client()
45+
46+
47+
@pytest.fixture
48+
def signer():
49+
return crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1')
50+
51+
52+
@pytest.fixture
53+
def fake_token(signer):
54+
now = calendar.timegm(datetime.datetime.utcnow().utctimetuple())
55+
payload = {
56+
'aud': 'example.com',
57+
'azp': '1234567890',
58+
'email': 'pubsub@example.iam.gserviceaccount.com',
59+
'email_verified': True,
60+
'iat': now,
61+
'exp': now + 3600,
62+
'iss': 'https://accounts.google.com',
63+
'sub': '1234567890'
64+
}
65+
header = {
66+
'alg': 'RS256',
67+
'kid': signer.key_id,
68+
'typ': 'JWT'
69+
}
70+
yield jwt.encode(signer, payload, header=header)
71+
72+
73+
def _verify_mocked_oauth2_token(token, request):
74+
claims = jwt.decode(token, certs=PUBLIC_CERT_BYTES, verify=True)
75+
return claims
76+
77+
78+
def test_index(client):
79+
r = client.get('/')
80+
assert r.status_code == 200
81+
82+
83+
def test_post_index(client):
84+
r = client.post('/', data={'payload': 'Test payload'})
85+
assert r.status_code == 200
86+
87+
88+
def test_push_endpoint(monkeypatch, client, fake_token):
89+
monkeypatch.setattr(id_token, 'verify_oauth2_token',
90+
_verify_mocked_oauth2_token)
91+
92+
url = '/_ah/push-handlers/receive_messages?token=' + \
93+
os.environ['PUBSUB_VERIFICATION_TOKEN']
94+
95+
r = client.post(
96+
url,
97+
data=json.dumps({
98+
"message": {
99+
"data": base64.b64encode(
100+
u'Test message'.encode('utf-8')
101+
).decode('utf-8')
102+
}
103+
}),
104+
headers=dict(
105+
Authorization="Bearer " + fake_token.decode('utf-8')
106+
)
107+
)
108+
assert r.status_code == 200
109+
110+
# Make sure the message is visible on the home page.
111+
r = client.get('/')
112+
assert r.status_code == 200
113+
assert 'Test message' in r.data.decode('utf-8')
114+
115+
116+
def test_push_endpoint_errors(client):
117+
# no token
118+
r = client.post('/_ah/push-handlers/receive_messages')
119+
assert r.status_code == 400
120+
121+
# invalid token
122+
r = client.post('/_ah/push-handlers/receive_messages?token=bad')
123+
assert r.status_code == 400
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Flask==1.0.2
2+
google-api-python-client==1.7.8
3+
google-auth==1.6.3
4+
google-cloud-pubsub==0.40.0
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"message": {
3+
"data": "SGVsbG8sIFdvcmxkIQ=="
4+
}
5+
}

0 commit comments

Comments
 (0)