Skip to content

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

Merged
merged 12 commits into from
Apr 8, 2019
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions appengine/standard_python37/pubsub/.gcloudignore
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
78 changes: 78 additions & 0 deletions appengine/standard_python37/pubsub/README.md
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

[![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.

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

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.
9 changes: 9 additions & 0 deletions appengine/standard_python37/pubsub/app.yaml
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]
27 changes: 27 additions & 0 deletions appengine/standard_python37/pubsub/data/privatekey.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
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-----
19 changes: 19 additions & 0 deletions appengine/standard_python37/pubsub/data/public_cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
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-----
108 changes: 108 additions & 0 deletions appengine/standard_python37/pubsub/main.py
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()
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)
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

@anguillanneuf anguillanneuf Apr 6, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it is not verifying the aud field. It decodes the JWT header as shown. (If --push-auth-token-audience isn't set when creating the push subscription, then the aud field will be set to the push endpoint.)

I don't have code that specifically checks the aud field, should I add?

# JWT header
{"alg":"RS256","kid":"7d680d8c70d44e947133cbd499ebc1a61c3d5abc","typ":"JWT"}

# JWT claim
{
   "aud":"https://example.com",
   "azp":"113774264463038321964",
   "email":"[email protected]",
   "sub":"113774264463038321964",
   "email_verified":true,
   "exp":1550185935,
   "iat":1550182335,
   "iss":"https://accounts.google.com"
  }

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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]
121 changes: 121 additions & 0 deletions appengine/standard_python37/pubsub/main_test.py
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)

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
4 changes: 4 additions & 0 deletions appengine/standard_python37/pubsub/requirements.txt
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
5 changes: 5 additions & 0 deletions appengine/standard_python37/pubsub/sample_message.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"message": {
"data": "SGVsbG8sIFdvcmxkIQ=="
}
}
Loading