From 6cf8ce4d48db3b3e42c8f6e76e0bf627d02a19e8 Mon Sep 17 00:00:00 2001 From: Kyle M Hall Date: Tue, 28 Feb 2017 15:56:01 +0000 Subject: [PATCH] Bug 18768 - Add support for authenticating against external SIP servers ala LDAP and CAS This feature allows Koha use to use other ILSs as an authenticate target in a manner someone similar to how we authenticate LDAP. It allows Koha to be configured with a list of external SIP2 servers to authenticate against. Koha will find the correct server to use based on the order and cardnumber prefix chosen, send a patron information request and handle the patron login based on the response. Test Plan: 1) Apply this patch set 2) Set up another Koha server as a SIP2 target 3) Follow the setup procedure detailed in the POD in Auth_with_sip.pm 4) Log in with a username/password that exists on the external server but not in this one --- C4/Auth.pm | 12 +- C4/Auth_with_sip.pm | 387 ++++++++++++++++++++++++++++++++++++++++++++++++++++ t/Auth_with_sip.t | 114 ++++++++++++++++ 3 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 C4/Auth_with_sip.pm create mode 100755 t/Auth_with_sip.t diff --git a/C4/Auth.pm b/C4/Auth.pm index 9e787c0..d3d2fbd 100644 --- a/C4/Auth.pm +++ b/C4/Auth.pm @@ -41,7 +41,7 @@ use List::MoreUtils qw/ any /; use Encode qw( encode is_utf8); # use utf8; -use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $debug $ldap $cas $caslogout $shib $shib_login); +use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $debug $ldap $cas $caslogout $shib $shib_login $sip); BEGIN { sub psgi_env { any { /^psgi\./ } keys %ENV } @@ -58,12 +58,17 @@ BEGIN { &get_all_subpermissions &get_user_subpermissions ); %EXPORT_TAGS = ( EditPermissions => [qw(get_all_subpermissions get_user_subpermissions)] ); + $sip = C4::Context->config('use_sip') || 0; $ldap = C4::Context->config('useldapserver') || 0; $cas = C4::Context->preference('casAuthentication'); $shib = C4::Context->config('useshibboleth') || 0; $caslogout = C4::Context->preference('casLogout'); require C4::Auth_with_cas; # no import + if ($sip) { + require C4::Auth_with_sip; + import C4::Auth_with_sip qw(checkpw_sip); + } if ($ldap) { require C4::Auth_with_ldap; import C4::Auth_with_ldap qw(checkpw_ldap); @@ -1786,6 +1791,11 @@ sub checkpw { } else { @return = (0); } + } elsif ($sip) { + $debug and print STDERR "## checkpw - checking SIP\n"; + my ( $retval, $retcard, $retuserid ) = checkpw_sip( $userid, $password ); # EXTERNAL AUTH + return 0 if $retval == -1; # Incorrect userid/password for SIP server login attempt + ($retval) and return ( $retval, $retcard, $retuserid ); } # If we are in a shibboleth session (shibboleth is enabled, and a shibboleth match attribute is present) diff --git a/C4/Auth_with_sip.pm b/C4/Auth_with_sip.pm new file mode 100644 index 0000000..0edd563 --- /dev/null +++ b/C4/Auth_with_sip.pm @@ -0,0 +1,387 @@ +package C4::Auth_with_sip; + +# Copyright 2017 Kyle M Hall +# +# This file is part of Koha. +# +# Koha 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 3 of the License, or +# (at your option) any later version. +# +# Koha 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 Koha; if not, see . + +use Modern::Perl; + +use C4::Context; +use C4::Members qw(AddMember); +use C4::Members::Messaging; + +use Koha::SIP::Client; +use Koha::Patrons; +use Koha::Patron::Categories; + +use vars qw( @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS ); + +BEGIN { + require Exporter; + @ISA = qw(Exporter); + @EXPORT = qw(checkpw_sip); +} + +my $sip = C4::Context->config("sip_servers") + or die 'No "sip_servers" in server hash from KOHA_CONF: ' . $ENV{KOHA_CONF}; + +my @sip_servers = $sip->{id} ? $sip : map { $sip->{sip_server}->{$_} } + keys %{ $sip->{sip_server} }; + +@sip_servers = sort { $a->{precedence} cmp $b->{precedence} } @sip_servers; + +=head1 checkpw_sip + + my ( $success, $cardnumber, $userid ) = checkpw_sip( $userid, $password ); + + Checks the given user id and password against a set of SIP servers. Returns + $success if the credentials were verified, along with the cardnumber and userid + to be used for the patron. + +=cut + +sub checkpw_sip { + my ( $userid, $password ) = @_; + + my $server = select_server( $userid, \@sip_servers ); + + return 0 unless $server; + + my $sip_patron; + $sip_patron = fetch_patron( $userid, $password, $server ); + + return 0 + unless $sip_patron + && $sip_patron->{BL} eq 'Y' + && $sip_patron->{CQ} eq 'Y'; + + my $patron = Koha::Patrons->find( { userid => $userid } ); + + my $patron_hashref; + if ( ( $patron && $server->{update} ) + || ( !$patron && $server->{replicate} ) ) + { + $patron_hashref = sip_to_koha_patron( $sip_patron, $server, $userid ); + } + + if ($patron) { + $patron->update_password( $userid, $password ) + if $server->{update_password}; + + if ( $server->{update} ) { # A1, B1 + $patron->set($patron_hashref); + $patron->store(); + } + else { # C1, D1 + return ( 1, $patron->cardnumber, $patron->userid ); + } + } + elsif ( $server->{replicate} ) { # A2, C2 + $patron_hashref->{password} = $password; + $patron_hashref->{cardnumber} //= $userid; + + my $borrowernumber = C4::Members::AddMember(%$patron_hashref) + or die "AddMember failed"; + + C4::Members::Messaging::SetMessagingPreferencesFromDefaults( + { + borrowernumber => $borrowernumber, + categorycode => $patron_hashref->{categorycode} + } + ); + } + else { + return 0; # B2, D2 + } +} + +=head1 sip_to_koha_patron + +my $patron_hashref = sip_to_koha_patron( $sip_patron_hashref, $sip_server, $patron_cardnumber ); + +Returns a Koha-style patron hashref derived from a SIP patron information response hashref and a server mapping definition. + +=cut + +sub sip_to_koha_patron { + my $sip_patron = shift; + my $server = shift; + my $cardnumber = shift; + + my $patron_hashref = { cardnumber => $cardnumber, userid => $cardnumber }; + + my $mapping = $server->{mapping}; + + foreach my $key ( keys %$mapping ) { + my $data = $sip_patron->{ $mapping->{$key}->{is} }; + unless ( defined $data ) { + $data = $mapping->{$key}->{content}; + } + + $patron_hashref->{$key} = $data; + } + + # Fall back to default patron category if the supplied one doesn't exit + my $patron_category = + Koha::Patron::Categories->find( $patron_hashref->{categorycode} ); + unless ($patron_category) { + my $default = $mapping->{categorycode}->{content}; + $patron_hashref->{categorycode} = $default; + } + + return $patron_hashref; +} + +=head1 sip_message_to_hashref + + my $sip_patron_hashref = sip_message_to_hashref( $sip_patron_information_response ); + + Converts a raw SIP patron response message to a more useful hashref of fixed and variable fields. + +=cut + +sub sip_message_to_hashref { + my ($data) = @_; + + my @parts = split( /\|/, $data ); + + my $fixed_fields = shift(@parts); + my $patron_status_field = substr( $fixed_fields, 2, 14 ); + my $patron_status; + $patron_status->{charge_privileges_denied} = + substr( $patron_status_field, 0, 1 ); + $patron_status->{renewal_privileges_denied} = + substr( $patron_status_field, 1, 1 ); + $patron_status->{recall_privileges_denied} = + substr( $patron_status_field, 2, 1 ); + $patron_status->{hold_privileges_denied} = + substr( $patron_status_field, 3, 1 ); + $patron_status->{card_reported_lost} = substr( $patron_status_field, 4, 1 ); + $patron_status->{too_many_items_charged} = + substr( $patron_status_field, 5, 1 ); + $patron_status->{too_many_items_overdue} = + substr( $patron_status_field, 6, 1 ); + $patron_status->{too_many_renewals} = substr( $patron_status_field, 7, 1 ); + $patron_status->{too_many_claims_of_items_returned} = + substr( $patron_status_field, 8, 1 ); + $patron_status->{too_many_items_lost} = + substr( $patron_status_field, 9, 1 ); + $patron_status->{excessive_outstanding_fines} = + substr( $patron_status_field, 10, 1 ); + $patron_status->{excessive_outstanding_fees} = + substr( $patron_status_field, 11, 1 ); + $patron_status->{recall_overdue} = substr( $patron_status_field, 12, 1 ); + $patron_status->{too_many_items_billed} = + substr( $patron_status_field, 13, 1 ); + + my $hold_items_count = substr( $fixed_fields, 37, 4 ); + + pop(@parts); + + my %fields = map { substr( $_, 0, 2 ) => substr( $_, 2 ) } @parts; + $fields{patron_status} = $patron_status; + $fields{hold_items_count} = $hold_items_count; + + return \%fields; +} + +=head1 select_server + + my $server = select_server( $userid, \@sip_servers ); + + Selects the correct SIP server to be checked for a given userid + and a set of ordered SIP servers. + +=cut + +sub select_server { + my ( $userid, $sip_servers ) = @_; + + foreach my $server (@$sip_servers) { + my $prefix = $server->{prefix}; + + # First server without prefix we come across is an automatic match + return $server unless $prefix; + + # If cardnumber starts with the server prefix, we have a match + my $match = substr( $userid, 0, length($prefix) ) eq $prefix; + return $server if $match; + } +} + +=head1 fetch_patron + + my $response = fetch_patron( $userid, $password, $server ); + + Returns a raw patron information response for a given user id, password, and server. + +=cut + +sub fetch_patron { + my ( $userid, $password, $server ) = @_; + + my $prefix = $server->{prefix}; + + # If a cardnumber prefix is set for this server, strip it off the userid + if ($prefix) { + my $prefix_length = length($prefix); + $userid = substr( $userid, $prefix_length ); + } + + my $host = $server->{host}; + my $port = $server->{port}; + my $sip_user = $server->{userid}; + my $sip_pass = $server->{password}; + my $location = $server->{location}; + my $terminator = $server->{terminator}; + + # Initial Login + my $client = Koha::SIP::Client->new( + { + host => $host, # sip server ip + port => $port, # sip server port + sip_user => $sip_user, # sip user + sip_pass => $sip_pass, # sip password + location => $location, # sip location code + terminator => $terminator, # CR or CRLF + } + ); + + my $response = $client->send( { message => 'login' } ); + + # Incorrect userid/password for SIP server login attempt + unless ( substr( $response, 0, 3 ) eq '941' ) { + return -1; + } + + # Patron Information request + $response = $client->send( + { + message => 'patron_information', + patron => $userid, + password => $password, + } + ); + + $response = sip_message_to_hashref($response); + + return $response; +} + +1; +__END__ + +=head1 NAME + +C4::Auth_with_sip - Authenticates Koha users against external SIP servers + +=head1 SYNOPSIS + + use C4::Auth_with_sip; + +=head1 Configuration + + This module is specific to SIP authentication. It requires one or more working external SIP servers. + + To use it: + * Add a sip_servers block in KOHA_CONF + * Establish field mapping in element to map SIP2 field codes to Koha field names + + Make sure that ALL required fields are populated by your LDAP database (and mapped in KOHA_CONF). + +=head1 KOHA_CONF and field mapping + +Example XML stanza for LDAP configuration in KOHA_CONF. + + + 1 + + + + 1 + + sip.example.com + 6001 + koha_sip + PASSWORD + LOC + + MPL + + 1 + 1 + 1 + + + + MPL + PT + + + + + + +The subelements establish the relationship between database table columns and SIP fields. +The element name is the database table column, with the "is" being set to the SIP field name. +Optionally, any content between the element tags is taken as a default value. + +=head1 CONFIGURATION + +Once a user has been accepted by the LDAP server, there are several possibilities for how Koha will behave, depending on +your configuration and the presence of a matching Koha user in your local DB: + + LOCAL + USER + OPTION UPDATE REPLICATE EXISTS? RESULT + A1 1 1 1 OK : We're updating them anyway. + A2 1 1 0 OK : We're adding them anyway. + B1 1 0 1 OK : We update them. + B2 1 0 0 FAIL: We cannot add new user. + C1 0 1 1 OK : We do nothing. (Except possibly update password) + C2 0 1 0 OK : We add the new user. + D1 0 0 1 OK : We do nothing. (Except possibly update password) + D2 0 0 0 FAIL: We cannot add new user. + +Note: failure here just means that Koha will fallback to checking the local DB. That is, a given user could login with +their SIP password OR their local one. If this is a problem, then you should enable update and supply a mapping for +password. Then the local value will be updated at successful LDAP login and the passwords will be synced. + +If you choose NOT to update local users, the borrowers table will not be affected at all. +Note that this means that patron passwords may appear to change if LDAP is ever disabled, because +the local table never contained the SIP values. + +=head2 replicate + +If this tag is set to true, then the patron record in Koha will be created based on the recieved +values from the SIP server if the patron does not exists in the database already. + +=head2 update + +If this tag is set to true, then the patron record in Koha will be updated based on the +values recieved from the SIP server if a matching patron already exists. + +=head2 update_password + +If this tag is set to a true value, then the user's SIP password +will be stored (hashed) in the local Koha database. If you don't want this +to happen, then set the value of this to '0'. Note that if passwords are not +stored locally, and the connection to the SIP server fails, then the users +will not be able to log in at all. + +This option works independently of 'update' and 'replicate'. + +=cut diff --git a/t/Auth_with_sip.t b/t/Auth_with_sip.t new file mode 100755 index 0000000..a2c01f7 --- /dev/null +++ b/t/Auth_with_sip.t @@ -0,0 +1,114 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Koha 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 3 of the License, or +# (at your option) any later version. +# +# Koha 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 Koha; if not, see . + +use Modern::Perl; + +use Test::More tests => 9; + +use_ok('C4::Auth_with_sip'); +can_ok( 'C4::Auth_with_sip', 'checkpw_sip' ); + +my $sip_message = +q{64 00120170516 103344000000000001000000000000AOMPL|AA9876543210|AEKyle M. Hall|BLY|CQY|CC3|BD123 Liberty Ave Natrona Heights PA 15065|BEkyle@kylehall.info|BF1234567890|PB19810610|PCST|PIY|AFGreetings from Koha. |} + . "\n"; +my $sip_hashref_expected = { + 'BL' => 'Y', + 'AA' => '9876543210', + 'AF' => 'Greetings from Koha. ', + 'PI' => 'Y', + 'patron_status' => { + 'too_many_items_overdue' => ' ', + 'too_many_items_lost' => ' ', + 'too_many_claims_of_items_returned' => ' ', + 'recall_overdue' => ' ', + 'excessive_outstanding_fees' => ' ', + 'hold_privileges_denied' => ' ', + 'too_many_items_billed' => ' ', + 'excessive_outstanding_fines' => ' ', + 'too_many_renewals' => ' ', + 'renewal_privileges_denied' => ' ', + 'recall_privileges_denied' => ' ', + 'charge_privileges_denied' => ' ', + 'card_reported_lost' => ' ', + 'too_many_items_charged' => ' ' + }, + 'PC' => 'ST', + 'hold_items_count' => '0000', + 'CQ' => 'Y', + 'PB' => '19810610', + 'CC' => '3', + 'AE' => 'Kyle M. Hall', + 'BE' => 'kyle@kylehall.info', + 'BF' => '1234567890', + 'BD' => '123 Liberty Ave Natrona Heights PA 15065' +}; +my $sip_hashref = C4::Auth_with_sip::sip_message_to_hashref($sip_message); +is_deeply( $sip_hashref, $sip_hashref_expected, + 'SIP message converted to hashref correctly' ); + +my $servers = [ + { priority => 1, prefix => 'AAA' }, + { priority => 2, prefix => 'BBB' }, + { priority => 3, prefix => 'CCC' }, + { priority => 4, prefix => undef }, + { priority => 4, prefix => 'XXX' }, +]; +my $server = C4::Auth_with_sip::select_server( 'AAA123', $servers ); +is( $server->{priority}, 1, 'Got correct server with prefix AAA' ); +$server = C4::Auth_with_sip::select_server( 'BBB123', $servers ); +is( $server->{priority}, 2, 'Got correct server with prefix BBB' ); +$server = C4::Auth_with_sip::select_server( 'CCC123', $servers ); +is( $server->{priority}, 3, 'Got correct server with prefix CCC' ); +$server = C4::Auth_with_sip::select_server( '123', $servers ); +is( $server->{priority}, 4, 'Got correct server with no prefix' ); +$server = C4::Auth_with_sip::select_server( 'XXX123', $servers ); +is( $server->{priority}, 4, + 'Got correct server with prefix XXX, server with no prefix is a catch-all' +); + +$server = { + 'precedence' => '1', + 'prefix' => 'MPL', + 'mapping' => { + 'categorycode' => { + 'content' => 'PT', + 'is' => 'PC' + }, + 'email' => { + 'is' => 'BE' + }, + 'branchcode' => { + 'is' => '', + 'content' => 'MPL' + }, + 'surname' => { + 'is' => 'AE' + } + }, +}; +my $patron_hashref_expected = { + 'email' => 'kyle@kylehall.info', + 'surname' => 'Kyle M. Hall', + 'branchcode' => 'MPL', + 'categorycode' => 'ST', + 'cardnumber' => 'MPL9876543210', + 'userid' => 'MPL9876543210' +}; +my $patron_hashref = + C4::Auth_with_sip::sip_to_koha_patron( $sip_hashref, $server, + 'MPL9876543210' ); +is_deeply( $patron_hashref, $patron_hashref_expected, 'Koha-style patron hashref matches expected patron hashref' ); -- 2.10.2