diff options
Diffstat (limited to 'opendc-web/opendc-web-api')
20 files changed, 314 insertions, 103 deletions
diff --git a/opendc-web/opendc-web-api/README.md b/opendc-web/opendc-web-api/README.md index e1d83daf..af3cf927 100644 --- a/opendc-web/opendc-web-api/README.md +++ b/opendc-web/opendc-web-api/README.md @@ -33,8 +33,8 @@ The `Util` package handles several miscellaneous tasks: database. * `Exceptions`: Holds definitions for exceptions used throughout the web server. * `Parameter Checker`: Recursively checks whether required `Request` parameters are present and correctly typed. -* `REST`: Parses HTTP messages into `Request` objects, and calls the appropriate `API` endpoint to get - a `Response` object to return to the `Main Server Loop`. +* `REST`: Parses HTTP messages into `Request` objects, and calls the appropriate `API` endpoint to get a `Response` + object to return to the `Main Server Loop`. ### API Package @@ -80,8 +80,8 @@ repository. #### Get and configure the code -Clone OpenDC and follow the [instructions in the main repository](../../) to set up a Google OAuth ID and environment -variables. +Clone OpenDC and follow the [instructions in the main repository](../../) to set up an [Auth0](https://auth0.com) +application and environment variables. **Important:** Be sure to set up environment variables according to those instructions, in a `.env` file. diff --git a/opendc-web/opendc-web-api/app.py b/opendc-web/opendc-web-api/app.py index 7a687678..ee4b3d32 100755 --- a/opendc-web/opendc-web-api/app.py +++ b/opendc-web/opendc-web-api/app.py @@ -3,16 +3,14 @@ import json import os import sys import traceback -import urllib.request from dotenv import load_dotenv from flask import Flask, request, jsonify from flask_compress import Compress from flask_cors import CORS -from oauth2client import client, crypt -from opendc.models.user import User from opendc.util import rest, path_parser, database +from opendc.util.auth import AuthError, AuthManager, AsymmetricJwtAlgorithm from opendc.util.exceptions import AuthorizationTokenError, RequestInitializationError from opendc.util.json import JSONEncoder @@ -50,46 +48,21 @@ CORS(app) compress = Compress() compress.init_app(app) -API_VERSIONS = {'v2'} - - -@app.route('/tokensignin', methods=['POST']) -def sign_in(): - """Authenticate a user with Google sign in""" +auth = AuthManager(AsymmetricJwtAlgorithm(jwks_url=f"https://{os.environ['AUTH0_DOMAIN']}/.well-known/jwks.json"), + issuer=f"https://{os.environ['AUTH0_DOMAIN']}/", audience=os.environ['AUTH0_AUDIENCE']) - try: - token = request.form['idtoken'] - except KeyError: - return 'No idtoken provided', 401 - - try: - idinfo = client.verify_id_token(token, os.environ['OPENDC_OAUTH_CLIENT_ID']) - - if idinfo['aud'] != os.environ['OPENDC_OAUTH_CLIENT_ID']: - raise crypt.AppIdentityError('Unrecognized client.') - - if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']: - raise crypt.AppIdentityError('Wrong issuer.') - except ValueError: - url = "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token={}".format(token) - req = urllib.request.Request(url) - response = urllib.request.urlopen(url=req, timeout=30) - res = response.read() - idinfo = json.loads(res) - except crypt.AppIdentityError as e: - return 'Did not successfully authenticate' - - user = User.from_google_id(idinfo['sub']) - - data = {'isNewUser': user.obj is None} +API_VERSIONS = {'v2'} - if user.obj is not None: - data['userId'] = user.get_id() - return jsonify(**data) +@app.errorhandler(AuthError) +def handle_auth_error(ex): + response = jsonify(ex.error) + response.status_code = ex.status_code + return response @app.route('/<string:version>/<path:endpoint_path>', methods=['GET', 'POST', 'PUT', 'DELETE']) +@auth.require def api_call(version, endpoint_path): """Call an API endpoint directly over HTTP.""" diff --git a/opendc-web/opendc-web-api/conftest.py b/opendc-web/opendc-web-api/conftest.py index 8bb55ccc..c502c078 100644 --- a/opendc-web/opendc-web-api/conftest.py +++ b/opendc-web/opendc-web-api/conftest.py @@ -1,14 +1,30 @@ """ Configuration file for all unit tests. """ + +from functools import wraps import pytest +from flask import _request_ctx_stack + -from app import app +def decorator(self, f): + @wraps(f) + def decorated_function(*args, **kwargs): + _request_ctx_stack.top.current_user = {'sub': 'test'} + return f(*args, **kwargs) + return decorated_function @pytest.fixture def client(): """Returns a Flask API client to interact with.""" + + # Disable authorization for test API endpoints + from opendc.util.auth import AuthManager + AuthManager.require = decorator + + from app import app + app.config['TESTING'] = True with app.test_client() as client: diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py index 0ba61a13..c856f4ce 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py @@ -11,7 +11,7 @@ def GET(request): portfolio = Portfolio.from_id(request.params_path['portfolioId']) portfolio.check_exists() - portfolio.check_user_access(request.google_id, False) + portfolio.check_user_access(request.current_user['sub'], False) return Response(200, 'Successfully retrieved portfolio.', portfolio.obj) @@ -30,7 +30,7 @@ def PUT(request): portfolio = Portfolio.from_id(request.params_path['portfolioId']) portfolio.check_exists() - portfolio.check_user_access(request.google_id, True) + portfolio.check_user_access(request.current_user['sub'], True) portfolio.set_property('name', request.params_body['portfolio']['name']) @@ -52,7 +52,7 @@ def DELETE(request): portfolio = Portfolio.from_id(request.params_path['portfolioId']) portfolio.check_exists() - portfolio.check_user_access(request.google_id, True) + portfolio.check_user_access(request.current_user['sub'], True) portfolio_id = portfolio.get_id() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py index 2f042e06..b12afce3 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py @@ -29,13 +29,13 @@ def POST(request): portfolio = Portfolio.from_id(request.params_path['portfolioId']) portfolio.check_exists() - portfolio.check_user_access(request.google_id, True) + portfolio.check_user_access(request.current_user['sub'], True) scenario = Scenario(request.params_body['scenario']) topology = Topology.from_id(scenario.obj['topology']['topologyId']) topology.check_exists() - topology.check_user_access(request.google_id, True) + topology.check_user_access(request.current_user['sub'], True) scenario.set_property('portfolioId', portfolio.get_id()) scenario.set_property('simulation', {'state': 'QUEUED'}) diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py index 0d9ad5cd..0de50851 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py @@ -7,7 +7,7 @@ from opendc.util.rest import Response def GET(request): """Return all prefabs the user is authorized to access""" - user = User.from_google_id(request.google_id) + user = User.from_google_id(request.current_user['sub']) user.check_exists() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py index 723a2f0d..e77c7150 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py @@ -15,7 +15,7 @@ def POST(request): prefab.set_property('datetimeCreated', Database.datetime_to_string(datetime.now())) prefab.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) - user = User.from_google_id(request.google_id) + user = User.from_google_id(request.current_user['sub']) prefab.set_property('authorId', user.get_id()) prefab.insert() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py index 7b81f546..f1cf1fcd 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py @@ -12,7 +12,7 @@ def GET(request): prefab = Prefab.from_id(request.params_path['prefabId']) prefab.check_exists() - prefab.check_user_access(request.google_id) + prefab.check_user_access(request.current_user['sub']) return Response(200, 'Successfully retrieved prefab', prefab.obj) @@ -25,7 +25,7 @@ def PUT(request): prefab = Prefab.from_id(request.params_path['prefabId']) prefab.check_exists() - prefab.check_user_access(request.google_id) + prefab.check_user_access(request.current_user['sub']) prefab.set_property('name', request.params_body['prefab']['name']) prefab.set_property('rack', request.params_body['prefab']['rack']) @@ -43,7 +43,7 @@ def DELETE(request): prefab = Prefab.from_id(request.params_path['prefabId']) prefab.check_exists() - prefab.check_user_access(request.google_id) + prefab.check_user_access(request.current_user['sub']) old_object = prefab.delete() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py index bf031382..dacbe6a4 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py @@ -25,7 +25,7 @@ def POST(request): topology.set_property('projectId', project.get_id()) topology.update() - user = User.from_google_id(request.google_id) + user = User.from_google_id(request.current_user['sub']) user.obj['authorizations'].append({'projectId': project.get_id(), 'authorizationLevel': 'OWN'}) user.update() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py index 9f6a60ec..1b229122 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py @@ -10,7 +10,7 @@ def GET(request): project = Project.from_id(request.params_path['projectId']) project.check_exists() - project.check_user_access(request.google_id, False) + project.check_user_access(request.current_user['sub'], False) authorizations = project.get_all_authorizations() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py index caac37ca..37cf1860 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py @@ -16,7 +16,7 @@ def GET(request): project = Project.from_id(request.params_path['projectId']) project.check_exists() - project.check_user_access(request.google_id, False) + project.check_user_access(request.current_user['sub'], False) return Response(200, 'Successfully retrieved project', project.obj) @@ -29,7 +29,7 @@ def PUT(request): project = Project.from_id(request.params_path['projectId']) project.check_exists() - project.check_user_access(request.google_id, True) + project.check_user_access(request.current_user['sub'], True) project.set_property('name', request.params_body['project']['name']) project.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now())) @@ -46,7 +46,7 @@ def DELETE(request): project = Project.from_id(request.params_path['projectId']) project.check_exists() - project.check_user_access(request.google_id, True) + project.check_user_access(request.current_user['sub'], True) for topology_id in project.obj['topologyIds']: topology = Topology.from_id(topology_id) @@ -56,7 +56,7 @@ def DELETE(request): portfolio = Portfolio.from_id(portfolio_id) portfolio.delete() - user = User.from_google_id(request.google_id) + user = User.from_google_id(request.current_user['sub']) user.obj['authorizations'] = list( filter(lambda x: x['projectId'] != project.get_id(), user.obj['authorizations'])) user.update() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py index 2cdb1194..18b4d007 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py @@ -20,7 +20,7 @@ def POST(request): project = Project.from_id(request.params_path['projectId']) project.check_exists() - project.check_user_access(request.google_id, True) + project.check_user_access(request.current_user['sub'], True) portfolio = Portfolio(request.params_body['portfolio']) diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py index 44a0d575..47f2a207 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py @@ -14,7 +14,7 @@ def POST(request): project = Project.from_id(request.params_path['projectId']) project.check_exists() - project.check_user_access(request.google_id, True) + project.check_user_access(request.current_user['sub'], True) topology = Topology({ 'projectId': project.get_id(), diff --git a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py index 88a74e9c..7399f98c 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py @@ -11,7 +11,7 @@ def GET(request): scenario = Scenario.from_id(request.params_path['scenarioId']) scenario.check_exists() - scenario.check_user_access(request.google_id, False) + scenario.check_user_access(request.current_user['sub'], False) return Response(200, 'Successfully retrieved scenario.', scenario.obj) @@ -26,7 +26,7 @@ def PUT(request): scenario = Scenario.from_id(request.params_path['scenarioId']) scenario.check_exists() - scenario.check_user_access(request.google_id, True) + scenario.check_user_access(request.current_user['sub'], True) scenario.set_property('name', request.params_body['scenario']['name']) @@ -44,7 +44,7 @@ def DELETE(request): scenario = Scenario.from_id(request.params_path['scenarioId']) scenario.check_exists() - scenario.check_user_access(request.google_id, True) + scenario.check_user_access(request.current_user['sub'], True) scenario_id = scenario.get_id() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py index ea82b2e2..80618190 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py @@ -14,7 +14,7 @@ def GET(request): topology = Topology.from_id(request.params_path['topologyId']) topology.check_exists() - topology.check_user_access(request.google_id, False) + topology.check_user_access(request.current_user['sub'], False) return Response(200, 'Successfully retrieved topology.', topology.obj) @@ -25,7 +25,7 @@ def PUT(request): topology = Topology.from_id(request.params_path['topologyId']) topology.check_exists() - topology.check_user_access(request.google_id, True) + topology.check_user_access(request.current_user['sub'], True) topology.set_property('name', request.params_body['topology']['name']) topology.set_property('rooms', request.params_body['topology']['rooms']) @@ -43,7 +43,7 @@ def DELETE(request): topology = Topology.from_id(request.params_path['topologyId']) topology.check_exists() - topology.check_user_access(request.google_id, True) + topology.check_user_access(request.current_user['sub'], True) topology_id = topology.get_id() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py index 0dcf2463..fe61ce25 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py @@ -20,7 +20,7 @@ def POST(request): request.check_required_parameters(body={'user': {'email': 'string'}}) user = User(request.params_body['user']) - user.set_property('googleId', request.google_id) + user.set_property('googleId', request.current_user['sub']) user.set_property('authorizations', []) user.check_already_exists() diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py index be3462c0..26ff7717 100644 --- a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py +++ b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py @@ -27,7 +27,7 @@ def PUT(request): user = User.from_id(request.params_path['userId']) user.check_exists() - user.check_correct_user(request.google_id) + user.check_correct_user(request.current_user['sub']) user.set_property('givenName', request.params_body['user']['givenName']) user.set_property('familyName', request.params_body['user']['familyName']) @@ -45,7 +45,7 @@ def DELETE(request): user = User.from_id(request.params_path['userId']) user.check_exists() - user.check_correct_user(request.google_id) + user.check_correct_user(request.current_user['sub']) for authorization in user.obj['authorizations']: if authorization['authorizationLevel'] != 'OWN': diff --git a/opendc-web/opendc-web-api/opendc/util/auth.py b/opendc-web/opendc-web-api/opendc/util/auth.py new file mode 100644 index 00000000..810b582a --- /dev/null +++ b/opendc-web/opendc-web-api/opendc/util/auth.py @@ -0,0 +1,253 @@ +# Copyright (c) 2021 AtLarge Research +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import json +import time +from functools import wraps + +import urllib3 +from flask import request, _request_ctx_stack +from jose import jwt, JWTError +from werkzeug.local import LocalProxy + +current_user = LocalProxy(lambda: getattr(_request_ctx_stack.top, 'current_user', None)) + + +class AuthError(Exception): + """ + This error is thrown when the request failed to authorize. + """ + + def __init__(self, error, status_code): + Exception.__init__(self, error) + self.error = error + self.status_code = status_code + + +class AuthManager: + """ + This class handles the authorization of requests. + """ + + def __init__(self, alg, issuer, audience): + self._alg = alg + self._issuer = issuer + self._audience = audience + + def require(self, f): + """Determines if the Access Token is valid + """ + + @wraps(f) + def decorated(*args, **kwargs): + token = _get_token() + try: + header = jwt.get_unverified_header(token) + except JWTError as e: + raise AuthError({"code": "invalid_token", + "description": str(e)}, 401) + + alg = header.get('alg', None) + if alg != self._alg.algorithm: + raise AuthError({"code": "invalid_header", + "description": f"Signature algorithm of {alg} is not supported. Expected the ID token " + f"to be signed with {self._alg.algorithm}"}, 401) + + kid = header.get('kid', None) + try: + secret_or_certificate = self._alg.get_key(key_id=kid) + except TokenValidationError as e: + raise AuthError({"code": "invalid_header", + "description": str(e)}, 401) + try: + payload = jwt.decode(token, + key=secret_or_certificate, + algorithms=[self._alg.algorithm], + audience=self._audience, + issuer=self._issuer) + _request_ctx_stack.top.current_user = payload + return f(*args, **kwargs) + except jwt.ExpiredSignatureError: + raise AuthError({"code": "token_expired", + "description": "token is expired"}, 401) + except jwt.JWTClaimsError: + raise AuthError({"code": "invalid_claims", + "description": + "incorrect claims," + "please check the audience and issuer"}, 401) + except Exception as e: + print(e) + raise AuthError({"code": "invalid_header", + "description": + "Unable to parse authentication" + " token."}, 401) + + return decorated + + +def _get_token(): + """ + Obtain the Access Token from the Authorization Header + """ + auth = request.headers.get("Authorization", None) + if not auth: + raise AuthError({"code": "authorization_header_missing", + "description": + "Authorization header is expected"}, 401) + + parts = auth.split() + + if parts[0].lower() != "bearer": + raise AuthError({"code": "invalid_header", + "description": + "Authorization header must start with" + " Bearer"}, 401) + if len(parts) == 1: + raise AuthError({"code": "invalid_header", + "description": "Token not found"}, 401) + if len(parts) > 2: + raise AuthError({"code": "invalid_header", + "description": + "Authorization header must be" + " Bearer token"}, 401) + + token = parts[1] + return token + + +class SymmetricJwtAlgorithm: + """Verifier for HMAC signatures, which rely on shared secrets. + Args: + shared_secret (str): The shared secret used to decode the token. + algorithm (str, optional): The expected signing algorithm. Defaults to "HS256". + """ + + def __init__(self, shared_secret, algorithm="HS256"): + self.algorithm = algorithm + self._shared_secret = shared_secret + + # pylint: disable=W0613 + def get_key(self, key_id=None): + """ + Obtain the key for this algorithm. + :param key_id: The identifier of the key. + :return: The JWK key. + """ + return self._shared_secret + + +class AsymmetricJwtAlgorithm: + """Verifier for RSA signatures, which rely on public key certificates. + Args: + jwks_url (str): The url where the JWK set is located. + algorithm (str, optional): The expected signing algorithm. Defaults to "RS256". + """ + + def __init__(self, jwks_url, algorithm="RS256"): + self.algorithm = algorithm + self._fetcher = JwksFetcher(jwks_url) + + def get_key(self, key_id=None): + """ + Obtain the key for this algorithm. + :param key_id: The identifier of the key. + :return: The JWK key. + """ + return self._fetcher.get_key(key_id) + + +class TokenValidationError(Exception): + """ + Error thrown when the token cannot be validated + """ + + +class JwksFetcher: + """Class that fetches and holds a JSON web key set. + This class makes use of an in-memory cache. For it to work properly, define this instance once and re-use it. + Args: + jwks_url (str): The url where the JWK set is located. + cache_ttl (str, optional): The lifetime of the JWK set cache in seconds. Defaults to 600 seconds. + """ + CACHE_TTL = 600 # 10 min cache lifetime + + def __init__(self, jwks_url, cache_ttl=CACHE_TTL): + self._jwks_url = jwks_url + self._http = urllib3.PoolManager() + self._cache_value = {} + self._cache_date = 0 + self._cache_ttl = cache_ttl + self._cache_is_fresh = False + + def _fetch_jwks(self, force=False): + """Attempts to obtain the JWK set from the cache, as long as it's still valid. + When not, it will perform a network request to the jwks_url to obtain a fresh result + and update the cache value with it. + Args: + force (bool, optional): whether to ignore the cache and force a network request or not. Defaults to False. + """ + has_expired = self._cache_date + self._cache_ttl < time.time() + + if not force and not has_expired: + # Return from cache + self._cache_is_fresh = False + return self._cache_value + + # Invalidate cache and fetch fresh data + self._cache_value = {} + response = self._http.request('GET', self._jwks_url) + + if response.status == 200: + # Update cache + jwks = json.loads(response.data.decode('utf-8')) + self._cache_value = self._parse_jwks(jwks) + self._cache_is_fresh = True + self._cache_date = time.time() + return self._cache_value + + @staticmethod + def _parse_jwks(jwks): + """Converts a JWK string representation into a binary certificate in PEM format. + """ + keys = {} + + for key in jwks['keys']: + keys[key["kid"]] = key + return keys + + def get_key(self, key_id): + """Obtains the JWK associated with the given key id. + Args: + key_id (str): The id of the key to fetch. + Returns: + the JWK associated with the given key id. + + Raises: + TokenValidationError: when a key with that id cannot be found + """ + keys = self._fetch_jwks() + + if keys and key_id in keys: + return keys[key_id] + + if not self._cache_is_fresh: + keys = self._fetch_jwks(force=True) + if keys and key_id in keys: + return keys[key_id] + raise TokenValidationError(f"RSA Public Key with ID {key_id} was not found.") diff --git a/opendc-web/opendc-web-api/opendc/util/rest.py b/opendc-web/opendc-web-api/opendc/util/rest.py index c9e98295..63d063b3 100644 --- a/opendc-web/opendc-web-api/opendc/util/rest.py +++ b/opendc-web/opendc-web-api/opendc/util/rest.py @@ -1,11 +1,9 @@ import importlib import json -import os - -from oauth2client import client, crypt from opendc.util import exceptions, parameter_checker from opendc.util.exceptions import ClientError +from opendc.util.auth import current_user class Request: @@ -57,16 +55,7 @@ class Request: raise exceptions.UnsupportedMethodError('Unimplemented method at endpoint {}: {}'.format( self.path, self.method)) - # Verify the user - - if "OPENDC_FLASK_TESTING" in os.environ: - self.google_id = 'test' - return - - try: - self.google_id = self._verify_token(self.token) - except crypt.AppIdentityError as e: - raise exceptions.AuthorizationTokenError(e) + self.current_user = current_user def check_required_parameters(self, **kwargs): """Raise an error if a parameter is missing or of the wrong type.""" @@ -99,27 +88,6 @@ class Request: return json.dumps(self.message) - @staticmethod - def _verify_token(token): - """Return the ID of the signed-in user. - - Or throw an Exception if the token is invalid. - """ - - try: - id_info = client.verify_id_token(token, os.environ['OPENDC_OAUTH_CLIENT_ID']) - except Exception as e: - print(e) - raise crypt.AppIdentityError('Exception caught trying to verify ID token: {}'.format(e)) - - if id_info['aud'] != os.environ['OPENDC_OAUTH_CLIENT_ID']: - raise crypt.AppIdentityError('Unrecognized client.') - - if id_info['iss'] not in ['accounts.google.com', 'https://accounts.google.com']: - raise crypt.AppIdentityError('Wrong issuer.') - - return id_info['sub'] - class Response: """Response to websocket mapping""" diff --git a/opendc-web/opendc-web-api/requirements.txt b/opendc-web/opendc-web-api/requirements.txt index 555ba751..a518da47 100644 --- a/opendc-web/opendc-web-api/requirements.txt +++ b/opendc-web/opendc-web-api/requirements.txt @@ -33,6 +33,7 @@ pytest-cov==2.11.1 pytest-env==0.6.2 pytest-mock==3.2.0 python-dotenv==0.14.0 +python-jose==3.2.0 rsa==4.7 sentry-sdk==0.19.2 six==1.15.0 |
