@@ -, +, @@ I've addressed a lot of Liz Rea's points. You can now reset the password by submitting either your useid or email address. Both fields can be filled, but the email address must be one of the borrower's (email, emailpro or b_email). When entering only the email address and two borrowers use that same address, the system tells the user to try with another address or to specify his userid. Takes the same argments as EnqueueLetter Sends the letter. Saves the letter in the message queue with a 'sent' status. TEST PLAN: 1) apply the patch 2) go to system preferences OPAC>>Privacy and set 'OpacResetPassword' to ON. 2b) make sure that OpacPasswordChange is also ON. 1) refresh front page, click on 'Forgot your password' and enter a VALID address 1b) Also try an INVALID address (valid yet not in your koha db). An error message will show up. 2) An email should be received at that address with a link. 3) Follow the link in the mail to fill the new password. Until a satisfactory new password is entered, the old password is not reset. 4) Go to main page try the new password. 1) Repeat the password reset, this time use the userid (username) field. 2) Try to reset the password using a userid and an email not linked to the account. An error appears. 3) Make sure the borrower has many available email addresses. 4) For each email, reset the password using both the userid and the email. The link should be sent to the specified address 1) Make sure two borrowers use the same email. 2) Repeat the reset procedure in test case A). An error message appears Author: Maxime Beaulieu --- C4/Passwordrecovery.pm | 173 ++++++++++++++++++++ Koha/Schema/Result/BorrowerPasswordRecovery.pm | 66 ++++++++ .../bug_8753-Add_forgot_password_link_to_OPAC.sql | 12 ++ .../data/mysql/en/mandatory/sample_notices.sql | 4 + .../prog/en/modules/admin/preferences/opac.pref | 8 + .../opac-tmpl/bootstrap/en/includes/masthead.inc | 6 + .../opac-tmpl/bootstrap/en/modules/opac-auth.tt | 6 + .../opac-tmpl/bootstrap/en/modules/opac-main.tt | 6 + .../bootstrap/en/modules/opac-password-recovery.tt | 134 +++++++++++++++ opac/opac-password-recovery.pl | 179 +++++++++++++++++++++ 10 files changed, 594 insertions(+) create mode 100644 C4/Passwordrecovery.pm create mode 100644 Koha/Schema/Result/BorrowerPasswordRecovery.pm create mode 100644 installer/data/mysql/atomicupdate/bug_8753-Add_forgot_password_link_to_OPAC.sql create mode 100644 koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-password-recovery.tt create mode 100755 opac/opac-password-recovery.pl --- a/C4/Passwordrecovery.pm +++ a/C4/Passwordrecovery.pm @@ -0,0 +1,173 @@ +package C4::Passwordrecovery; + +# Copyright 2014 Solutions InLibro inc. +# +# 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 vars qw($VERSION @ISA @EXPORT); + +BEGIN { + # set the version for version checking + $VERSION = 3.07.00.049; + require Exporter; + @ISA = qw(Exporter); + push @EXPORT, qw( + &ValidateBorrowernumber + &SendPasswordRecoveryEmail + &GetValidLinkInfo + &CompletePasswordRecovery + ); +} + +=head1 NAME + +C4::Passwordrecovery - Koha password recovery module + +=head1 SYNOPSIS + +use C4::Passwordrecovery; + +=head1 FUNCTIONS + +=head2 ValidateBorrowernumber + +$alread = ValidateBorrowernumber( $borrower_number ); + +Check if the system already start recovery + +Returns true false + +=cut + +sub ValidateBorrowernumber { + my ($borrower_number) = @_; + my $schema = Koha::Database->new->schema; + + my $rs = $schema->resultset('BorrowerPasswordRecovery')->search( + { + borrowernumber => $borrower_number, + valid_until => \'> NOW()' + }, { + columns => 'borrowernumber' + }); + + if ($rs->next){ + return 1; + } + + return 0; +} + +=head2 GetValidLinkInfo + + Check if the link is still valid and return some info. + +=cut + +sub GetValidLinkInfo { + my ($uniqueKey) = @_; + my $dbh = C4::Context->dbh; + my $query = ' + SELECT borrower_password_recovery.borrowernumber, userid + FROM borrower_password_recovery, borrowers + WHERE borrowers.borrowernumber = borrower_password_recovery.borrowernumber + AND NOW() < valid_until + AND uuid = ? + '; + my $sth = $dbh->prepare($query); + $sth->execute($uniqueKey); + return $sth->fetchrow; +} + +=head2 SendPasswordRecoveryEmail + + It creates an email using the templates and send it to the user, using the specified email + +=cut + +sub SendPasswordRecoveryEmail { + my $borrower = shift; # from GetMember + my $userEmail = shift; #to_address (the one specified in the request) + my $protocol = shift; #only required to determine if 'http' or 'https' + my $update = shift; + + my $schema = Koha::Database->new->schema; + + # generate UUID + my @chars = ("A".."Z", "a".."z", "0".."9"); + my $uuid_str; + $uuid_str .= $chars[rand @chars] for 1..32; + + # insert into database + my $expirydate = DateTime->now(time_zone => C4::Context->tz())->add( days => 2 ); + if($update){ + my $rs = $schema->resultset('BorrowerPasswordRecovery')->search( + { + borrowernumber => $borrower->{'borrowernumber'}, + }); + $rs->update({uuid => $uuid_str, valid_until => $expirydate->datetime()}); + } else { + my $rs = $schema->resultset('BorrowerPasswordRecovery')->create({ + borrowernumber=>$borrower->{'borrowernumber'}, + uuid => $uuid_str, + valid_until=> $expirydate->datetime() + }); + } + + # create link + my $uuidLink = $protocol . C4::Context->preference( 'OPACBaseURL' ) . "/cgi-bin/koha/opac-password-recovery.pl?uniqueKey=$uuid_str"; + + # prepare the email + my $letter = C4::Letters::GetPreparedLetter ( + module => 'members', + letter_code => 'PASSWORD_RESET', + branchcode => $borrower->{branchcode}, + substitute => {passwordreseturl => $uuidLink, user => $borrower->{userid} }, + ); + + # define to/from emails + my $kohaEmail = C4::Context->preference( 'KohaAdminEmailAddress' ); # from + + C4::Letters::EnqueueLetter( { + letter => $letter, + borrowernumber => $borrower->{borrowernumber}, + to_address => $userEmail, + from_address => $kohaEmail, + message_transport_type => 'email', + } ); + + return 1; +} +=head2 CompletePasswordRecovery + + $bool = CompletePasswordRevovery($uuid); + + Deletes a password recovery entry. + +=cut +sub CompletePasswordRecovery{ + my $uniqueKey = shift; + my $model = Koha::Database->new->schema->resultset('BorrowerPasswordRecovery'); + my $entry = $model->search({-or => [uuid => $uniqueKey, valid_until => \'< NOW()']}); + return $entry->delete(); +} + +END { } # module clean-up code here (global destructor) + +1; --- a/Koha/Schema/Result/BorrowerPasswordRecovery.pm +++ a/Koha/Schema/Result/BorrowerPasswordRecovery.pm @@ -0,0 +1,66 @@ +use utf8; +package Koha::Schema::Result::BorrowerPasswordRecovery; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Koha::Schema::Result::BorrowerPasswordRecovery + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("borrower_password_recovery"); + +=head1 ACCESSORS + +=head2 borrowernumber + + data_type: 'integer' + is_nullable: 0 + +=head2 uuid + + data_type: 'varchar' + is_nullable: 0 + size: 128 + +=head2 valid_until + + data_type: 'timestamp' + datetime_undef_if_invalid: 1 + default_value: current_timestamp + is_nullable: 0 + +=cut + +__PACKAGE__->add_columns( + "borrowernumber", + { data_type => "integer", is_nullable => 0 }, + "uuid", + { data_type => "varchar", is_nullable => 0, size => 128 }, + "valid_until", + { + data_type => "timestamp", + datetime_undef_if_invalid => 1, + default_value => \"current_timestamp", + is_nullable => 0, + }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07039 @ 2014-11-03 12:08:20 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ORAWxUHIkefSfPSqrcfeXA + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; --- a/installer/data/mysql/atomicupdate/bug_8753-Add_forgot_password_link_to_OPAC.sql +++ a/installer/data/mysql/atomicupdate/bug_8753-Add_forgot_password_link_to_OPAC.sql @@ -0,0 +1,12 @@ +INSERT IGNORE INTO systempreferences (variable,value,options,explanation,type) +VALUES ('OpacResetPassword', '0','','Shows the ''Forgot your password?'' link in the OPAC','YesNo'); + +CREATE TABLE IF NOT EXISTS borrower_password_recovery ( + borrowernumber int(11) NOT NULL, + uuid varchar(128) NOT NULL, + valid_until timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY borrowernumber (borrowernumber) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +INSERT IGNORE INTO `letter` (module, code, branchcode, name, is_html, title, content, message_transport_type) +VALUES ('members','PASSWORD_RESET','','Online password reset',1,'Koha password recovery','\r\n

This email has been sent in response to your password recovery request for the account <>.\r\n

\r\n

\r\nYou can now create your new password using the following link:\r\n
>\"><>\r\n

\r\n

This link will be valid for 2 days from this email\'s reception, then you must reapply if you do not change your password.

\r\n

Thank you.

\r\n\r\n','email'); --- a/installer/data/mysql/en/mandatory/sample_notices.sql +++ a/installer/data/mysql/en/mandatory/sample_notices.sql @@ -162,3 +162,7 @@ VALUES ( 'circulation', 'OVERDUES_SLIP', '', 'Overdues Slip', '0', 'OVERDUES_SLI "<>" by <>, <>, Barcode: <> Fine: <> ', 'print' ); + +INSERT INTO `letter` (module, code, branchcode, name, is_html, title, content, message_transport_type) +VALUES ('members','PASSWORD_RESET','','Online password reset',1,'Koha password recovery','\r\n

This email has been sent in response to your password recovery request for the account <>.\r\n

\r\n

\r\nYou can now create your new password using the following link:\r\n
>\"><>\r\n

\r\n

This link will be valid for 2 days from this email\'s reception, then you must reapply if you do not change your password.

\r\n

Thank you.

\r\n\r\n','email' +); --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref @@ -346,6 +346,14 @@ OPAC: no: "Don't allow" - patrons to change their own password on the OPAC. Note that this must be off to use LDAP authentication. - + - "The user " + - pref: OpacResetPassword + default: 1 + choices: + yes: "can reset" + no: "can not reset" + - " their password on OPAC." + - - pref: OPACPatronDetails choices: yes: Allow --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc +++ a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc @@ -316,6 +316,12 @@
+ [% IF Koha.Preference('OpacPasswordChange') && Koha.Preference('OpacResetPassword') %] +
+
Forgot your password?
+

If you do not remember your password, click here to create a new one.

+
+ [% END %] [% IF Koha.Preference( 'NoLoginInstructions' ) %]
[% Koha.Preference( 'NoLoginInstructions' ) %] --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt +++ a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt @@ -150,6 +150,12 @@
+ [% IF Koha.Preference('OpacPasswordChange') && Koha.Preference('OpacResetPassword') %] +
+
Forgot your password?
+

If you do not remember your password, click here to create a new one.

+
+ [% END %]
[% IF Koha.Preference('NoLoginInstructions') %] [% Koha.Preference('NoLoginInstructions') %] --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-main.tt +++ a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-main.tt @@ -85,6 +85,12 @@ [% IF PatronSelfRegistration && PatronSelfRegistrationDefaultCategory %]

Don't have an account? Register here.

[% END %] + [% IF Koha.Preference('OpacPasswordChange') && Koha.Preference('OpacResetPassword') %] +
+
Forgot your password?
+

If you do not remember your password, click here to create a new one.

+
+ [% END %] [% IF Koha.Preference( 'NoLoginInstructions' ) %]
[% Koha.Preference( 'NoLoginInstructions' ) %] --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-password-recovery.tt +++ a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-password-recovery.tt @@ -0,0 +1,134 @@ +[% USE Koha %] +[% INCLUDE 'doc-head-open.inc' %] +[% IF (LibraryNameTitle) %][% LibraryNameTitle %][% ELSE %]Koha online[% END %] catalog › +[% INCLUDE 'doc-head-close.inc' %] +[% BLOCK cssinclude %][% END %] +[% BLOCK jsinclude %] + +[% END %] + +[% INCLUDE 'bodytag.inc' bodyid='opac-password-recovery' %] +[% INCLUDE 'masthead.inc' %] + +
+ + +
+
+
+ +
+
+

Password recovery

+ [% IF (hasError) %] +
+

An error occured

+

+ [% IF (sendmailError) %] + An error has occured while sending you the password recovery link. +
Please try again later. + [% ELSIF (errNoBorrowerFound) %] + No account was found with the provided information. +
Check if you typed it correctly. + [% ELSIF (errBadEmail) %] + The provided email address is not tied to this account. + [% ELSIF (errTooManyEmailFound) %] + More than one account has been found for the email address: "[% email %]" +
Try to use your username or an alternative email if you have one. + [% ELSIF (errNoBorrowerEmail) %] + This account has no email address we can send the email to. + [% ELSIF (errAlreadyStartRecovery) %] + The process of password recovery has already started for this account ("[% username %]") +
Check your emails; you should receive the link to reset your password. +
If you did not receive it, click here to get a new password recovery link. + [% ELSIF (errPassNotMatch) %] + The passwords entered does not match. +
Please try again. + [% ELSIF (errPassTooShort) %] + The password is too short. +
The password must contain at least [% minPassLength %] characters. + [% ELSIF (errLinkNotValid) %] + We could not authenticate you as the account owner. +
Be sure to use the link you received in your email. + [% END %] +

+

Please contact the staff if you need further assistance.

+
+ [% END %] +
+[% IF (!Koha.Preference('OpacResetPassword')) %] +
You can't reset your password.
+[% ELSIF (password_recovery) %] +
+ +
+

To reset your password, enter your username or email address. +
A link to reset your password will be sent at this address.

+ + + + +
+ +
+
+
+[% ELSIF (new_password) %] +
+ +
+
The password must contain at least [% minPassLength %] characters.
+ + + + +
+ + + +
+
+
+[% ELSIF (mail_sent) %] +
+

+ An email has been sent to "[% email %]". +
It contains a link to create a new password. +
This link will be valid for 2 days starting now. +

+ Click here to return to the main page. +
+[% ELSIF (password_reset_done) %] +
+

The password has been changed for user "[% username %]".

+ Click here to login. +
+[% END %] +
+
+
+
+
+[% INCLUDE 'opac-bottom.inc' %] --- a/opac/opac-password-recovery.pl +++ a/opac/opac-password-recovery.pl @@ -0,0 +1,179 @@ +#!/usr/bin/perl + +use strict; +use Modern::Perl; +use CGI; + +use C4::Auth; +use C4::Koha; +use C4::Members qw(changepassword Search); +use C4::Output; +use C4::Context; +use C4::Passwordrecovery qw(SendPasswordRecoveryEmail ValidateBorrowernumber GetValidLinkInfo CompletePasswordRecovery); +use Koha::AuthUtils qw(hash_password); +my $query = new CGI; +use HTML::Entities; + +my ( $template, $dummy, $cookie ) = get_template_and_user( + { + template_name => "opac-password-recovery.tt", + query => $query, + type => "opac", + authnotrequired => 1, + debug => 1, + } +); + +my $email = $query->param('email') // q{}; +my $password = $query->param('password'); +my $repeatPassword = $query->param('repeatPassword'); +my $minPassLength = C4::Context->preference('minPasswordLength'); +my $id = $query->param('id'); +my $uniqueKey = $query->param('uniqueKey'); +my $username = $query->param('username'); +my $borrower_number; + +#errors +my $hasError; + +#email form error +my $errNoBorrowerFound; +my $errNoBorrowerEmail; +my $errAlreadyStartRecovery; +my $errTooManyEmailFound; +my $errBadEmail; + +#new password form error +my $errLinkNotValid; +my $errPassNotMatch; +my $errPassTooShort; + +if ( $query->param('sendEmail') || $query->param('resendEmail') ) { + my $protocol = $query->https() ? "https://" : "http://"; + #try with the main email + $email ||= ''; # avoid undef + my $borrower; + my $search_results; + + # Find the borrower by his userid or email + if( $username ){ + $search_results = Search({ userid => $username }); + } + elsif ( $email ){ + $search_results = Search({ '' => $email }, undef, undef, undef, ['emailpro', 'email', 'B_email']); + } + + if(scalar @$search_results > 1){ # Many matching borrowers + $hasError = 1; + $errTooManyEmailFound = 1; + } + elsif( $borrower = shift @$search_results ){ # One matching borrower + $username ||= $borrower->{'userid'}; + my @emails = ( $borrower->{'email'}, $borrower->{'emailpro'}, $borrower->{'B_email'} ); + # Is the given email one of the borrower's ? + if( $email && !($email ~~ @emails) ){ + $hasError = 1; + $errBadEmail = 1; + } + # If we dont have an email yet. Get one of the borrower's email or raise an error. + # FIXME: That ugly shift-grep contraption. + # $email = shift [ grep { length() } @emails ] + # It's supposed to get a non-empty string from the @emails array. There's surely a simpler way + elsif( !$email && !($email = shift [ grep { length() } @emails ]) ){ + $hasError = 1; + $errNoBorrowerEmail = 1; + } + # Check if a password reset already issued for this borrower AND we are not asking for a new email + elsif( ValidateBorrowernumber( $borrower->{'borrowernumber'} ) && !$query->param('resendEmail') ){ + $hasError = 1; + $errAlreadyStartRecovery = 1; + } + } + else{ # 0 matching borrower + $hasError = 1; + $errNoBorrowerFound = 1; + } + if ($hasError) { + $template->param( + hasError => 1, + errNoBorrowerFound => $errNoBorrowerFound, + errTooManyEmailFound => $errTooManyEmailFound, + errAlreadyStartRecovery => $errAlreadyStartRecovery, + errBadEmail => $errBadEmail, + errNoBorrowerEmail => $errNoBorrowerEmail, + password_recovery => 1, + email => HTML::Entities::encode($email), + username => $username + ); + } + elsif ( SendPasswordRecoveryEmail( $borrower, $email, $protocol, $query->param('resendEmail') ) ) {#generate uuid and send recovery email + $template->param( + mail_sent => 1, + email => $email + ); + } + else {# if it doesnt work.... + $template->param( + password_recovery => 1, + sendmailError => 1 + ); + } +} +elsif ( $query->param('passwordReset') ) { + ( $borrower_number, $username ) = GetValidLinkInfo($uniqueKey); + #validate password length & match + if ( ($borrower_number) + && ( $password eq $repeatPassword ) + && ( length($password) >= $minPassLength ) ) + { #apply changes + changepassword( $username, $borrower_number, hash_password($password) ); + CompletePasswordRecovery($uniqueKey); + $template->param( + password_reset_done => 1, + username => $username + ); + } + else { #errors + if ( !$borrower_number ) { #parameters not valid + $errLinkNotValid = 1; + } + elsif ( $password ne $repeatPassword ) { #passwords does not match + $errPassNotMatch = 1; + } + elsif ( length($password) < $minPassLength ) { #password too short + $errPassTooShort = 1; + } + $template->param( + new_password => 1, + minPassLength => $minPassLength, + email => $email, + uniqueKey => $uniqueKey, + errLinkNotValid => $errLinkNotValid, + errPassNotMatch => $errPassNotMatch, + errPassTooShort => $errPassTooShort, + hasError => 1 + ); + } +} +elsif ($uniqueKey) { #reset password form + #check if the link is valid + ( $borrower_number, $username ) = GetValidLinkInfo($uniqueKey); + + if ( !$borrower_number ) { + $errLinkNotValid = 1; + } + + $template->param( + new_password => 1, + minPassLength => $minPassLength, + email => $email, + uniqueKey => $uniqueKey, + username => $username, + errLinkNotValid => $errLinkNotValid + ); +} +else { #password recovery form (to send email) + $template->param( password_recovery => 1 ); +} + +output_html_with_http_headers $query, $cookie, $template->output; --