diff --git a/README.rst b/README.rst index bb2985af..4cbc2e08 100644 --- a/README.rst +++ b/README.rst @@ -15,11 +15,20 @@ services: User Management --------------- -If ``ENABLE_AUTH`` is set to True, an identity management system will be -enabled. Users will need to create accounts in order to make requests. +If ``ENABLE_AUTH`` is set to True and `DISABLE_USER_MGMT` is False, +an identity management system will be enabled. +Users will need to create accounts in order to make requests. The system consists of two components: authentication (verification of each user's identity) and authorization (control of access to API resources). +If ``ENABLE_AUTH`` and ``DISABLE_USER_MGMT`` are both set to True, then you +will need to generate your own JWT refresh tokens externally using +``JWT_SECRET_KEY``, and provide them to end users. +The payload must contain :code:`"type": "refresh"` and an :code:`identity` claim +(Flask-JWT-extended +`deviates `__ +from the standard :code:`sub` claim). + Authentication ************** Authentication is currently implemented via `Globus `_, diff --git a/app.conf.template b/app.conf.template index b2d63f97..3bd10dd9 100644 --- a/app.conf.template +++ b/app.conf.template @@ -10,6 +10,11 @@ DOCS_BASE_URL = 'https://servicex.readthedocs.io/en/latest/' # Enable JWT auth on public endpoints ENABLE_AUTH=False +# Disable built-in ServiceX user management system (OAuth via Globus) +# If set to True while ENABLE_AUTH is also True, then you must generate your +# own JWT refresh tokens with identity claims using the JWT_SECRET_KEY +DISABLE_USER_MGMT = False + # Globus configuration - obtained at https://auth.globus.org/v2/web/developers GLOBUS_CLIENT_ID='globus-client-id' GLOBUS_CLIENT_SECRET='globus-client-secret' diff --git a/servicex/decorators.py b/servicex/decorators.py index 917010de..fe514a84 100644 --- a/servicex/decorators.py +++ b/servicex/decorators.py @@ -40,10 +40,18 @@ def inner(*args, **kwargs) -> Response: return fn(*args, **kwargs) elif session.get('is_authenticated'): return fn(*args, **kwargs) + + # Check for an access token try: verify_jwt_in_request() except NoAuthorizationError as exc: return make_response({'message': str(exc)}, 401) + + # If user management is disabled, we are done + if current_app.config.get('DISABLE_USER_MGMT'): + return fn(*args, **kwargs) + + # Otherwise, check that the user still exists and is not pending user = UserModel.find_by_sub(get_jwt_identity()) if not user: msg = 'Not Authorized: No user found matching this API token. ' \ @@ -60,6 +68,7 @@ def inner(*args, **kwargs) -> Response: return inner +# Todo - Replace this with a parameterized decorator: @auth_required(admin=True) def admin_required(fn: Callable[..., Response]) -> Callable[..., Response]: """Mark an API resource as requiring administrator role.""" @@ -73,10 +82,15 @@ def inner(*args, **kwargs) -> Response: return fn(*args, **kwargs) else: return make_response({'message': msg}, 401) + try: verify_jwt_in_request() except NoAuthorizationError as exc: return make_response({'message': str(exc)}, 401) + + if current_app.config.get('DISABLE_USER_MGMT'): + return fn(*args, **kwargs) + user = UserModel.find_by_sub(get_jwt_identity()) if not (user and user.admin): return make_response({'message': msg}, 401) diff --git a/servicex/resources/servicex_resource.py b/servicex/resources/servicex_resource.py index effb519c..a2fb9bcb 100644 --- a/servicex/resources/servicex_resource.py +++ b/servicex/resources/servicex_resource.py @@ -43,14 +43,15 @@ def _generate_advertised_endpoint(cls, endpoint): @staticmethod def get_requesting_user() -> Optional[UserModel]: """ - :return: User who submitted request for resource. - If auth is enabled, this cannot be None for JWT-protected resources - which are decorated with @auth_required or @admin_required. + :return: ServiceX user who submitted request for resource. + Returns None if auth or user management is disabled. """ - user = None - if current_app.config.get('ENABLE_AUTH'): - user = UserModel.find_by_sub(get_jwt_identity()) - return user + config = current_app.config + if not config.get('ENABLE_AUTH') or config.get('DISABLE_USER_MGMT'): + return None + sub = get_jwt_identity() + if sub is not None: + return UserModel.find_by_sub(sub) @classmethod def _get_app_version(cls): diff --git a/servicex/resources/users/token_refresh.py b/servicex/resources/users/token_refresh.py index d61677ad..a72c10ad 100644 --- a/servicex/resources/users/token_refresh.py +++ b/servicex/resources/users/token_refresh.py @@ -26,6 +26,7 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from flask import current_app from flask_restful import Resource from flask_jwt_extended import (create_access_token, get_raw_jwt, decode_token, jwt_refresh_token_required, get_jwt_identity) @@ -35,11 +36,12 @@ class TokenRefresh(Resource): @jwt_refresh_token_required def post(self): - user = UserModel.find_by_sub(get_jwt_identity()) - claims = get_raw_jwt() - decoded = decode_token(user.refresh_token) - if not claims['jti'] == decoded['jti']: - return {'message': 'Invalid or outdated refresh token'}, 401 + if not current_app.config.get('DISABLE_USER_MGMT'): + user = UserModel.find_by_sub(get_jwt_identity()) + claims = get_raw_jwt() + decoded = decode_token(user.refresh_token) + if not claims['jti'] == decoded['jti']: + return {'message': 'Invalid or outdated refresh token'}, 401 current_user = get_jwt_identity() access_token = create_access_token(identity=current_user) return {'access_token': access_token} diff --git a/servicex/templates/base.html b/servicex/templates/base.html index a6d5391d..1dbdd05c 100644 --- a/servicex/templates/base.html +++ b/servicex/templates/base.html @@ -34,12 +34,12 @@