Skip to content
This repository was archived by the owner on Oct 1, 2022. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/vimalloc/flask-jwt-extended/issues/264#issuecomment-517929886>`__
from the standard :code:`sub` claim).

Authentication
**************
Authentication is currently implemented via `Globus <https://www.globus.org/>`_,
Expand Down
5 changes: 5 additions & 0 deletions app.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
14 changes: 14 additions & 0 deletions servicex/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. ' \
Expand All @@ -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."""

Expand All @@ -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)
Expand Down
15 changes: 8 additions & 7 deletions servicex/resources/servicex_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 7 additions & 5 deletions servicex/resources/users/token_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}
4 changes: 2 additions & 2 deletions servicex/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@
<div class="collapse navbar-collapse" id="navbarToggle">
<div class="navbar-nav mr-auto">
<a class="nav-item nav-link" href="{{ config['DOCS_BASE_URL'] }}">Docs</a>
{% if not config['ENABLE_AUTH'] %}
{% if not config['ENABLE_AUTH'] or config['DISABLE_USER_MGMT'] %}
<a href="{{ url_for('global-dashboard') }}" class="nav-item nav-link">Dashboard</a>
{% endif %}
</div>
<!-- Navbar Right Side -->
{% if config['ENABLE_AUTH'] %}
{% if config['ENABLE_AUTH'] and not config['DISABLE_USER_MGMT'] %}
<div class="navbar-nav">
{% if not session['is_authenticated'] %}
<a href="{{ url_for('sign_in') }}" class="nav-item nav-link">Sign In</a>
Expand Down
10 changes: 7 additions & 3 deletions tests/resource_test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from unittest.mock import MagicMock

from flask.testing import FlaskClient
from pytest import fixture

from servicex import create_app
Expand Down Expand Up @@ -78,7 +79,7 @@ def _test_client(
code_gen_service=MagicMock(CodeGenAdapter),
lookup_result_processor=MagicMock(LookupResultProcessor),
docker_repo_adapter=None
):
) -> FlaskClient:
config = ResourceTestBase._app_config()
config['TRANSFORMER_MANAGER_ENABLED'] = False
config['TRANSFORMER_MANAGER_MODE'] = 'external'
Expand All @@ -99,7 +100,7 @@ def _test_client(
return app.test_client()

@fixture
def client(self):
def client(self) -> FlaskClient:
return self._test_client()

@staticmethod
Expand Down Expand Up @@ -145,7 +146,10 @@ def mock_requesting_user(self, mocker):
mock_user.admin = False
mock_user.pending = False
mocker.patch(
'servicex.resources.servicex_resource.UserModel.find_by_sub',
'servicex.decorators.UserModel.find_by_sub', return_value=mock_user
)
mocker.patch(
'servicex.resources.servicex_resource.ServiceXResource.get_requesting_user',
return_value=mock_user)
return mock_user

Expand Down
35 changes: 31 additions & 4 deletions tests/resources/test_servicex_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,42 @@
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import pkg_resources

from pytest import fixture

from servicex.resources.servicex_resource import ServiceXResource
from tests.resource_test_base import ResourceTestBase


class TestServiceXResource(ResourceTestBase):
module = ServiceXResource.__module__

@fixture
def mock_user(self, mocker):
return mocker.patch(f"{self.module}.UserModel.find_by_sub").return_value

class TestServiceXResource:
def test_get_app_version_no_servicex_app(self, mocker):
mock_get_distribution = mocker.patch(
"servicex.resources.servicex_resource.pkg_resources.get_distribution",
side_effect=pkg_resources.DistributionNotFound(None, None))
f"{self.module}.pkg_resources.get_distribution",
side_effect=pkg_resources.DistributionNotFound(None, None)
)

from servicex.resources.servicex_resource import ServiceXResource
version = ServiceXResource._get_app_version()

mock_get_distribution.assert_called_with("servicex_app")
assert version == 'develop'

def test_get_requesting_user_no_auth(self, client):
with client.application.app_context():
assert ServiceXResource.get_requesting_user() is None

def test_get_requesting_user_with_auth(self, mocker, mock_user):
mocker.patch(f"{self.module}.get_jwt_identity").return_value = "abcd"
client = self._test_client(extra_config={'ENABLE_AUTH': True})
with client.application.app_context():
assert ServiceXResource.get_requesting_user() == mock_user

def test_get_requesting_user_no_identity(self, mocker):
mocker.patch(f"{self.module}.get_jwt_identity").return_value = None
client = self._test_client(extra_config={'ENABLE_AUTH': True})
with client.application.app_context():
assert ServiceXResource.get_requesting_user() is None
55 changes: 55 additions & 0 deletions tests/resources/users/test_token_refresh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from types import SimpleNamespace
from unittest.mock import MagicMock

from flask import Response
from pytest import fixture

from tests.resource_test_base import ResourceTestBase


class TestTokenRefresh(ResourceTestBase):
module = 'servicex.resources.users.token_refresh'
endpoint = '/token/refresh'
fake_token = 'abcd'

@fixture(autouse=True, scope="class")
def unwrap(self):
"""Remove the @jwt_refresh_token_required decorator."""
from servicex.resources.users.token_refresh import TokenRefresh
TokenRefresh.post = TokenRefresh.post.__wrapped__

@fixture
def jwt_funcs(self, mocker) -> SimpleNamespace:
m = self.module
patch = mocker.patch
sub = "[email protected]"
return SimpleNamespace(
get_jwt_identity=patch(f"{m}.get_jwt_identity", return_value=sub),
create_access_token=patch(f"{m}.create_access_token", return_value=self.fake_token),
get_raw_jwt=patch(f"{m}.get_raw_jwt", return_value={"jti": "1234"}),
decode_token=mocker.patch(f"{m}.decode_token")
)

@fixture
def mock_user(self, mocker) -> MagicMock:
return mocker.patch(f"{self.module}.UserModel.find_by_sub").return_value

def make_request(self, client):
response: Response = client.post(self.endpoint)
assert response.status_code == 200
assert response.json == {'access_token': self.fake_token}

def test_post_valid_refresh_token(self, client, jwt_funcs, mock_user):
jwt_funcs.decode_token.return_value = jwt_funcs.get_raw_jwt.return_value
self.make_request(client)

def test_post_invalid_refresh_token(self, client, jwt_funcs, mock_user):
jwt_funcs.decode_token.return_value = {"jti": "this value will not match"}
response: Response = client.post(self.endpoint)
assert response.status_code == 401
assert response.json == {"message": "Invalid or outdated refresh token"}

def test_post_user_mgmt_disabled(self, jwt_funcs):
client = self._test_client(extra_config={'DISABLE_USER_MGMT': True})
self.make_request(client)
jwt_funcs.get_jwt_identity.assert_called_once()
26 changes: 26 additions & 0 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,22 @@ def test_auth_decorator_integration_oauth(self, mocker, user):
assert response.status_code == 200
assert response.json == data

def test_auth_decorator_integration_user_mgmt_disabled(self, mocker):
cfg = {'ENABLE_AUTH': True, 'DISABLE_USER_MGMT': True}
client = self._test_client(extra_config=cfg)
fake_transform_id = 123
data = {'id': fake_transform_id}
mock = mocker.patch('servicex.resources.transformation_request'
'.TransformRequest.return_request').return_value
mock.to_json.return_value = data
with client.application.app_context():
response: Response = client.get(
f'servicex/transformation/{fake_transform_id}',
headers=self.fake_header()
)
assert response.status_code == 200
assert response.json == data

def test_admin_decorator_integration_auth_disabled(self, mocker, client):
data = {'users': [{'id': 1234}]}
mocker.patch('servicex.models.UserModel.return_all', return_value=data)
Expand Down Expand Up @@ -229,3 +245,13 @@ def test_admin_decorator_integration_oauth_not_authorized(self, user):
response: Response = client.get('users')
assert response.status_code == 401
assert 'restricted' in response.json['message']

def test_admin_decorator_integration_user_mgmt_disabled(self, mocker):
cfg = {'ENABLE_AUTH': True, 'DISABLE_USER_MGMT': True}
client = self._test_client(extra_config=cfg)
data = {'users': [{'id': 1234}]}
mocker.patch('servicex.models.UserModel.return_all', return_value=data)
with client.application.app_context():
response: Response = client.get('users', headers=self.fake_header())
assert response.status_code == 200
assert response.json == data