From 046810b3fb6dec3a1c22b8eca17932d906e2c460 Mon Sep 17 00:00:00 2001 From: Tomas Cohen Arazi Date: Tue, 5 Aug 2025 15:34:27 -0300 Subject: [PATCH] Bug 40596: Add CAS and Shibboleth authentication clients Implements modern authentication clients for CAS and Shibboleth protocols: Koha::Auth::Client::CAS: * Uses Authen::CAS::Client for ticket validation * Provides get_cas_login_url() and get_cas_logout_url() methods * Handles CAS service validation and user data extraction Koha::Auth::Client::Shibboleth: * Extracts Shibboleth attributes from environment variables * Maps attributes using provider configuration * Supports flexible attribute mapping Both clients extend Koha::Auth::Client and integrate with the modern identity provider architecture while maintaining compatibility with existing authentication flows. Test plan: 1. Apply patch 2. Configure CAS/Shibboleth identity providers 3. Test authentication flows 4. Verify user data extraction and mapping 5. Run t/db_dependent/Koha/Auth/Client.t --- Koha/Auth/Client/CAS.pm | 147 ++++++++++++++++ Koha/Auth/Client/Shibboleth.pm | 114 ++++++++++++ t/db_dependent/Koha/Auth/Client/CAS.t | 174 +++++++++++++++++++ t/db_dependent/Koha/Auth/Client/Shibboleth.t | 128 ++++++++++++++ 4 files changed, 563 insertions(+) create mode 100644 Koha/Auth/Client/CAS.pm create mode 100644 Koha/Auth/Client/Shibboleth.pm create mode 100755 t/db_dependent/Koha/Auth/Client/CAS.t create mode 100755 t/db_dependent/Koha/Auth/Client/Shibboleth.t diff --git a/Koha/Auth/Client/CAS.pm b/Koha/Auth/Client/CAS.pm new file mode 100644 index 00000000000..1961f095b8e --- /dev/null +++ b/Koha/Auth/Client/CAS.pm @@ -0,0 +1,147 @@ +package Koha::Auth::Client::CAS; + +# Copyright 2025 Koha Development team +# +# 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 Authen::CAS::Client; +use Try::Tiny qw( catch try ); + +use Koha::Logger; + +use base qw(Koha::Auth::Client); + +=head1 NAME + +Koha::Auth::Client::CAS - CAS authentication client + +=head1 API + +=head2 Class methods + +=head3 new + + my $client = Koha::Auth::Client::CAS->new( { provider => $provider } ); + +Constructor. Inherits from L. + +=cut + +=head2 Instance methods + +=head3 get_user + + my ( $user_data, $patron ) = $client->get_user( { ticket => $ticket, service => $service_url } ); + +Authenticates a user and returns their data and patron object. This method is +inherited from the parent class and calls C<_get_data_and_patron> internally. + +=cut + +=head3 _get_data_and_patron + + my ( $user_data, $patron ) = $client->_get_data_and_patron( { ticket => $ticket, service => $service_url } ); + +Validates a CAS ticket and returns user data and patron object. + +=cut + +sub _get_data_and_patron { + my ( $self, $data ) = @_; + + my $config = $self->provider->get_config; + my $ticket = $data->{ticket}; + my $service_url = $data->{service}; + + my $logger = Koha::Logger->get(); + + return ( {}, undef ) unless $ticket && $service_url; + + try { + # Initialize CAS client + my $cas = Authen::CAS::Client->new( $config->{cas_url} ); + + # Validate the ticket + my $response = $cas->service_validate( + service => $service_url, + ticket => $ticket + ); + + if ( $response->is_success ) { + my $user_identifier = $response->user; + $logger->info("CAS authentication successful for user: $user_identifier"); + + # Map CAS response to user data + my $user_data = { + userid => $user_identifier, + email => $user_identifier, # CAS typically returns userid, may need mapping + }; + + # Find patron using the configured matchpoint + my $patron = $self->_get_patron($user_data); + + return ( $user_data, $patron ); + } else { + $logger->warn( "CAS ticket validation failed: " . $response->code ); + return ( {}, undef ); + } + } catch { + $logger->error("CAS authentication error: $_"); + return ( {}, undef ); + }; +} + +=head3 get_cas_login_url + + my $login_url = $client->get_cas_login_url( $config, $service_url ); + +Returns the CAS login URL for the given service URL. + +=cut + +sub get_cas_login_url { + my ( $self, $config, $service_url ) = @_; + + my $cas = Authen::CAS::Client->new( $config->{cas_url} ); + + return $cas->login_url( service => $service_url ); +} + +=head3 get_cas_logout_url + + my $logout_url = $client->get_cas_logout_url( $config, $return_url ); + +Returns the CAS logout URL for the given return URL. + +=cut + +sub get_cas_logout_url { + my ( $self, $config, $return_url ) = @_; + + my $cas = Authen::CAS::Client->new( $config->{cas_url} ); + + return $cas->logout_url( url => $return_url ); +} + +=head1 AUTHOR + +Koha Development Team + +=cut + +1; diff --git a/Koha/Auth/Client/Shibboleth.pm b/Koha/Auth/Client/Shibboleth.pm new file mode 100644 index 00000000000..8ccefc7bc86 --- /dev/null +++ b/Koha/Auth/Client/Shibboleth.pm @@ -0,0 +1,114 @@ +package Koha::Auth::Client::Shibboleth; + +# Copyright 2025 Koha Development team +# +# 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 Koha::Logger; + +use base qw(Koha::Auth::Client); + +=head1 NAME + +Koha::Auth::Client::Shibboleth - Shibboleth authentication client + +=head1 API + +=head2 Class methods + +=head3 new + + my $client = Koha::Auth::Client::Shibboleth->new( { provider => $provider } ); + +Constructor. Inherits from L. + +=cut + +=head2 Instance methods + +=head3 get_user + + my ( $user_data, $patron ) = $client->get_user( { user_identifier => $user_id } ); + +Authenticates a user and returns their data and patron object. This method is +inherited from the parent class and calls C<_get_data_and_patron> internally. + +=cut + +=head3 _get_data_and_patron + + my ( $user_data, $patron ) = $client->_get_data_and_patron( { user_identifier => $user_id } ); + +Internal method that extracts Shibboleth attributes from the environment and +maps them to Koha patron fields according to the provider's configuration. + +This method: + +=over 4 + +=item * Extracts attributes from environment variables set by the Shibboleth SP + +=item * Maps Shibboleth attributes to Koha patron fields using the provider's mapping configuration + +=item * Ensures the user identifier is included in the user data + +=item * Attempts to find an existing patron using the configured matchpoint + +=item * Logs authentication attempts for auditing purposes + +=back + +=cut + +sub _get_data_and_patron { + my ( $self, $data ) = @_; + + my $user_identifier = $data->{user_identifier}; + return ( {}, undef ) unless $user_identifier; + + my $logger = Koha::Logger->get(); + + # Extract Shibboleth attributes from environment + my $mapping = $self->provider->get_mapping; + my $user_data = {}; + + foreach my $koha_field ( keys %$mapping ) { + my $shib_attribute = $mapping->{$koha_field}; + if ( exists $ENV{$shib_attribute} ) { + $user_data->{$koha_field} = $ENV{$shib_attribute}; + } + } + + # Ensure we have the user identifier + $user_data->{userid} = $user_identifier unless $user_data->{userid}; + + $logger->info("Shibboleth authentication for user: $user_identifier"); + + # Find patron using the configured matchpoint + my $patron = $self->_get_patron($user_data); + + return ( $user_data, $patron ); +} + +=head1 AUTHOR + +Koha Development Team + +=cut + +1; diff --git a/t/db_dependent/Koha/Auth/Client/CAS.t b/t/db_dependent/Koha/Auth/Client/CAS.t new file mode 100755 index 00000000000..d68c2b94ff5 --- /dev/null +++ b/t/db_dependent/Koha/Auth/Client/CAS.t @@ -0,0 +1,174 @@ +#!/usr/bin/perl + +# Copyright 2025 Koha Development team +# +# 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 => 5; +use Test::MockModule; +use Test::MockObject; +use Test::NoWarnings; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use Koha::Database; +use Koha::Auth::Identity::Providers; + +BEGIN { + use_ok('Koha::Auth::Client::CAS'); +} + +my $schema = Koha::Database->schema; +my $builder = t::lib::TestBuilder->new; + +subtest 'CAS client instantiation' => sub { + plan tests => 2; + + $schema->storage->txn_begin; + + my $client = Koha::Auth::Client::CAS->new(); + isa_ok( $client, 'Koha::Auth::Client::CAS' ); + isa_ok( $client, 'Koha::Auth::Client' ); + + $schema->storage->txn_rollback; +}; + +subtest '_get_data_and_patron() tests' => sub { + plan tests => 8; + + $schema->storage->txn_begin; + + # Create a CAS provider + my $provider = $builder->build_object( + { + class => 'Koha::Auth::Identity::Providers', + value => { + protocol => 'CAS', + config => '{"cas_url": "https://cas.example.com/cas"}', + mapping => '{"userid": "user", "email": "mail"}', + matchpoint => 'userid' + } + } + ); + + # Create a patron to match against + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { userid => 'testuser' } + } + ); + + my $client = Koha::Auth::Client::CAS->new(); + + # Mock Authen::CAS::Client + my $cas_mock = Test::MockModule->new('Authen::CAS::Client'); + my $response_mock = Test::MockObject->new(); + + # Test successful ticket validation + $response_mock->mock( 'is_success', sub { return 1; } ); + $response_mock->mock( 'user', sub { return 'testuser'; } ); + + my $cas_client_mock = Test::MockObject->new(); + $cas_client_mock->mock( 'service_validate', sub { return $response_mock; } ); + + $cas_mock->mock( 'new', sub { return $cas_client_mock; } ); + + # Mock the client methods that depend on provider + my $client_mock = Test::MockModule->new('Koha::Auth::Client::CAS'); + $client_mock->mock( 'provider', sub { return $provider; } ); + $client_mock->mock( '_get_patron', sub { return $patron; } ); + + my ( $user_data, $returned_patron ) = $client->_get_data_and_patron( + { + ticket => 'ST-123456', + service => 'https://koha.example.com/cgi-bin/koha/opac-main.pl' + } + ); + + is( ref($user_data), 'HASH', 'Returns user data as hashref' ); + is( $user_data->{userid}, 'testuser', 'User data contains correct userid' ); + is( $user_data->{email}, 'testuser', 'User data contains email (mapped from userid)' ); + isa_ok( $returned_patron, 'Koha::Patron', 'Returns patron object' ); + is( $returned_patron->id, $patron->id, 'Returns correct patron' ); + + # Test failed ticket validation + $response_mock->mock( 'is_success', sub { return 0; } ); + $response_mock->mock( 'code', sub { return 'INVALID_TICKET'; } ); + + ( $user_data, $returned_patron ) = $client->_get_data_and_patron( + { + ticket => 'ST-invalid', + service => 'https://koha.example.com/cgi-bin/koha/opac-main.pl' + } + ); + + is( ref($user_data), 'HASH', 'Returns empty hashref on failure' ); + is( scalar keys %$user_data, 0, 'User data is empty on failure' ); + is( $returned_patron, undef, 'Returns undef patron on failure' ); + + $schema->storage->txn_rollback; +}; + +subtest 'CAS URL generation methods' => sub { + plan tests => 4; + + $schema->storage->txn_begin; + + my $client = Koha::Auth::Client::CAS->new(); + my $config = { cas_url => 'https://cas.example.com/cas' }; + + # Mock Authen::CAS::Client + my $cas_mock = Test::MockModule->new('Authen::CAS::Client'); + my $cas_client_mock = Test::MockObject->new(); + + $cas_client_mock->mock( + 'login_url', + sub { + my ( $self, %params ) = @_; + return 'https://cas.example.com/cas/login?service=' . $params{service}; + } + ); + + $cas_client_mock->mock( + 'logout_url', + sub { + my ( $self, %params ) = @_; + return 'https://cas.example.com/cas/logout?url=' . $params{url}; + } + ); + + $cas_mock->mock( 'new', sub { return $cas_client_mock; } ); + + # Test login URL generation + my $service_url = 'https://koha.example.com/cgi-bin/koha/opac-main.pl'; + my $login_url = $client->get_cas_login_url( $config, $service_url ); + + like( $login_url, qr/https:\/\/cas\.example\.com\/cas\/login/, 'Login URL contains CAS login endpoint' ); + like( $login_url, qr/service=/, 'Login URL contains service parameter' ); + + # Test logout URL generation + my $return_url = 'https://koha.example.com/cgi-bin/koha/opac-main.pl'; + my $logout_url = $client->get_cas_logout_url( $config, $return_url ); + + like( $logout_url, qr/https:\/\/cas\.example\.com\/cas\/logout/, 'Logout URL contains CAS logout endpoint' ); + like( $logout_url, qr/url=/, 'Logout URL contains return URL parameter' ); + + $schema->storage->txn_rollback; +}; diff --git a/t/db_dependent/Koha/Auth/Client/Shibboleth.t b/t/db_dependent/Koha/Auth/Client/Shibboleth.t new file mode 100755 index 00000000000..4e07c4aa05d --- /dev/null +++ b/t/db_dependent/Koha/Auth/Client/Shibboleth.t @@ -0,0 +1,128 @@ +#!/usr/bin/perl + +# Copyright 2025 Koha Development team +# +# 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 => 4; +use Test::MockModule; +use Test::NoWarnings; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use Koha::Database; +use Koha::Auth::Identity::Providers; + +BEGIN { + use_ok('Koha::Auth::Client::Shibboleth'); +} + +my $schema = Koha::Database->schema; +my $builder = t::lib::TestBuilder->new; + +subtest 'Shibboleth client instantiation' => sub { + plan tests => 2; + + $schema->storage->txn_begin; + + my $client = Koha::Auth::Client::Shibboleth->new(); + isa_ok( $client, 'Koha::Auth::Client::Shibboleth' ); + isa_ok( $client, 'Koha::Auth::Client' ); + + $schema->storage->txn_rollback; +}; + +subtest '_get_data_and_patron() tests' => sub { + plan tests => 12; + + $schema->storage->txn_begin; + + # Create a Shibboleth provider + my $provider = $builder->build_object( + { + class => 'Koha::Auth::Identity::Providers', + value => { + protocol => 'Shibboleth', + config => + '{"sso_url": "https://idp.example.com/sso", "slo_url": "https://idp.example.com/slo", "entity_id": "https://idp.example.com"}', + mapping => '{"userid": "REMOTE_USER", "email": "mail", "firstname": "givenName", "surname": "sn"}', + matchpoint => 'userid' + } + } + ); + + # Create a patron to match against + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { userid => 'jdoe' } + } + ); + + my $client = Koha::Auth::Client::Shibboleth->new(); + + # Mock the client methods that depend on provider + my $client_mock = Test::MockModule->new('Koha::Auth::Client::Shibboleth'); + $client_mock->mock( 'provider', sub { return $provider; } ); + $client_mock->mock( '_get_patron', sub { return $patron; } ); + + # Test with complete Shibboleth environment variables + { + local %ENV = ( + %ENV, + 'REMOTE_USER' => 'jdoe', + 'mail' => 'john.doe@example.edu', + 'givenName' => 'John', + 'sn' => 'Doe' + ); + + my ( $user_data, $returned_patron ) = $client->_get_data_and_patron( { user_identifier => 'jdoe' } ); + + is( ref($user_data), 'HASH', 'Returns user data as hashref' ); + is( $user_data->{userid}, 'jdoe', 'User data contains correct userid' ); + is( $user_data->{email}, 'john.doe@example.edu', 'User data contains correct email' ); + is( $user_data->{firstname}, 'John', 'User data contains correct firstname' ); + is( $user_data->{surname}, 'Doe', 'User data contains correct surname' ); + isa_ok( $returned_patron, 'Koha::Patron', 'Returns patron object' ); + is( $returned_patron->id, $patron->id, 'Returns correct patron' ); + } + + # Test with missing environment variables + { + local %ENV = %ENV; + delete $ENV{'mail'}; + delete $ENV{'givenName'}; + delete $ENV{'sn'}; + $ENV{'REMOTE_USER'} = 'jdoe'; + + my ( $user_data, $returned_patron ) = $client->_get_data_and_patron( { user_identifier => 'jdoe' } ); + + is( $user_data->{userid}, 'jdoe', 'User data contains userid even when other attributes missing' ); + is( $user_data->{email}, undef, 'Missing environment variables result in undef values' ); + is( $user_data->{firstname}, undef, 'Missing environment variables result in undef values' ); + } + + # Test with no user identifier + my ( $user_data, $returned_patron ) = $client->_get_data_and_patron( {} ); + + is( ref($user_data), 'HASH', 'Returns empty hashref when no user identifier' ); + is( $returned_patron, undef, 'Returns undef patron when no user identifier' ); + + $schema->storage->txn_rollback; +}; -- 2.50.1