From 3cb181ede945e55375be5adac1ffc14b3ac80bbe Mon Sep 17 00:00:00 2001 From: Jacob O'Mara Date: Thu, 10 Apr 2025 12:29:55 +0100 Subject: [PATCH] Bug 40824: Create borrower password history object classes These are named as PatronPasswordHistory(ies) for consistency with the patron.pm class objects --- Koha/PatronPasswordHistories.pm | 177 ++++++++++++++++++++++++++++++++ Koha/PatronPasswordHistory.pm | 115 +++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 Koha/PatronPasswordHistories.pm create mode 100644 Koha/PatronPasswordHistory.pm diff --git a/Koha/PatronPasswordHistories.pm b/Koha/PatronPasswordHistories.pm new file mode 100644 index 00000000000..ada409354d7 --- /dev/null +++ b/Koha/PatronPasswordHistories.pm @@ -0,0 +1,177 @@ +package Koha::PatronPasswordHistories; + +# Copyright 2023 +# +# 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::Database; +use Koha::PatronPasswordHistory; +use C4::Context; +use Koha::AuthUtils; + +use base qw(Koha::Objects); + +=head1 NAME + +Koha::PatronPasswordHistories - Koha PatronPasswordHistory Object set class + +=head1 API + +=head2 Class Methods + +=head3 cleanup_old_password_history + + Koha::PatronPasswordHistories->cleanup_old_password_history($borrowernumber, $count); + +Clean up old password history for a patron, keeping only the specified number of most recent entries. +If count is 0 or negative, all password history for the patron will be deleted. + +=cut + +sub cleanup_old_password_history { + my ($class, $borrowernumber, $count) = @_; + + return unless $borrowernumber; + + # If count is 0 or negative, delete all entries + if ($count <= 0) { + my $deleted = $class->search({ + borrowernumber => $borrowernumber + })->delete; + return $deleted; + } + + # Get IDs of entries to keep (the most recent ones) + my @keep_ids = $class->search( + { borrowernumber => $borrowernumber }, + { + order_by => { -desc => 'created_on' }, + rows => $count, + columns => ['id'] + } + )->get_column('id'); + + # Delete all entries for this borrower except the ones we're keeping + my $deleted = 0; + if (@keep_ids) { + $deleted = $class->search({ + borrowernumber => $borrowernumber, + id => { -not_in => \@keep_ids } + })->delete; + } + + return $deleted; +} + +=head3 _type + +Return the DBIC resultset type for this class + +=cut + +sub _type { + # The table name in DB is borrower_password_history + return 'BorrowerPasswordHistory'; +} + +=head3 object_class + +Return the object class for items in this collection + +=cut + +sub object_class { + return 'Koha::PatronPasswordHistory'; +} + +=head3 has_used_password + + my $bool = Koha::PatronPasswordHistories->has_used_password({ + borrowernumber => $borrowernumber, + password => $plain_text_password, + current_password => $hashed_current_password # Optional + }); + +Check if the given password exists in the password history for this patron. +If current_password is provided, it will also check against that. +Returns true if the password has been used before, false otherwise. + +=cut + +sub has_used_password { + my ($class, $params) = @_; + + my $borrowernumber = $params->{borrowernumber}; + my $password = $params->{password}; + my $current_password = $params->{current_password}; + + unless ($borrowernumber && $password) { + return 0; + } + + my $history_count = C4::Context->preference('PasswordHistoryCount') || 0; + + # If history count is 0, don't check history at all + if ($history_count == 0) { + return 0; + } + + # If history count is 1, only check current password + if ($history_count == 1) { + my $matches_current = $current_password && C4::Auth::checkpw_hash($password, $current_password); + return $matches_current ? 1 : 0; + } + + # First check if password matches current one, if provided + if ($current_password && C4::Auth::checkpw_hash($password, $current_password)) { + return 1; # Current password counts as a used password + } + + # Adjust history count to account for current password + my $history_entries_to_check = $current_password ? $history_count - 1 : $history_count; + + return 0 if $history_entries_to_check <= 0; # No need to check history if only checking current + + # Get the most recent password history entries + my $password_history = $class->search( + { borrowernumber => $borrowernumber }, + { + order_by => { -desc => 'created_on' }, + rows => $history_entries_to_check + } + ); + + # Check each password history entry + while (my $history_entry = $password_history->next) { + # Use checkpw_hash to properly compare the plaintext password with stored hash + my $matches = C4::Auth::checkpw_hash($password, $history_entry->password); + if ($matches) { + return 1; # Password found in history + } + } + + return 0; # Password not found in history +} + +=head1 AUTHOR + +Koha Development Team + +=cut + +1; \ No newline at end of file diff --git a/Koha/PatronPasswordHistory.pm b/Koha/PatronPasswordHistory.pm new file mode 100644 index 00000000000..175f58de079 --- /dev/null +++ b/Koha/PatronPasswordHistory.pm @@ -0,0 +1,115 @@ +package Koha::PatronPasswordHistory; + +# Copyright 2023 +# +# 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::Database; +use Koha::AuthUtils; +use C4::Context; +use C4::Auth; +use Koha::Patrons; + +use base qw(Koha::Object); + +=head1 NAME + +Koha::PatronPasswordHistory - Koha PatronPasswordHistory Object class + +=head1 API + +=head2 Class methods + +=head3 _type + +Return the DBIC resultset type for this object + +=cut + +sub _type { + # The table name in DB is borrower_password_history + return 'BorrowerPasswordHistory'; +} + +=head3 koha_objects_class + +Define relationship with the Koha::PatronPasswordHistories class + +=cut + +sub koha_objects_class { + return 'Koha::PatronPasswordHistories'; +} + +=head3 store + +Overridden store method to ensure the password is hashed before storing. +Also handles cleanup of old password history entries. + +=cut + +sub store { + my ($self) = @_; + + my $borrowernumber = $self->borrowernumber; + my $password = $self->password; + + unless ($password) { + return; + } + + # Note: Don't use string comparison for password hashes - this is a common pattern + # where we're intentionally storing the old password in history + # The old check would fail when the patron's current hashed password was exactly + # what we were trying to store (which is the expected case when changing passwords) + + # Hash the password if it hasn't been hashed already + unless ( $self->in_storage ) { + if ( $password && $password !~ /\$2a\$/ ) { + $self->password( Koha::AuthUtils::hash_password($password) ); + } + } + + # Store in database + $self = $self->SUPER::store(); + + # Get the history count preference + my $history_count = C4::Context->preference('PasswordHistoryCount') || 0; + + # Clean up old history entries + # Adjust history count to account for current password + my $history_entries_to_keep = $history_count <= 1 + ? $history_count # If 0 or 1, keep that many + : $history_count - 1; # Otherwise keep historical entries minus current + + # Clean up old entries + Koha::PatronPasswordHistories->cleanup_old_password_history( + $borrowernumber, + $history_entries_to_keep + ); + + return $self; +} + +=head1 AUTHOR + +Koha Development Team + +=cut + +1; \ No newline at end of file -- 2.39.5