Skip to content

Commit

Permalink
Merge pull request #51 from keitaroinc/slo
Browse files Browse the repository at this point in the history
Add IDP logout functionality when user logs out from CKAN
  • Loading branch information
duskobogdanovski authored May 31, 2021
2 parents 3abe6c0 + d83466b commit 4d88a74
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 7 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Required:
# Corresponding SAML user field for email
ckanext.saml2auth.user_email = email


Optional:

# URL route of the endpoint where the SAML assertion is sent, also known as Assertion Consumer Service (ACS).
Expand Down Expand Up @@ -128,6 +129,13 @@ Optional:
# Comparison could be one of this: exact, minimum, maximum or better
ckanext.saml2auth.requested_authn_context_comparison = exact

# Indicates if this entity will sign the Logout Requests originated from it
ckanext.saml2auth.logout_requests_signed = False

# Saml logout request preferred binding settings variable
# Default: urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST
ckanext.saml2auth.logout_expected_binding = urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST

## Plugin interface

This extension provides the [ISaml2Auth]{.title-ref} interface that
Expand Down
44 changes: 44 additions & 0 deletions ckanext/saml2auth/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# encoding: utf-8
"""
Copyright (c) 2020 Keitaro AB
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import logging

from saml2.ident import code, decode

log = logging.getLogger(__name__)


def set_subject_id(session, subject_id):
session['_saml2_subject_id'] = code(subject_id)


def get_subject_id(session):
try:
return decode(session['_saml2_subject_id'])
except KeyError:
return None


def set_saml_session_info(session, saml_session_info):
session['_saml_session_info'] = saml_session_info


def get_saml_session_info(session):
try:
return session['_saml_session_info']
except KeyError:
return None
35 changes: 35 additions & 0 deletions ckanext/saml2auth/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# encoding: utf-8
"""
Copyright (c) 2020 Keitaro AB
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import logging
from saml2.client import Saml2Client

from ckanext.saml2auth.spconfig import get_config as sp_config

log = logging.getLogger(__name__)


class Saml2Client(Saml2Client):

def do_logout(self, *args, **kwargs):
if not kwargs.get('expected_binding'):
try:
kwargs['expected_binding'] = sp_config()[u'logout_expected_binding']
except AttributeError:
log.warning('ckanext.saml2auth.logout_expected_binding'
'is not defined. Default binding will be used.')
return super().do_logout(*args, **kwargs)
13 changes: 12 additions & 1 deletion ckanext/saml2auth/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@
import secrets
from six import text_type

from saml2.client import Saml2Client
from ckanext.saml2auth.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


log = logging.getLogger(__name__)


Expand Down Expand Up @@ -96,3 +98,12 @@ def ensure_unique_username_from_email(email):
return name

return cleaned_localpart


def get_location(http_info):
'''Extract the redirect URL from a pysaml2 http_info object'''
try:
headers = dict(http_info['headers'])
return headers['Location']
except KeyError:
return http_info['url']
74 changes: 71 additions & 3 deletions ckanext/saml2auth/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,33 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import logging

from saml2.client_base import LogoutError
from saml2 import entity

from flask import session, redirect

import ckan.plugins as plugins
import ckan.plugins.toolkit as toolkit
from ckan.common import g
import ckan.lib.base as base

from ckanext.saml2auth.views.saml2auth import saml2auth
from ckanext.saml2auth.cache import get_subject_id, get_saml_session_info
from ckanext.saml2auth.spconfig import get_config as sp_config
from ckanext.saml2auth import helpers as h


log = logging.getLogger(__name__)


class Saml2AuthPlugin(plugins.SingletonPlugin):
plugins.implements(plugins.IConfigurer)
plugins.implements(plugins.IBlueprint)
plugins.implements(plugins.IConfigurable)
plugins.implements(plugins.ITemplateHelpers)
plugins.implements(plugins.IAuthenticator)

# ITemplateHelpers

Expand Down Expand Up @@ -60,9 +74,8 @@ def configure(self, config):
full_name = config.get('ckanext.saml2auth.user_fullname')

if not first_and_last_name and not full_name:
raise RuntimeError('''
You need to provide both ckanext.saml2auth.user_firstname +
ckanext.saml2auth.user_lastname or ckanext.saml2auth.user_fullname'''.strip())
raise RuntimeError('''You need to provide both ckanext.saml2auth.user_firstname
+ ckanext.saml2auth.user_lastname or ckanext.saml2auth.user_fullname'''.strip())

acs_endpoint = config.get('ckanext.saml2auth.acs_endpoint')
if acs_endpoint and not acs_endpoint.startswith('/'):
Expand All @@ -79,3 +92,58 @@ 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):
pass

def login(self):
pass

def logout(self):

client = h.saml_client(
sp_config()
)
saml_session_info = get_saml_session_info(session)
subject_id = get_subject_id(session)

if subject_id is None:
log.warning(
'The session does not contain the subject id for user {}'.format(g.user))
else:
try:
client.users.add_information_about_person(saml_session_info)
result = client.global_logout(name_id=subject_id)
except LogoutError as e:
log.exception(
'SLO not supported by IDP: {}'.format(e))
# clear session

if not result:
log.error(
'Looks like the user {} is not logged in any IdP/AA'.format(subject_id))

if len(result) > 1:
log.error(
'Sorry, I do not know how to logout from several sources.'
' I will logout just from the first one')

for entity_id, logout_info in result.items():
if isinstance(logout_info, tuple):
binding, http_info = logout_info
if binding == entity.BINDING_HTTP_POST:
log.debug(
'Returning form to the IdP to continue the logout process')
body = ''.join(http_info['data'])
extra_vars = {
u'body': body
}
return base.render(u'saml2auth/idp_logout.html', extra_vars)
elif binding == entity.BINDING_HTTP_REDIRECT:
log.debug(
'Redirecting to the IdP to continue the logout process')
return redirect(h.get_location(http_info), code=302)
else:
log.error('Failed to log out from Idp. Unknown binding: {}'.format(binding))
9 changes: 8 additions & 1 deletion ckanext/saml2auth/spconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"""

from saml2.saml import NAME_FORMAT_URI
from saml2 import entity

from ckan.common import config as ckan_config
from ckan.common import asbool, aslist
Expand Down Expand Up @@ -55,6 +56,10 @@ def get_config():
cert_file = ckan_config.get(u'ckanext.saml2auth.cert_file_path', None)
attribute_map_dir = ckan_config.get(u'ckanext.saml2auth.attribute_map_dir', None)
acs_endpoint = ckan_config.get('ckanext.saml2auth.acs_endpoint', '/acs')
logout_requests_signed = \
asbool(ckan_config.get(u'ckanext.saml2auth.logout_requests_signed', False))
logout_expected_binding = ckan_config.get(u'ckanext.saml2auth.logout_expected_binding',
entity.BINDING_HTTP_POST)

config = {
u'entityid': entity_id,
Expand All @@ -71,9 +76,11 @@ def get_config():
u'name_id_format': name_id_format,
u'want_response_signed': response_signed,
u'want_assertions_signed': assertion_signed,
u'want_assertions_or_response_signed': any_signed
u'want_assertions_or_response_signed': any_signed,
u'logout_requests_signed': logout_requests_signed
}
},
u'logout_expected_binding': logout_expected_binding,
u'metadata': {},
u'debug': 1 if debug else 0,
u'name_form': NAME_FORMAT_URI
Expand Down
1 change: 1 addition & 0 deletions ckanext/saml2auth/templates/saml2auth/idp_logout.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{ body | safe }}
14 changes: 13 additions & 1 deletion ckanext/saml2auth/views/saml2auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import logging
import copy

from flask import Blueprint
from flask import Blueprint, session
from saml2 import entity
from saml2.authn_context import requested_authn_context

Expand All @@ -35,6 +35,7 @@
from ckanext.saml2auth.spconfig import get_config as sp_config
from ckanext.saml2auth import helpers as h
from ckanext.saml2auth.interfaces import ISaml2Auth
from ckanext.saml2auth.cache import set_subject_id, set_saml_session_info


log = logging.getLogger(__name__)
Expand Down Expand Up @@ -230,6 +231,7 @@ def acs():

auth_response.get_identity()
user_info = auth_response.get_subject()
session_info = auth_response.session_info()

# SAML username - unique
saml_id = user_info.text
Expand Down Expand Up @@ -262,6 +264,8 @@ def acs():

# log the user in programmatically
set_repoze_user(g.user, resp)
set_saml_session_info(session, session_info)
set_subject_id(session, session_info['name_id'])

for plugin in plugins.PluginImplementations(ISaml2Auth):
resp = plugin.after_saml2_login(resp, auth_response.ava)
Expand Down Expand Up @@ -311,6 +315,13 @@ def disable_default_login_register():
return base.render(u'error_document_template.html', extra_vars), 403


def slo():
u'''View function that handles the IDP logout
request response and finish with logging out the user from CKAN
'''
return toolkit.redirect_to(u'user.logout')


acs_endpoint = config.get('ckanext.saml2auth.acs_endpoint', '/acs')
saml2auth.add_url_rule(acs_endpoint, view_func=acs, methods=[u'GET', u'POST'])
saml2auth.add_url_rule(u'/user/saml2login', view_func=saml2login)
Expand All @@ -319,3 +330,4 @@ def disable_default_login_register():
u'/user/login', view_func=disable_default_login_register)
saml2auth.add_url_rule(
u'/user/register', view_func=disable_default_login_register)
saml2auth.add_url_rule(u'/slo', view_func=slo, methods=[u'GET', u'POST'])
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
packages=find_packages(exclude=['contrib', 'docs', 'tests*']),
namespace_packages=['ckanext'],

install_requires=['pysaml2>=6.3.0'],
install_requires=['pysaml2>=6.5.1'],

# 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
Expand Down

0 comments on commit 4d88a74

Please sign in to comment.