Koha/misc/bin/connexion_import_daemon.pl
Nick Clemens 16851a23aa Bug 15720: Add connexion user and password options to connexion daemon
Currently the connexion daemon does not utilize the user and password passed in the requests, it expects a
user and password to be defined in the config file and for that user to be a valid Koha user with
cataloging permissions.

With that user in place all requests to the daemon are authorized.

As the connections are over TCP we allow defining a new connexion user and password to protect Koha account information.

If not defined current behaviour is preserved. Connexion user and password must both be set it either is set.

Sample config file:
host:
port: 8888
koha:http://localhost:8081
log:/var/log/koha/kohadev/connexion.log
match:ISBN
user:kohauser
password:kohapass
overlay_action:replace
nomatch_action:create_new
item_action:always_add
import_mode:redirect
debug:1

To test:
 1 - Create connexion file and save on the Koha serve
 2 - perl misc/bin/connexion_import_daemon.pl -c /kohadevbox/koha/connexion.cnf
 3 - Ensure the user specified above (connexuser) exists and has edit catalogue permissions
 4 - In another terminal make a request to the server:
        echo -en 'U6turtleA9connexionP5shell00024    a62clear00024   4500' | nc -v localhost 8888
 5 - The request should succeed and record added to batch (probably the import fails, but not important)
 6 - Add to config file
        connexion_user:conuser
 7 - Stop and restart the daemon - it should fail on missing connexion_password
 8 - Comment out connexion_user and add
        connexion_password:conpass
 9 - Stop and restart daemon, it fails on missing connexion_user
10 - Uncomment the user and restart
11 - Make another request
        echo -en 'U6turtleA9connexionP5shell00024    a62clear00024   4500' | nc -v localhost 8888
12 - It fails 'Unauthorized request'
13 - Make another request
        echo -en 'U7conuserA9connexionP7conpass00024    a62clear00024   4500' | nc -v localhost 8888
14 - It succeeds!

Signed-off-by: Allison Blanning <ablanning@hotchkiss.org>

Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
2021-05-26 09:27:19 +02:00

424 lines
12 KiB
Perl
Executable file

#!/usr/bin/perl -w
# Copyright 2012 CatalystIT
#
# 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 <http://www.gnu.org/licenses>.
use strict;
use warnings;
use Getopt::Long;
my ($help, $config, $daemon);
GetOptions(
'config|c=s' => \$config,
'daemon|d' => \$daemon,
'help|?' => \$help,
);
if($help || !$config){
print <<EOF
$0 --config=my.conf
Parameters :
--daemon | -d - go to background; prints pid to stdout
--config | -c - config file
--help | -? - this message
Config file format:
Lines of the form:
name: value
# comments are supported
No quotes
Parameter Names:
host - ip address or hostname to bind to, defaults all available
port - port to bind to, mandatory
log - log file path, stderr if omitted
debug - dumps requests to the log file, passwords inclusive
koha - koha intranet base url, eg http://librarian.koha
user - koha user, authentication
password - koha user password, authentication
match - marc_matchers.code: ISBN or ISSN
overlay_action - import_batches.overlay_action: replace, create_new or ignore
nomatch_action - import_batches.nomatch_action: create_new or ignore
item_action - import_batches.item_action: always_add,
add_only_for_matches, add_only_for_new or ignore
import_mode - stage or direct
framework - to be used if import_mode is direct
connexion_user - User sent from connexion client
connexion_password - Password sent from connexion client
Note: If connexion parameters are not defined request authentication will not be checked
You should specify a different user for connexion to protect the Koha credentials
All process related parameters (all but ip and port) have default values as
per Koha import process.
EOF
;
exit;
}
my $server = ImportProxyServer->new($config);
if ($daemon) {
print $server->background;
} else {
$server->run;
}
exit;
{
package ImportProxyServer;
use Carp;
use IO::Socket::INET;
# use IO::Socket::IP;
use IO::Select;
use POSIX;
use HTTP::Status qw(:constants);
use strict;
use warnings;
use LWP::UserAgent;
use XML::Simple;
use MARC::Record;
use MARC::File::XML;
use constant CLIENT_READ_TIMEOUT => 5;
use constant CLIENT_READ_BUFFER_SIZE => 100000;
use constant AUTH_URI => "/cgi-bin/koha/mainpage.pl";
use constant IMPORT_SVC_URI => "/cgi-bin/koha/svc/import_bib";
sub new {
my $class = shift;
my $config_file = shift or croak "No config file";
my $self = {time_to_die => 0, config_file => $config_file };
bless $self, $class;
$self->parse_config;
return $self;
}
sub parse_config {
my $self = shift;
my $config_file = $self->{config_file};
open (my $conf_fh, '<', $config_file) or die "Cannot open config file $config: $!";
my %param;
my $line = 0;
while (<$conf_fh>) {
$line++;
chomp;
s/\s*#.*//o; # remove comments
s/^\s+//o; # trim leading spaces
s/\s+$//o; # trim trailing spaces
next unless $_;
my ($p, $v) = m/(\S+?):\s*(.*)/o;
die "Invalid config line $line: $_" unless defined $v;
$param{$p} = $v;
}
close($conf_fh);
$self->{koha} = delete( $param{koha} )
or die "No koha base url in config file";
$self->{user} = delete( $param{user} )
or die "No koha user in config file";
$self->{password} = delete( $param{password} )
or die "No koha user password in config file";
if( defined $param{connexion_user} || defined $param{connexion_password}){
# If either is defined we expect both
$self->{connexion_user} = delete( $param{connexion_user} )
or die "No koha connexion_user in config file";
$self->{connexion_password} = delete( $param{connexion_password} )
or die "No koha user connexion_password in config file";
}
$self->{host} = delete( $param{host} );
$self->{port} = delete( $param{port} )
or die "Port not specified";
$self->{debug} = delete( $param{debug} );
my $log_fh;
close $self->{log_fh} if $self->{log_fh};
if (my $logfile = delete $param{log}) {
open ($log_fh, '>>', $logfile) or die "Cannot open $logfile for write: $!";
} else {
$log_fh = \*STDERR;
}
$self->{log_fh} = $log_fh;
$self->{params} = \%param;
}
sub log {
my $self = shift;
my $log_fh = $self->{log_fh}
or warn "No log fh",
return;
my $t = localtime;
print $log_fh map "$t: $_\n", @_;
}
sub background {
my $self = shift;
my $pid = fork;
return ($pid) if $pid; # parent
die "Couldn't fork: $!" unless defined($pid);
POSIX::setsid() or die "Can't start a new session: $!";
$SIG{INT} = $SIG{TERM} = $SIG{HUP} = sub { $self->{time_to_die} = 1 };
# trap or ignore $SIG{PIPE}
$SIG{USR1} = sub { $self->parse_config };
$self->run;
}
sub run {
my $self = shift;
my $server_port = $self->{port};
my $server_host = $self->{host};
my $server = IO::Socket::INET->new(
LocalHost => $server_host,
LocalPort => $server_port,
Type => SOCK_STREAM,
Proto => "tcp",
Listen => 12,
Blocking => 1,
ReuseAddr => 1,
) or die "Couldn't be a tcp server on port $server_port: $! $@";
$self->log("Started tcp listener on $server_host:$server_port");
$self->{ua} = _ua();
while ("FOREVER") {
my $client = $server->accept()
or die "Cannot accept: $!";
my $oldfh = select($client);
$self->handle_request($client);
select($oldfh);
last if $self->{time_to_die};
}
close($server);
}
sub _ua {
my $ua = LWP::UserAgent->new;
$ua->timeout(10);
$ua->cookie_jar({});
return $ua;
}
sub read_request {
my ( $self, $io ) = @_;
my ($in, @in_arr, $timeout, $bad_marc);
my $select = IO::Select->new($io) ;
while ( "FOREVER" ) {
if ( $select->can_read(CLIENT_READ_TIMEOUT) ){
$io->recv($in, CLIENT_READ_BUFFER_SIZE);
last unless $in;
# XXX ignore after NULL
if ( $in =~ m/^(.*)\000/so ) { # null received, EOT
push @in_arr, $1;
last;
}
push @in_arr, $in;
}
else {
last;
}
}
$in = join '', @in_arr;
$in =~ m/(.)$/;
my $lastchar = $1;
my ($xml, $user, $password, $local_user);
my $data = $in; # copy for diagmostic purposes
while () {
my $first = substr( $data, 0, 1 );
if (!defined $first) {
last;
}
$first eq 'U' && do {
($user, $data) = _trim_identifier($data);
next;
};
$first eq 'A' && do {
($local_user, $data) = _trim_identifier($data);
next;
};
$first eq 'P' && do {
($password, $data) = _trim_identifier($data);
next;
};
$first eq ' ' && do {
$data = substr( $data, 1 ); # trim
next;
};
$data =~ m/^[0-9]/ && do {
# What we have here might be a MARC record...
my $marc_record;
eval { $marc_record = MARC::Record->new_from_usmarc($data); };
if ($@) {
$bad_marc = 1;
}
else {
$xml = $marc_record->as_xml();
}
last;
};
last; # unexpected input
}
my @details;
push @details, "Timeout" if $timeout;
push @details, "Bad MARC" if $bad_marc;
push @details, "User: $user" if $user;
push @details, "Password: " . ( $self->{debug} ? $password : ("x" x length($password)) ) if $password;
push @details, "Local user: $local_user" if $local_user;
push @details, "XML: $xml" if $xml;
push @details, "Remaining data: $data" if ($data && !$xml);
unless ($xml) {
$self->log("Invalid request", $in, @details);
return;
}
$user = $local_user if !$user && $local_user;
$self->log("Request", @details);
$self->log($in) if $self->{debug};
return ($xml, $user, $password);
}
sub _trim_identifier {
#my ($a, $len) = unpack "cc", substr( $_[0], 0, 2 );
my $len=ord(substr ($_[0], 1, 1)) - 64;
if ($len <0) { #length is numeric, and thus comes from the web client, not the desktop client.
$_[0] =~ m/.(\d+)/;
$len = $1;
return ( substr( $_[0], length($len)+1 , $len ), substr( $_[0], length($len) + 1 + $len ) );
}
return ( substr( $_[0], 2 , $len ), substr( $_[0], 2 + $len ) );
}
sub handle_request {
my ( $self, $io ) = @_;
my ($data, $user, $password) = $self->read_request($io)
or return $self->error_response("Bad request");
unless(
!(defined $self->{connexion_user}) ||
($user eq $self->{connexion_user} && $password eq $self->{connexion_password})
){
return $self->error_response("Unauthorized request");
}
my $ua;
if ($self->{user}) {
$user = $self->{user};
$password = $self->{password};
$ua = $self->{ua};
}
else {
$ua = _ua(); # fresh one, needs to authenticate
}
my $base_url = $self->{koha};
my $resp = $ua->post( $base_url.IMPORT_SVC_URI,
{'nomatch_action' => $self->{params}->{nomatch_action},
'overlay_action' => $self->{params}->{overlay_action},
'match' => $self->{params}->{match},
'import_mode' => $self->{params}->{import_mode},
'framework' => $self->{params}->{framework},
'item_action' => $self->{params}->{item_action},
'xml' => $data});
my $status = $resp->code;
if ($status == HTTP_UNAUTHORIZED || $status == HTTP_FORBIDDEN) {
my $user = $self->{user};
my $password = $self->{password};
$resp = $ua->post( $base_url.AUTH_URI, { userid => $user, password => $password } );
$resp = $ua->post( $base_url.IMPORT_SVC_URI,
{'nomatch_action' => $self->{params}->{nomatch_action},
'overlay_action' => $self->{params}->{overlay_action},
'match' => $self->{params}->{match},
'import_mode' => $self->{params}->{import_mode},
'framework' => $self->{params}->{framework},
'item_action' => $self->{params}->{item_action},
'xml' => $data})
if $resp->is_success;
}
unless ($resp->is_success) {
$self->log("Unsuccessful request", $resp->request->as_string, $resp->as_string);
return $self->error_response("Unsuccessful request");
}
my ($koha_status, $bib, $overlay, $batch_id, $error, $url);
if ( my $r = eval { XMLin($resp->content) } ) {
$koha_status = $r->{status};
$batch_id = $r->{import_batch_id};
$error = $r->{error};
$bib = $r->{biblionumber};
$overlay = $r->{match_status};
$url = $r->{url};
}
else {
$koha_status = "error";
$self->log("Response format error:\n$resp->content");
return $self->error_response("Invalid response");
}
if ($koha_status eq "ok") {
my $response_string = sprintf( "Success. Batch number %s - biblio record number %s",
$batch_id,$bib);
$response_string .= $overlay eq 'no_match' ? ' added to Koha.' : ' overlaid by import.';
$response_string .= "\n\n$url";
return $self->response( $response_string );
}
return $self->error_response( sprintf( "%s. Please contact administrator.", $error ) );
}
sub error_response {
my $self = shift;
$self->response(@_);
}
sub response {
my $self = shift;
$self->log("Response: $_[0]");
printf $_[0] . "\0";
}
} # package