From a1d859515016a5e4e6fa41545f8cb310d40341d4 Mon Sep 17 00:00:00 2001 From: Mike Tung Date: Sun, 4 Mar 2018 16:47:19 -0500 Subject: [PATCH 1/4] #20 started working on google oauth. --- .dockerignore | 1 + .gitignore | 3 +- app.py | 7 ++- coder_directory_api/api.py | 1 + coder_directory_api/resources/__init__.py | 4 +- coder_directory_api/resources/google.py | 54 +++++++++++++++++++++++ coder_directory_api/settings.py | 7 +++ mock_data/auth.json | 2 +- 8 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 coder_directory_api/resources/google.py diff --git a/.dockerignore b/.dockerignore index db00cf6..e8ffa4a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ *.json .gitignore .travis.yml +/google_secrets.json diff --git a/.gitignore b/.gitignore index 8797299..576b473 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,5 @@ ENV/ .idea #secrets -coder_directory_api/prod_settings.json \ No newline at end of file +coder_directory_api/prod_settings.json +/google_secrets.json diff --git a/app.py b/app.py index 1a3ef65..8a61f3e 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ def create_app() -> Flask: """ api = Flask('__name__') + api.secret_key = SECRET_KEY CORS(api) api.wsgi_app = ProxyFix(api.wsgi_app) api.url_map.strict_slashes = False @@ -49,7 +50,9 @@ def register_resources(api, bp, route=None): if __name__ == '__main__': app = create_app() - app.run(host=HOST, + app.run( + host=HOST, port=PORT, debug=DEBUG, - threaded=MULTITHREADING) + threaded=MULTITHREADING, + ) diff --git a/coder_directory_api/api.py b/coder_directory_api/api.py index edf05b0..fa129c8 100644 --- a/coder_directory_api/api.py +++ b/coder_directory_api/api.py @@ -20,6 +20,7 @@ def create_app() -> Flask: """ api = Flask('__name__') + api.secret_key = SECRET_KEY CORS(api) api.wsgi_app = ProxyFix(api.wsgi_app) api.url_map.strict_slashes = False diff --git a/coder_directory_api/resources/__init__.py b/coder_directory_api/resources/__init__.py index 4f1e804..da34c74 100644 --- a/coder_directory_api/resources/__init__.py +++ b/coder_directory_api/resources/__init__.py @@ -13,6 +13,7 @@ from .home import api as home_api from .login import api as login_api from .register import api as register_api +from .google import api as google_api # Create a list of resource objects to register in api api_resources = [ @@ -20,5 +21,6 @@ {'bp': users_api, 'route': 'users'}, {'bp': home_api, 'route': None}, {'bp': login_api, 'route': 'login'}, - {'bp': register_api, 'route': 'register'} + {'bp': register_api, 'route': 'register'}, + {'bp': google_api, 'route': 'google'} ] diff --git a/coder_directory_api/resources/google.py b/coder_directory_api/resources/google.py new file mode 100644 index 0000000..fd581ce --- /dev/null +++ b/coder_directory_api/resources/google.py @@ -0,0 +1,54 @@ +from flask import Blueprint, jsonify, redirect, request +from coder_directory_api.settings import GOOGLE_SECRETS +from coder_directory_api.engines.auth_engine import AuthEngine +import coder_directory_api.auth as auth + +import google_auth_oauthlib.flow +import googleapiclient.discovery + +api = Blueprint('google', __name__) +auth_engine = AuthEngine() + +# setup a flow object to manage OAuth exchange. +flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file( + GOOGLE_SECRETS, + scopes=[ + 'https://www.googleapis.com/auth/plus.login', + 'https://www.googleapis.com/auth/plus.me', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ]) +flow.redirect_uri = 'http://localhost:3000/api/google/callback' +authorization_url, state = flow.authorization_url( + access_type='offline', + include_granted_scopes='true' +) + + +@api.route('/', methods=['GET']) +def google_login() -> redirect: + """ + Google OAuth login resource for initiating OAuth Protocol. + Returns: + redirect to google login page. + """ + return redirect(authorization_url) + + +@api.route('/callback') +def callback_url() -> None: + """ + Callback uri for handling the second piece of google OAuth after user has + consented. + Returns: + Nothing. + """ + authorization_response = request.url + flow.fetch_token(authorization_response=authorization_response) + credentials = flow.credentials + d = googleapiclient.discovery.build('oauth2', 'v2', credentials=credentials) + data = d.userinfo().v2().me().get().execute() + auth_doc = {'user': data['email'], 'googleId': data['id']} + auth_engine.add_one(auth_doc) + payload = auth.make_token(auth_doc['user']) + return jsonify(payload) diff --git a/coder_directory_api/settings.py b/coder_directory_api/settings.py index d26f395..71c2a34 100644 --- a/coder_directory_api/settings.py +++ b/coder_directory_api/settings.py @@ -15,6 +15,7 @@ ENV = os.environ.get('API_ENV', 'DEV') BASE_URL = os.environ.get('API_BASE_URL', '/dev') + with open(SECRET, 'r') as s: creds = json.load(s) @@ -26,9 +27,15 @@ SECRET_KEY = creds['secretKey'] +GOOGLE_SECRETS = os.environ.get('GOOGLE', None) +with open(GOOGLE_SECRETS, 'r') as g: + google_secrets = json.load(g) + + if ENV == 'DEV': DEBUG = True MULTITHREADING = False + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' elif ENV == 'PROD': DEBUG = False diff --git a/mock_data/auth.json b/mock_data/auth.json index 05be759..4e08a6f 100644 --- a/mock_data/auth.json +++ b/mock_data/auth.json @@ -11,7 +11,7 @@ }, "refresh_token": { "iss": "coder directory", - "sub": "seekheart", + "sub": "test credentials", "created": "12/06/2017 01:54:11", "jti": "928f8e06-453c-436d-8b11-6fe21a2614d2", "iat": "1512543251000" From 3c85abb4ae633bb112da783d749795d5a881beb9 Mon Sep 17 00:00:00 2001 From: Mike Tung Date: Sun, 4 Mar 2018 17:32:33 -0500 Subject: [PATCH 2/4] #20 added validation of google tokens endpoint and tests. Updated home to show new google resource. --- coder_directory_api/resources/google.py | 75 ++++++++++++++++++++++--- coder_directory_api/resources/home.py | 3 +- tests/api/test_api_google.py | 28 +++++++++ 3 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 tests/api/test_api_google.py diff --git a/coder_directory_api/resources/google.py b/coder_directory_api/resources/google.py index fd581ce..2f003f3 100644 --- a/coder_directory_api/resources/google.py +++ b/coder_directory_api/resources/google.py @@ -1,3 +1,10 @@ +""" +Google resource for Coder Directory + +Copyright (c) 2017 by Mike Tung. +MIT License, see LICENSE for details. +""" + from flask import Blueprint, jsonify, redirect, request from coder_directory_api.settings import GOOGLE_SECRETS from coder_directory_api.engines.auth_engine import AuthEngine @@ -5,6 +12,8 @@ import google_auth_oauthlib.flow import googleapiclient.discovery +from google.oauth2 import id_token +from google.auth.transport import requests api = Blueprint('google', __name__) auth_engine = AuthEngine() @@ -13,26 +22,47 @@ flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file( GOOGLE_SECRETS, scopes=[ - 'https://www.googleapis.com/auth/plus.login', - 'https://www.googleapis.com/auth/plus.me', - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - ]) + 'https://www.googleapis.com/auth/plus.login', + 'https://www.googleapis.com/auth/plus.me', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ]) flow.redirect_uri = 'http://localhost:3000/api/google/callback' authorization_url, state = flow.authorization_url( - access_type='offline', - include_granted_scopes='true' + access_type='offline', + include_granted_scopes='true' ) +# setup white list for valid issuers +white_list = ['accounts.google.com', 'https://accounts.google.com'] -@api.route('/', methods=['GET']) + +@api.route('/', methods=['GET', 'POST']) def google_login() -> redirect: """ Google OAuth login resource for initiating OAuth Protocol. Returns: redirect to google login page. """ - return redirect(authorization_url) + if request.method == 'GET': + return redirect(authorization_url) + elif request.method == 'POST': + data = request.get_json() + token = data['token'] + client_id = data['clientId'] + + is_valid, data = _validate_google_token( + token=token, + client_id=client_id + ) + + if is_valid: + user = auth_engine.find_one(data) + if not user: + return jsonify({'message': 'User not registered!'}), 404 + return jsonify(user) + else: + return jsonify({'message': data}) @api.route('/callback') @@ -52,3 +82,30 @@ def callback_url() -> None: auth_engine.add_one(auth_doc) payload = auth.make_token(auth_doc['user']) return jsonify(payload) + + +def _validate_google_token(token, client_id) -> tuple: + """ + Helper function to validate a google oauth token + + Args: + token: google token + client_id: application id which was used to get token. + + Returns: + indicator as to whether google token is valid or not and + message/username. + """ + + try: + id_info = id_token.verify_oauth2_token( + token, + requests.Request(), + client_id) + if id_info['iss'] not in white_list: + raise ValueError('Wrong Issuer!') + user_name = id_info['email'] + except ValueError: + return False, 'Invalid Google token!' + + return True, user_name diff --git a/coder_directory_api/resources/home.py b/coder_directory_api/resources/home.py index bc7706f..b14e4c3 100644 --- a/coder_directory_api/resources/home.py +++ b/coder_directory_api/resources/home.py @@ -23,7 +23,8 @@ def home() -> tuple: 'languages': 'Programming languages resource.', 'users': 'Users resource.', 'login': 'Login to api for access.', - 'register': 'Registration route for access to api.' + 'register': 'Registration route for access to api.', + 'google': 'OAuth2 Google sign in for access' } ] diff --git a/tests/api/test_api_google.py b/tests/api/test_api_google.py new file mode 100644 index 0000000..8318eae --- /dev/null +++ b/tests/api/test_api_google.py @@ -0,0 +1,28 @@ +""" +Tests for Google Resource + +Copyright (c) 2017 by Mike Tung. +MIT License, see LICENSE for details. +""" + +from .common_test_setup import CommonApiTest +import json +from coder_directory_api.engines import AuthEngine + +class GoogleResourceTest(CommonApiTest): + def setUp(self): + """Setup Google Tests""" + super(GoogleResourceTest, self).setUp() + self.endpoint = '{}/google'.format(self.base_url) + self.engine = AuthEngine() + + def tearDown(self): + """Teardown method for Google Tests""" + super(GoogleResourceTest, self).tearDown() + + def test_redirect(self): + """Test google oauth sign in redirect""" + result = self.app.get(self.endpoint) + self.assertEqual(result.status_code, + 302, + msg='Expected 301 redirect to Google') \ No newline at end of file From 0161dcbb71254d24c184c55e3e31a551c9270faf Mon Sep 17 00:00:00 2001 From: Mike Tung Date: Sun, 4 Mar 2018 17:36:49 -0500 Subject: [PATCH 3/4] #20 updated readme, dependencies, and docker-compose to support google OAuth --- README.md | 4 +++- docker-compose.yml | 2 ++ requirements.txt | 17 ++++++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f643f3..dfe7d55 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,14 @@ coders and programming languages. | /register | Registers a user/app to use api | | /login | Login user to obtain token | | /login/token | Send your tokens here to refresh your access before it expires | +| /google | Sign in to google and get access token | | /users | Access users resource for GET/POST | | /users/{id} | Access users resource for GET/PATCH/DELETE for 1 user | | /languages | Access language resource for GET/POST | | /languages/{id} | Access language resource for GET/PATCH/DELETE of 1 language | -With the exception of the `register` and `login` endpoints all resources + +With the exception of the `register`, `google`, and `login` endpoints all resources require a jwt to be sent in the `Authorization` header with `Bearer` scheme. ## Development diff --git a/docker-compose.yml b/docker-compose.yml index f76be61..b59f032 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,10 @@ services: - HOST=0.0.0.0 - API_ENV=PROD - API_SECRET=/app/coder_directory_api/prod_settings.json + - GOOGLE=/app/google_secrets.json volumes: - ./coder_directory_api/prod_settings.json:/app/coder_directory_api/prod_settings.json + - ./google_secrets.json:/app/google_secrets.json links: - mongodb mongodb: diff --git a/requirements.txt b/requirements.txt index 7f393b1..dc4a920 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,18 @@ aniso8601==1.3.0 -certifi==2017.11.5 +asn1crypto==0.24.0 +cachetools==2.0.1 +certifi==2018.1.18 +cffi==1.11.5 chardet==3.0.4 click==6.7 +cryptography==2.1.4 Flask==0.12.2 Flask-Cors==3.0.3 +google-api-python-client==1.6.5 +google-auth==1.4.1 +google-auth-httplib2==0.0.3 +google-auth-oauthlib==0.2.0 +httplib2==0.10.3 idna==2.6 itsdangerous==0.24 Jinja2==2.10 @@ -12,7 +21,11 @@ lazy==1.3 MarkupSafe==1.0 mistune==0.8.1 nose==1.3.7 +oauth2client==4.1.2 oauthlib==2.0.6 +pyasn1==0.4.2 +pyasn1-modules==0.2.1 +pycparser==2.18 PyJWT==1.5.3 pymongo==3.5.1 python-dateutil==2.6.1 @@ -20,7 +33,9 @@ pytz==2017.3 PyYAML==3.12 requests==2.18.4 requests-oauthlib==0.8.0 +rsa==3.4.2 six==1.11.0 +uritemplate==3.0.0 urllib3==1.22 URLObject==2.4.3 Werkzeug==0.12.2 From 5fc75053de714c0ce7130758a0202aa58385bd89 Mon Sep 17 00:00:00 2001 From: Mike Tung Date: Sun, 4 Mar 2018 17:51:26 -0500 Subject: [PATCH 4/4] #20 added encrypted secrets for travis CI --- .travis.yml | 3 +++ google_secrets.json.enc | Bin 0 -> 512 bytes 2 files changed, 3 insertions(+) create mode 100644 google_secrets.json.enc diff --git a/.travis.yml b/.travis.yml index b62fc6f..19ddd3e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,9 @@ install: services: - mongodb +before_install: + - openssl aes-256-cbc -K $encrypted_fedd13c2de32_key -iv $encrypted_fedd13c2de32_iv -in google_secrets.json.enc -out google_secrets.json -d + # Seed the mongodb with some test data before_script: - bash mock_data/db_init.sh diff --git a/google_secrets.json.enc b/google_secrets.json.enc new file mode 100644 index 0000000000000000000000000000000000000000..cf2c877dad5eb62a0e23307d77929cd8dc6d70c3 GIT binary patch literal 512 zcmV+b0{{I)&&SKI0|mvnxA7)pCA1kN@8Pc&Q!YIwesRT8t4)Tjm`-XFEAlNhiVyQ1 zV!)|$7O6lE3$o3lCQ*%|=}adw3aLE9Vf{oax7+=+M*6-^fkRwhhQD;dvG(B!$)knk z(QOvXOUo}8e)J_tCrBU9&OqXdgK632Qg($ZcJ)CPzPPObdtNEL`VVY8fd}!ckjV;6 zb}U3@0!_Iz*Qss<;D1NuAkr6{^k-12O{ZN;id#akcwBZ?!{tAFXgd@YgKcamg0(5) zAiDYvN(wqb3F*J5rQV)R25<&kn1d&DAip03*V`S#eNs5;ZeeX|lpB%cI(K5Zj(mxX zh0ddd!uue}rLm~WgC_MmJtlIU9v_LVVl@r#-3X2JPqg=0ny;kF?900mj