LP#1817645: configurable HTTP API for patron auth/retrieval
authorJeff Davis <jeff.davis@bc.libraries.coop>
Wed, 27 Feb 2019 02:02:58 +0000 (18:02 -0800)
committerGalen Charlton <gmc@equinoxinitiative.org>
Fri, 6 Sep 2019 20:25:50 +0000 (16:25 -0400)
Signed-off-by: Jeff Davis <jeff.davis@bc.libraries.coop>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>

Open-ILS/examples/apache_24/eg.conf.in
Open-ILS/examples/apache_24/eg_startup.in
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/WWW/RemoteAuth.pm [new file with mode: 0644]
Open-ILS/src/sql/Pg/150.remoteauth.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/sql_file_manifest
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.remoteauth.sql [new file with mode: 0644]

index 2ee0153..b872a3e 100644 (file)
@@ -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
index 0ced7a9..f805c60 100755 (executable)
@@ -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;
 
index b8fde1a..dc236ae 100644 (file)
@@ -12940,6 +12940,32 @@ SELECT  usr,
                </permacrud>
        </class>
 
+       <class id="cra" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::remoteauth_profile" oils_persist:tablename="config.remoteauth_profile" reporter:label="Remote Patron Authentication Configuration Profile">
+               <fields oils_persist:primary="name">
+                       <field name="name" reporter:datatype="text"/>
+                       <field name="description" reporter:datatype="text"/>
+                       <field name="context_org" reporter:datatype="org_unit"/>
+                       <field name="enabled" reporter:datatype="bool"/>
+                       <field name="perm" reporter:datatype="link"/>
+                       <field name="restrict_to_org" reporter:datatype="bool"/>
+                       <field name="allow_inactive" reporter:datatype="bool"/>
+                       <field name="allow_expired" reporter:datatype="bool"/>
+                       <field name="block_list" reporter:datatype="text"/>
+               </fields>
+               <links>
+                       <link field="context_org" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="perm" reltype="has_a" key="id" map="" class="ppl"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="ADMIN_REMOTEAUTH" context_field="context_org"/>
+                               <retrieve permission="STAFF_LOGIN" context_field="context_org"/>
+                               <update permission="ADMIN_REMOTEAUTH" context_field="context_org"/>
+                               <delete permission="ADMIN_REMOTEAUTH" context_field="context_org"/>
+                       </actions>
+               </permacrud>
+       </class>
+
        <!-- ********************************************************************************************************************* -->
 </IDL>
 
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 (file)
index 0000000..7726429
--- /dev/null
@@ -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 (file)
index 0000000..0e7f823
--- /dev/null
@@ -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;
+
index 9637a21..fb8f0b5 100644 (file)
@@ -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' ))
 ;
 
 
index 97c92a0..e4f9152 100644 (file)
@@ -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 (file)
index 0000000..6936d7e
--- /dev/null
@@ -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;
+