From cb29e8821557c242fd231ed0ab9b960cb4f1bb33 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Tue, 3 Nov 2020 18:27:05 +0100 Subject: [PATCH 01/19] Initial implementation of CKAN SSO with saml2 authorization --- .gitignore | 3 + ckanext/saml2auth/plugin.py | 152 ++++++++++++++++++++++++++++ ckanext/saml2auth/views/__init__.py | 0 ckanext/saml2auth/views/saml2acs.py | 12 +++ requirements.txt | 1 + 5 files changed, 168 insertions(+) create mode 100644 ckanext/saml2auth/views/__init__.py create mode 100644 ckanext/saml2auth/views/saml2acs.py diff --git a/.gitignore b/.gitignore index 8570dc5c..d51b3666 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ coverage.xml # Sphinx documentation docs/_build/ + +# Saml2 config +idp.xml diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index d1990581..6a264085 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -1,9 +1,92 @@ +import os import ckan.plugins as plugins 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.views.user import set_repoze_user +from ckan.logic.action.create import _get_random_username_from_email + +from ckan.common import _, c, g, request + +from saml2 import ( + BINDING_HTTP_POST, + BINDING_HTTP_REDIRECT, + entity +) +from saml2.saml import NAME_FORMAT_URI +from saml2.client import Saml2Client +from saml2.config import Config as Saml2Config + +from ckanext.saml2auth.views.saml2acs import saml2acs + +CONFIG_PATH = os.path.dirname(__file__) + +BASE = 'http://localhost:5000/' + +#TODO move into separate config file +def saml_client(): + + settings = { + 'entityid': 'urn:mace:umu.se:saml:ckan:sp', + 'description': 'CKAN saml2 authorizer', + 'service': { + 'sp': { + 'name': 'CKAN SP', + 'endpoints': { + 'assertion_consumer_service': [BASE + 'acs'], + 'single_logout_service': [(BASE + 'slo', + BINDING_HTTP_REDIRECT)], + }, + 'required_attributes': [ + 'uid', + 'name', + 'mail', + 'status', + 'field_display_name', + 'realname', + 'field_unique_id', + ], + 'allow_unsolicited': True, + 'optional_attributes': [], + 'idp': ['urn:mace:umu.se:saml:ckan:idp'], + } + }, + 'debug': 0, + 'metadata': { + 'local': [CONFIG_PATH + '/idp.xml'], + }, + 'contact_person': [{ + 'given_name': 'John', + 'sur_name': 'Smith', + 'email_address': ['john.smith@example.com'], + 'contact_type': 'technical', + }, + ], + 'name_form': NAME_FORMAT_URI, + 'logger': { + 'rotating': { + 'filename': '/tmp/sp.log', + 'maxBytes': 100000, + 'backupCount': 5, + }, + 'loglevel': 'error', + } + } + spConfig = Saml2Config() + spConfig.load(settings) + spConfig.allow_unknown_attributes = True + saml_client = Saml2Client(config=spConfig) + return saml_client class Saml2AuthPlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) + plugins.implements(plugins.IAuthenticator) + plugins.implements(plugins.IBlueprint) + + def get_blueprint(self): + return [saml2acs] # IConfigurer @@ -12,3 +95,72 @@ def update_config(self, config_): toolkit.add_public_directory(config_, 'public') toolkit.add_resource('fanstatic', 'saml2auth') + + def identify(self): + u'''Called to identify the user. + + If the user is identified then it should set: + + - g.user: The name of the user + - g.userobj: The actual user object + ''' + + g.user = None + g.userobj = None + + if request.form.get('SAMLResponse', None): + client = saml_client() + auth_response = client.parse_authn_request_response( + request.form.get('SAMLResponse', None), + entity.BINDING_HTTP_POST) + auth_response.get_identity() + user_info = auth_response.get_subject() + + context = { + u'ignore_auth': True, + u'model': model + } + data_dict = { + 'name': _get_random_username_from_email(user_info.text), + 'fullname': auth_response.ava['name'][0] + ' ' + auth_response.ava['lastname'][0], + 'email': auth_response.ava['email'][0], + # TODO generate strong password + 'password': 'somestrongpass' + } + + user = model.User.by_email(auth_response.ava['email'][0]) + if not user: + g.user = logic.get_action(u'user_create')(context, data_dict)['name'] + else: + model_dictize.user_dictize(user[0], context) + data_dict['id'] = user[0].id + data_dict['name'] = user[0].name + g.user = logic.get_action(u'user_update')(context, data_dict)['name'] + print('----------------------------------------------g.user', g.user) + g.userobj = model.User.by_name(g.user) + resp = toolkit.redirect_to(u'user.me') + set_repoze_user(data_dict[u'name'], resp) + return resp + + def login(self): + u'''Called before the login starts (that is before asking the user for + user name and a password in the default authentication). + ''' + client = saml_client() + reqid, info = client.prepare_for_authenticate() + + redirect_url = None + for key, value in info['headers']: + if key is 'Location': + redirect_url = value + return toolkit.redirect_to(redirect_url) + + def logout(self): + u'''Called before the logout starts (that is before clicking the logout + button in the default authentication). + ''' + + def abort(self, status_code, detail, headers, comment): + u'''Called on abort. This allows aborts due to authorization issues + to be overridden''' + return (status_code, detail, headers, comment) diff --git a/ckanext/saml2auth/views/__init__.py b/ckanext/saml2auth/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ckanext/saml2auth/views/saml2acs.py b/ckanext/saml2auth/views/saml2acs.py new file mode 100644 index 00000000..8e50188f --- /dev/null +++ b/ckanext/saml2auth/views/saml2acs.py @@ -0,0 +1,12 @@ +# encoding: utf-8 +from flask import Blueprint +import ckan.plugins.toolkit as toolkit + +saml2acs = Blueprint(u'saml2acs', __name__) + + +def acs(): + return toolkit.redirect_to('home.index') + + +saml2acs.add_url_rule(u'/acs', view_func=acs, methods=[u'GET', u'POST']) diff --git a/requirements.txt b/requirements.txt index e69de29b..4ee801cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +pysaml2 \ No newline at end of file From 9866d67e22375a161aba8f5e16a71c479c134a7b Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Wed, 4 Nov 2020 17:46:24 +0100 Subject: [PATCH 02/19] Code refactoring --- ckanext/saml2auth/plugin.py | 81 ++++++++++++++++++++++++------------- requirements.txt | 1 + 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index 6a264085..645aa6e8 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -1,13 +1,6 @@ -import os -import ckan.plugins as plugins -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.views.user import set_repoze_user -from ckan.logic.action.create import _get_random_username_from_email +# encoding: utf-8 -from ckan.common import _, c, g, request +import os from saml2 import ( BINDING_HTTP_POST, @@ -18,12 +11,22 @@ from saml2.client import Saml2Client from saml2.config import Config as Saml2Config +import ckan.plugins as plugins +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.views.user import set_repoze_user +from ckan.logic.action.create import _get_random_username_from_email +from ckan.common import _, c, g, request + from ckanext.saml2auth.views.saml2acs import saml2acs CONFIG_PATH = os.path.dirname(__file__) BASE = 'http://localhost:5000/' + #TODO move into separate config file def saml_client(): @@ -109,6 +112,12 @@ def identify(self): g.userobj = None if request.form.get('SAMLResponse', None): + + context = { + u'ignore_auth': True, + u'model': model + } + client = saml_client() auth_response = client.parse_authn_request_response( request.form.get('SAMLResponse', None), @@ -116,30 +125,46 @@ def identify(self): auth_response.get_identity() user_info = auth_response.get_subject() - context = { - u'ignore_auth': True, - u'model': model - } - data_dict = { - 'name': _get_random_username_from_email(user_info.text), - 'fullname': auth_response.ava['name'][0] + ' ' + auth_response.ava['lastname'][0], - 'email': auth_response.ava['email'][0], - # TODO generate strong password - 'password': 'somestrongpass' - } + saml_id = user_info.text + email = auth_response.ava['email'][0] + name = auth_response.ava['name'][0] + lastname = auth_response.ava['lastname'][0] + + user = model.Session.query(model.User)\ + .filter(model.User.plugin_extras[('saml2auth', 'saml_id')].astext == saml_id)\ + .first() - user = model.User.by_email(auth_response.ava['email'][0]) if not user: + + data_dict = {'name': _get_random_username_from_email(email), + 'fullname': name + ' ' + lastname, + 'email': email, + 'password': 'somestrongpass', + 'plugin_extras': { + 'saml2auth': { + 'saml_id': saml_id + } + }} g.user = logic.get_action(u'user_create')(context, data_dict)['name'] + else: - model_dictize.user_dictize(user[0], context) - data_dict['id'] = user[0].id - data_dict['name'] = user[0].name - g.user = logic.get_action(u'user_update')(context, data_dict)['name'] - print('----------------------------------------------g.user', g.user) - g.userobj = model.User.by_name(g.user) + + model_dictize.user_dictize(user, context) + + if email != user.email \ + or name != user.fullname.split(' ')[0] \ + or lastname != user.fullname.split(' ')[1]: + + data_dict = {'id': user.id, + 'fullname': name + ' ' + lastname, + 'email': email + } + logic.get_action(u'user_update')(context, data_dict) + g.user = user.name + + # g.userobj = model.User.by_name(g.user) resp = toolkit.redirect_to(u'user.me') - set_repoze_user(data_dict[u'name'], resp) + set_repoze_user(g.user, resp) return resp def login(self): diff --git a/requirements.txt b/requirements.txt index 4ee801cf..2a8575d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ +#sudo apt install xmlsec1 pysaml2 \ No newline at end of file From 3f274f889f60cea5268e74149de1de5560f83ef7 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Wed, 4 Nov 2020 18:38:41 +0100 Subject: [PATCH 03/19] Add config for user attributes mapping and check if config exists on runtime --- ckanext/saml2auth/plugin.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index 645aa6e8..fb38ba01 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -18,7 +18,7 @@ import ckan.lib.dictization.model_dictize as model_dictize from ckan.views.user import set_repoze_user from ckan.logic.action.create import _get_random_username_from_email -from ckan.common import _, c, g, request +from ckan.common import _, config, g, request from ckanext.saml2auth.views.saml2acs import saml2acs @@ -87,6 +87,24 @@ class Saml2AuthPlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) plugins.implements(plugins.IAuthenticator) plugins.implements(plugins.IBlueprint) + plugins.implements(plugins.IConfigurable) + + # IConfigurable + + def configure(self, config): + # Certain config options must exists for the plugin to work. Raise an + # exception if they're missing. + missing_config = "{0} is not configured. Please amend your .ini file." + config_options = ( + 'ckanext.saml2auth.user_firstname', + 'ckanext.saml2auth.user_lastname', + 'ckanext.saml2auth.user_email' + ) + for option in config_options: + if not config.get(option, None): + raise RuntimeError(missing_config.format(option)) + + # IBlueprint def get_blueprint(self): return [saml2acs] @@ -99,6 +117,8 @@ def update_config(self, config_): toolkit.add_resource('fanstatic', 'saml2auth') + # IAuthenticator + def identify(self): u'''Called to identify the user. @@ -126,9 +146,9 @@ def identify(self): user_info = auth_response.get_subject() saml_id = user_info.text - email = auth_response.ava['email'][0] - name = auth_response.ava['name'][0] - lastname = auth_response.ava['lastname'][0] + email = auth_response.ava[config.get('ckanext.saml2auth.user_email')][0] + firstname = auth_response.ava[config.get('ckanext.saml2auth.user_firstname')][0] + lastname = auth_response.ava[config.get('ckanext.saml2auth.user_lastname')][0] user = model.Session.query(model.User)\ .filter(model.User.plugin_extras[('saml2auth', 'saml_id')].astext == saml_id)\ @@ -137,7 +157,7 @@ def identify(self): if not user: data_dict = {'name': _get_random_username_from_email(email), - 'fullname': name + ' ' + lastname, + 'fullname': firstname + ' ' + lastname, 'email': email, 'password': 'somestrongpass', 'plugin_extras': { @@ -152,11 +172,11 @@ def identify(self): model_dictize.user_dictize(user, context) if email != user.email \ - or name != user.fullname.split(' ')[0] \ + or firstname != user.fullname.split(' ')[0] \ or lastname != user.fullname.split(' ')[1]: data_dict = {'id': user.id, - 'fullname': name + ' ' + lastname, + 'fullname': firstname + ' ' + lastname, 'email': email } logic.get_action(u'user_update')(context, data_dict) From d0ac1c09b4a55ef2b440eb406a306d39eee57f06 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 5 Nov 2020 11:23:52 +0100 Subject: [PATCH 04/19] Move config from plugin to a separate module and make it to read the specific attributes from the CKAN config, add comments --- ckanext/saml2auth/plugin.py | 85 +++++++---------------------------- ckanext/saml2auth/spconfig.py | 55 +++++++++++++++++++++++ 2 files changed, 70 insertions(+), 70 deletions(-) create mode 100644 ckanext/saml2auth/spconfig.py diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index fb38ba01..491d6f53 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -1,15 +1,7 @@ # encoding: utf-8 +import logging -import os - -from saml2 import ( - BINDING_HTTP_POST, - BINDING_HTTP_REDIRECT, - entity -) -from saml2.saml import NAME_FORMAT_URI -from saml2.client import Saml2Client -from saml2.config import Config as Saml2Config +from saml2 import entity import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit @@ -21,66 +13,9 @@ from ckan.common import _, config, g, request from ckanext.saml2auth.views.saml2acs import saml2acs +from ckanext.saml2auth.spconfig import saml_client -CONFIG_PATH = os.path.dirname(__file__) - -BASE = 'http://localhost:5000/' - - -#TODO move into separate config file -def saml_client(): - - settings = { - 'entityid': 'urn:mace:umu.se:saml:ckan:sp', - 'description': 'CKAN saml2 authorizer', - 'service': { - 'sp': { - 'name': 'CKAN SP', - 'endpoints': { - 'assertion_consumer_service': [BASE + 'acs'], - 'single_logout_service': [(BASE + 'slo', - BINDING_HTTP_REDIRECT)], - }, - 'required_attributes': [ - 'uid', - 'name', - 'mail', - 'status', - 'field_display_name', - 'realname', - 'field_unique_id', - ], - 'allow_unsolicited': True, - 'optional_attributes': [], - 'idp': ['urn:mace:umu.se:saml:ckan:idp'], - } - }, - 'debug': 0, - 'metadata': { - 'local': [CONFIG_PATH + '/idp.xml'], - }, - 'contact_person': [{ - 'given_name': 'John', - 'sur_name': 'Smith', - 'email_address': ['john.smith@example.com'], - 'contact_type': 'technical', - }, - ], - 'name_form': NAME_FORMAT_URI, - 'logger': { - 'rotating': { - 'filename': '/tmp/sp.log', - 'maxBytes': 100000, - 'backupCount': 5, - }, - 'loglevel': 'error', - } - } - spConfig = Saml2Config() - spConfig.load(settings) - spConfig.allow_unknown_attributes = True - saml_client = Saml2Client(config=spConfig) - return saml_client +log = logging.getLogger(__name__) class Saml2AuthPlugin(plugins.SingletonPlugin): @@ -131,6 +66,8 @@ def identify(self): g.user = None g.userobj = None + # Check for user identification only if there is a SAML + # response which means only when SAML login is initiated if request.form.get('SAMLResponse', None): context = { @@ -145,11 +82,14 @@ def identify(self): auth_response.get_identity() user_info = auth_response.get_subject() + # SAML username - unique saml_id = user_info.text + # Required user attributes for user creation email = auth_response.ava[config.get('ckanext.saml2auth.user_email')][0] firstname = auth_response.ava[config.get('ckanext.saml2auth.user_firstname')][0] lastname = auth_response.ava[config.get('ckanext.saml2auth.user_lastname')][0] + # Check if CKAN user exists for the current SAML login user = model.Session.query(model.User)\ .filter(model.User.plugin_extras[('saml2auth', 'saml_id')].astext == saml_id)\ .first() @@ -170,7 +110,8 @@ def identify(self): else: model_dictize.user_dictize(user, context) - + # Update the existing CKAN user only if + # SAML user name or SAML user email are changed if email != user.email \ or firstname != user.fullname.split(' ')[0] \ or lastname != user.fullname.split(' ')[1]: @@ -182,7 +123,11 @@ def identify(self): logic.get_action(u'user_update')(context, data_dict) g.user = user.name + # Guess we don't need to set g.userobj because + # CKAN will set it if it's missing in the original identify_user() function # g.userobj = model.User.by_name(g.user) + + # log the user in programmatically resp = toolkit.redirect_to(u'user.me') set_repoze_user(g.user, resp) return resp diff --git a/ckanext/saml2auth/spconfig.py b/ckanext/saml2auth/spconfig.py new file mode 100644 index 00000000..cf77e164 --- /dev/null +++ b/ckanext/saml2auth/spconfig.py @@ -0,0 +1,55 @@ +# encoding: utf-8 +import os + +from saml2.saml import NAME_FORMAT_URI +from saml2.client import Saml2Client +from saml2.config import Config as Saml2Config + +from ckan.common import config as ckan_config + +CONFIG_PATH = os.path.dirname(__file__) +BASE = ckan_config.get('ckan.site_url') + +settings = { + 'entityid': 'urn:mace:umu.se:saml:ckan:sp', + 'description': 'CKAN saml2 authorizer', + 'service': { + 'sp': { + 'name': 'CKAN SP', + 'endpoints': { + 'assertion_consumer_service': [BASE + '/acs'] + }, + 'allow_unsolicited': True, + } + }, + 'debug': 0, + 'metadata': { + # TODO make the location to be read from ckan config + 'local': [CONFIG_PATH + '/idp.xml'], + }, + 'contact_person': [{ + 'given_name': 'John', + 'sur_name': 'Smith', + 'email_address': ['john.smith@example.com'], + 'contact_type': 'technical', + }, + ], + 'name_form': NAME_FORMAT_URI, + 'logger': { + 'rotating': { + 'filename': '/tmp/sp.log', + 'maxBytes': 100000, + 'backupCount': 5, + }, + 'loglevel': 'error', + } +} + + +def saml_client(): + + sp_config = Saml2Config() + sp_config.load(settings) + sp_config.allow_unknown_attributes = True + client = Saml2Client(config=sp_config) + return client From 11a28753828d7dcb2bff4727c647292c767ca331 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 5 Nov 2020 11:47:32 +0100 Subject: [PATCH 05/19] Add function for generating password --- ckanext/saml2auth/helpers.py | 9 +++++++++ ckanext/saml2auth/plugin.py | 16 ++++++++++++---- ckanext/saml2auth/spconfig.py | 1 - 3 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 ckanext/saml2auth/helpers.py diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py new file mode 100644 index 00000000..30425e27 --- /dev/null +++ b/ckanext/saml2auth/helpers.py @@ -0,0 +1,9 @@ +# encoding: utf-8 +import string +import secrets + + +def generate_password(): + alphabet = string.ascii_letters + string.digits + password = ''.join(secrets.choice(alphabet) for i in range(8)) + return password diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index 491d6f53..c7ff2143 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -14,6 +14,7 @@ from ckanext.saml2auth.views.saml2acs import saml2acs from ckanext.saml2auth.spconfig import saml_client +from ckanext.saml2auth.helpers import generate_password log = logging.getLogger(__name__) @@ -39,6 +40,11 @@ def configure(self, config): if not config.get(option, None): raise RuntimeError(missing_config.format(option)) + self.firstname = config.get('ckanext.saml2auth.user_firstname') + self.lastname = config.get('ckanext.saml2auth.user_lastname') + self.email = config.get('ckanext.saml2auth.user_email') + + # IBlueprint def get_blueprint(self): @@ -85,9 +91,9 @@ def identify(self): # SAML username - unique saml_id = user_info.text # Required user attributes for user creation - email = auth_response.ava[config.get('ckanext.saml2auth.user_email')][0] - firstname = auth_response.ava[config.get('ckanext.saml2auth.user_firstname')][0] - lastname = auth_response.ava[config.get('ckanext.saml2auth.user_lastname')][0] + email = auth_response.ava[self.email][0] + firstname = auth_response.ava[self.firstname][0] + lastname = auth_response.ava[self.lastname][0] # Check if CKAN user exists for the current SAML login user = model.Session.query(model.User)\ @@ -99,9 +105,11 @@ def identify(self): data_dict = {'name': _get_random_username_from_email(email), 'fullname': firstname + ' ' + lastname, 'email': email, - 'password': 'somestrongpass', + 'password': generate_password(), 'plugin_extras': { 'saml2auth': { + # Store the saml username + # in the corresponding CKAN user 'saml_id': saml_id } }} diff --git a/ckanext/saml2auth/spconfig.py b/ckanext/saml2auth/spconfig.py index cf77e164..b4cf55bb 100644 --- a/ckanext/saml2auth/spconfig.py +++ b/ckanext/saml2auth/spconfig.py @@ -47,7 +47,6 @@ def saml_client(): - sp_config = Saml2Config() sp_config.load(settings) sp_config.allow_unknown_attributes = True From 0b6e49aa0db35215194af417b0e34e662a82ea68 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 5 Nov 2020 11:58:31 +0100 Subject: [PATCH 06/19] Use string formating for user fullname --- ckanext/saml2auth/plugin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index c7ff2143..bf5ae672 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -103,7 +103,7 @@ def identify(self): if not user: data_dict = {'name': _get_random_username_from_email(email), - 'fullname': firstname + ' ' + lastname, + 'fullname': '{0} {1}'.format(firstname, lastname), 'email': email, 'password': generate_password(), 'plugin_extras': { @@ -120,12 +120,13 @@ def identify(self): model_dictize.user_dictize(user, context) # Update the existing CKAN user only if # SAML user name or SAML user email are changed + # in the identity provider if email != user.email \ or firstname != user.fullname.split(' ')[0] \ or lastname != user.fullname.split(' ')[1]: data_dict = {'id': user.id, - 'fullname': firstname + ' ' + lastname, + 'fullname': '{0} {1}'.format(firstname, lastname), 'email': email } logic.get_action(u'user_update')(context, data_dict) From 31c10a9e5de4449c5ad4ab5bd5c888b887eef874 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 5 Nov 2020 15:47:52 +0100 Subject: [PATCH 07/19] Add required dependencies and update docs --- README.rst | 38 +++++++++++++++++++++++++------------- dev-requirements.txt | 1 + requirements.txt | 1 - setup.py | 6 +----- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index 7e5e61cb..4bb8bb44 100644 --- a/README.rst +++ b/README.rst @@ -24,9 +24,9 @@ :target: https://pypi.org/project/ckanext-saml2auth/ :alt: License -============= +================== ckanext-saml2auth -============= +================== .. Put a description of your extension here: What does it do? What features does it have? @@ -51,19 +51,24 @@ Installation To install ckanext-saml2auth: -1. Activate your CKAN virtual environment, for example:: +1. Install the required packages:: + + sudo apt install xmlsec1 + + +2. Activate your CKAN virtual environment, for example:: . /usr/lib/ckan/default/bin/activate -2. Install the ckanext-saml2auth Python package into your virtual environment:: +3. Install the ckanext-saml2auth Python package into your virtual environment:: pip install ckanext-saml2auth -3. Add ``saml2auth`` to the ``ckan.plugins`` setting in your CKAN +4. Add ``saml2auth`` to the ``ckan.plugins`` setting in your CKAN config file (by default the config file is located at ``/etc/ckan/default/ckan.ini``). -4. Restart CKAN. For example if you've deployed CKAN with Apache on Ubuntu:: +5. Restart CKAN. For example if you've deployed CKAN with Apache on Ubuntu:: sudo service apache2 reload @@ -72,13 +77,18 @@ To install ckanext-saml2auth: Config settings --------------- -None at present +Required:: + -.. Document any optional config settings here. For example:: + # Corresponding SAML user field for firstname + ckanext.saml2auth.user_firstname = firstname + + # Corresponding SAML user field for lastname + ckanext.saml2auth.user_lastname = lastname + + # Corresponding SAML user field for email + ckanext.saml2auth.user_email = email -.. # The minimum number of hours to wait before re-checking a resource - # (optional, default: 24). - ckanext.saml2auth.some_setting = some_default_value ---------------------- @@ -88,6 +98,8 @@ Developer installation To install ckanext-saml2auth for development, activate your CKAN virtualenv and do:: + + sudo apt install xmlsec1 git clone https://github.com/duskobogdanovski/ckanext-saml2auth.git cd ckanext-saml2auth python setup.py develop @@ -108,9 +120,9 @@ To run the tests and produce a coverage report, first make sure you have pytest --ckan-ini=test.ini --cov=ckanext.saml2auth ----------------------------------------- +-------------------------------------------- Releasing a new version of ckanext-saml2auth ----------------------------------------- +-------------------------------------------- ckanext-saml2auth should be available on PyPI as https://pypi.org/project/ckanext-saml2auth. To publish a new version to PyPI follow these steps: diff --git a/dev-requirements.txt b/dev-requirements.txt index 66cb8c06..99b226ec 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1 +1,2 @@ flake8 # for the travis build +pysaml2 diff --git a/requirements.txt b/requirements.txt index 2a8575d5..4ee801cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -#sudo apt install xmlsec1 pysaml2 \ No newline at end of file diff --git a/setup.py b/setup.py index 831b0be4..3c809ce7 100644 --- a/setup.py +++ b/setup.py @@ -60,11 +60,7 @@ namespace_packages=['ckanext'], install_requires=[ - # CKAN extensions should not list dependencies here, but in a separate - # ``requirements.txt`` file. - # - # http://docs.ckan.org/en/latest/extensions/best-practices.html - # add-third-party-libraries-to-requirements-txt + 'pysaml2=6.3.0' ], # If there are data files included in your packages that need to be From 1b8b2fb18b8eab3e2d5974ffd6d451d21c5f6345 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 5 Nov 2020 23:11:43 +0100 Subject: [PATCH 08/19] Add config to enable default ckan login and register as fallback --- README.rst | 6 + ckanext/saml2auth/helpers.py | 8 + ckanext/saml2auth/plugin.py | 143 ++---------------- ckanext/saml2auth/templates/header.html | 13 ++ .../templates/user/snippets/login_form.html | 5 + ckanext/saml2auth/views/saml2acs.py | 12 -- ckanext/saml2auth/views/saml2auth.py | 123 +++++++++++++++ 7 files changed, 168 insertions(+), 142 deletions(-) create mode 100644 ckanext/saml2auth/templates/header.html create mode 100644 ckanext/saml2auth/templates/user/snippets/login_form.html delete mode 100644 ckanext/saml2auth/views/saml2acs.py create mode 100644 ckanext/saml2auth/views/saml2auth.py diff --git a/README.rst b/README.rst index 4bb8bb44..3fdb0310 100644 --- a/README.rst +++ b/README.rst @@ -90,6 +90,12 @@ Required:: ckanext.saml2auth.user_email = email +Optional:: + + # Configuration setting that enables CKAN's default register/login functionality as well + # Default: False + ckanext.saml2auth.enable_default_login = True + ---------------------- Developer installation diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index 30425e27..74b52144 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -2,8 +2,16 @@ import string import secrets +from ckan.common import config, asbool + def generate_password(): alphabet = string.ascii_letters + string.digits password = ''.join(secrets.choice(alphabet) for i in range(8)) return password + + +def is_default_login_enabled(): + return asbool( + config.get('ckanext.saml2auth.enable_default_login', + False)) diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index bf5ae672..720fb051 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -1,29 +1,24 @@ # encoding: utf-8 -import logging - -from saml2 import entity - import ckan.plugins as plugins 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.views.user import set_repoze_user -from ckan.logic.action.create import _get_random_username_from_email -from ckan.common import _, config, g, request -from ckanext.saml2auth.views.saml2acs import saml2acs -from ckanext.saml2auth.spconfig import saml_client -from ckanext.saml2auth.helpers import generate_password - -log = logging.getLogger(__name__) +from ckanext.saml2auth.views.saml2auth import saml2auth +from ckanext.saml2auth import helpers as h class Saml2AuthPlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) - plugins.implements(plugins.IAuthenticator) plugins.implements(plugins.IBlueprint) plugins.implements(plugins.IConfigurable) + plugins.implements(plugins.ITemplateHelpers) + + # ITemplateHelpers + + def get_helpers(self): + return { + 'is_default_login_enabled': + h.is_default_login_enabled + } # IConfigurable @@ -40,126 +35,14 @@ def configure(self, config): if not config.get(option, None): raise RuntimeError(missing_config.format(option)) - self.firstname = config.get('ckanext.saml2auth.user_firstname') - self.lastname = config.get('ckanext.saml2auth.user_lastname') - self.email = config.get('ckanext.saml2auth.user_email') - - # IBlueprint def get_blueprint(self): - return [saml2acs] + return [saml2auth] # IConfigurer def update_config(self, config_): toolkit.add_template_directory(config_, 'templates') toolkit.add_public_directory(config_, 'public') - toolkit.add_resource('fanstatic', - 'saml2auth') - - # IAuthenticator - - def identify(self): - u'''Called to identify the user. - - If the user is identified then it should set: - - - g.user: The name of the user - - g.userobj: The actual user object - ''' - - g.user = None - g.userobj = None - - # Check for user identification only if there is a SAML - # response which means only when SAML login is initiated - if request.form.get('SAMLResponse', None): - - context = { - u'ignore_auth': True, - u'model': model - } - - client = saml_client() - auth_response = client.parse_authn_request_response( - request.form.get('SAMLResponse', None), - entity.BINDING_HTTP_POST) - auth_response.get_identity() - user_info = auth_response.get_subject() - - # SAML username - unique - saml_id = user_info.text - # Required user attributes for user creation - email = auth_response.ava[self.email][0] - firstname = auth_response.ava[self.firstname][0] - lastname = auth_response.ava[self.lastname][0] - - # Check if CKAN user exists for the current SAML login - user = model.Session.query(model.User)\ - .filter(model.User.plugin_extras[('saml2auth', 'saml_id')].astext == saml_id)\ - .first() - - if not user: - - data_dict = {'name': _get_random_username_from_email(email), - 'fullname': '{0} {1}'.format(firstname, lastname), - 'email': email, - 'password': generate_password(), - 'plugin_extras': { - 'saml2auth': { - # Store the saml username - # in the corresponding CKAN user - 'saml_id': saml_id - } - }} - g.user = logic.get_action(u'user_create')(context, data_dict)['name'] - - else: - - model_dictize.user_dictize(user, context) - # Update the existing CKAN user only if - # SAML user name or SAML user email are changed - # in the identity provider - if email != user.email \ - or firstname != user.fullname.split(' ')[0] \ - or lastname != user.fullname.split(' ')[1]: - - data_dict = {'id': user.id, - 'fullname': '{0} {1}'.format(firstname, lastname), - 'email': email - } - logic.get_action(u'user_update')(context, data_dict) - g.user = user.name - - # Guess we don't need to set g.userobj because - # CKAN will set it if it's missing in the original identify_user() function - # g.userobj = model.User.by_name(g.user) - - # log the user in programmatically - resp = toolkit.redirect_to(u'user.me') - set_repoze_user(g.user, resp) - return resp - - def login(self): - u'''Called before the login starts (that is before asking the user for - user name and a password in the default authentication). - ''' - client = saml_client() - reqid, info = client.prepare_for_authenticate() - - redirect_url = None - for key, value in info['headers']: - if key is 'Location': - redirect_url = value - return toolkit.redirect_to(redirect_url) - - def logout(self): - u'''Called before the logout starts (that is before clicking the logout - button in the default authentication). - ''' - - def abort(self, status_code, detail, headers, comment): - u'''Called on abort. This allows aborts due to authorization issues - to be overridden''' - return (status_code, detail, headers, comment) + toolkit.add_resource('fanstatic', 'saml2auth') diff --git a/ckanext/saml2auth/templates/header.html b/ckanext/saml2auth/templates/header.html new file mode 100644 index 00000000..ff68a176 --- /dev/null +++ b/ckanext/saml2auth/templates/header.html @@ -0,0 +1,13 @@ +{% ckan_extends %} +{% block header_account_notlogged %} + + {% if h.is_default_login_enabled() %} +
  • {% link_for _('Log in'), named_route='user.login' %}
  • + {% if h.check_access('user_create') %} +
  • {% link_for _('Register'), named_route='user.register', class_='sub' %}
  • + {% endif %} + {% else %} +
  • {% link_for _('Log in'), named_route='saml2auth.saml2login' %}
  • + {% endif %} + +{% endblock %} \ No newline at end of file diff --git a/ckanext/saml2auth/templates/user/snippets/login_form.html b/ckanext/saml2auth/templates/user/snippets/login_form.html new file mode 100644 index 00000000..174ee58e --- /dev/null +++ b/ckanext/saml2auth/templates/user/snippets/login_form.html @@ -0,0 +1,5 @@ +{% ckan_extends %} +{% block login_button %} + + {{ _('SSO') }} +{% endblock %} \ No newline at end of file diff --git a/ckanext/saml2auth/views/saml2acs.py b/ckanext/saml2auth/views/saml2acs.py deleted file mode 100644 index 8e50188f..00000000 --- a/ckanext/saml2auth/views/saml2acs.py +++ /dev/null @@ -1,12 +0,0 @@ -# encoding: utf-8 -from flask import Blueprint -import ckan.plugins.toolkit as toolkit - -saml2acs = Blueprint(u'saml2acs', __name__) - - -def acs(): - return toolkit.redirect_to('home.index') - - -saml2acs.add_url_rule(u'/acs', view_func=acs, methods=[u'GET', u'POST']) diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py new file mode 100644 index 00000000..4b631cae --- /dev/null +++ b/ckanext/saml2auth/views/saml2auth.py @@ -0,0 +1,123 @@ +# encoding: utf-8 +from flask import Blueprint +from saml2 import entity + +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 +from ckan.logic.action.create import _get_random_username_from_email +from ckan.common import _, config, g, request, asbool + +from ckanext.saml2auth.spconfig import saml_client +from ckanext.saml2auth import helpers as h + + +saml2auth = Blueprint(u'saml2auth', __name__) + + +def acs(): + u'''The location where the SAML assertion is sent with a HTTP POST. + This is often referred to as the SAML Assertion Consumer Service (ACS) URL. + ''' + g.user = None + g.userobj = None + + context = { + u'ignore_auth': True, + u'model': model + } + + saml_user_firstname = \ + config.get(u'ckanext.saml2auth.user_firstname') + saml_user_lastname = \ + config.get(u'ckanext.saml2auth.user_lastname') + saml_user_email = \ + config.get(u'ckanext.saml2auth.user_email') + + client = saml_client() + auth_response = client.parse_authn_request_response( + request.form.get(u'SAMLResponse', None), + entity.BINDING_HTTP_POST) + auth_response.get_identity() + user_info = auth_response.get_subject() + + # SAML username - unique + saml_id = user_info.text + # Required user attributes for user creation + email = auth_response.ava[saml_user_email][0] + firstname = auth_response.ava[saml_user_firstname][0] + lastname = auth_response.ava[saml_user_lastname][0] + + # Check if CKAN user exists for the current SAML login + user = model.Session.query(model.User) \ + .filter(model.User.plugin_extras[(u'saml2auth', u'saml_id')].astext == saml_id) \ + .first() + + if not user: + data_dict = {u'name': _get_random_username_from_email(email), + u'fullname': u'{0} {1}'.format(firstname, lastname), + u'email': email, + u'password': h.generate_password(), + u'plugin_extras': { + u'saml2auth': { + # Store the saml username + # in the corresponding CKAN user + u'saml_id': saml_id + } + }} + g.user = logic.get_action(u'user_create')(context, data_dict)[u'name'] + else: + model_dictize.user_dictize(user, context) + # Update the existing CKAN user only if + # SAML user name or SAML user email are changed + # in the identity provider + if email != user.email \ + or firstname != user.fullname.split(' ')[0] \ + or lastname != user.fullname.split(' ')[1]: + data_dict = {u'id': user.id, + u'fullname': u'{0} {1}'.format(firstname, lastname), + u'email': email + } + logic.get_action(u'user_update')(context, data_dict) + g.user = user.name + + g.userobj = model.User.by_name(g.user) + # log the user in programmatically + resp = toolkit.redirect_to(u'user.me') + set_repoze_user(g.user, resp) + return resp + + +def saml2login(): + u'''Redirects the user to the + configured identity provider for authentication + ''' + client = saml_client() + reqid, info = client.prepare_for_authenticate() + + redirect_url = None + for key, value in info[u'headers']: + if key is u'Location': + redirect_url = value + return toolkit.redirect_to(redirect_url) + + +def disable_default_login_register(): + u'''View function used to + override and disable default Register/Login routes + ''' + extra_vars = {u'code': [403], u'content': u'This resource is forbidden ' + u'by the system administrator.'} + return base.render(u'error_document_template.html', extra_vars), 403 + + +saml2auth.add_url_rule(u'/acs', view_func=acs, methods=[u'GET', u'POST']) +saml2auth.add_url_rule(u'/user/saml2login', view_func=saml2login) +if not h.is_default_login_enabled(): + saml2auth.add_url_rule( + u'/user/login', view_func=disable_default_login_register) + saml2auth.add_url_rule( + u'/user/register', view_func=disable_default_login_register) From 5db5ed502b5584a784ef325f8552fa35da2cc882 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Fri, 6 Nov 2020 11:42:07 +0100 Subject: [PATCH 09/19] Update gonfig setting name --- README.rst | 4 ++-- ckanext/saml2auth/helpers.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 3fdb0310..b43e6126 100644 --- a/README.rst +++ b/README.rst @@ -92,9 +92,9 @@ Required:: Optional:: - # Configuration setting that enables CKAN's default register/login functionality as well + # Configuration setting that enables CKAN's internal register/login functionality as well # Default: False - ckanext.saml2auth.enable_default_login = True + ckanext.saml2auth.enable_ckan_internal_login = True ---------------------- diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index 74b52144..7da49e78 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -13,5 +13,5 @@ def generate_password(): def is_default_login_enabled(): return asbool( - config.get('ckanext.saml2auth.enable_default_login', + config.get('ckanext.saml2auth.enable_ckan_internal_login', False)) From 63ca584f86b9684c74bd96e92c4246650f854441 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Fri, 6 Nov 2020 18:04:17 +0100 Subject: [PATCH 10/19] Add exception handling on create and update user --- ckanext/saml2auth/views/saml2auth.py | 33 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 4b631cae..7bdb02c2 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -27,6 +27,7 @@ def acs(): context = { u'ignore_auth': True, + u'keep_email': True, u'model': model } @@ -68,21 +69,27 @@ def acs(): u'saml_id': saml_id } }} - g.user = logic.get_action(u'user_create')(context, data_dict)[u'name'] + try: + g.user = 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) + else: - model_dictize.user_dictize(user, context) + user_dict = model_dictize.user_dictize(user, context) # Update the existing CKAN user only if # SAML user name or SAML user email are changed # in the identity provider - if email != user.email \ - or firstname != user.fullname.split(' ')[0] \ - or lastname != user.fullname.split(' ')[1]: - data_dict = {u'id': user.id, - u'fullname': u'{0} {1}'.format(firstname, lastname), - u'email': email - } - logic.get_action(u'user_update')(context, data_dict) - g.user = user.name + if email != user_dict['email'] \ + or u'{0} {1}'.format(firstname, lastname) != user_dict['fullname']: + user_dict['email'] = email + user_dict['fullname'] = u'{0} {1}'.format(firstname, lastname) + 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) + g.user = user_dict['name'] g.userobj = model.User.by_name(g.user) # log the user in programmatically @@ -110,7 +117,9 @@ def disable_default_login_register(): override and disable default Register/Login routes ''' extra_vars = {u'code': [403], u'content': u'This resource is forbidden ' - u'by the system administrator.'} + u'by the system administrator. ' + u'Only SSO through SAML2 authorization' + u' is available at this moment.'} return base.render(u'error_document_template.html', extra_vars), 403 From ddd28682ae94e524b9c8ecf830f1320a794d064d Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Fri, 6 Nov 2020 21:23:03 +0100 Subject: [PATCH 11/19] Add functionality that makes users sysadmins if user email is found in a previously configured list of email addresses and oposite --- README.rst | 3 +++ ckanext/saml2auth/helpers.py | 21 ++++++++++++++++++++- ckanext/saml2auth/views/saml2auth.py | 4 ++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b43e6126..07cff78d 100644 --- a/README.rst +++ b/README.rst @@ -96,6 +96,9 @@ Optional:: # Default: False ckanext.saml2auth.enable_ckan_internal_login = True + # List of email addresses from users that should be created as sysadmins (system administrators) + ckanext.saml2auth.sysadmins_list = mail@domain.com mail2@domain.com mail3@domain.com + ---------------------- Developer installation diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index 7da49e78..8a1400a8 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -1,8 +1,11 @@ # encoding: utf-8 import string import secrets +from six import text_type -from ckan.common import config, asbool +import ckan.model as model +import ckan.authz as authz +from ckan.common import config, asbool, aslist def generate_password(): @@ -15,3 +18,19 @@ def is_default_login_enabled(): return asbool( config.get('ckanext.saml2auth.enable_ckan_internal_login', False)) + + +def update_user_sysadmin_status(username, email): + sysadmins_list = aslist( + config.get('ckanext.saml2auth.sysadmins_list')) + user = model.User.by_name(text_type(username)) + sysadmin = authz.is_sysadmin(username) + + if sysadmin and email not in sysadmins_list: + user.sysadmin = False + model.Session.add(user) + model.Session.commit() + elif not sysadmin and email in sysadmins_list: + user.sysadmin = True + model.Session.add(user) + model.Session.commit() diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 7bdb02c2..d90b08cb 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -91,6 +91,10 @@ def acs(): base.abort(400, error_message) g.user = user_dict['name'] + # If user email is in given list of emails + # make that user sysadmin and opposite + h.update_user_sysadmin_status(g.user, email) + g.userobj = model.User.by_name(g.user) # log the user in programmatically resp = toolkit.redirect_to(u'user.me') From f9478d748b0802a07856c9d12c66993ce62da6b4 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Mon, 9 Nov 2020 14:59:43 +0100 Subject: [PATCH 12/19] Add additional configuration parameters --- README.rst | 24 +++++++++++ ckanext/saml2auth/plugin.py | 1 + ckanext/saml2auth/spconfig.py | 79 +++++++++++++++++++++-------------- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/README.rst b/README.rst index 07cff78d..ada260f6 100644 --- a/README.rst +++ b/README.rst @@ -79,6 +79,21 @@ Config settings Required:: + # Specifies the metadata location type + # Options: local or remote + ckanext.saml2auth.idp_metadata.location = remote + + # Path to a local file accessible on the server the service runs on + # Ignore this config if the idp metadata location is set to: remote + ckanext.saml2auth.idp_metadata.local_path = /opt/metadata/idp.xml + + # A remote URL serving aggregate metadata + # Ignore this config if the idp metadata location is set to: local + ckanext.saml2auth.idp_metadata.remote_url = https://kalmar2.org/simplesaml/module.php/aggregator/?id=kalmarcentral2&set=saml2 + + # Path to a local file accessible on the server the service runs on + # Ignore this config if the idp metadata location is set to: local + ckanext.saml2auth.idp_metadata.remote_cert = /opt/metadata/kalmar2.cert # Corresponding SAML user field for firstname ckanext.saml2auth.user_firstname = firstname @@ -99,6 +114,15 @@ Optional:: # List of email addresses from users that should be created as sysadmins (system administrators) ckanext.saml2auth.sysadmins_list = mail@domain.com mail2@domain.com mail3@domain.com + # Indicates that attributes that are not recognized (they are not configured in attribute-mapping), + # will not be discarded. + # Default: False + ckanext.saml2auth.allow_unknown_attributes = True + + # A list of string values that will be used to set the element of the metadata of an entity. + # Default: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + ckanext.saml2auth.sp.name_id_format = urn:oasis:names:tc:SAML:2.0:nameid-format:persistent urn:oasis:names:tc:SAML:2.0:nameid-format:transient + ---------------------- Developer installation diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index 720fb051..4f0bfe49 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -27,6 +27,7 @@ def configure(self, config): # exception if they're missing. missing_config = "{0} is not configured. Please amend your .ini file." config_options = ( + 'ckanext.saml2auth.idp_metadata.local_path', 'ckanext.saml2auth.user_firstname', 'ckanext.saml2auth.user_lastname', 'ckanext.saml2auth.user_email' diff --git a/ckanext/saml2auth/spconfig.py b/ckanext/saml2auth/spconfig.py index b4cf55bb..e9c50d6e 100644 --- a/ckanext/saml2auth/spconfig.py +++ b/ckanext/saml2auth/spconfig.py @@ -6,45 +6,62 @@ from saml2.config import Config as Saml2Config from ckan.common import config as ckan_config +from ckan.common import asbool, aslist -CONFIG_PATH = os.path.dirname(__file__) BASE = ckan_config.get('ckan.site_url') +DEBUG = asbool(ckan_config.get('debug')) + +ALLOW_UNKNOWN_ATTRIBUTES = \ + ckan_config.get(u'ckanext.saml2auth.allow_unknown_attributes', False) + +NAME_ID_FORMAT = \ + aslist(ckan_config.get(u'ckanext.saml2auth.sp.name_id_format', + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent")) +METADATA_LOCATION = \ + ckan_config.get(u'ckanext.saml2auth.idp_metadata.location') + +METADATA_LOCAL_PATH = \ + ckan_config.get(u'ckanext.saml2auth.idp_metadata.local_path') + +METADATA_REMOTE_URL = \ + ckan_config.get(u'ckanext.saml2auth.idp_metadata.remote_url') + +# Consider different name +METADATA_REMOTE_CERT = \ + ckan_config.get(u'ckanext.saml2auth.idp_metadata.remote_cert') + + settings = { - 'entityid': 'urn:mace:umu.se:saml:ckan:sp', - 'description': 'CKAN saml2 authorizer', - 'service': { - 'sp': { - 'name': 'CKAN SP', - 'endpoints': { - 'assertion_consumer_service': [BASE + '/acs'] - }, - 'allow_unsolicited': True, - } - }, - 'debug': 0, - 'metadata': { - # TODO make the location to be read from ckan config - 'local': [CONFIG_PATH + '/idp.xml'], - }, - 'contact_person': [{ - 'given_name': 'John', - 'sur_name': 'Smith', - 'email_address': ['john.smith@example.com'], - 'contact_type': 'technical', - }, - ], - 'name_form': NAME_FORMAT_URI, - 'logger': { - 'rotating': { - 'filename': '/tmp/sp.log', - 'maxBytes': 100000, - 'backupCount': 5, + u'entityid': u'urn:mace:umu.se:saml:ckan:sp', + u'description': u'CKAN saml2 Service Provider', + # Set True if eg.Azure or Microsoft Idp used + u'allow_unknown_attributes': ALLOW_UNKNOWN_ATTRIBUTES, + u'service': { + u'sp': { + u'name': u'CKAN SP', + u'endpoints': { + u'assertion_consumer_service': [BASE + u'/acs'] }, - 'loglevel': 'error', + u'allow_unsolicited': True, + u"name_id_policy_format": NAME_ID_FORMAT, + u"name_id_format": NAME_ID_FORMAT, } + }, + u'metadata': {}, + u'debug': 1 if DEBUG else 0, + u'name_form': NAME_FORMAT_URI } +if METADATA_LOCATION == u'local': + settings[u'metadata'][u'local'] = [METADATA_LOCAL_PATH] +elif METADATA_LOCATION == u'remote': + remote = [{ + u'url': METADATA_REMOTE_URL, + u'cert': METADATA_REMOTE_CERT + }] + settings[u'metadata'][u'remote'] = remote + def saml_client(): sp_config = Saml2Config() From 096cb8a11f6d6bc657b674b4bf9e1914aac6eace Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Tue, 10 Nov 2020 17:21:38 +0100 Subject: [PATCH 13/19] When internal login enabled there might be a regular CKAN user with same email like the incoming SAML user, in this case use that existing CKAN user and link it to the SAML user --- ckanext/saml2auth/helpers.py | 12 +++++ ckanext/saml2auth/views/saml2auth.py | 70 +++++++++++++++++++--------- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index 8a1400a8..d6b85e1e 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -1,4 +1,5 @@ # encoding: utf-8 +import logging import string import secrets from six import text_type @@ -7,6 +8,8 @@ import ckan.authz as authz from ckan.common import config, asbool, aslist +log = logging.getLogger(__name__) + def generate_password(): alphabet = string.ascii_letters + string.digits @@ -34,3 +37,12 @@ def update_user_sysadmin_status(username, email): user.sysadmin = True model.Session.add(user) model.Session.commit() + + +def activate_user_if_deleted(userobj): + u'''Reactivates deleted user.''' + if userobj.is_deleted(): + userobj.activate() + userobj.commit() + log.info(u'User {} reactivated'.format(userobj.name)) + diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index d90b08cb..d7d81432 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -52,32 +52,60 @@ def acs(): firstname = auth_response.ava[saml_user_firstname][0] lastname = auth_response.ava[saml_user_lastname][0] - # Check if CKAN user exists for the current SAML login - user = model.Session.query(model.User) \ + # Check if CKAN-SAML user exists for the current SAML login + saml_user = model.Session.query(model.User) \ .filter(model.User.plugin_extras[(u'saml2auth', u'saml_id')].astext == saml_id) \ .first() - if not user: - data_dict = {u'name': _get_random_username_from_email(email), - u'fullname': u'{0} {1}'.format(firstname, lastname), - u'email': email, - u'password': h.generate_password(), - u'plugin_extras': { - u'saml2auth': { - # Store the saml username - # in the corresponding CKAN user - u'saml_id': saml_id - } - }} - try: - g.user = 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) + # First we check if there is a SAML-CKAN user + if not saml_user: + # 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)[0] + if ckan_user: + # If account exists and is deleted, reactivate it. + h.activate_user_if_deleted(ckan_user) + + ckan_user_dict = model_dictize.user_dictize(ckan_user, 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 + } + } + g.user = 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) + else: + data_dict = {u'name': _get_random_username_from_email(email), + u'fullname': u'{0} {1}'.format(firstname, lastname), + u'email': email, + u'password': h.generate_password(), + u'plugin_extras': { + u'saml2auth': { + # Store the saml username + # in the corresponding CKAN user + u'saml_id': saml_id + } + }} + try: + g.user = 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) else: - user_dict = model_dictize.user_dictize(user, context) - # Update the existing CKAN user only if + # 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'] \ From fb3f8ab883d14fc5e2780448b171fbe83cd7a51f Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Wed, 11 Nov 2020 15:15:19 +0100 Subject: [PATCH 14/19] Code refactoring --- README.rst | 3 +- ckanext/saml2auth/helpers.py | 10 +++++ ckanext/saml2auth/spconfig.py | 20 ++-------- ckanext/saml2auth/views/saml2auth.py | 8 ++-- test.ini | 59 ++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 test.ini diff --git a/README.rst b/README.rst index ada260f6..678d2753 100644 --- a/README.rst +++ b/README.rst @@ -37,8 +37,7 @@ ckanext-saml2auth Requirements ------------ -For example, you might want to mention here which versions of CKAN this -extension works with. +This extension works with CKAN 2.9+. ------------ diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index d6b85e1e..489ac49f 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -4,6 +4,9 @@ import secrets from six import text_type +from saml2.client import Saml2Client +from saml2.config import Config as Saml2Config + import ckan.model as model import ckan.authz as authz from ckan.common import config, asbool, aslist @@ -11,6 +14,13 @@ log = logging.getLogger(__name__) +def saml_client(config): + sp_config = Saml2Config() + sp_config.load(config) + client = Saml2Client(config=sp_config) + return client + + def generate_password(): alphabet = string.ascii_letters + string.digits password = ''.join(secrets.choice(alphabet) for i in range(8)) diff --git a/ckanext/saml2auth/spconfig.py b/ckanext/saml2auth/spconfig.py index e9c50d6e..dd0764ed 100644 --- a/ckanext/saml2auth/spconfig.py +++ b/ckanext/saml2auth/spconfig.py @@ -1,9 +1,5 @@ # encoding: utf-8 -import os - from saml2.saml import NAME_FORMAT_URI -from saml2.client import Saml2Client -from saml2.config import Config as Saml2Config from ckan.common import config as ckan_config from ckan.common import asbool, aslist @@ -13,7 +9,7 @@ DEBUG = asbool(ckan_config.get('debug')) ALLOW_UNKNOWN_ATTRIBUTES = \ - ckan_config.get(u'ckanext.saml2auth.allow_unknown_attributes', False) + ckan_config.get(u'ckanext.saml2auth.allow_unknown_attributes', True) NAME_ID_FORMAT = \ aslist(ckan_config.get(u'ckanext.saml2auth.sp.name_id_format', @@ -32,7 +28,7 @@ ckan_config.get(u'ckanext.saml2auth.idp_metadata.remote_cert') -settings = { +config = { u'entityid': u'urn:mace:umu.se:saml:ckan:sp', u'description': u'CKAN saml2 Service Provider', # Set True if eg.Azure or Microsoft Idp used @@ -54,18 +50,10 @@ } if METADATA_LOCATION == u'local': - settings[u'metadata'][u'local'] = [METADATA_LOCAL_PATH] + config[u'metadata'][u'local'] = [METADATA_LOCAL_PATH] elif METADATA_LOCATION == u'remote': remote = [{ u'url': METADATA_REMOTE_URL, u'cert': METADATA_REMOTE_CERT }] - settings[u'metadata'][u'remote'] = remote - - -def saml_client(): - sp_config = Saml2Config() - sp_config.load(settings) - sp_config.allow_unknown_attributes = True - client = Saml2Client(config=sp_config) - return client + config[u'metadata'][u'remote'] = remote diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index d7d81432..6cf676ba 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -11,7 +11,7 @@ from ckan.logic.action.create import _get_random_username_from_email from ckan.common import _, config, g, request, asbool -from ckanext.saml2auth.spconfig import saml_client +from ckanext.saml2auth.spconfig import config as sp_config from ckanext.saml2auth import helpers as h @@ -38,7 +38,7 @@ def acs(): saml_user_email = \ config.get(u'ckanext.saml2auth.user_email') - client = saml_client() + client = h.saml_client(sp_config) auth_response = client.parse_authn_request_response( request.form.get(u'SAMLResponse', None), entity.BINDING_HTTP_POST) @@ -67,7 +67,7 @@ def acs(): if ckan_user: # If account exists and is deleted, reactivate it. h.activate_user_if_deleted(ckan_user) - + ckan_user_dict = model_dictize.user_dictize(ckan_user, context) try: ckan_user_dict[u'password'] = h.generate_password() @@ -134,7 +134,7 @@ def saml2login(): u'''Redirects the user to the configured identity provider for authentication ''' - client = saml_client() + client = h.saml_client(sp_config) reqid, info = client.prepare_for_authenticate() redirect_url = None diff --git a/test.ini b/test.ini new file mode 100644 index 00000000..e467aa05 --- /dev/null +++ b/test.ini @@ -0,0 +1,59 @@ +[DEFAULT] +debug = false +smtp_server = localhost +error_email_from = paste@localhost + +[server:main] +use = egg:Paste#http +host = 0.0.0.0 +port = 5000 + +[app:main] +use = config:../ckan/test-core.ini + +# Insert any custom config settings to be used when running your extension's +# tests here. + +## ckanext-saml2auth settings + +ckanext.saml2auth.idp_metadata.location = local +ckanext.saml2auth.idp_metadata.local_path = /home/dule/keitaro/ckan29/src/ckanext-saml2auth/ckanext/saml2auth/idp.xml +ckanext.saml2auth.user_firstname = name +ckanext.saml2auth.user_lastname = lastname +ckanext.saml2auth.user_email = email +ckanext.saml2auth.enable_ckan_internal_login = true +ckanext.saml2auth.sysadmins_list = dule@keitaro.com dule.bogdanovski@gmail.com dusko.bogdanovski@keitaro.com + + +# Logging configuration +[loggers] +keys = root, ckan, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_ckan] +qualname = ckan +handlers = +level = INFO + +[logger_sqlalchemy] +handlers = +qualname = sqlalchemy.engine +level = WARN + +[handler_console] +class = StreamHandler +args = (sys.stdout,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s \ No newline at end of file From 0ca223876110a133de387f3f50efaa040502a3b4 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 12 Nov 2020 17:41:13 +0100 Subject: [PATCH 15/19] Add tests --- README.rst | 4 +- ckanext/saml2auth/tests/test_blueprint.py | 45 ++++++++++++ ckanext/saml2auth/tests/test_helpers.py | 89 +++++++++++++++++++++++ ckanext/saml2auth/tests/test_plugin.py | 5 -- conftest.py | 6 ++ test.ini | 11 ++- 6 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 ckanext/saml2auth/tests/test_blueprint.py create mode 100644 ckanext/saml2auth/tests/test_helpers.py delete mode 100644 ckanext/saml2auth/tests/test_plugin.py create mode 100644 conftest.py diff --git a/README.rst b/README.rst index 678d2753..0c536a67 100644 --- a/README.rst +++ b/README.rst @@ -115,8 +115,8 @@ Optional:: # Indicates that attributes that are not recognized (they are not configured in attribute-mapping), # will not be discarded. - # Default: False - ckanext.saml2auth.allow_unknown_attributes = True + # Default: True + ckanext.saml2auth.allow_unknown_attributes = False # A list of string values that will be used to set the element of the metadata of an entity. # Default: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent diff --git a/ckanext/saml2auth/tests/test_blueprint.py b/ckanext/saml2auth/tests/test_blueprint.py new file mode 100644 index 00000000..869505d5 --- /dev/null +++ b/ckanext/saml2auth/tests/test_blueprint.py @@ -0,0 +1,45 @@ +# encoding: utf-8 +import pytest + +import ckan.tests.factories as factories +import ckan.tests.helpers as helpers +from ckan import model +from ckan.lib.helpers import url_for + + +@pytest.mark.usefixtures('clean_db', 'clean_index') +@pytest.mark.ckan_config('ckan.plugins', 'saml2auth') +class TestBlueprint(object): + + def test_user_register_disabled_by_default(self, app): + url = url_for("user.register") + response = app.get(url=url) + assert 403 == response.status_code + + assert u'This resource is forbidden' \ + u' by the system administrator. ' \ + u'Only SSO through SAML2 authorization ' \ + u'is available at this moment.' in response + + def test_internal_user_login_disabled_by_deafult(self, app): + url = url_for("user.login") + response = app.get(url=url) + assert 403 == response.status_code + + assert u'This resource is forbidden' \ + u' by the system administrator. ' \ + u'Only SSO through SAML2 authorization ' \ + u'is available at this moment.' in response + + # @pytest.mark.ckan_config(u'ckanext.saml2auth.enable_ckan_internal_login', 'true') + # def test_user_register_enabled(self, monkeypatch, make_app, ckan_config): + # monkeypatch.setitem(ckan_config, u'ckanext.saml2auth.enable_ckan_internal_login', True) + # url = url_for("user.register") + # app = make_app() + # response = app.get(url=url) + # assert 200 == response.status_code + + # def test_saml2_login(self, app): + # url = url_for("saml2auth.saml2login") + # response = app.get(url=url) + # assert 302 == response.status_code diff --git a/ckanext/saml2auth/tests/test_helpers.py b/ckanext/saml2auth/tests/test_helpers.py new file mode 100644 index 00000000..4e06a207 --- /dev/null +++ b/ckanext/saml2auth/tests/test_helpers.py @@ -0,0 +1,89 @@ +# encoding: utf-8 +import pytest + +import ckan.authz as authz +import ckan.model as model +import ckan.tests.factories as factories +import ckan.tests.helpers as helpers + +from ckanext.saml2auth import helpers as h + + +def test_generate_password(): + password = h.generate_password() + assert len(password) == 8 + assert type(password) == str + + +def test_default_login_disabled_by_default(): + assert not h.is_default_login_enabled() + + +@pytest.mark.ckan_config('ckanext.saml2auth.enable_ckan_internal_login', True) +def test_default_login_enabled(): + assert h.is_default_login_enabled() + + +@pytest.mark.usefixtures('clean_db', 'clean_index') +@pytest.mark.ckan_config('ckanext.saml2auth.sysadmins_list', '') +def test_00_update_user_sysadmin_status_continue_as_regular(): + + user = factories.User(email='useroneemail@example.com') + h.update_user_sysadmin_status(user['name'], user['email']) + user_show = helpers.call_action("user_show", id=user["id"]) + is_sysadmin = authz.is_sysadmin(user_show['name']) + + assert not is_sysadmin + + +@pytest.mark.usefixtures('clean_db', 'clean_index') +@pytest.mark.ckan_config('ckanext.saml2auth.sysadmins_list', + 'useroneemail@example.com') +def test_01_update_user_sysadmin_status_make_sysadmin(): + + user = factories.User(email='useroneemail@example.com') + h.update_user_sysadmin_status(user['name'], user['email']) + user_show = helpers.call_action("user_show", id=user["id"]) + is_sysadmin = authz.is_sysadmin(user_show['name']) + + assert is_sysadmin + + +@pytest.mark.usefixtures('clean_db', 'clean_index') +@pytest.mark.ckan_config('ckanext.saml2auth.sysadmins_list', '') +def test_02_update_user_sysadmin_status_remove_sysadmin_role(): + + user = factories.Sysadmin(email='useroneemail@example.com') + h.update_user_sysadmin_status(user['name'], user['email']) + user_show = helpers.call_action("user_show", id=user["id"]) + is_sysadmin = authz.is_sysadmin(user_show['name']) + + assert not is_sysadmin + + +@pytest.mark.usefixtures('clean_db', 'clean_index') +@pytest.mark.ckan_config('ckanext.saml2auth.sysadmins_list', + 'useroneemail@example.com') +def test_03_update_user_sysadmin_status_continue_as_sysadmin(): + + user = factories.Sysadmin(email='useroneemail@example.com') + h.update_user_sysadmin_status(user['name'], user['email']) + user_show = helpers.call_action("user_show", id=user["id"]) + is_sysadmin = authz.is_sysadmin(user_show['name']) + + assert is_sysadmin + + +@pytest.mark.usefixtures('clean_db', 'clean_index') +def test_activate_user_if_deleted(): + user = factories.User() + user = model.User.get(user["name"]) + user.delete() + h.activate_user_if_deleted(user) + assert not user.is_deleted() + + + + + + diff --git a/ckanext/saml2auth/tests/test_plugin.py b/ckanext/saml2auth/tests/test_plugin.py deleted file mode 100644 index 88a078f8..00000000 --- a/ckanext/saml2auth/tests/test_plugin.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Tests for plugin.py.""" -import ckanext.saml2auth.plugin as plugin - -def test_plugin(): - pass diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..fae1fc4a --- /dev/null +++ b/conftest.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +pytest_plugins = [ + u'ckan.tests.pytest_ckan.ckan_setup', + u'ckan.tests.pytest_ckan.fixtures', +] diff --git a/test.ini b/test.ini index e467aa05..4be4cdc8 100644 --- a/test.ini +++ b/test.ini @@ -11,18 +11,23 @@ port = 5000 [app:main] use = config:../ckan/test-core.ini +# Specify the Postgres database for SQLAlchemy to use +sqlalchemy.url = postgresql://ckan_default:password@localhost/ckan_test + +## Datastore +ckan.datastore.write_url = postgresql://ckan_default:password@localhost/datastore_test +ckan.datastore.read_url = postgresql://datastore_default:password@localhost/datastore_test + # Insert any custom config settings to be used when running your extension's # tests here. ## ckanext-saml2auth settings ckanext.saml2auth.idp_metadata.location = local -ckanext.saml2auth.idp_metadata.local_path = /home/dule/keitaro/ckan29/src/ckanext-saml2auth/ckanext/saml2auth/idp.xml +ckanext.saml2auth.idp_metadata.local_path = /path/to/idp.xml ckanext.saml2auth.user_firstname = name ckanext.saml2auth.user_lastname = lastname ckanext.saml2auth.user_email = email -ckanext.saml2auth.enable_ckan_internal_login = true -ckanext.saml2auth.sysadmins_list = dule@keitaro.com dule.bogdanovski@gmail.com dusko.bogdanovski@keitaro.com # Logging configuration From ca22d99b64b486f2ac1d28392c26fd5f3ba13e02 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 12 Nov 2020 18:10:26 +0100 Subject: [PATCH 16/19] Add tests --- ckanext/saml2auth/tests/test_blueprint.py | 8 ++-- ckanext/saml2auth/tests/test_helpers.py | 58 +++++++++++------------ ckanext/saml2auth/tests/test_spconfig.py | 12 +++++ 3 files changed, 45 insertions(+), 33 deletions(-) create mode 100644 ckanext/saml2auth/tests/test_spconfig.py diff --git a/ckanext/saml2auth/tests/test_blueprint.py b/ckanext/saml2auth/tests/test_blueprint.py index 869505d5..0c35b067 100644 --- a/ckanext/saml2auth/tests/test_blueprint.py +++ b/ckanext/saml2auth/tests/test_blueprint.py @@ -7,12 +7,12 @@ from ckan.lib.helpers import url_for -@pytest.mark.usefixtures('clean_db', 'clean_index') -@pytest.mark.ckan_config('ckan.plugins', 'saml2auth') +@pytest.mark.usefixtures(u'clean_db', u'clean_index') +@pytest.mark.ckan_config(u'ckan.plugins', u'saml2auth') class TestBlueprint(object): def test_user_register_disabled_by_default(self, app): - url = url_for("user.register") + url = url_for(u'user.register') response = app.get(url=url) assert 403 == response.status_code @@ -22,7 +22,7 @@ def test_user_register_disabled_by_default(self, app): u'is available at this moment.' in response def test_internal_user_login_disabled_by_deafult(self, app): - url = url_for("user.login") + url = url_for(u'user.login') response = app.get(url=url) assert 403 == response.status_code diff --git a/ckanext/saml2auth/tests/test_helpers.py b/ckanext/saml2auth/tests/test_helpers.py index 4e06a207..09fff208 100644 --- a/ckanext/saml2auth/tests/test_helpers.py +++ b/ckanext/saml2auth/tests/test_helpers.py @@ -19,65 +19,65 @@ def test_default_login_disabled_by_default(): assert not h.is_default_login_enabled() -@pytest.mark.ckan_config('ckanext.saml2auth.enable_ckan_internal_login', True) +@pytest.mark.ckan_config(u'ckanext.saml2auth.enable_ckan_internal_login', True) def test_default_login_enabled(): assert h.is_default_login_enabled() -@pytest.mark.usefixtures('clean_db', 'clean_index') -@pytest.mark.ckan_config('ckanext.saml2auth.sysadmins_list', '') +@pytest.mark.usefixtures(u'clean_db', u'clean_index') +@pytest.mark.ckan_config(u'ckanext.saml2auth.sysadmins_list', '') def test_00_update_user_sysadmin_status_continue_as_regular(): - user = factories.User(email='useroneemail@example.com') - h.update_user_sysadmin_status(user['name'], user['email']) - user_show = helpers.call_action("user_show", id=user["id"]) - is_sysadmin = authz.is_sysadmin(user_show['name']) + user = factories.User(email=u'useroneemail@example.com') + h.update_user_sysadmin_status(user[u'name'], user[u'email']) + user_show = helpers.call_action(u'user_show', id=user['id']) + is_sysadmin = authz.is_sysadmin(user_show[u'name']) assert not is_sysadmin -@pytest.mark.usefixtures('clean_db', 'clean_index') -@pytest.mark.ckan_config('ckanext.saml2auth.sysadmins_list', - 'useroneemail@example.com') +@pytest.mark.usefixtures(u'clean_db', u'clean_index') +@pytest.mark.ckan_config(u'ckanext.saml2auth.sysadmins_list', + u'useroneemail@example.com') def test_01_update_user_sysadmin_status_make_sysadmin(): - user = factories.User(email='useroneemail@example.com') - h.update_user_sysadmin_status(user['name'], user['email']) - user_show = helpers.call_action("user_show", id=user["id"]) - is_sysadmin = authz.is_sysadmin(user_show['name']) + user = factories.User(email=u'useroneemail@example.com') + h.update_user_sysadmin_status(user[u'name'], user[u'email']) + user_show = helpers.call_action(u'user_show', id=user[u'id']) + is_sysadmin = authz.is_sysadmin(user_show[u'name']) assert is_sysadmin -@pytest.mark.usefixtures('clean_db', 'clean_index') -@pytest.mark.ckan_config('ckanext.saml2auth.sysadmins_list', '') +@pytest.mark.usefixtures(u'clean_db', u'clean_index') +@pytest.mark.ckan_config(u'ckanext.saml2auth.sysadmins_list', '') def test_02_update_user_sysadmin_status_remove_sysadmin_role(): - user = factories.Sysadmin(email='useroneemail@example.com') - h.update_user_sysadmin_status(user['name'], user['email']) - user_show = helpers.call_action("user_show", id=user["id"]) - is_sysadmin = authz.is_sysadmin(user_show['name']) + user = factories.Sysadmin(email=u'useroneemail@example.com') + h.update_user_sysadmin_status(user[u'name'], user[u'email']) + user_show = helpers.call_action(u'user_show', id=user[u'id']) + is_sysadmin = authz.is_sysadmin(user_show[u'name']) assert not is_sysadmin -@pytest.mark.usefixtures('clean_db', 'clean_index') -@pytest.mark.ckan_config('ckanext.saml2auth.sysadmins_list', - 'useroneemail@example.com') +@pytest.mark.usefixtures(u'clean_db', u'clean_index') +@pytest.mark.ckan_config(u'ckanext.saml2auth.sysadmins_list', + u'useroneemail@example.com') def test_03_update_user_sysadmin_status_continue_as_sysadmin(): - user = factories.Sysadmin(email='useroneemail@example.com') - h.update_user_sysadmin_status(user['name'], user['email']) - user_show = helpers.call_action("user_show", id=user["id"]) - is_sysadmin = authz.is_sysadmin(user_show['name']) + user = factories.Sysadmin(email=u'useroneemail@example.com') + h.update_user_sysadmin_status(user[u'name'], user[u'email']) + user_show = helpers.call_action(u'user_show', id=user[u'id']) + is_sysadmin = authz.is_sysadmin(user_show[u'name']) assert is_sysadmin -@pytest.mark.usefixtures('clean_db', 'clean_index') +@pytest.mark.usefixtures(u'clean_db', u'clean_index') def test_activate_user_if_deleted(): user = factories.User() - user = model.User.get(user["name"]) + user = model.User.get(user[u'name']) user.delete() h.activate_user_if_deleted(user) assert not user.is_deleted() diff --git a/ckanext/saml2auth/tests/test_spconfig.py b/ckanext/saml2auth/tests/test_spconfig.py new file mode 100644 index 00000000..02015663 --- /dev/null +++ b/ckanext/saml2auth/tests/test_spconfig.py @@ -0,0 +1,12 @@ +# encoding: utf-8 +import pytest + +from ckanext.saml2auth.spconfig import config + + +@pytest.mark.ckan_config(u'ckanext.saml2auth.idp_metadata.location', u'local') +@pytest.mark.ckan_config(u'ckanext.saml2auth.idp_metadata.local_path', '/path/to/idp.xml') +def test_read_metadata_local_config(): + assert config[u'metadata'][u'local'] == ['/path/to/idp.xml'] + + From a5711888337ea1f3bce86f7b3324c6735fe97cb4 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Sun, 15 Nov 2020 21:19:50 +0100 Subject: [PATCH 17/19] Fix travis build and PEP8 code style fixes --- .travis.yml | 46 +++++------------------ README.rst | 21 ++--------- bin/travis-build.bash | 10 +---- bin/travis-run.sh | 9 +---- ckanext/saml2auth/helpers.py | 1 - ckanext/saml2auth/tests/test_blueprint.py | 3 -- ckanext/saml2auth/tests/test_helpers.py | 6 --- ckanext/saml2auth/tests/test_spconfig.py | 2 - ckanext/saml2auth/views/saml2auth.py | 4 +- setup.py | 6 +-- 10 files changed, 20 insertions(+), 88 deletions(-) diff --git a/.travis.yml b/.travis.yml index 39b381bb..a31ed792 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,44 +1,16 @@ language: python sudo: required - -# use an older trusty image, because the newer images cause build errors with -# psycopg2 that comes with CKAN<2.8: -#  "Error: could not determine PostgreSQL version from '10.1'" -# see https://github.com/travis-ci/travis-ci/issues/8897 -dist: trusty -group: deprecated-2017Q4 - -# matrix python: - - 2.7 -env: - - CKANVERSION=master - - CKANVERSION=2.7 - - CKANVERSION=2.8 - -# tests + - "3.8" +env: CKANVERSION=2.9 services: - - postgresql - - redis-server + - postgresql + - redis + - docker install: - - bash bin/travis-build.bash - - pip install coveralls + - bash bin/travis-build.bash + - pip install coveralls + - pip freeze script: sh bin/travis-run.sh after_success: - - coveralls - -# additional jobs -matrix: - include: - - name: "Flake8 on Python 3.7" - dist: xenial # required for Python 3.7 - cache: pip - install: pip install flake8 - script: - - flake8 --version - - flake8 . --count --max-complexity=10 --max-line-length=127 --statistics --exclude ckan,ckanext-saml2auth - python: 3.7 - # overwrite matrix - env: - - FLAKE8=true - - CKANVERSION=master + - coveralls \ No newline at end of file diff --git a/README.rst b/README.rst index 0c536a67..6be6b1e1 100644 --- a/README.rst +++ b/README.rst @@ -2,27 +2,14 @@ these badges work. The necessary Travis and Coverage config files have been generated for you. -.. image:: https://travis-ci.org/duskobogdanovski/ckanext-saml2auth.svg?branch=master - :target: https://travis-ci.org/duskobogdanovski/ckanext-saml2auth +.. image:: https://travis-ci.com/keitaroinc/ckanext-saml2auth.svg?branch=initial-implementation + :target: https://travis-ci.com/keitaroinc/ckanext-saml2auth -.. image:: https://coveralls.io/repos/duskobogdanovski/ckanext-saml2auth/badge.svg - :target: https://coveralls.io/r/duskobogdanovski/ckanext-saml2auth +.. image:: https://coveralls.io/repos/github/keitaroinc/ckanext-saml2auth/badge.svg?branch=initial-implementation + :target: https://coveralls.io/github/keitaroinc/ckanext-saml2auth?branch=initial-implementation -.. image:: https://img.shields.io/pypi/v/ckanext-saml2auth.svg - :target: https://pypi.org/project/ckanext-saml2auth/ - :alt: Latest Version -.. image:: https://img.shields.io/pypi/pyversions/ckanext-saml2auth.svg - :target: https://pypi.org/project/ckanext-saml2auth/ - :alt: Supported Python versions -.. image:: https://img.shields.io/pypi/status/ckanext-saml2auth.svg - :target: https://pypi.org/project/ckanext-saml2auth/ - :alt: Development Status - -.. image:: https://img.shields.io/pypi/l/ckanext-saml2auth.svg - :target: https://pypi.org/project/ckanext-saml2auth/ - :alt: License ================== ckanext-saml2auth diff --git a/bin/travis-build.bash b/bin/travis-build.bash index 91b6ccad..c2aa1ea6 100755 --- a/bin/travis-build.bash +++ b/bin/travis-build.bash @@ -5,7 +5,6 @@ echo "This is travis-build.bash..." echo "Installing the packages that CKAN requires..." sudo apt-get update -qq -sudo apt-get install solr-jetty echo "Installing CKAN and its Python dependencies..." git clone https://github.com/ckan/ckan @@ -42,16 +41,11 @@ sudo -u postgres psql -c "CREATE USER ckan_default WITH PASSWORD 'pass';" sudo -u postgres psql -c 'CREATE DATABASE ckan_test WITH OWNER ckan_default;' echo "Setting up Solr..." -# Solr is multicore for tests on ckan master, but it's easier to run tests on -# Travis single-core. See https://github.com/ckan/ckan/issues/2972 -sed -i -e 's/solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' ckan/test-core.ini -printf "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty -sudo cp ckan/ckan/config/solr/schema.xml /etc/solr/conf/schema.xml -sudo service jetty restart +docker run --name ckan-solr -p 8983:8983 -d openknowledge/ckan-solr-dev:$CKANVERSION echo "Initialising the database..." cd ckan -paster db init -c test-core.ini +ckan -c test-core.ini db init cd - echo "Installing ckanext-saml2auth and its requirements..." diff --git a/bin/travis-run.sh b/bin/travis-run.sh index 6acd2ae4..ccc0466c 100755 --- a/bin/travis-run.sh +++ b/bin/travis-run.sh @@ -1,12 +1,5 @@ #!/bin/sh -e -set -ex -flake8 --version -# stop the build if there are Python syntax errors or undefined names -flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics --exclude ckan,ckanext-saml2auth +pytest --ckan-ini=subdir/test.ini --cov=ckanext.saml2auth --disable-warnings ckanext/saml2auth/tests -pytest --ckan-ini=subdir/test.ini \ - --cov=ckanext.saml2auth - -# strict linting flake8 . --count --max-complexity=10 --max-line-length=127 --statistics --exclude ckan,ckanext-saml2auth diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index 489ac49f..5633c9e4 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -55,4 +55,3 @@ def activate_user_if_deleted(userobj): userobj.activate() userobj.commit() log.info(u'User {} reactivated'.format(userobj.name)) - diff --git a/ckanext/saml2auth/tests/test_blueprint.py b/ckanext/saml2auth/tests/test_blueprint.py index 0c35b067..f67835ca 100644 --- a/ckanext/saml2auth/tests/test_blueprint.py +++ b/ckanext/saml2auth/tests/test_blueprint.py @@ -1,9 +1,6 @@ # encoding: utf-8 import pytest -import ckan.tests.factories as factories -import ckan.tests.helpers as helpers -from ckan import model from ckan.lib.helpers import url_for diff --git a/ckanext/saml2auth/tests/test_helpers.py b/ckanext/saml2auth/tests/test_helpers.py index 09fff208..bd2017f7 100644 --- a/ckanext/saml2auth/tests/test_helpers.py +++ b/ckanext/saml2auth/tests/test_helpers.py @@ -81,9 +81,3 @@ def test_activate_user_if_deleted(): user.delete() h.activate_user_if_deleted(user) assert not user.is_deleted() - - - - - - diff --git a/ckanext/saml2auth/tests/test_spconfig.py b/ckanext/saml2auth/tests/test_spconfig.py index 02015663..f1886e14 100644 --- a/ckanext/saml2auth/tests/test_spconfig.py +++ b/ckanext/saml2auth/tests/test_spconfig.py @@ -8,5 +8,3 @@ @pytest.mark.ckan_config(u'ckanext.saml2auth.idp_metadata.local_path', '/path/to/idp.xml') def test_read_metadata_local_config(): assert config[u'metadata'][u'local'] == ['/path/to/idp.xml'] - - diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 6cf676ba..4eccd983 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -9,7 +9,7 @@ from ckan.lib import base from ckan.views.user import set_repoze_user from ckan.logic.action.create import _get_random_username_from_email -from ckan.common import _, config, g, request, asbool +from ckan.common import config, g, request from ckanext.saml2auth.spconfig import config as sp_config from ckanext.saml2auth import helpers as h @@ -139,7 +139,7 @@ def saml2login(): redirect_url = None for key, value in info[u'headers']: - if key is u'Location': + if key == u'Location': redirect_url = value return toolkit.redirect_to(redirect_url) diff --git a/setup.py b/setup.py index 3c809ce7..5c1e0f39 100644 --- a/setup.py +++ b/setup.py @@ -57,11 +57,9 @@ # You can just specify the packages manually here if your project is # simple. Or you can use find_packages(). packages=find_packages(exclude=['contrib', 'docs', 'tests*']), - namespace_packages=['ckanext'], + namespace_packages=['ckanext'], - install_requires=[ - 'pysaml2=6.3.0' - ], + install_requires=['pysaml2>=6.3.0'], # If there are data files included in your packages that need to be # installed, specify them here. If using Python 2.6 or less, then these From 7b2e82152df1e5dfa4efa08aeb6e4f2bc7fffbe7 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Tue, 17 Nov 2020 10:38:09 +0100 Subject: [PATCH 18/19] Bug fix, update docs --- README.rst | 9 +++++++-- ckanext/saml2auth/spconfig.py | 4 ++-- ckanext/saml2auth/views/saml2auth.py | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 6be6b1e1..4da34bce 100644 --- a/README.rst +++ b/README.rst @@ -50,11 +50,16 @@ To install ckanext-saml2auth: pip install ckanext-saml2auth -4. Add ``saml2auth`` to the ``ckan.plugins`` setting in your CKAN + +4. Install the python modules required by the extension (adjusting the path according to where ckanext-saml2auth was installed in the previous step):: + + pip install -r requirements.txt + +5. Add ``saml2auth`` to the ``ckan.plugins`` setting in your CKAN config file (by default the config file is located at ``/etc/ckan/default/ckan.ini``). -5. Restart CKAN. For example if you've deployed CKAN with Apache on Ubuntu:: +6. Restart CKAN. For example if you've deployed CKAN with Apache on Ubuntu:: sudo service apache2 reload diff --git a/ckanext/saml2auth/spconfig.py b/ckanext/saml2auth/spconfig.py index dd0764ed..36f0ec66 100644 --- a/ckanext/saml2auth/spconfig.py +++ b/ckanext/saml2auth/spconfig.py @@ -40,8 +40,8 @@ u'assertion_consumer_service': [BASE + u'/acs'] }, u'allow_unsolicited': True, - u"name_id_policy_format": NAME_ID_FORMAT, - u"name_id_format": NAME_ID_FORMAT, + u'name_id_policy_format': NAME_ID_FORMAT, + u'name_id_format': NAME_ID_FORMAT } }, u'metadata': {}, diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 4eccd983..cd085328 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -63,12 +63,12 @@ def acs(): # 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)[0] + 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) + h.activate_user_if_deleted(ckan_user[0]) - ckan_user_dict = model_dictize.user_dictize(ckan_user, context) + 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'] = { From 9f32873e3a98dfda6faee1275d3722db84558434 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Tue, 17 Nov 2020 10:43:05 +0100 Subject: [PATCH 19/19] Add todo --- ckanext/saml2auth/tests/test_blueprint.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ckanext/saml2auth/tests/test_blueprint.py b/ckanext/saml2auth/tests/test_blueprint.py index f67835ca..cea2a21d 100644 --- a/ckanext/saml2auth/tests/test_blueprint.py +++ b/ckanext/saml2auth/tests/test_blueprint.py @@ -28,6 +28,7 @@ def test_internal_user_login_disabled_by_deafult(self, app): u'Only SSO through SAML2 authorization ' \ u'is available at this moment.' in response + # TODO write tests will all different config variations and test ACS service with mock IDP # @pytest.mark.ckan_config(u'ckanext.saml2auth.enable_ckan_internal_login', 'true') # def test_user_register_enabled(self, monkeypatch, make_app, ckan_config): # monkeypatch.setitem(ckan_config, u'ckanext.saml2auth.enable_ckan_internal_login', True)