diff --git a/.gitignore b/.gitignore index f1fadf08..290fa534 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ idp.xml .vscode/ .pre-commit-config.yaml .flake8 + +*.pem +test-signed.xml +test-signed-encrypted.xml diff --git a/README.rst b/README.rst index 264dda51..db8b4ddf 100644 --- a/README.rst +++ b/README.rst @@ -123,22 +123,31 @@ Optional:: # Default: ckanext.saml2auth.sp.name_id_policy_format = urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - # Entity ID (also know as Issuer) - # Define the entity ID. Default is urn:mace:umu.se:saml:ckan:sp - ckanext.saml2auth.entity_id = urn:gov:gsa:SAML:2.0.profiles:sp:sso:gsa:catalog-dev - - # Signed responses and assertions - ckanext.saml2auth.want_response_signed = False - ckanext.saml2auth.want_assertions_signed = False - ckanext.saml2auth.want_assertions_or_response_signed = True + # Entity ID (also know as Issuer) + # Define the entity ID. Default is urn:mace:umu.se:saml:ckan:sp + ckanext.saml2auth.entity_id = urn:gov:gsa:SAML:2.0.profiles:sp:sso:gsa:catalog-dev + + # Signed responses and assertions + 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 + # 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 - + # Attribute map directory + ckanext.saml2auth.attribute_map_dir = /path/to/dir/attributemaps + + # Authentication context request before redirect to login + # e.g. to ask for a PIV card with login.gov provider (https://developers.login.gov/oidc/#aal-values) use: + ckanext.saml2auth.requested_authn_context = http://idmanagement.gov/ns/assurance/aal/3?hspd12=true + # You can use multiple context separated by spaces + ckanext.saml2auth.requested_authn_context = req1 req2 + + # Define the comparison value for RequestedAuthnContext + # Comparison could be one of this: exact, minimum, maximum or better + ckanext.saml2auth.requested_authn_context_comparison = exact ---------------------- Developer installation diff --git a/ckanext/saml2auth/tests/test_client.py b/ckanext/saml2auth/tests/test_client.py new file mode 100644 index 00000000..ffc4bfde --- /dev/null +++ b/ckanext/saml2auth/tests/test_client.py @@ -0,0 +1,19 @@ +# encoding: utf-8 +import os +import pytest +from ckanext.saml2auth.views.saml2auth import saml2login + + +here = os.path.dirname(os.path.abspath(__file__)) +extras_folder = os.path.join(here, 'extras') + + +@pytest.mark.ckan_config(u'ckanext.saml2auth.requested_authn_context_comparison', 'bad_value') +@pytest.mark.ckan_config(u'ckanext.saml2auth.requested_authn_context', 'req1 req2') +@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')) +def test_empty_comparison(): + with pytest.raises(ValueError) as e: + saml2login() + assert 'Unexpected comparison' in e \ No newline at end of file diff --git a/ckanext/saml2auth/tests/test_spconfig.py b/ckanext/saml2auth/tests/test_spconfig.py index ef492c00..0692de94 100644 --- a/ckanext/saml2auth/tests/test_spconfig.py +++ b/ckanext/saml2auth/tests/test_spconfig.py @@ -2,6 +2,7 @@ import pytest from ckanext.saml2auth.spconfig import get_config +from ckanext.saml2auth.views.saml2auth import _get_requested_authn_contexts @pytest.mark.ckan_config(u'ckanext.saml2auth.idp_metadata.location', u'local') @@ -76,3 +77,25 @@ def test_read_acs_endpoint(): acs_endpoint = get_config()[u'service'][u'sp'][u'endpoints'][u'assertion_consumer_service'][0] assert acs_endpoint.endswith('/my/acs/endpoint') + + +@pytest.mark.ckan_config(u'ckanext.saml2auth.requested_authn_context', u'req1') +def test_one_requested_authn_context(): + + contexts = _get_requested_authn_contexts() + assert contexts[0] == u'req1' + + +@pytest.mark.ckan_config(u'ckanext.saml2auth.requested_authn_context', u'req1 req2') +def test_two_requested_authn_context(): + + contexts = _get_requested_authn_contexts() + assert u'req1' in contexts + assert u'req2' in contexts + + +@pytest.mark.ckan_config(u'ckanext.saml2auth.requested_authn_context', None) +def test_empty_requested_authn_context(): + + contexts = _get_requested_authn_contexts() + assert contexts == [] diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index def90d05..d1e1fcda 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -2,6 +2,8 @@ import logging from flask import Blueprint from saml2 import entity +from saml2.saml import AuthnContextClassRef +from saml2.samlp import RequestedAuthnContext import ckan.plugins.toolkit as toolkit import ckan.model as model @@ -20,6 +22,15 @@ saml2auth = Blueprint(u'saml2auth', __name__) +def _get_requested_authn_contexts(): + requested_authn_contexts = config.get('ckanext.saml2auth.requested_authn_context', + None) + if requested_authn_contexts is None: + return [] + + return requested_authn_contexts.strip().split() + + def process_user(email, saml_id, firstname, lastname): """ Check if CKAN-SAML user exists for the current SAML login """ @@ -159,7 +170,26 @@ def saml2login(): configured identity provider for authentication ''' client = h.saml_client(sp_config()) - reqid, info = client.prepare_for_authenticate() + requested_authn_contexts = _get_requested_authn_contexts() + + if len(requested_authn_contexts) > 0: + comparison = config.get('ckanext.saml2auth.requested_authn_context_comparison', + 'minimum') + if comparison not in ['exact', 'minimum', 'maximum', 'better']: + error = 'Unexpected comparison value {}'.format(comparison) + raise ValueError(error) + requested_authn_context = RequestedAuthnContext(comparison=comparison) + + for item in requested_authn_contexts: + context_class_ref = AuthnContextClassRef() + context_class_ref.text = item + log.debug('requested_authn_context added {}'.format(item)) + requested_authn_context.authn_context_class_ref.append(context_class_ref) + + reqid, info = client.prepare_for_authenticate( + requested_authn_context=requested_authn_context) + else: + reqid, info = client.prepare_for_authenticate() redirect_url = None for key, value in info[u'headers']: