From babfa4428045cc908051854bfde2d1806f3dffda Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 19 Feb 2021 12:21:07 +0100 Subject: [PATCH 1/6] Refactor process_user to make it more readable, use toolkit --- ckanext/saml2auth/helpers.py | 2 + ckanext/saml2auth/views/saml2auth.py | 139 ++++++++++++++++++--------- 2 files changed, 94 insertions(+), 47 deletions(-) diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index c7b4a110..5b8ad6dc 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -54,6 +54,8 @@ def update_user_sysadmin_status(username, email): def activate_user_if_deleted(userobj): u'''Reactivates deleted user.''' + if not userobj: + return if userobj.is_deleted(): userobj.activate() userobj.commit() diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 3740c881..0ddc6fd2 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -1,12 +1,12 @@ # encoding: utf-8 import logging + from flask import Blueprint from saml2 import entity from saml2.authn_context import requested_authn_context import ckan.plugins.toolkit as toolkit import ckan.model as model -import ckan.logic as logic import ckan.lib.dictization.model_dictize as model_dictize from ckan.lib import base from ckan.views.user import set_repoze_user @@ -29,64 +29,111 @@ def _get_requested_authn_contexts(): return requested_authn_contexts.strip().split() -def process_user(email, saml_id, full_name): - """ Check if CKAN-SAML user exists for the current SAML login """ - +def _dictize_user(user_obj): context = { - u'ignore_auth': True, u'keep_email': True, - u'model': model } + return model_dictize.user_dictize(user_obj, context) - saml_user = model.Session.query(model.User) \ + +def _get_user_by_saml_id(saml_id): + user_obj = model.Session.query(model.User) \ .filter(model.User.plugin_extras[(u'saml2auth', u'saml_id')].astext == saml_id) \ .first() + h.activate_user_if_deleted(user_obj) + + return _dictize_user(user_obj) if user_obj else None + + +def _get_user_by_email(email): + + user_obj = model.User.by_email(email) + + h.activate_user_if_deleted(user_obj) + + return _dictize_user(user_obj) if user_obj else None + + +def _update_user(user_dict): + context = { + u'ignore_auth': True, + } + + try: + return toolkit.get_action(u'user_update')(context, user_dict) + except toolkit.ValidationError as e: + error_message = (e.error_summary or e.message or e.error_dict) + base.abort(400, error_message) + + +def _create_user(user_dict): + context = { + u'ignore_auth': True, + } + + try: + return toolkit.get_action(u'user_create')(context, user_dict) + except toolkit.ValidationError as e: + error_message = (e.error_summary or e.message or e.error_dict) + base.abort(400, error_message) + + +def process_user(email, saml_id, full_name): + u''' + Check if a CKAN-SAML user exists for the current SAML login, if not create + a new one + + Here are the checks performed in order: + + 1. Is there an existing user that matches the provided saml_id (in plugin_extras)? + 2. Is there an existing user that matches the provided email? + 3. If no CKAN user found, create a new one with the provided saml id + + Returns the user name + ''' + + user_dict = _get_user_by_saml_id(saml_id) + # First we check if there is a SAML-CKAN user - if saml_user: - # If account exists and is deleted, reactivate it. - h.activate_user_if_deleted(saml_user) - - user_dict = model_dictize.user_dictize(saml_user, context) - # Update the existing CKAN-SAML user only if - # SAML user name or SAML user email are changed - # in the identity provider - if email != user_dict['email'] \ - or full_name != user_dict['fullname']: + if user_dict: + + # Update the existing CKAN-SAML user only if the SAML user name or + # email are changed in the IdP, or if another plugin modified the + # user dict + if email != user_dict['email'] or full_name != user_dict['fullname']: user_dict['email'] = email user_dict['fullname'] = full_name - try: - user_dict = logic.get_action(u'user_update')(context, user_dict) - except logic.ValidationError as e: - error_message = (e.error_summary or e.message or e.error_dict) - base.abort(400, error_message) + + user_dict = _update_user(user_dict) + return user_dict['name'] # If there is no SAML user but there is a regular CKAN # user with the same email as the current login, # make that user a SAML-CKAN user and change - # it's pass so the user can use only SSO - ckan_user = model.User.by_email(email) - if ckan_user: - # If account exists and is deleted, reactivate it. - h.activate_user_if_deleted(ckan_user[0]) - - ckan_user_dict = model_dictize.user_dictize(ckan_user[0], context) - try: - ckan_user_dict[u'password'] = h.generate_password() - ckan_user_dict[u'plugin_extras'] = { - u'saml2auth': { - # Store the saml username - # in the corresponding CKAN user - u'saml_id': saml_id - } + # its password so the user can use only SSO + + user_dict = _get_user_by_email(email) + + if user_dict: + user_dict[u'password'] = h.generate_password() + user_dict[u'plugin_extras'] = { + u'saml2auth': { + # Store the saml username + # in the corresponding CKAN user + u'saml_id': saml_id } - return logic.get_action(u'user_update')(context, ckan_user_dict)[u'name'] - except logic.ValidationError as e: - error_message = (e.error_summary or e.message or e.error_dict) - base.abort(400, error_message) + } + + user_dict = _update_user(user_dict) - data_dict = { + return user_dict['name'] + + # This is the first time this SAML user has logged in, let's create a CKAN user + # for them + + user_dict = { u'name': h.ensure_unique_username_from_email(email), u'fullname': full_name, u'email': email, @@ -99,11 +146,9 @@ def process_user(email, saml_id, full_name): } } } - try: - return logic.get_action(u'user_create')(context, data_dict)[u'name'] - except logic.ValidationError as e: - error_message = (e.error_summary or e.message or e.error_dict) - base.abort(400, error_message) + + user_dict = _create_user(user_dict) + return user_dict[u'name'] def acs(): From f4e1b30242435b53d2bc4813f551f357ef8c1c46 Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 19 Feb 2021 12:30:50 +0100 Subject: [PATCH 2/6] Add interface file and plugin hooks in views --- ckanext/saml2auth/interfaces.py | 39 ++++++++++++++++++++++++++++ ckanext/saml2auth/views/saml2auth.py | 30 +++++++++++++++++---- 2 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 ckanext/saml2auth/interfaces.py diff --git a/ckanext/saml2auth/interfaces.py b/ckanext/saml2auth/interfaces.py new file mode 100644 index 00000000..424a200c --- /dev/null +++ b/ckanext/saml2auth/interfaces.py @@ -0,0 +1,39 @@ +from ckan.plugins.interfaces import Interface + + +class ISaml2Auth(Interface): + u''' + This interface allows plugins to hook into the Saml2 authorization flow + ''' + def before_saml2_user_update(self, user_dict, saml_attributes): + u''' + Called just before updating an existing user + + :param user_dict: User metadata dict that will be passed to user_update + :param saml_attributes: A dict containing extra SAML attributes returned + as part of the SAML Response + ''' + pass + + def before_saml2_user_create(self, user_dict, saml_attributes): + u''' + Called just before creating a new user + + :param user_dict: User metadata dict that will be passed to user_create + :param saml_attributes: A dict containing extra SAML attributes returned + as part of the SAML Response + ''' + pass + + def after_saml2_login(self, resp, saml_attributes): + u''' + Called once the user has been logged in programatically, just before + returning the request. The logged in user can be accessed using g.user + or g.userobj + + :param resp: A Flask response object. Can be used to issue + redirects, add headers, etc + :param saml_attributes: A dict containing extra SAML attributes returned + as part of the SAML Response + ''' + pass diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 0ddc6fd2..963b61a3 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -1,5 +1,6 @@ # encoding: utf-8 import logging +import copy from flask import Blueprint from saml2 import entity @@ -7,6 +8,7 @@ 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.views.user import set_repoze_user @@ -14,6 +16,7 @@ from ckanext.saml2auth.spconfig import get_config as sp_config from ckanext.saml2auth import helpers as h +from ckanext.saml2auth.interfaces import ISaml2Auth log = logging.getLogger(__name__) @@ -79,7 +82,7 @@ def _create_user(user_dict): base.abort(400, error_message) -def process_user(email, saml_id, full_name): +def process_user(email, saml_id, full_name, saml_attributes): u''' Check if a CKAN-SAML user exists for the current SAML login, if not create a new one @@ -98,13 +101,20 @@ def process_user(email, saml_id, full_name): # First we check if there is a SAML-CKAN user if user_dict: - # Update the existing CKAN-SAML user only if the SAML user name or - # email are changed in the IdP, or if another plugin modified the - # user dict + current_user_dict = copy.deepcopy(user_dict) + if email != user_dict['email'] or full_name != user_dict['fullname']: user_dict['email'] = email user_dict['fullname'] = full_name + for plugin in plugins.PluginImplementations(ISaml2Auth): + plugin.before_saml2_user_update(user_dict, saml_attributes) + + # Update the existing CKAN-SAML user only if the SAML user name or + # email are changed in the IdP, or if another plugin modified the + # user dict + if current_user_dict != user_dict: + user_dict = _update_user(user_dict) return user_dict['name'] @@ -126,6 +136,9 @@ def process_user(email, saml_id, full_name): } } + for plugin in plugins.PluginImplementations(ISaml2Auth): + plugin.before_saml2_user_update(user_dict, saml_attributes) + user_dict = _update_user(user_dict) return user_dict['name'] @@ -147,6 +160,9 @@ def process_user(email, saml_id, full_name): } } + for plugin in plugins.PluginImplementations(ISaml2Auth): + plugin.before_saml2_user_create(user_dict, saml_attributes) + user_dict = _create_user(user_dict) return user_dict[u'name'] @@ -204,7 +220,7 @@ def acs(): else: full_name = u'{} {}'.format(email.split('@')[0], email.split('@')[1]) - g.user = process_user(email, saml_id, full_name) + g.user = process_user(email, saml_id, full_name, auth_response.ava) # Check if the authenticated user email is in given list of emails # and make that user sysadmin and opposite @@ -214,6 +230,10 @@ def acs(): # log the user in programmatically resp = toolkit.redirect_to(u'user.me') set_repoze_user(g.user, resp) + + for plugin in plugins.PluginImplementations(ISaml2Auth): + plugin.after_saml2_login(resp, auth_response.ava) + return resp From fb357f7a80858b944a36b2c18e9d79457e200b33 Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 19 Feb 2021 21:35:52 +0100 Subject: [PATCH 3/6] Refactor blueprint tests so it's easier to reuse functions --- .../tests/test_blueprint_get_request.py | 94 ++++++------------- 1 file changed, 31 insertions(+), 63 deletions(-) diff --git a/ckanext/saml2auth/tests/test_blueprint_get_request.py b/ckanext/saml2auth/tests/test_blueprint_get_request.py index 1f66f4d0..f6801763 100644 --- a/ckanext/saml2auth/tests/test_blueprint_get_request.py +++ b/ckanext/saml2auth/tests/test_blueprint_get_request.py @@ -20,6 +20,31 @@ responses_folder = os.path.join(here, 'responses') +def _b4_encode_string(message): + message_bytes = message.encode('ascii') + base64_bytes = base64.b64encode(message_bytes) + return base64_bytes.decode('ascii') + + +def _prepare_unsigned_response(): + # read about saml2 responses: https://www.samltool.com/generic_sso_res.php + unsigned_response_file = os.path.join(responses_folder, 'unsigned0.xml') + unsigned_response = open(unsigned_response_file).read() + # parse values + context = { + '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() + } + t = Template(unsigned_response) + final_response = t.render(**context) + + encoded_response = _b4_encode_string(final_response) + + return encoded_response + + @pytest.mark.usefixtures(u'clean_db', u'clean_index') @pytest.mark.ckan_config(u'ckan.plugins', u'saml2auth') class TestGetRequest: @@ -55,11 +80,6 @@ def test_bad_request(self, app): assert 400 == response.status_code assert u'Bad login request' in response - def _b4_encode_string(self, message): - message_bytes = message.encode('ascii') - base64_bytes = base64.b64encode(message_bytes) - return base64_bytes.decode('ascii') - @pytest.mark.ckan_config(u'ckanext.saml2auth.entity_id', u'urn:gov:gsa:SAML:2.0.profiles:sp:sso:test:entity') @pytest.mark.ckan_config(u'ckanext.saml2auth.idp_metadata.location', u'local') @pytest.mark.ckan_config(u'ckanext.saml2auth.idp_metadata.local_path', os.path.join(extras_folder, 'provider0', 'idp.xml')) @@ -68,20 +88,7 @@ def _b4_encode_string(self, message): @pytest.mark.ckan_config(u'ckanext.saml2auth.want_assertions_or_response_signed', u'False') def test_unsigned_request(self, app): - # read about saml2 responses: https://www.samltool.com/generic_sso_res.php - unsigned_response_file = os.path.join(responses_folder, 'unsigned0.xml') - unsigned_response = open(unsigned_response_file).read() - # parse values - context = { - '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() - } - t = Template(unsigned_response) - final_response = t.render(**context) - - encoded_response = self._b4_encode_string(final_response) + encoded_response = _prepare_unsigned_response() url = '/acs' data = { @@ -272,7 +279,7 @@ def test_encrypted_assertion(self, app): f = open(os.path.join(extras_folder, 'provider1', 'test-signed-encrypted.xml'), 'w') f.write(final_signed_response) f.close() - encoded_response = self._b4_encode_string(final_signed_response) + encoded_response = _b4_encode_string(final_signed_response) url = '/acs' data = { @@ -331,7 +338,7 @@ def test_signed_not_encrypted_assertion(self, app): f = open(os.path.join(extras_folder, 'provider1', 'test-signed.xml'), 'w') f.write(final_signed_response) f.close() - encoded_response = self._b4_encode_string(final_signed_response) + encoded_response = _b4_encode_string(final_signed_response) url = '/acs' data = { @@ -348,20 +355,7 @@ def test_signed_not_encrypted_assertion(self, app): @pytest.mark.ckan_config(u'ckanext.saml2auth.want_assertions_or_response_signed', u'False') def test_user_fullname_using_first_last_name(self, app): - # read about saml2 responses: https://www.samltool.com/generic_sso_res.php - unsigned_response_file = os.path.join(responses_folder, 'unsigned0.xml') - unsigned_response = open(unsigned_response_file).read() - # parse values - context = { - '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() - } - t = Template(unsigned_response) - final_response = t.render(**context) - - encoded_response = self._b4_encode_string(final_response) + encoded_response = _prepare_unsigned_response() url = '/acs' data = { @@ -385,20 +379,7 @@ def test_user_fullname_using_first_last_name(self, app): @pytest.mark.ckan_config(u'ckanext.saml2auth.user_lastname', None) def test_user_fullname_using_fullname(self, app): - # read about saml2 responses: https://www.samltool.com/generic_sso_res.php - unsigned_response_file = os.path.join(responses_folder, 'unsigned0.xml') - unsigned_response = open(unsigned_response_file).read() - # parse values - context = { - '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() - } - t = Template(unsigned_response) - final_response = t.render(**context) - - encoded_response = self._b4_encode_string(final_response) + encoded_response = _prepare_unsigned_response() url = '/acs' data = { @@ -419,20 +400,7 @@ def test_user_fullname_using_fullname(self, app): @pytest.mark.ckan_config(u'ckanext.saml2auth.want_assertions_or_response_signed', u'False') def test_relay_state_redirects_to_local_page(self, app): - # read about saml2 responses: https://www.samltool.com/generic_sso_res.php - unsigned_response_file = os.path.join(responses_folder, 'unsigned0.xml') - unsigned_response = open(unsigned_response_file).read() - # parse values - context = { - '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() - } - t = Template(unsigned_response) - final_response = t.render(**context) - - encoded_response = self._b4_encode_string(final_response) + encoded_response = _prepare_unsigned_response() url = '/acs' data = { From 474fcb3d8766dbb1881d92711734201f98f90772 Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 19 Feb 2021 21:36:50 +0100 Subject: [PATCH 4/6] Fixes in the interface logic --- ckanext/saml2auth/interfaces.py | 5 ++++- ckanext/saml2auth/views/saml2auth.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/ckanext/saml2auth/interfaces.py b/ckanext/saml2auth/interfaces.py index 424a200c..32d8e4a0 100644 --- a/ckanext/saml2auth/interfaces.py +++ b/ckanext/saml2auth/interfaces.py @@ -31,9 +31,12 @@ def after_saml2_login(self, resp, saml_attributes): returning the request. The logged in user can be accessed using g.user or g.userobj + It should always return the provided response object (which can be of course + modified) + :param resp: A Flask response object. Can be used to issue redirects, add headers, etc :param saml_attributes: A dict containing extra SAML attributes returned as part of the SAML Response ''' - pass + return resp diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 282ac690..a9f7b172 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -35,8 +35,14 @@ def _get_requested_authn_contexts(): def _dictize_user(user_obj): context = { u'keep_email': True, + u'model': model, } - return model_dictize.user_dictize(user_obj, context) + user_dict = model_dictize.user_dictize(user_obj, context) + # Make sure plugin_extras are included or plugins might drop the saml_id one + # Make a copy so SQLAlchemy can track changes properly + user_dict['plugin_extras'] = copy.deepcopy(user_obj.plugin_extras) + + return user_dict def _get_user_by_saml_id(saml_id): @@ -52,6 +58,8 @@ def _get_user_by_saml_id(saml_id): def _get_user_by_email(email): user_obj = model.User.by_email(email) + if user_obj: + user_obj = user_obj[0] h.activate_user_if_deleted(user_obj) @@ -238,7 +246,7 @@ def acs(): set_repoze_user(g.user, resp) for plugin in plugins.PluginImplementations(ISaml2Auth): - plugin.after_saml2_login(resp, auth_response.ava) + resp = plugin.after_saml2_login(resp, auth_response.ava) return resp From 37a939837568adb745550c72107be43ded8d6264 Mon Sep 17 00:00:00 2001 From: amercader Date: Fri, 19 Feb 2021 22:03:50 +0100 Subject: [PATCH 5/6] Add interface tests --- ckanext/saml2auth/tests/test_interface.py | 162 ++++++++++++++++++++++ setup.py | 3 + 2 files changed, 165 insertions(+) create mode 100644 ckanext/saml2auth/tests/test_interface.py diff --git a/ckanext/saml2auth/tests/test_interface.py b/ckanext/saml2auth/tests/test_interface.py new file mode 100644 index 00000000..cfa2047d --- /dev/null +++ b/ckanext/saml2auth/tests/test_interface.py @@ -0,0 +1,162 @@ +import os +from collections import defaultdict + +import pytest + +import ckan.model as model +import ckan.plugins as plugins +from ckan.tests import factories + +from ckanext.saml2auth.interfaces import ISaml2Auth +from ckanext.saml2auth.tests.test_blueprint_get_request import ( + _prepare_unsigned_response +) + +here = os.path.dirname(os.path.abspath(__file__)) +extras_folder = os.path.join(here, 'extras') + + +class ExampleISaml2AuthPlugin(plugins.SingletonPlugin): + + plugins.implements(ISaml2Auth, inherit=True) + + def __init__(self, *args, **kwargs): + + self.calls = defaultdict(int) + + def before_saml2_user_update(self, user_dict, saml_attributes): + + self.calls['before_saml2_user_update'] += 1 + + user_dict['fullname'] += ' TEST UPDATE' + + user_dict['plugin_extras']['my_plugin'] = {} + user_dict['plugin_extras']['my_plugin']['key1'] = 'value1' + + def before_saml2_user_create(self, user_dict, saml_attributes): + + self.calls['before_saml2_user_create'] += 1 + + user_dict['fullname'] += ' TEST CREATE' + + user_dict['plugin_extras']['my_plugin'] = {} + user_dict['plugin_extras']['my_plugin']['key2'] = 'value2' + + def after_saml2_login(self, resp, saml_attributes): + + self.calls['after_saml2_login'] += 1 + + resp.headers['X-Custom-header'] = 'test' + + return resp + + +@pytest.mark.usefixtures(u'clean_db', u'with_plugins') +@pytest.mark.ckan_config(u'ckan.plugins', u'saml2auth') +@pytest.mark.ckan_config(u'ckanext.saml2auth.entity_id', u'urn:gov:gsa:SAML:2.0.profiles:sp:sso:test:entity') +@pytest.mark.ckan_config(u'ckanext.saml2auth.idp_metadata.location', u'local') +@pytest.mark.ckan_config(u'ckanext.saml2auth.idp_metadata.local_path', os.path.join(extras_folder, 'provider0', 'idp.xml')) +@pytest.mark.ckan_config(u'ckanext.saml2auth.want_response_signed', u'False') +@pytest.mark.ckan_config(u'ckanext.saml2auth.want_assertions_signed', u'False') +@pytest.mark.ckan_config(u'ckanext.saml2auth.want_assertions_or_response_signed', u'False') +class TestInterface(object): + + def test_after_login_is_called(self, app): + + encoded_response = _prepare_unsigned_response() + url = '/acs' + + data = { + 'SAMLResponse': encoded_response + } + + with plugins.use_plugin("test_saml2auth") as plugin: + response = app.post(url=url, params=data, follow_redirects=False) + assert 302 == response.status_code + + assert plugin.calls["after_saml2_login"] == 1, plugin.calls + + assert response.headers['X-Custom-header'] == 'test' + + def test_before_create_is_called(self, app): + + encoded_response = _prepare_unsigned_response() + url = '/acs' + + data = { + 'SAMLResponse': encoded_response + } + + with plugins.use_plugin("test_saml2auth") as plugin: + response = app.post(url=url, params=data, follow_redirects=False) + assert 302 == response.status_code + + assert plugin.calls["before_saml2_user_create"] == 1, plugin.calls + + user = model.User.by_email('test@example.com')[0] + + assert user.fullname.endswith('TEST CREATE') + + assert user.plugin_extras['my_plugin']['key2'] == 'value2' + + assert 'saml_id' in user.plugin_extras['saml2auth'] + + def test_before_update_is_called_on_saml_user(self, app): + + # From unsigned0.xml + saml_id = '_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7' + + user = factories.User( + email='test@example.com', + plugin_extras={ + 'saml2auth': { + 'saml_id': saml_id, + } + } + ) + + encoded_response = _prepare_unsigned_response() + url = '/acs' + + data = { + 'SAMLResponse': encoded_response + } + + with plugins.use_plugin("test_saml2auth") as plugin: + response = app.post(url=url, params=data, follow_redirects=False) + assert 302 == response.status_code + + assert plugin.calls["before_saml2_user_update"] == 1, plugin.calls + + user = model.User.by_email('test@example.com')[0] + + assert user.fullname.endswith('TEST UPDATE') + + assert user.plugin_extras['my_plugin']['key1'] == 'value1' + + assert user.plugin_extras['saml2auth']['saml_id'] == saml_id + + def test_before_update_is_called_on_ckan_user(self, app): + + user = factories.User( + email='test@example.com', + ) + + encoded_response = _prepare_unsigned_response() + url = '/acs' + + data = { + 'SAMLResponse': encoded_response + } + + with plugins.use_plugin("test_saml2auth") as plugin: + response = app.post(url=url, params=data, follow_redirects=False) + assert 302 == response.status_code + + assert plugin.calls["before_saml2_user_update"] == 1, plugin.calls + + user = model.User.by_email('test@example.com')[0] + + assert user.fullname.endswith('TEST UPDATE') + + assert 'saml_id' in user.plugin_extras['saml2auth'] diff --git a/setup.py b/setup.py index 5c1e0f39..d8195a37 100644 --- a/setup.py +++ b/setup.py @@ -82,6 +82,9 @@ [ckan.plugins] saml2auth=ckanext.saml2auth.plugin:Saml2AuthPlugin + # Test plugins + test_saml2auth=ckanext.saml2auth.tests.test_interface:ExampleISaml2AuthPlugin + [babel.extractors] ckan = ckan.lib.extract:extract_ckan ''', From 31d7611b8973ee60e5cf5e1f5355fdc4fb78b1b4 Mon Sep 17 00:00:00 2001 From: amercader Date: Mon, 22 Feb 2021 10:56:56 +0100 Subject: [PATCH 6/6] Mention interface in README --- README.rst | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 03ac61af..ea043c98 100644 --- a/README.rst +++ b/README.rst @@ -137,11 +137,11 @@ Optional:: ckanext.saml2auth.want_response_signed = False ckanext.saml2auth.want_assertions_signed = False ckanext.saml2auth.want_assertions_or_response_signed = True - + # Cert & key files ckanext.saml2auth.key_file_path = /path/to/mykey.pem ckanext.saml2auth.cert_file_path = /path/to/mycert.pem - + # Attribute map directory ckanext.saml2auth.attribute_map_dir = /path/to/dir/attributemaps @@ -155,6 +155,20 @@ Optional:: # Comparison could be one of this: exact, minimum, maximum or better ckanext.saml2auth.requested_authn_context_comparison = exact +---------------- +Plugin interface +---------------- + +This extension provides the `ISaml2Auth` interface that allows other plugins to hook into the Saml2 authorization flow. +This allows plugins to integrate custom logic like: + +* Include additional attributes returned via the IdP as `plugin_extras` in the CKAN users +* Assign users to specific organizations with specific roles based on Saml2 attributes +* Customize the flow response, to eg issue redirects or include custom headers. + +For a list of available methods and their parameters check the [`ckanext/saml2auth/interfaces.py`](ckanext/saml2auth/interfaces.py) file, and for a basic example see the [`ExampleISaml2AuthPlugin`](ckanext/saml2auth/tests/test_interface.py) class. + + ---------------------- Developer installation ----------------------