From 7d326e56383f8b1904e0de339f4e8499c2cdfe3d Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Wed, 16 Nov 2016 14:54:21 -0800 Subject: [PATCH 1/2] Switch firenotes to google-auth Change-Id: I603d4cce393cf51f7648a18b98aa91459ec3a9f9 --- .../standard/firebase/firenotes/README.md | 7 - .../firebase/firenotes/backend/app.yaml | 6 - .../firenotes/backend/firebase_helper.py | 123 ------------ .../firenotes/backend/firebase_helper_test.py | 176 ------------------ .../firebase/firenotes/backend/main.py | 17 +- .../firebase/firenotes/backend/main_test.py | 3 +- .../firenotes/backend/requirements.txt | 3 + 7 files changed, 18 insertions(+), 317 deletions(-) delete mode 100644 appengine/standard/firebase/firenotes/backend/firebase_helper.py delete mode 100644 appengine/standard/firebase/firenotes/backend/firebase_helper_test.py diff --git a/appengine/standard/firebase/firenotes/README.md b/appengine/standard/firebase/firenotes/README.md index 0f81d591d4e..2a544a8bbcc 100644 --- a/appengine/standard/firebase/firenotes/README.md +++ b/appengine/standard/firebase/firenotes/README.md @@ -25,13 +25,6 @@ this sample. 1. Within a virtualenv, install the dependencies to the backend service: pip install -r requirements.txt -t lib - pip install pycrypto - - Although the pycrypto library is built in to the App Engine standard - environment, it will not be bundled until deployment since it is - platform-dependent. Thus, the app.yaml file includes the bundled version of - pycrypto at runtime, but you still need to install it manually to run the - application on the App Engine local development server. 1. [Add Firebase to your app.](https://firebase.google.com/docs/web/setup#add_firebase_to_your_app) 1. Add your Firebase project ID to the backend’s `app.yaml` file as an diff --git a/appengine/standard/firebase/firenotes/backend/app.yaml b/appengine/standard/firebase/firenotes/backend/app.yaml index bb6c6628996..0c0a9aaf1ac 100644 --- a/appengine/standard/firebase/firenotes/backend/app.yaml +++ b/appengine/standard/firebase/firenotes/backend/app.yaml @@ -7,12 +7,6 @@ handlers: - url: /.* script: main.app -libraries: -- name: ssl - version: 2.7.11 -- name: pycrypto - version: 2.6 - env_variables: # Replace with your Firebase project ID. FIREBASE_PROJECT_ID: '' diff --git a/appengine/standard/firebase/firenotes/backend/firebase_helper.py b/appengine/standard/firebase/firenotes/backend/firebase_helper.py deleted file mode 100644 index e5e44e83130..00000000000 --- a/appengine/standard/firebase/firenotes/backend/firebase_helper.py +++ /dev/null @@ -1,123 +0,0 @@ -# Copyright 2016 Google Inc. -# -# 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 json -import logging -import os -import ssl - -from Crypto.Util import asn1 -from google.appengine.api import urlfetch -from google.appengine.api import urlfetch_errors -import jwt -from jwt.contrib.algorithms.pycrypto import RSAAlgorithm -import jwt.exceptions - - -# For App Engine, pyjwt needs to use PyCrypto instead of Cryptography. -jwt.register_algorithm('RS256', RSAAlgorithm(RSAAlgorithm.SHA256)) - -# [START fetch_certificates] -# This URL contains a list of active certificates used to sign Firebase -# auth tokens. -FIREBASE_CERTIFICATES_URL = ( - 'https://www.googleapis.com/robot/v1/metadata/x509/' - 'securetoken@system.gserviceaccount.com') - - -# [START get_firebase_certificates] -def get_firebase_certificates(): - """Fetches the current Firebase certificates. - - Note: in a production application, you should cache this for at least - an hour. - """ - try: - result = urlfetch.Fetch( - FIREBASE_CERTIFICATES_URL, - validate_certificate=True) - data = result.content - except urlfetch_errors.Error: - logging.error('Error while fetching Firebase certificates.') - raise - - certificates = json.loads(data) - - return certificates -# [END get_firebase_certificates] -# [END fetch_certificates] - - -# [START extract_public_key_from_certificate] -def extract_public_key_from_certificate(x509_certificate): - """Extracts the PEM public key from an x509 certificate.""" - der_certificate_string = ssl.PEM_cert_to_DER_cert(x509_certificate) - - # Extract subjectPublicKeyInfo field from X.509 certificate (see RFC3280) - der_certificate = asn1.DerSequence() - der_certificate.decode(der_certificate_string) - tbs_certification = asn1.DerSequence() # To Be Signed certificate - tbs_certification.decode(der_certificate[0]) - - subject_public_key_info = tbs_certification[6] - - return subject_public_key_info -# [END extract_public_key_from_certificate] - - -# [START verify_auth_token] -def verify_auth_token(request): - """Verifies the JWT auth token in the request. - - If no token is found or if the token is invalid, returns None. - Otherwise, it returns a dictionary containing the JWT claims. - """ - if 'Authorization' not in request.headers: - return None - - # Auth header is in format 'Bearer {jwt}'. - request_jwt = request.headers['Authorization'].split(' ').pop() - - # Determine which certificate was used to sign the JWT. - header = jwt.get_unverified_header(request_jwt) - kid = header['kid'] - - certificates = get_firebase_certificates() - - try: - certificate = certificates[kid] - except KeyError: - logging.warning('JWT signed with unkown kid {}'.format(header['kid'])) - return None - - # Get the public key from the certificate. This is used to verify the - # JWT signature. - public_key = extract_public_key_from_certificate(certificate) - - # [START decrypt_token] - try: - claims = jwt.decode( - request_jwt, - public_key, - algorithms=['RS256'], - audience=os.environ['FIREBASE_PROJECT_ID'], - issuer='https://securetoken.google.com/{}'.format( - os.environ['FIREBASE_PROJECT_ID'])) - except jwt.exceptions.InvalidTokenError as e: - logging.warning('JWT verification failed: {}'.format(e)) - return None - # [END decrypt_token] - - return claims -# [END verify_auth_token] diff --git a/appengine/standard/firebase/firenotes/backend/firebase_helper_test.py b/appengine/standard/firebase/firenotes/backend/firebase_helper_test.py deleted file mode 100644 index 207bbf7832f..00000000000 --- a/appengine/standard/firebase/firenotes/backend/firebase_helper_test.py +++ /dev/null @@ -1,176 +0,0 @@ -# Copyright 2016 Google Inc. All Rights Reserved. -# -# 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 datetime -import os -import time - -# Remove any existing pyjwt handlers, as firebase_helper will register -# its own. -try: - import jwt - jwt.unregister_algorithm('RS256') -except KeyError: - pass - -import mock -import pytest - -import firebase_helper - - -def test_get_firebase_certificates(testbed): - certs = firebase_helper.get_firebase_certificates() - assert certs - assert len(certs.keys()) - - -@pytest.fixture -def test_certificate(): - from cryptography import utils - from cryptography import x509 - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.hazmat.primitives import serialization - from cryptography.x509.oid import NameOID - - one_day = datetime.timedelta(1, 0, 0) - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - backend=default_backend()) - public_key = private_key.public_key() - builder = x509.CertificateBuilder() - - builder = builder.subject_name(x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, u'example.com'), - ])) - builder = builder.issuer_name(x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, u'example.com'), - ])) - builder = builder.not_valid_before(datetime.datetime.today() - one_day) - builder = builder.not_valid_after(datetime.datetime.today() + one_day) - builder = builder.serial_number( - utils.int_from_bytes(os.urandom(20), "big") >> 1) - builder = builder.public_key(public_key) - - builder = builder.add_extension( - x509.BasicConstraints(ca=False, path_length=None), critical=True) - - certificate = builder.sign( - private_key=private_key, algorithm=hashes.SHA256(), - backend=default_backend()) - - certificate_pem = certificate.public_bytes(serialization.Encoding.PEM) - public_key_bytes = certificate.public_key().public_bytes( - serialization.Encoding.DER, - serialization.PublicFormat.SubjectPublicKeyInfo) - private_key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption()) - - yield certificate, certificate_pem, public_key_bytes, private_key_bytes - - -def test_extract_public_key_from_certificate(test_certificate): - _, certificate_pem, public_key_bytes, _ = test_certificate - public_key = firebase_helper.extract_public_key_from_certificate( - certificate_pem) - assert public_key == public_key_bytes - - -def make_jwt(private_key_bytes, claims=None, headers=None): - jwt_claims = { - 'iss': 'https://securetoken.google.com/test_audience', - 'aud': 'test_audience', - 'user_id': '123', - 'sub': '123', - 'iat': int(time.time()), - 'exp': int(time.time()) + 60, - 'email': 'user@example.com' - } - - jwt_claims.update(claims if claims else {}) - if not headers: - headers = {} - - return jwt.encode( - jwt_claims, private_key_bytes, algorithm='RS256', - headers=headers) - - -def test_verify_auth_token(test_certificate, monkeypatch): - _, certificate_pem, _, private_key_bytes = test_certificate - - # The Firebase project ID is used as the JWT audience. - monkeypatch.setenv('FIREBASE_PROJECT_ID', 'test_audience') - - # Generate a jwt to include in the request. - jwt = make_jwt(private_key_bytes, headers={'kid': '1'}) - - # Make a mock request - request = mock.Mock() - request.headers = {'Authorization': 'Bearer {}'.format(jwt)} - - get_cert_patch = mock.patch('firebase_helper.get_firebase_certificates') - with get_cert_patch as get_cert_mock: - # Make get_firebase_certificates return our test certificate. - get_cert_mock.return_value = {'1': certificate_pem} - claims = firebase_helper.verify_auth_token(request) - - assert claims['user_id'] == '123' - - -def test_verify_auth_token_no_auth_header(): - request = mock.Mock() - request.headers = {} - assert firebase_helper.verify_auth_token(request) is None - - -def test_verify_auth_token_invalid_key_id(test_certificate): - _, _, _, private_key_bytes = test_certificate - jwt = make_jwt(private_key_bytes, headers={'kid': 'invalid'}) - request = mock.Mock() - request.headers = {'Authorization': 'Bearer {}'.format(jwt)} - - get_cert_patch = mock.patch('firebase_helper.get_firebase_certificates') - with get_cert_patch as get_cert_mock: - # Make get_firebase_certificates return no certificates - get_cert_mock.return_value = {} - assert firebase_helper.verify_auth_token(request) is None - - -def test_verify_auth_token_expired(test_certificate, monkeypatch): - _, certificate_pem, _, private_key_bytes = test_certificate - - # The Firebase project ID is used as the JWT audience. - monkeypatch.setenv('FIREBASE_PROJECT_ID', 'test_audience') - - # Generate a jwt to include in the request. - jwt = make_jwt( - private_key_bytes, - claims={'exp': int(time.time()) - 60}, - headers={'kid': '1'}) - - # Make a mock request - request = mock.Mock() - request.headers = {'Authorization': 'Bearer {}'.format(jwt)} - - get_cert_patch = mock.patch('firebase_helper.get_firebase_certificates') - with get_cert_patch as get_cert_mock: - # Make get_firebase_certificates return our test certificate. - get_cert_mock.return_value = {'1': certificate_pem} - assert firebase_helper.verify_auth_token(request) is None diff --git a/appengine/standard/firebase/firenotes/backend/main.py b/appengine/standard/firebase/firenotes/backend/main.py index aaa74d4eb19..c9db383c5a0 100644 --- a/appengine/standard/firebase/firenotes/backend/main.py +++ b/appengine/standard/firebase/firenotes/backend/main.py @@ -18,9 +18,14 @@ from flask import Flask, jsonify, request import flask_cors from google.appengine.ext import ndb +import google.auth.transport.requests +import google.oauth2.id_token +import requests_toolbelt.adapters.appengine -import firebase_helper - +# Use the App Engine Requests adapter. This makes sure that Requests uses +# URLFetch. +requests_toolbelt.adapters.appengine.monkeypatch() +HTTP_REQUEST = google.auth.transport.requests.Request() app = Flask(__name__) flask_cors.CORS(app) @@ -68,7 +73,9 @@ def list_notes(): """Returns a list of notes added by the current Firebase user.""" # Verify Firebase auth. - claims = firebase_helper.verify_auth_token(request) + id_token = request.headers['Authorization'].split(' ').pop() + claims = google.oauth2.id_token.verify_firebase_token( + id_token, HTTP_REQUEST) if not claims: return 'Unauthorized', 401 @@ -90,7 +97,9 @@ def add_note(): """ # Verify Firebase auth. - claims = firebase_helper.verify_auth_token(request) + id_token = request.headers['Authorization'].split(' ').pop() + claims = google.oauth2.id_token.verify_firebase_token( + id_token, HTTP_REQUEST) if not claims: return 'Unauthorized', 401 diff --git a/appengine/standard/firebase/firenotes/backend/main_test.py b/appengine/standard/firebase/firenotes/backend/main_test.py index a11e988b0d9..78e0e792f34 100644 --- a/appengine/standard/firebase/firenotes/backend/main_test.py +++ b/appengine/standard/firebase/firenotes/backend/main_test.py @@ -36,7 +36,8 @@ def app(): @pytest.fixture def mock_token(): - with mock.patch('main.firebase_helper.verify_auth_token') as mock_verify: + patch = mock.patch('google.auth.id_token.verify_firebase_token') + with patch as mock_verify: yield mock_verify diff --git a/appengine/standard/firebase/firenotes/backend/requirements.txt b/appengine/standard/firebase/firenotes/backend/requirements.txt index bbbafa1103e..88c20ba8e9a 100644 --- a/appengine/standard/firebase/firenotes/backend/requirements.txt +++ b/appengine/standard/firebase/firenotes/backend/requirements.txt @@ -1,3 +1,6 @@ Flask==0.11.1 pyjwt==1.4.2 flask-cors==3.0.2 +google-auth==0.3.1 +requests==2.12.0 +requests-toolbelt==0.7.0 From 2a3bd56e90e317161d9d09d70890c603b7bed0de Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Thu, 17 Nov 2016 11:29:46 -0800 Subject: [PATCH 2/2] Fix tests Change-Id: Iac3a630cb535ad27295694057d264dd1ad56c187 --- appengine/standard/bigquery/main.py | 2 +- .../standard/firebase/firenotes/backend/main_test.py | 11 ++++++----- testing/requirements-dev.in | 1 + testing/requirements-dev.txt | 2 ++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/appengine/standard/bigquery/main.py b/appengine/standard/bigquery/main.py index 1c035272ce7..ccd1f4f44d2 100644 --- a/appengine/standard/bigquery/main.py +++ b/appengine/standard/bigquery/main.py @@ -25,7 +25,7 @@ import os from googleapiclient.discovery import build -from oauth2client.appengine import OAuth2DecoratorFromClientSecrets +from oauth2client.contrib.appengine import OAuth2DecoratorFromClientSecrets import webapp2 diff --git a/appengine/standard/firebase/firenotes/backend/main_test.py b/appengine/standard/firebase/firenotes/backend/main_test.py index 78e0e792f34..3aa7e944734 100644 --- a/appengine/standard/firebase/firenotes/backend/main_test.py +++ b/appengine/standard/firebase/firenotes/backend/main_test.py @@ -36,7 +36,7 @@ def app(): @pytest.fixture def mock_token(): - patch = mock.patch('google.auth.id_token.verify_firebase_token') + patch = mock.patch('google.oauth2.id_token.verify_firebase_token') with patch as mock_verify: yield mock_verify @@ -56,7 +56,7 @@ def test_data(): def test_list_notes_with_mock_token(testbed, app, mock_token, test_data): mock_token.return_value = {'sub': '123'} - r = app.get('/notes') + r = app.get('/notes', headers={'Authorization': 'Bearer 123'}) assert r.status_code == 200 data = json.loads(r.data) @@ -67,7 +67,7 @@ def test_list_notes_with_mock_token(testbed, app, mock_token, test_data): def test_list_notes_with_bad_mock_token(testbed, app, mock_token): mock_token.return_value = None - r = app.get('/notes') + r = app.get('/notes', headers={'Authorization': 'Bearer 123'}) assert r.status_code == 401 @@ -77,7 +77,8 @@ def test_add_note_with_mock_token(testbed, app, mock_token): r = app.post( '/notes', data=json.dumps({'message': 'Hello, world!'}), - content_type='application/json') + content_type='application/json', + headers={'Authorization': 'Bearer 123'}) assert r.status_code == 200 @@ -91,5 +92,5 @@ def test_add_note_with_mock_token(testbed, app, mock_token): def test_add_note_with_bad_mock_token(testbed, app, mock_token): mock_token.return_value = None - r = app.post('/notes') + r = app.post('/notes', headers={'Authorization': 'Bearer 123'}) assert r.status_code == 401 diff --git a/testing/requirements-dev.in b/testing/requirements-dev.in index 46e415ffcc7..da3f8472387 100644 --- a/testing/requirements-dev.in +++ b/testing/requirements-dev.in @@ -10,3 +10,4 @@ pytest==3.0.4 pyyaml==3.12 responses==0.5.1 WebTest==2.0.23 +webapp2==3.0.0b1 diff --git a/testing/requirements-dev.txt b/testing/requirements-dev.txt index c92c3c1babe..f216980d9f0 100644 --- a/testing/requirements-dev.txt +++ b/testing/requirements-dev.txt @@ -13,6 +13,7 @@ fluent-logger==0.4.4 funcsigs==1.0.2 functools32==3.2.3-2; python_version < "3" google-api-python-client==1.5.5 +google-auth==0.3.1 google-cloud-bigquery==0.21.0 google-cloud-bigtable==0.21.0 google-cloud-core==0.21.0 @@ -63,5 +64,6 @@ sleekxmpp==1.3.1 SQLAlchemy==1.1.4 twilio==6.3.dev0 uritemplate==3.0.0 +webapp2==3.0.0b1 WebTest==2.0.23 wheel==0.30.0a0