diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7d2a8f1..0e2df0ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: '3.10' - name: Install flake8 run: | @@ -26,16 +26,16 @@ jobs: - name: Lint with flake8 run: | - flake8 . --count --max-complexity=10 --max-line-length=127 --statistics --exclude ckan,ckanext-saml2auth + flake8 . --count --max-complexity=12 --max-line-length=127 --statistics --exclude ckan,ckanext-saml2auth test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ '3.7', '3.8', '3.9'] - ckan-version: ["2.9", "2.10"] - name: Python ${{ matrix.python-version }} extension test + python-version: ['3.9', '3.10'] + ckan-version: ["2.10", "2.11"] + name: Python ${{ matrix.python-version }} CKAN ${{ matrix.ckan-version }} extension test services: postgresql: @@ -61,9 +61,7 @@ jobs: - 6379:6379 ckan-solr: - # Workflow level env variables are not addressable on job level, only on steps level - # image: ghcr.io/keitaroinc/ckan-solr-dev:{{ env.CKANVERSION }} - image: ghcr.io/keitaroinc/ckan-solr-dev:2.9 + image: ckan/ckan-solr:2.10 ports: - 8983:8983 @@ -90,46 +88,5 @@ jobs: - name: Test with pytest run: | + echo "Running SAML2AUTH tests" pytest --ckan-ini=subdir/test.ini --cov=ckanext.saml2auth --disable-warnings ckanext/saml2auth/tests - - - name: Coveralls - uses: AndreMiras/coveralls-python-action@develop - with: - parallel: true - flag-name: Python ${{ matrix.python-version }} Unit Test - - publish: - needs: test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.8' - - - name: Install setup requirements - run: | - python -m pip install --upgrade setuptools wheel twine - - - name: Build and package - run: | - python setup.py sdist bdist_wheel - twine check dist/* - - - name: Publish package - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} - - coveralls_finish: - needs: test - runs-on: ubuntu-latest - steps: - - name: Coveralls Finished - uses: AndreMiras/coveralls-python-action@develop - with: - parallel-finished: true diff --git a/README.md b/README.md index 9aef2eae..bfbb573f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ [![CI][]][1] [![Coverage][]][2] [![Gitter][]][3] [![Pypi][]][4] [![Python][]][5] [![CKAN][]][6] +# Temporary fork + +**This is a temporary fork from OKFN** to work with CKAN 2.10 waiting for upstream repo at https://github.com/keitaroinc/ckanext-saml2auth to be updated. + # ckanext-saml2auth A [CKAN](https://ckan.org) extension to enable Single Sign-On (SSO) for CKAN data portals via SAML2 Authentication. @@ -135,6 +139,8 @@ Optional: # Saml logout request preferred binding settings variable # Default: urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST ckanext.saml2auth.logout_expected_binding = urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST + # If you don't want to logout from external source you can use + ckanext.saml2auth.logout_expected_binding = skip-external-logout # Default fallback endpoint to redirect to if no RelayState provided in the SAML Response # Default: user.me (ie /dashboard) diff --git a/bin/setup-ckan.bash b/bin/setup-ckan.bash index 3ace4257..a1172a24 100755 --- a/bin/setup-ckan.bash +++ b/bin/setup-ckan.bash @@ -46,9 +46,10 @@ cd ckan ckan -c test-core.ini db init cd - -echo "Installing ckanext-saml2auth and its requirements..." -python setup.py develop +echo "Installing saml2 requirements..." pip install -r dev-requirements.txt +echo "Installing ckanext-saml2auth..." +pip install -e . echo "Moving test.ini into a subdir..." mkdir subdir diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index 8e0ed57c..7976222a 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -120,9 +120,12 @@ def _perform_slo(): response = None - client = h.saml_client( - sp_config() - ) + config = sp_config() + if config.get('logout_expected_binding') == 'skip-external-logout': + log.debug('Skipping external logout') + return + + client = h.saml_client(config) saml_session_info = get_saml_session_info(session) subject_id = get_subject_id(session) diff --git a/ckanext/saml2auth/tests/responses/unsigned0.xml b/ckanext/saml2auth/tests/responses/unsigned0.xml index 7398230e..f2f74d54 100644 --- a/ckanext/saml2auth/tests/responses/unsigned0.xml +++ b/ckanext/saml2auth/tests/responses/unsigned0.xml @@ -17,7 +17,7 @@ _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 diff --git a/ckanext/saml2auth/tests/test_blueprint_get_request.py b/ckanext/saml2auth/tests/test_blueprint_get_request.py index 4095f54b..a844df66 100644 --- a/ckanext/saml2auth/tests/test_blueprint_get_request.py +++ b/ckanext/saml2auth/tests/test_blueprint_get_request.py @@ -58,7 +58,7 @@ def _prepare_unsigned_response(): 'entity_id': 'urn:gov:gsa:SAML:2.0.profiles:sp:sso:test:entity', 'destination': 'http://test.ckan.net/acs', 'recipient': 'http://test.ckan.net/acs', - 'issue_instant': datetime.now().isoformat() + 'issue_instant': datetime.now().isoformat(), } t = Template(unsigned_response) final_response = t.render(**context) @@ -118,6 +118,9 @@ def test_unsigned_request(self, app): 'SAMLResponse': encoded_response } response = app.post(url=url, params=data) + if response.status_code != 200: + assert False, f'Failed test_unsigned_request: {response.body}' + # Can't use response, too old (now=2024-07-31T17:42:38Z + slack=0 > not_on_or_after=2024-01-18T06:21:48Z assert 200 == response.status_code def render_file(self, path, context, save_as=None): diff --git a/ckanext/saml2auth/tests/test_get_user_by_email.py b/ckanext/saml2auth/tests/test_get_user_by_email.py new file mode 100644 index 00000000..1a25e149 --- /dev/null +++ b/ckanext/saml2auth/tests/test_get_user_by_email.py @@ -0,0 +1,46 @@ +import pytest +from types import SimpleNamespace +import ckan.model as model +from ckan.tests import factories +from ckan.plugins import toolkit +from ckanext.saml2auth.views.saml2auth import _get_user_by_email + + +@pytest.fixture +def tdv_data(): + """TestDatasetViews setup data""" + obj = SimpleNamespace() + obj.user1 = factories.User( + email='user1@example.com', + plugin_extras={'saml2auth': {'saml_id': 'saml_id1'}} + ) + obj.user2 = factories.User( + email='user2@example.com', + plugin_extras={'saml2auth': {'saml_id': 'saml_id2'}} + ) + return obj + + +@pytest.mark.usefixtures(u'clean_db', u'clean_index') +@pytest.mark.ckan_config(u'ckan.plugins', u'saml2auth') +class TestDatasetViews(object): + def test_get_user_by_email_empty(self, tdv_data): + """ The the function _get_user_by_email for empty response """ + ret = _get_user_by_email('user3@example.com') + assert ret is None + + def test_get_user_by_email_ok(self, tdv_data): + """ The the function _get_user_by_email for empty response """ + ret = _get_user_by_email(tdv_data.user1['email']) + assert ret is not None + assert ret['email'] == tdv_data.user1['email'] + + def test_get_user_by_email_multiple(self, tdv_data): + """ The the function _get_user_by_email for duplicated emails """ + # Generate a duplciate email + user2 = model.User.get(tdv_data.user2['id']) + user2.email = tdv_data.user1['email'].upper() + model.Session.commit() + + with pytest.raises(toolkit.ValidationError): + _get_user_by_email(tdv_data.user1['email']) diff --git a/ckanext/saml2auth/tests/test_helpers.py b/ckanext/saml2auth/tests/test_helpers.py index 46d692e6..196cfe1d 100644 --- a/ckanext/saml2auth/tests/test_helpers.py +++ b/ckanext/saml2auth/tests/test_helpers.py @@ -30,7 +30,7 @@ def test_generate_password(): password = h.generate_password() assert len(password) == 8 - assert type(password) == str + assert isinstance(password, str) def test_default_login_disabled_by_default(): diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 36a6c0f7..798dfadf 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -23,12 +23,12 @@ from flask import Blueprint, session from saml2 import entity from saml2.authn_context import requested_authn_context - +from sqlalchemy.sql import func import ckan.plugins.toolkit as toolkit import ckan.model as model import ckan.plugins as plugins import ckan.lib.dictization.model_dictize as model_dictize -from ckan.lib import base +from ckan.lib import base, signals from ckan.views.user import set_repoze_user from ckan.common import config, g, request @@ -76,13 +76,18 @@ def _get_user_by_saml_id(saml_id): def _get_user_by_email(email): - user = model.User.by_email(email) - if user and isinstance(user, list): - user = user[0] + users = model.Session.query(model.User).filter( + func.lower(model.User.email) == func.lower(email) + ).all() - h.activate_user_if_deleted(user) + if len(users) == 0: + return None + if len(users) > 1: + raise toolkit.ValidationError(f'Multiple users with the same email found {email}') - return _dictize_user(user) if user else None + user = users[0] + h.activate_user_if_deleted(user) + return _dictize_user(user) def _update_user(user_dict): @@ -227,6 +232,8 @@ def acs(): if error is not None: log.error(error) extra_vars = {u'code': [400], u'content': error} + # Trigger the CKAN failed login signal + signals.failed_login.send('Unknown_SAML2_user') return base.render(u'error_document_template.html', extra_vars), 400 auth_response.get_identity() diff --git a/dev-requirements.txt b/dev-requirements.txt index 61015aef..fcd5547a 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,2 +1,3 @@ flake8 # for the CI build pysaml2 +packaging>=22.0 diff --git a/setup.py b/setup.py index cc2da1c6..f433b8b6 100644 --- a/setup.py +++ b/setup.py @@ -34,15 +34,14 @@ # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # http://packaging.python.org/en/latest/tutorial.html#version - version='1.3.0', + version='1.3.1', description='''An extension to enable Single Sign On(SSO) for CKAN data portals via SAML2 Authentication.''', long_description=long_description, long_description_content_type='text/markdown', # The project's main homepage. - url='https://github.com/keitaroinc/'\ - 'ckanext-saml2auth', + url='https://github.com/keitaroinc/ckanext-saml2auth', # Author details author='''Keitaro Inc''', @@ -65,9 +64,9 @@ # Specify the Python versions you support here. In particular, ensure # that you indicate whether you support Python 2, Python 3 or both. 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', ],