Bugzilla – Attachment 185171 Details for
Bug 40596
Migrate CAS and Shibboleth into the identity providers
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 40596: Add CAS and Shibboleth authentication clients
Bug-40596-Add-CAS-and-Shibboleth-authentication-cl.patch (text/plain), 19.60 KB, created by
Tomás Cohen Arazi (tcohen)
on 2025-08-06 20:05:04 UTC
(
hide
)
Description:
Bug 40596: Add CAS and Shibboleth authentication clients
Filename:
MIME Type:
Creator:
Tomás Cohen Arazi (tcohen)
Created:
2025-08-06 20:05:04 UTC
Size:
19.60 KB
patch
obsolete
>From 046810b3fb6dec3a1c22b8eca17932d906e2c460 Mon Sep 17 00:00:00 2001 >From: Tomas Cohen Arazi <tomascohen@theke.io> >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 <http://www.gnu.org/licenses>. >+ >+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<Koha::Auth::Client>. >+ >+=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 <http://www.gnu.org/licenses>. >+ >+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<Koha::Auth::Client>. >+ >+=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 <http://www.gnu.org/licenses>. >+ >+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 <http://www.gnu.org/licenses>. >+ >+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
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 40596
:
185168
|
185169
|
185170
| 185171 |
185172
|
185173
|
185174
|
185175
|
185176
|
185177