From 8589d20554a27ec6ba27d2fb43d5b72bbc75d6dd Mon Sep 17 00:00:00 2001 From: Jeff Davis Date: Tue, 26 Feb 2019 18:02:58 -0800 Subject: [PATCH] LP#1817645: configurable HTTP API for patron auth/retrieval Signed-off-by: Jeff Davis Signed-off-by: Galen Charlton --- Open-ILS/examples/apache_24/eg.conf.in | 1 + Open-ILS/examples/apache_24/eg_startup.in | 2 +- Open-ILS/examples/fm_IDL.xml | 26 ++ .../perlmods/lib/OpenILS/WWW/RemoteAuth.pm | 233 ++++++++++++++++++ Open-ILS/src/sql/Pg/150.remoteauth.sql | 85 +++++++ Open-ILS/src/sql/Pg/950.data.seed-values.sql | 4 +- Open-ILS/src/sql/Pg/sql_file_manifest | 1 + .../sql/Pg/upgrade/XXXX.schema.remoteauth.sql | 91 +++++++ 8 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/WWW/RemoteAuth.pm create mode 100644 Open-ILS/src/sql/Pg/150.remoteauth.sql create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.schema.remoteauth.sql diff --git a/Open-ILS/examples/apache_24/eg.conf.in b/Open-ILS/examples/apache_24/eg.conf.in index 2ee0153ad2..b872a3e1a0 100644 --- a/Open-ILS/examples/apache_24/eg.conf.in +++ b/Open-ILS/examples/apache_24/eg.conf.in @@ -21,6 +21,7 @@ PerlChildInitHandler OpenILS::WWW::AddedContent::child_init PerlChildInitHandler OpenILS::WWW::AutoSuggest::child_init PerlChildInitHandler OpenILS::WWW::PhoneList::child_init PerlChildInitHandler OpenILS::WWW::EGWeb::child_init +PerlChildInitHandler OpenILS::WWW::RemoteAuth::child_init # ---------------------------------------------------------------------------------- # Set some defaults for our working directories diff --git a/Open-ILS/examples/apache_24/eg_startup.in b/Open-ILS/examples/apache_24/eg_startup.in index 0ced7a9787..f805c60f42 100755 --- a/Open-ILS/examples/apache_24/eg_startup.in +++ b/Open-ILS/examples/apache_24/eg_startup.in @@ -14,6 +14,7 @@ use OpenILS::WWW::EGWeb ('@sysconfdir@/opensrf_core.xml', 'OpenILS::WWW::EGCatLo use OpenILS::WWW::IDL2js ('@sysconfdir@/opensrf_core.xml'); use OpenILS::WWW::FlatFielder; use OpenILS::WWW::PhoneList ('@sysconfdir@/opensrf_core.xml'); +use OpenILS::WWW::RemoteAuth ('@sysconfdir@/opensrf_core.xml'); # Pass second argument of '1' to enable template caching. use OpenILS::WWW::PrintTemplate ('/openils/conf/opensrf_core.xml', 0); @@ -27,6 +28,5 @@ use OpenILS::WWW::PrintTemplate ('/openils/conf/opensrf_core.xml', 0); #OpenILS::WWW::Redirect->parse_ips_file('@sysconfdir@/lib_ips.txt'); - 1; diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index b8fde1a4c6..dc236aeb55 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -12940,6 +12940,32 @@ SELECT usr, + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/RemoteAuth.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/RemoteAuth.pm new file mode 100644 index 0000000000..7726429c34 --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/RemoteAuth.pm @@ -0,0 +1,233 @@ +# Copyright (C) 2019 BC Libraries Cooperative +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +# ====================================================================== +# - base class for configurable HTTP API for patron auth/retrieval +# - provides generic methods shared by all handler subclasses +# - handlers take care of endpoint-specific implementation details +# ====================================================================== + +package OpenILS::WWW::RemoteAuth; +use strict; use warnings; +use Apache2::Const -compile => qw(OK DECLINED FORBIDDEN AUTH_REQUIRED HTTP_INTERNAL_SERVER_ERROR REDIRECT HTTP_BAD_REQUEST); +use DateTime::Format::ISO8601; + +use OpenSRF::EX qw(:try); +use OpenSRF::Utils::Logger qw/$logger/; +use OpenSRF::System; +use OpenILS::Utils::CStoreEditor qw/:funcs/; +use OpenILS::Application::AppUtils; +our $U = "OpenILS::Application::AppUtils"; + +my $bootstrap_config; +my @handlers_to_preinit = (); + +sub editor { + my ($self, $editor) = @_; + $self->{editor} = $editor if $editor; + return $self->{editor}; +} + +sub config { + my ($self, $config) = @_; + $self->{config} = $config if $config; + return $self->{config}; +} + +sub import { + my ($self, $bootstrap_config, $handlers) = @_; + @handlers_to_preinit = split /\s+/, $handlers, -1 if defined($handlers); +} + +sub child_init { + OpenSRF::System->bootstrap_client(config_file => $bootstrap_config); + my $idl = OpenSRF::Utils::SettingsClient->new->config_value("IDL"); + Fieldmapper->import(IDL => $idl); + OpenILS::Utils::CStoreEditor->init; + foreach my $module (@handlers_to_preinit) { + eval { + $module->use; + }; + } + return Apache2::Const::OK; +} + +sub handler { + my $r = shift; + + my $stat = Apache2::Const::AUTH_REQUIRED; + + # load the appropriate module and process our request + try { + my $module = $r->dir_config('OILSRemoteAuthHandler'); + $module->use; + my $handler = $module->new; + $stat = $handler->process($r); + } catch Error with { + $logger->error("processing RemoteAuth handler failed: @_"); + $stat = Apache2::Const::HTTP_INTERNAL_SERVER_ERROR; + }; + + return $stat; +} + +sub load_config { + my ($self, $e, $r) = @_; + + # name to use for config lookup + my $name = $r->dir_config('OILSRemoteAuthProfile'); + return undef unless $name; + + # load config + my $config = $e->retrieve_config_remoteauth_profile($name); + if ($config and $U->is_true($config->enabled)) { + return $config; + } + $logger->info("RemoteAuth: config profile $name not found (or not enabled)"); + return undef; +} + +sub do_client_auth { + my ($self, $client_username, $client_password) = @_; + my $login_resp = $U->simplereq( + 'open-ils.auth', + 'open-ils.auth.login', { + username => $client_username, + password => $client_password, + type => 'staff' + } + ); + if ($login_resp->{textcode} eq 'SUCCESS') { + return $login_resp->{payload}->{authtoken}; + } + $logger->info("RemoteAuth: failed to authenticate client $client_username"); + return undef; +} + +sub do_patron_auth { + my ($self, $e, $config, $id, $password) = @_; + my $org_unit = $config->context_org; + + return $self->backend_error unless $e->checkauth; + + # XXX + my $args = { + type => 'opac', + org => $org_unit, + identifier => $id, + password => $password, + agent => 'remoteauth' + }; + + my $response = $U->simplereq( + 'open-ils.auth', + 'open-ils.auth.login', $args); + if($U->event_code($response)) { + $logger->info("RemoteAuth: failed to authenticate user $id at org unit $org_unit"); + return $self->patron_not_authenticated; + } + + # get basic patron info via user authtoken + my $authtoken = $response->{payload}->{authtoken}; + my $user = $U->simplereq( + 'open-ils.auth', + 'open-ils.auth.session.retrieve', $authtoken); + if (!$user or $U->event_code($user)) { + $logger->error("RemoteAuth: failed to retrieve user for session $authtoken"); + return $self->backend_error; + } + my $userid = $user->id; + my $home_ou = $user->home_ou; + + unless ($e->allowed('VIEW_USER', $home_ou)) { + $logger->info("RemoteAuth: client does not have permission to view user $userid"); + return $self->client_not_authorized; + } + + # do basic validation (and skip the permit test where applicable) + if ($U->is_true($user->deleted)) { + $logger->info("RemoteAuth: user $userid is deleted"); + return $self->patron_not_found; + } + + if ($U->is_true($user->barred)) { + $logger->info("RemoteAuth: user $userid is barred"); + return $self->patron_is_blocked; + } + + # check if remoteauth is permitted for this user + my $permit_test = $e->json_query( + {from => ['actor.permit_remoteauth', $config->name, $userid]} + )->[0]{'actor.permit_remoteauth'};; + + if ($permit_test eq 'success') { + return $self->success($user); + } elsif ($permit_test eq 'not_found') { + return $self->patron_not_found; + } elsif ($permit_test eq 'expired') { + return $self->patron_is_expired; + } else { + return $self->patron_is_blocked; + } +} + +# Dummy methods for responding to the client based on +# different error (or success) conditions. +# The handler will normally want to override these methods +# with its own version of them. + +# patron auth succeeded +sub success { + return Apache2::Const::OK; +} + +# generic backend error +sub backend_error { + return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR; +} + +# client error (e.g. missing params) +sub client_error { + return Apache2::Const::HTTP_BAD_REQUEST; +} + +# client auth failed +sub client_not_authorized { + return Apache2::Const::AUTH_REQUIRED; +} + +# patron auth failed (bad password etc) +sub patron_not_authenticated { + return Apache2::Const::FORBIDDEN; +} + +# patron does not exist or is inactive/deleted +sub patron_not_found { + return Apache2::Const::DECLINED; +} + +# patron is barred or has blocking penalties +sub patron_is_blocked { + return Apache2::Const::FORBIDDEN; +} + +# patron is expired +sub patron_is_expired { + return Apache2::Const::DECLINED; +} + +1; + diff --git a/Open-ILS/src/sql/Pg/150.remoteauth.sql b/Open-ILS/src/sql/Pg/150.remoteauth.sql new file mode 100644 index 0000000000..0e7f823cb1 --- /dev/null +++ b/Open-ILS/src/sql/Pg/150.remoteauth.sql @@ -0,0 +1,85 @@ +BEGIN; + +CREATE TABLE config.remoteauth_profile ( + name TEXT PRIMARY KEY, + description TEXT, + context_org INT NOT NULL REFERENCES actor.org_unit(id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + perm INT NOT NULL REFERENCES permission.perm_list(id) ON UPDATE CASCADE ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, + restrict_to_org BOOLEAN NOT NULL DEFAULT TRUE, + allow_inactive BOOL NOT NULL DEFAULT FALSE, + allow_expired BOOL NOT NULL DEFAULT FALSE, + block_list TEXT +); + +CREATE OR REPLACE FUNCTION actor.permit_remoteauth (profile_name TEXT, userid BIGINT) RETURNS TEXT AS $func$ +DECLARE + usr actor.usr%ROWTYPE; + profile config.remoteauth_profile%ROWTYPE; + perm TEXT; + context_org_list INT[]; + home_prox INT; + block TEXT; + penalty_count INT; +BEGIN + + SELECT INTO usr * FROM actor.usr WHERE id = userid AND NOT deleted; + IF usr IS NULL THEN + RETURN 'not_found'; + END IF; + + IF usr.barred IS TRUE THEN + RETURN 'blocked'; + END IF; + + SELECT INTO profile * FROM config.remoteauth_profile WHERE name = profile_name; + SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( profile.context_org ); + + -- user's home library must be within the context org + IF profile.restrict_to_org IS TRUE AND usr.home_ou NOT IN (SELECT * FROM UNNEST(context_org_list)) THEN + RETURN 'not_found'; + END IF; + + SELECT INTO perm code FROM permission.perm_list WHERE id = profile.perm; + IF permission.usr_has_perm(usr.id, perm, profile.context_org) IS FALSE THEN + RETURN 'not_found'; + END IF; + + IF usr.expire_date < NOW() AND profile.allow_expired IS FALSE THEN + RETURN 'expired'; + END IF; + + IF usr.active IS FALSE AND profile.allow_inactive IS FALSE THEN + RETURN 'blocked'; + END IF; + + -- Proximity of user's home_ou to context_org to see if penalties should be ignored. + SELECT INTO home_prox prox FROM actor.org_unit_proximity WHERE from_org = usr.home_ou AND to_org = profile.context_org; + + -- Loop through the block list to see if the user has any matching penalties. + IF profile.block_list IS NOT NULL THEN + FOR block IN SELECT UNNEST(STRING_TO_ARRAY(profile.block_list, '|')) LOOP + SELECT INTO penalty_count COUNT(DISTINCT csp.*) + FROM actor.usr_standing_penalty usp + JOIN config.standing_penalty csp ON (csp.id = usp.standing_penalty) + WHERE usp.usr = usr.id + AND usp.org_unit IN ( SELECT * FROM UNNEST(context_org_list) ) + AND ( usp.stop_date IS NULL or usp.stop_date > NOW() ) + AND ( csp.ignore_proximity IS NULL OR csp.ignore_proximity < home_prox ) + AND csp.block_list ~ block; + IF penalty_count > 0 THEN + -- User has penalties that match this block, so auth is not permitted. + -- Don't bother testing the rest of the block list. + RETURN 'blocked'; + END IF; + END LOOP; + END IF; + + -- User has passed all tests. + RETURN 'success'; + +END; +$func$ LANGUAGE plpgsql; + +COMMIT; + 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 9637a212ff..fb8f0b5ad6 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -1923,7 +1923,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES ( 613, 'ADMIN_CAROUSEL', oils_i18n_gettext(613, 'Allow a user to manage carousels', 'ppl', 'description')), ( 614, 'REFRESH_CAROUSEL', oils_i18n_gettext(614, - 'Allow a user to refresh carousels', 'ppl', 'description')) + 'Allow a user to refresh carousels', 'ppl', 'description')), + ( 615, 'ADMIN_REMOTEAUTH', oils_i18n_gettext( 615, + 'Administer remote patron authentication', 'ppl', 'description' )) ; diff --git a/Open-ILS/src/sql/Pg/sql_file_manifest b/Open-ILS/src/sql/Pg/sql_file_manifest index 97c92a0a35..e4f9152166 100644 --- a/Open-ILS/src/sql/Pg/sql_file_manifest +++ b/Open-ILS/src/sql/Pg/sql_file_manifest @@ -36,6 +36,7 @@ FTS_CONFIG_FILE 100.circ_matrix.sql 110.hold_matrix.sql 120.floating_groups.sql +150.remoteauth.sql 210.schema.serials.sql 200.schema.acq.sql diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.remoteauth.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.remoteauth.sql new file mode 100644 index 0000000000..6936d7e3bf --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.remoteauth.sql @@ -0,0 +1,91 @@ +BEGIN; + +INSERT INTO config.upgrade_log (version) VALUES ('XXXX'); + +INSERT INTO permission.perm_list ( id, code, description ) VALUES + ( 615, 'ADMIN_REMOTEAUTH', oils_i18n_gettext( 615, + 'Administer remote patron authentication', 'ppl', 'description' )); + +CREATE TABLE config.remoteauth_profile ( + name TEXT PRIMARY KEY, + description TEXT, + context_org INT NOT NULL REFERENCES actor.org_unit(id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + perm INT NOT NULL REFERENCES permission.perm_list(id) ON UPDATE CASCADE ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, + restrict_to_org BOOLEAN NOT NULL DEFAULT TRUE, + allow_inactive BOOL NOT NULL DEFAULT FALSE, + allow_expired BOOL NOT NULL DEFAULT FALSE, + block_list TEXT +); + +CREATE OR REPLACE FUNCTION actor.permit_remoteauth (profile_name TEXT, userid BIGINT) RETURNS TEXT AS $func$ +DECLARE + usr actor.usr%ROWTYPE; + profile config.remoteauth_profile%ROWTYPE; + perm TEXT; + context_org_list INT[]; + home_prox INT; + block TEXT; + penalty_count INT; +BEGIN + + SELECT INTO usr * FROM actor.usr WHERE id = userid AND NOT deleted; + IF usr IS NULL THEN + RETURN 'not_found'; + END IF; + + IF usr.barred IS TRUE THEN + RETURN 'blocked'; + END IF; + + SELECT INTO profile * FROM config.remoteauth_profile WHERE name = profile_name; + SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( profile.context_org ); + + -- user's home library must be within the context org + IF profile.restrict_to_org IS TRUE AND usr.home_ou NOT IN (SELECT * FROM UNNEST(context_org_list)) THEN + RETURN 'not_found'; + END IF; + + SELECT INTO perm code FROM permission.perm_list WHERE id = profile.perm; + IF permission.usr_has_perm(usr.id, perm, profile.context_org) IS FALSE THEN + RETURN 'not_found'; + END IF; + + IF usr.expire_date < NOW() AND profile.allow_expired IS FALSE THEN + RETURN 'expired'; + END IF; + + IF usr.active IS FALSE AND profile.allow_inactive IS FALSE THEN + RETURN 'blocked'; + END IF; + + -- Proximity of user's home_ou to context_org to see if penalties should be ignored. + SELECT INTO home_prox prox FROM actor.org_unit_proximity WHERE from_org = usr.home_ou AND to_org = profile.context_org; + + -- Loop through the block list to see if the user has any matching penalties. + IF profile.block_list IS NOT NULL THEN + FOR block IN SELECT UNNEST(STRING_TO_ARRAY(profile.block_list, '|')) LOOP + SELECT INTO penalty_count COUNT(DISTINCT csp.*) + FROM actor.usr_standing_penalty usp + JOIN config.standing_penalty csp ON (csp.id = usp.standing_penalty) + WHERE usp.usr = usr.id + AND usp.org_unit IN ( SELECT * FROM UNNEST(context_org_list) ) + AND ( usp.stop_date IS NULL or usp.stop_date > NOW() ) + AND ( csp.ignore_proximity IS NULL OR csp.ignore_proximity < home_prox ) + AND csp.block_list ~ block; + IF penalty_count > 0 THEN + -- User has penalties that match this block, so auth is not permitted. + -- Don't bother testing the rest of the block list. + RETURN 'blocked'; + END IF; + END LOOP; + END IF; + + -- User has passed all tests. + RETURN 'success'; + +END; +$func$ LANGUAGE plpgsql; + +COMMIT; + -- 2.43.2