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/.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/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/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..2f003f3 --- /dev/null +++ b/coder_directory_api/resources/google.py @@ -0,0 +1,111 @@ +""" +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 +import coder_directory_api.auth as auth + +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() + +# 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' +) + +# setup white list for valid issuers +white_list = ['accounts.google.com', 'https://accounts.google.com'] + + +@api.route('/', methods=['GET', 'POST']) +def google_login() -> redirect: + """ + Google OAuth login resource for initiating OAuth Protocol. + Returns: + redirect to google login page. + """ + 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') +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) + + +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/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/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/google_secrets.json.enc b/google_secrets.json.enc new file mode 100644 index 0000000..cf2c877 Binary files /dev/null and b/google_secrets.json.enc differ 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" 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 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