From 6dae50798574f3c35c4d6713355cf90cfe4adef4 Mon Sep 17 00:00:00 2001 From: Mike Rylander Date: Fri, 28 Aug 2020 15:38:57 -0400 Subject: [PATCH 1/1] LP#1871211: Shibboleth integration support This commit adds Shibboleth integration to Evergreen for use in the OPAC. Using Shibboleth, libraries can authenticate patrons against a wide variety of 3rd party services, using many different protocols and standards. Several settings control if, when and how to make use of the Shibboleth integration: * Enable Shibboleth SSO for the OPAC - The main on/off switch. * Allow both Shibboleth and native OPAC authentication - By default only one or the other will be allowed. This enables both native and Shibboleth login. * Log out of the Shibboleth IdP - If supported by the IdP configured for use on the other side of Shibboleth, this tells Evergreen to tell Shibboleth to log out of the IdP on Evergreen logout. * Shibboleth SSO Entity ID - If multiple IdPs are configured for Shibboleth, and available to a particular hostname, this setting defines the one to use for a given context org unit. * Evergreen SSO matchpoint - The Evergreen-side user field to use when looking up the patron after successful SSO login. * Shibboleth SSO matchpoint - The Shibboleth-side field, defined in the attribute map, that contains the IdP user identifier value used to look up the Evergreen patron. Two apache sesttings control how Evergreen interacts with Shibboeth: * SetEnv sso_loc XXX, which acts in a way analogous to the physical_loc environment variable to define the context OU for SSO settings. * ShibRequestSetting applicationId XXX, which helps Shibboleth identify the correct set of entity ID and attribute mapping configuration. Additional Shibboleth-focused documentation and examples will be provided for system administrators. Signed-off-by: Mike Rylander Signed-off-by: Christine Burns Signed-off-by: Jane Sandberg --- Open-ILS/examples/apache_24/eg_vhost.conf.in | 4 + .../perlmods/lib/OpenILS/WWW/EGCatLoader.pm | 197 ++++++++++++++---- Open-ILS/src/sql/Pg/950.data.seed-values.sql | 41 +++- .../src/sql/Pg/upgrade/XXXX.data.shib_sso.sql | 46 ++++ .../src/templates/opac/parts/login/form.tt2 | 44 ++-- Open-ILS/src/templates/opac/parts/topnav.tt2 | 2 +- 6 files changed, 282 insertions(+), 52 deletions(-) create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.data.shib_sso.sql diff --git a/Open-ILS/examples/apache_24/eg_vhost.conf.in b/Open-ILS/examples/apache_24/eg_vhost.conf.in index 07c12ac8a8..809d31172a 100644 --- a/Open-ILS/examples/apache_24/eg_vhost.conf.in +++ b/Open-ILS/examples/apache_24/eg_vhost.conf.in @@ -733,6 +733,10 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT] + # Uncomment the entries below to enable Shibboleth authentication + #AuthType shibboleth + #Require shibboleth + PerlSetVar OILSWebContextLoader "OpenILS::WWW::EGCatLoader" # Expire the HTML quickly since we're loading dynamic data for each page ExpiresActive On diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm index 053e4b6c2d..111ea8b4d8 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm @@ -13,6 +13,7 @@ use OpenILS::Application::AppUtils; use OpenILS::Utils::CStoreEditor qw/:funcs/; use OpenILS::Utils::Fieldmapper; use OpenSRF::Utils::Cache; +use OpenILS::Event; use DateTime::Format::ISO8601; use CGI qw(:all -utf8); use Time::HiRes; @@ -33,6 +34,8 @@ my $U = 'OpenILS::Application::AppUtils'; use constant COOKIE_SES => 'ses'; use constant COOKIE_LOGGEDIN => 'eg_loggedin'; +use constant COOKIE_SHIB_LOGGEDOUT => 'eg_shib_logged_out'; +use constant COOKIE_SHIB_LOGGEDIN => 'eg_shib_logged_in'; use constant COOKIE_TZ => 'client_tz'; use constant COOKIE_PHYSICAL_LOC => 'eg_physical_loc'; use constant COOKIE_SSS_EXPAND => 'eg_sss_expand'; @@ -224,6 +227,7 @@ sub load { } } + return $self->load_manual_shib_login if $path =~ m|opac/manual_shib_login|; # ---------------------------------------------------------------- # Everything below here requires authentication # ---------------------------------------------------------------- @@ -294,9 +298,17 @@ sub redirect_ssl { # ----------------------------------------------------------------------------- sub redirect_auth { my $self = shift; - my $login_page = sprintf('%s://%s%s/login',($self->ctx->{is_staff} ? 'oils' : 'https'), $self->ctx->{hostname}, $self->ctx->{opac_root}); + + my $sso_org = $ENV{sso_loc} || $self->get_physical_loc || $self->_get_search_lib(); + my $sso_enabled = $self->ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.enable'); + my $sso_native = $self->ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.allow_native'); + + my $login_type = ($sso_enabled and !$sso_native) ? 'manual_shib_login' : 'login'; + my $login_page = sprintf('%s://%s%s/%s',($self->ctx->{is_staff} ? 'oils' : 'https'), $self->ctx->{hostname}, $self->ctx->{opac_root}, $login_type); my $redirect_to = uri_escape_utf8($self->apache->unparsed_uri); - return $self->generic_redirect("$login_page?redirect_to=$redirect_to"); + my $redirect_url = "$login_page?redirect_to=$redirect_to"; + + return $self->generic_redirect($redirect_url); } # ----------------------------------------------------------------------------- @@ -547,6 +559,13 @@ sub load_login { $self->timelog("Load login begins"); + my $sso_org = $ENV{sso_loc} || $self->get_physical_loc || $self->_get_search_lib(); + $ctx->{sso_org} = $sso_org; + my $sso_enabled = $ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.enable'); + my $sso_native = $ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.allow_native'); + my $sso_eg_match = $ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.evergreen_matchpoint') || 'usrname'; + my $sso_shib_match = $ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.shib_matchpoint') || 'uid'; + $ctx->{page} = 'login'; my $username = $cgi->param('username') || ''; @@ -556,50 +575,84 @@ sub load_login { my $persist = $cgi->param('persist'); my $client_tz = $cgi->param('client_tz'); - # initial log form only - return Apache2::Const::OK unless $username and $password; + my $sso_user_match_value; + my $response; + my $sso_logged_in; + $self->timelog("SSO is enabled") if ($sso_enabled); + if ($sso_enabled + and $sso_user_match_value = $ENV{$sso_shib_match} + and !$self->cgi->cookie(COOKIE_SHIB_LOGGEDOUT) + ) { # we have a shib session, and have not cleared a previous shib-login cookie + $self->timelog("Have an SSO user match value: $sso_user_match_value"); + + if ($sso_eg_match eq 'barcode') { # barcode is special + my $card = $self->editor->search_actor_card({barcode => $sso_user_match_value})->[0]; + $sso_user_match_value = $card ? $card->usr : undef; + $sso_eg_match = 'id'; + } - my $auth_proxy_enabled = 0; # default false - try { # if the service is not running, just let this fail silently - $auth_proxy_enabled = $U->simplereq( - 'open-ils.auth_proxy', - 'open-ils.auth_proxy.enabled'); - } catch Error with {}; + if ($sso_user_match_value && $sso_eg_match) { + my $user = $self->editor->search_actor_user({ $sso_eg_match => $sso_user_match_value })->[0]; + if ($user) { # create a session + $response = $U->simplereq( + 'open-ils.auth_internal', + 'open-ils.auth_internal.session.create', + { user_id => $user->id, login_type => 'opac' } + ); + $sso_logged_in = $response ? 1 : 0; + } + } - $self->timelog("Checked for auth proxy: $auth_proxy_enabled; org = $org_unit; username = $username"); + $self->timelog("Checked for SSO login"); + } - my $args = { - type => ($persist) ? 'persist' : 'opac', - org => $org_unit, - agent => 'opac' - }; + if (!$sso_enabled || (!$response && $sso_native)) { + # initial log form only + return Apache2::Const::OK unless $username and $password; - my $bc_regex = $ctx->{get_org_setting}->($org_unit, 'opac.barcode_regex'); + my $auth_proxy_enabled = 0; # default false + try { # if the service is not running, just let this fail silently + $auth_proxy_enabled = $U->simplereq( + 'open-ils.auth_proxy', + 'open-ils.auth_proxy.enabled'); + } catch Error with {}; - # To avoid surprises, default to "Barcodes start with digits" - $bc_regex = '^\d' unless $bc_regex; + $self->timelog("Checked for auth proxy: $auth_proxy_enabled; org = $org_unit; username = $username"); - if ($bc_regex and ($username =~ /$bc_regex/)) { - $args->{barcode} = $username; - } else { - $args->{username} = $username; - } + my $args = { + type => ($persist) ? 'persist' : 'opac', + org => $org_unit, + agent => 'opac' + }; - my $response; - if (!$auth_proxy_enabled) { - my $seed = $U->simplereq( - 'open-ils.auth', - 'open-ils.auth.authenticate.init', $username); - $args->{password} = md5_hex($seed . md5_hex($password)); - $response = $U->simplereq( - 'open-ils.auth', 'open-ils.auth.authenticate.complete', $args); + my $bc_regex = $ctx->{get_org_setting}->($org_unit, 'opac.barcode_regex'); + + # To avoid surprises, default to "Barcodes start with digits" + $bc_regex = '^\d' unless $bc_regex; + + if ($bc_regex and ($username =~ /$bc_regex/)) { + $args->{barcode} = $username; + } else { + $args->{username} = $username; + } + + if (!$auth_proxy_enabled) { + my $seed = $U->simplereq( + 'open-ils.auth', + 'open-ils.auth.authenticate.init', $username); + $args->{password} = md5_hex($seed . md5_hex($password)); + $response = $U->simplereq( + 'open-ils.auth', 'open-ils.auth.authenticate.complete', $args); + } else { + $args->{password} = $password; + $response = $U->simplereq( + 'open-ils.auth_proxy', + 'open-ils.auth_proxy.login', $args); + } + $self->timelog("Checked password"); } else { - $args->{password} = $password; - $response = $U->simplereq( - 'open-ils.auth_proxy', - 'open-ils.auth_proxy.login', $args); + $response ||= OpenILS::Event->new( 'LOGIN_FAILED' ); # assume failure } - $self->timelog("Checked password"); if($U->event_code($response)) { # login failed, report the reason to the template @@ -647,18 +700,76 @@ sub load_login { ); } + if ($sso_logged_in) { + # tells us if we're logged in via shib, so we can decide whether to try logging in again. + push @$cookie_list, $cgi->cookie( + -name => COOKIE_SHIB_LOGGEDOUT, + -path => '/', + -secure => 0, + -value => '0', + -expires => '-1h' + ); + push @$cookie_list, $cgi->cookie( + -name => COOKIE_SHIB_LOGGEDIN, + -path => '/', + -secure => 0, + -value => '1', + -expires => $login_cookie_expires + ); + } + return $self->generic_redirect( $cgi->param('redirect_to') || $acct, $cookie_list ); } +sub load_manual_shib_login { + my $self = shift; + my $redirect_to = shift || $self->cgi->param('redirect_to'); + + my $sso_org = $ENV{sso_loc} || $self->get_physical_loc || $self->_get_search_lib(); + my $sso_entity_id = $self->ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.entityId'); + my $sso_shib_match = $self->ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.shib_matchpoint') || 'uid'; + + return $self->load_login if ($ENV{$sso_shib_match}); + + my $url = '/Shibboleth.sso/Login?target=' . ($redirect_to || $self->ctx->{home_page}); + if ($sso_entity_id) { + $url .= '&entityID=' . $sso_entity_id; + } + + return $self->generic_redirect( $url, + [ + $self->cgi->cookie( + -name => COOKIE_SHIB_LOGGEDOUT, + -path => '/', + -value => '0', + -expires => '-1h' + ) + ] + ); +} + # ----------------------------------------------------------------------------- # Log out and redirect to the home page # ----------------------------------------------------------------------------- sub load_logout { my $self = shift; my $redirect_to = shift || $self->cgi->param('redirect_to'); + my $active_logout = $self->cgi->param('active_logout'); + + my $sso_org = $ENV{sso_loc} || $self->get_physical_loc || $self->_get_search_lib(); + $self->ctx->{sso_org} = $sso_org; + my $sso_enabled = $self->ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.enable'); + my $sso_entity_id = $self->ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.entityId'); + my $sso_logout = $self->ctx->{get_org_setting}->($sso_org, 'opac.login.shib_sso.logout'); + if ($sso_enabled && $sso_logout) { + $redirect_to = '/Shibboleth.sso/Logout?return=' . ($redirect_to || $self->ctx->{home_page}); + if ($sso_entity_id) { + $redirect_to .= '&entityID=' . $sso_entity_id; + } + } # If the user was adding anyting to an anonymous cache # while logged in, go ahead and clear it out. @@ -687,6 +798,18 @@ sub load_logout { -path => '/', -value => '', -expires => '-1h' + ), + ($active_logout ? ($self->cgi->cookie( + -name => COOKIE_SHIB_LOGGEDOUT, + -path => '/', + -value => '1', + -expires => '2147483647' + )) : ()), + $self->cgi->cookie( + -name => COOKIE_SHIB_LOGGEDIN, + -path => '/', + -value => '0', + -expires => '-1h' ) ] ); diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index 4f2948e957..3fd6d82672 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -1948,7 +1948,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES ( 625, 'VIEW_BOOKING_RESERVATION', oils_i18n_gettext(625, 'View booking reservations', 'ppl', 'description')), ( 626, 'VIEW_BOOKING_RESERVATION_ATTR_MAP', oils_i18n_gettext(626, - 'View booking reservation attribute maps', 'ppl', 'description')) + 'View booking reservation attribute maps', 'ppl', 'description')), + ( 627, 'SSO_ADMIN', oils_i18n_gettext(627, + 'Modify patron SSO settings', 'ppl', 'description')) ; @@ -20282,6 +20284,43 @@ INSERT into config.org_unit_setting_type 'coust', 'description'), 'bool', null); +INSERT INTO config.org_unit_setting_type +( name, grp, label, description, datatype, update_perm ) +VALUES +('opac.login.shib_sso.enable', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.enable', 'Enable Shibboleth SSO for the OPAC', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.enable', 'Enable Shibboleth SSO for the OPAC', 'coust', 'description'), + 'bool', 627), +('opac.login.shib_sso.entityId', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.entityId', 'Shibboleth SSO Entity ID', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.entityId', 'Which configured Entity ID to use for SSO when there is more than one available to Shibboleth', 'coust', 'description'), + 'string', 627), +('opac.login.shib_sso.logout', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.logout', 'Log out of the Shibboleth IdP', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.logout', 'When logging out of Evergreen, also force a logout of the IdP behind Shibboleth', 'coust', 'description'), + 'bool', 627), +('opac.login.shib_sso.allow_native', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.allow_native', 'Allow both Shibboleth and native OPAC authentication', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.allow_native', 'When Shibboleth SSO is enabled, also allow native Evergreen authentication', 'coust', 'description'), + 'bool', 627), +('opac.login.shib_sso.evergreen_matchpoint', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.evergreen_matchpoint', 'Evergreen SSO matchpoint', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.evergreen_matchpoint', + 'Evergreen-side field to match a patron against for Shibboleth SSO. Default is usrname. Other reasonable values would be barcode or email.', + 'coust', 'description'), + 'string', 627), +('opac.login.shib_sso.shib_matchpoint', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.shib_matchpoint', 'Shibboleth SSO matchpoint', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.shib_matchpoint', + 'Shibboleth-side field to match a patron against for Shibboleth SSO. Default is uid; use eppn for Active Directory', 'coust', 'description'), + 'string', 627) +; INSERT INTO config.workstation_setting_type (name, grp, datatype, label) VALUES ( diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.shib_sso.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.shib_sso.sql new file mode 100644 index 0000000000..4dd5f8454e --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.shib_sso.sql @@ -0,0 +1,46 @@ +BEGIN; + +SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version); + +-- XXX Check perm number collisions, and adjust update_perm below if necessary! +INSERT INTO permission.perm_list (id,code,description) VALUES (627,'SSO_ADMIN','Modify patron SSO settings'); + +INSERT INTO config.org_unit_setting_type +( name, grp, label, description, datatype, update_perm ) +VALUES +('opac.login.shib_sso.enable', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.enable', 'Enable Shibboleth SSO for the OPAC', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.enable', 'Enable Shibboleth SSO for the OPAC', 'coust', 'description'), + 'bool', 627), +('opac.login.shib_sso.entityId', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.entityId', 'Shibboleth SSO Entity ID', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.entityId', 'Which configured Entity ID to use for SSO when there is more than one available to Shibboleth', 'coust', 'description'), + 'string', 627), +('opac.login.shib_sso.logout', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.logout', 'Log out of the Shibboleth IdP', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.logout', 'When logging out of Evergreen, also force a logout of the IdP behind Shibboleth', 'coust', 'description'), + 'bool', 627), +('opac.login.shib_sso.allow_native', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.allow_native', 'Allow both Shibboleth and native OPAC authentication', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.allow_native', 'When Shibboleth SSO is enabled, also allow native Evergreen authentication', 'coust', 'description'), + 'bool', 627), +('opac.login.shib_sso.evergreen_matchpoint', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.evergreen_matchpoint', 'Evergreen SSO matchpoint', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.evergreen_matchpoint', + 'Evergreen-side field to match a patron against for Shibboleth SSO. Default is usrname. Other reasonable values would be barcode or email.', + 'coust', 'description'), + 'string', 627), +('opac.login.shib_sso.shib_matchpoint', + 'opac', + oils_i18n_gettext('opac.login.shib_sso.shib_matchpoint', 'Shibboleth SSO matchpoint', 'coust', 'label'), + oils_i18n_gettext('opac.login.shib_sso.shib_matchpoint', + 'Shibboleth-side field to match a patron against for Shibboleth SSO. Default is uid; use eppn for Active Directory', 'coust', 'description'), + 'string', 627) +; + +COMMIT; diff --git a/Open-ILS/src/templates/opac/parts/login/form.tt2 b/Open-ILS/src/templates/opac/parts/login/form.tt2 index fa391f0ff0..6ceca0b5dd 100644 --- a/Open-ILS/src/templates/opac/parts/login/form.tt2 +++ b/Open-ILS/src/templates/opac/parts/login/form.tt2 @@ -13,8 +13,38 @@ [% END %] +[% + redirect = CGI.param('redirect_to'); + # Don't use referer unless we got here from elsewhere within the TPAC + IF !redirect AND ctx.referer.match('^https?://' _ ctx.hostname _ ctx.opac_root); + redirect = ctx.referer; + END; + # If no redirect is offered or it's leading us back to the + # login form, redirect the user to My Account + IF !redirect OR redirect.match(ctx.path_info _ '$'); + redirect = CGI.url('-full' => 1) _ '/opac/myopac/main'; + END; + redirect = redirect | replace('^http:', 'https:'); +%] + +[% sso_enabled = ctx.get_org_setting(ctx.sso_org, 'opac.login.shib_sso.enable'); + sso_native = ctx.get_org_setting(ctx.sso_org, 'opac.login.shib_sso.allow_native'); +%] + [% INCLUDE "opac/parts/login/help.tt2" %] diff --git a/Open-ILS/src/templates/opac/parts/topnav.tt2 b/Open-ILS/src/templates/opac/parts/topnav.tt2 index f6390b71ab..4c7c40178c 100644 --- a/Open-ILS/src/templates/opac/parts/topnav.tt2 +++ b/Open-ILS/src/templates/opac/parts/topnav.tt2 @@ -41,7 +41,7 @@ class="opac-button">[% l('My Account') %] [% l('My Lists') %] - [% l('Logout') %] -- 2.43.2