Skip to content

Commit

Permalink
Merge pull request keitaroinc#43 from okfn/isaml2auht-interface
Browse files Browse the repository at this point in the history
ISaml2Auth interface
  • Loading branch information
duskobogdanovski authored Feb 22, 2021
2 parents 5ae1564 + febb88f commit e8d5388
Show file tree
Hide file tree
Showing 7 changed files with 377 additions and 113 deletions.
18 changes: 16 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,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

Expand All @@ -160,6 +160,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
----------------------
Expand Down
2 changes: 2 additions & 0 deletions ckanext/saml2auth/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions ckanext/saml2auth/interfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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
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
'''
return resp
94 changes: 31 additions & 63 deletions ckanext/saml2auth/tests/test_blueprint_get_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'))
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand Down
162 changes: 162 additions & 0 deletions ckanext/saml2auth/tests/test_interface.py
Original file line number Diff line number Diff line change
@@ -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('[email protected]')[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='[email protected]',
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('[email protected]')[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='[email protected]',
)

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('[email protected]')[0]

assert user.fullname.endswith('TEST UPDATE')

assert 'saml_id' in user.plugin_extras['saml2auth']
Loading

0 comments on commit e8d5388

Please sign in to comment.