From 63025faabaee2221d4b5ff2b1a0da1a0b9fab351 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Fri, 18 Aug 2017 15:47:43 +0300 Subject: [PATCH] Bug 19133: Allow third party services to send password recovery emails instead of Koha Adds a new parameter "skip_mail" to POST /api/v1/public/patrons/password/recovery. This allows third party services to avoid Koha enqueueing recovery email to message queue. Instead, letter content and the link containing uuid is returned. Requires permission to do so. Adds a new permission borrowers.get_password_reset_uuid that allows the above. To test: 1. Apply patch and run updatedatabase.pl 2. prove t/db_dependent/api/v1/patrons_password_recovery.t 3. Add yourself borrowers.get_password_reset_uuid permission 4. Send POST request to /api/v1/public/patrons/password/recovery containing a JSON body { "email": "patrons@email.com", "userid": "patrons userid", "custom_link": "https://allowed/reset.pl?token={uuid}", "skip_mail": true } (Make sure custom link is allowed by the system preference introduced in previous patch) 5. Check that the message contents and other related data is returned 6. Check borrower_password_recovery table - there should be a new entry for you 7. Check message_queue table - this message should not be enqueued Sponsored-by: Koha-Suomi Oy --- Koha/Patron/Password/Recovery.pm | 12 ++ Koha/REST/V1/Patron/Password.pm | 163 ++++++++++++++++++ Koha/REST/V1/Patrons/Password.pm | 31 +++- .../definitions/patron_password_recovery.json | 14 +- api/v1/swagger/paths/patrons.json | 8 + ...dd_permission_get_password_reset_uuid.perl | 8 + installer/data/mysql/userpermissions.sql | 1 + .../api/v1/patrons_password_recovery.t | 74 +++++++- 8 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 Koha/REST/V1/Patron/Password.pm create mode 100644 installer/data/mysql/atomicupdate/Bug-19133-2_Add_permission_get_password_reset_uuid.perl diff --git a/Koha/Patron/Password/Recovery.pm b/Koha/Patron/Password/Recovery.pm index 4a71d68595..9ba841c0f5 100644 --- a/Koha/Patron/Password/Recovery.pm +++ b/Koha/Patron/Password/Recovery.pm @@ -106,6 +106,9 @@ sub GetValidLinkInfo { C<$update> Update existing password recovery request C<$params> Additional parameters as follows: url: Custom password reset link (third party integrations) + skip_mail: If true, will not enqueue the letter, but return it + instead. Use this flag if you want a third party + service to handle emailing instead of Koha. =cut sub SendPasswordRecoveryEmail { @@ -179,6 +182,15 @@ sub SendPasswordRecoveryEmail { { passwordreseturl => $uuidLink, user => $borrower->userid }, ); + if ($params->{skip_mail}) { + return { + to_address => $userEmail, + uuid => $uuid_str, + uuidLink => $uuidLink, + letter => $letter->{content}, + }; + } + # define from emails my $library = $borrower->library; my $kohaEmail = $library->branchemail || C4::Context->preference('KohaAdminEmailAddress'); # send from patron's branch or Koha Admin diff --git a/Koha/REST/V1/Patron/Password.pm b/Koha/REST/V1/Patron/Password.pm new file mode 100644 index 0000000000..0d73497125 --- /dev/null +++ b/Koha/REST/V1/Patron/Password.pm @@ -0,0 +1,163 @@ +package Koha::REST::V1::Patron::Password; + +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; + +use C4::Context; +use C4::Members; +use Koha::AuthUtils qw(hash_password); +use Koha::Database; +use Koha::Patron::Password::Recovery qw( + SendPasswordRecoveryEmail + ValidateBorrowernumber + CompletePasswordRecovery +); +use Koha::Patrons; + +use Koha::Exceptions; +use Koha::Exceptions::Authentication; +use Koha::Exceptions::Authorization; + +use Try::Tiny; + +sub recovery { + my $c = shift->openapi->valid_input or return; + + my $patron; + return try { + my $body = $c->req->json; + + unless (C4::Context->preference('OpacPasswordChange') and + C4::Context->preference('OpacPasswordReset')) + { + return $c->render(status => 403, openapi => { + error => 'Password recovery is disabled.' + }); + } + + unless (defined $body->{userid} or defined $body->{cardnumber}) { + Koha::Exceptions::BadParameter->throw( + error => 'Either userid or cardnumber must be given.' + ); + } + + my $patron = Koha::Patrons->search({ + email => $body->{email}, + '-or' => { + userid => $body->{userid}, + cardnumber => $body->{cardnumber}, + } + })->next; + + unless ($patron) { + Koha::Exceptions::Patron::NotFound->throw( + error => 'Patron not found' + ); + } + + my $link = $body->{'custom_link'}; + my $skip_mail = 0; + if ($body->{'skip_mail'}) { + Koha::Exceptions::Authentication::Required->throw( + error => 'Authentication required while skip_mail parameter is on' + ) unless ref($c->stash('koha.user')) eq 'Koha::Patron'; + if (my $user = $c->stash('koha.user')) { + Koha::Exceptions::Authorization::Unauthorized->throw( + error => 'Permission get_password_reset_uuid required while ' + .'skip_mail parameter is on' + ) unless C4::Auth::haspermission($user->userid, { + borrowers => 'get_password_reset_uuid' + }); + $skip_mail = 1; + } + } + + my $resend = ValidateBorrowernumber($patron->borrowernumber); + + my $ret = SendPasswordRecoveryEmail($patron, $patron->email, $resend, { + url => $link, + skip_mail => $skip_mail, + }); + + if (ref($ret) eq 'HASH') { + $ret->{status} = 2; + return $c->render(status => 201, openapi => $ret); + } + + return $c->render(status => 201, openapi => { + status => 1, + to_address => $patron->email + }); + } + catch { + if ($_->isa('Koha::Exceptions::BadParameter')) { + return $c->render(status => 400, openapi => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::Patron::NotFound')) { + return $c->render(status => 404, openapi => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::WrongParameter')) { + return $c->render(status => 400, openapi => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::Authentication::Required')) { + return $c->render(status => 401, openapi => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) { + return $c->render(status => 403, openapi => { error => $_->error }); + } + Koha::Exceptions::rethrow_exception($_); + }; +} + +sub complete_recovery { + my $c = shift->openapi->valid_input or return; + + my $rs = Koha::Database->new->schema->resultset('BorrowerPasswordRecovery'); + return try { + my $body = $c->req->json; + + my $password_recovery = $rs->find({ + uuid => $body->{uuid} + }); + unless ($password_recovery) { + return $c->render(status => 404, openapi => { + error => 'Password recovery request with given uuid not found.' + }); + } + + my $patron = Koha::Patrons->find($password_recovery->borrowernumber); + my $categorycode = $patron->categorycode; + my ($success, $error, $errmsg) = C4::Members::ValidateMemberPassword( + $categorycode, $body->{new_password}, $body->{confirm_new_password} + ); + if ($error) { + return $c->render(status => 400, openapi => { + error => $errmsg + }); + } + my $password = $body->{new_password}; + $patron->update_password( $patron->userid, hash_password($password) ); + return $c->render(status => 200, openapi => {}); + } + catch { + Koha::Exceptions::rethrow_exception($_); + }; +} + +1; diff --git a/Koha/REST/V1/Patrons/Password.pm b/Koha/REST/V1/Patrons/Password.pm index 8703b72d5a..ef7a4424c9 100644 --- a/Koha/REST/V1/Patrons/Password.pm +++ b/Koha/REST/V1/Patrons/Password.pm @@ -80,13 +80,34 @@ sub recovery { } my $link = $body->{'custom_link'}; + my $skip_mail = 0; + if ($body->{'skip_mail'}) { + Koha::Exceptions::Authentication::Required->throw( + error => 'Authentication required while skip_mail parameter is on' + ) unless ref($c->stash('koha.user')) eq 'Koha::Patron'; + if (my $user = $c->stash('koha.user')) { + Koha::Exceptions::Authorization::Unauthorized->throw( + error => 'Permission get_password_reset_uuid required while ' + .'skip_mail parameter is on' + ) unless C4::Auth::haspermission($user->userid, { + borrowers => 'get_password_reset_uuid' + }); + $skip_mail = 1; + } + } my $resend = ValidateBorrowernumber($patron->borrowernumber); - SendPasswordRecoveryEmail($patron, $patron->email, $resend, { - url => $link + my $ret = SendPasswordRecoveryEmail($patron, $patron->email, $resend, { + url => $link, + skip_mail => $skip_mail, }); + if (ref($ret) eq 'HASH') { + $ret->{status} = 2; + return $c->render(status => 201, openapi => $ret); + } + return $c->render(status => 201, openapi => { status => 1, to_address => $patron->email @@ -103,6 +124,12 @@ sub recovery { elsif ($_->isa('Koha::Exceptions::WrongParameter')) { return $c->render(status => 400, openapi => { error => $_->error }); } + elsif ($_->isa('Koha::Exceptions::Authentication::Required')) { + return $c->render(status => 401, openapi => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) { + return $c->render(status => 403, openapi => { error => $_->error }); + } elsif ($_->isa('Koha::Exceptions::Patron::NotFound')) { return $c->render(status => 404, openapi => { error => $_->error }); } diff --git a/api/v1/swagger/definitions/patron_password_recovery.json b/api/v1/swagger/definitions/patron_password_recovery.json index 30d62de385..164fdd5713 100644 --- a/api/v1/swagger/definitions/patron_password_recovery.json +++ b/api/v1/swagger/definitions/patron_password_recovery.json @@ -1,12 +1,24 @@ { "type": "object", "properties": { + "letter": { + "description": "Content of the password recovery letter (returned only if Koha skips letter enqueueing)", + "type": "string" + }, "to_address": { "description": "Patron email address", "type": ["string", "null"] }, + "uuid": { + "description": "Uuid of password recovery request (the token that patron can reset their password with) (returned only if Koha skips letter enqueueing)", + "type": "string" + }, + "uuidLink": { + "description": "The URL that password reset can be completed at (returned only if Koha skips letter enqueueing)", + "type": "string" + }, "status": { - "description": "Status code. 1 = email has been enqueued", + "description": "Status code. 1 = email has been enqueued in Koha, 2 = recovery request stored and uuid returned (but not emailed - API user should decide how to deliver the recovery request to patron)", "type": "integer" } } diff --git a/api/v1/swagger/paths/patrons.json b/api/v1/swagger/paths/patrons.json index eca6dbfd73..b9454d29e2 100644 --- a/api/v1/swagger/paths/patrons.json +++ b/api/v1/swagger/paths/patrons.json @@ -730,6 +730,10 @@ "complete_url": { "description": "The location where patron will complete their password recovery. This location will be added into the password recovery email. To avoid malicious use, whitelist certain hosts in OpacResetPasswordHostWhitelist system preference and forbid all other custom links. Use {uuid} for adding the password recovery uuid into your custom link. It will be replaced by the actual uuid in the email.", "type": "string" + }, + "skip_email": { + "description": "If true, Koha will not enqueue password recovery email. Makes this endpoint return the letter, uuid and patron email. Requires permission borrowers.get_password_recovery_uuid. Useful for third party service integrations that wish to handle emailing instead of Koha.", + "type": "boolean" } }, "required": ["email"] @@ -745,6 +749,10 @@ "description": "Bad parameter(s)", "schema": { "$ref": "../definitions.json#/error" } }, + "401": { + "description": "Authentication required", + "schema": { "$ref": "../definitions.json#/error" } + }, "403": { "description": "Password recovery disabled, no access to this endpoint", "schema": { diff --git a/installer/data/mysql/atomicupdate/Bug-19133-2_Add_permission_get_password_reset_uuid.perl b/installer/data/mysql/atomicupdate/Bug-19133-2_Add_permission_get_password_reset_uuid.perl new file mode 100644 index 0000000000..924631ce35 --- /dev/null +++ b/installer/data/mysql/atomicupdate/Bug-19133-2_Add_permission_get_password_reset_uuid.perl @@ -0,0 +1,8 @@ +$DBversion = 'XXX'; # will be replaced by the RM +if( CheckVersion( $DBversion ) ) { + + $dbh->do( "INSERT INTO permissions (module_bit, code, description) VALUES ( 4, 'get_password_reset_uuid', 'Allow the user to get password reset uuid when recovering passwords. Useful for third party service integrations that wish to do something with the uuid, such as handle emails themselves instead of Koha.')" ); + + SetVersion( $DBversion ); + print "Upgrade to $DBversion done (Bug 19133 - Add permission get_password_reset_uuid.)\n"; +} diff --git a/installer/data/mysql/userpermissions.sql b/installer/data/mysql/userpermissions.sql index d9caa0e454..0a111a6440 100644 --- a/installer/data/mysql/userpermissions.sql +++ b/installer/data/mysql/userpermissions.sql @@ -34,6 +34,7 @@ INSERT INTO permissions (module_bit, code, description) VALUES ( 3, 'manage_keyboard_shortcuts', 'Manage keyboard shortcuts for the advanced cataloging editor'), ( 4, 'edit_borrowers', 'Add, modify and view patron information'), ( 4, 'view_borrower_infos_from_any_libraries', 'View patron infos from any libraries'), + ( 4, 'get_password_reset_uuid', 'Allow the user to get password reset uuid when recovering passwords. Useful for third party service integrations that wish to do something with the uuid, such as handle emails themselves instead of Koha.'), ( 6, 'place_holds', 'Place holds for patrons'), ( 6, 'modify_holds_priority', 'Modify holds priority'), ( 9, 'edit_catalogue', 'Edit catalog (Modify bibliographic/holdings data)'), diff --git a/t/db_dependent/api/v1/patrons_password_recovery.t b/t/db_dependent/api/v1/patrons_password_recovery.t index 3a1c57614f..62cb6e2885 100644 --- a/t/db_dependent/api/v1/patrons_password_recovery.t +++ b/t/db_dependent/api/v1/patrons_password_recovery.t @@ -43,7 +43,7 @@ my $remote_address = '127.0.0.1'; my $t = Test::Mojo->new('Koha::REST::V1'); subtest 'recovery() tests' => sub { - plan tests => 31; + plan tests => 32; $schema->storage->txn_begin; @@ -245,6 +245,78 @@ subtest 'recovery() tests' => sub { ); }; + subtest 'skip letter enqueueing' => sub { + plan tests => 10; + + t::lib::Mocks::mock_preference( + 'OpacResetPasswordHostWhitelist', 'anotherallowed' + ); + my ($service_borrowernumber, $service_session) = create_user_and_session({ + authorized => 1 + }); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + userid => $patron->userid, + custom_link => 'https://anotherallowed/reset-password.pl', + skip_mail => Mojo::JSON->true + }); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(401); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + userid => $patron->userid, + custom_link => 'https://anotherallowed/reset-password.pl', + skip_mail => Mojo::JSON->true + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(403); + + Koha::Notice::Messages->search({ + borrowernumber => $patron->borrowernumber, + letter_code => 'PASSWORD_RESET', + message_transport_type => 'email' + })->delete; + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + userid => $patron->userid, + custom_link => 'https://notallowed/reset-password.pl', + skip_mail => Mojo::JSON->true, + }); + $tx->req->cookies({name => 'CGISESSID', value => $service_session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(400); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + userid => $patron->userid, + custom_link => 'https://anotherallowed/reset-password.pl', + skip_mail => Mojo::JSON->true, + }); + $tx->req->cookies({name => 'CGISESSID', value => $service_session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(201); + my $uuid = $rs->search({ + borrowernumber => $patron->borrowernumber + }, { + order_by => { '-desc' => 'valid_until' } + })->next->uuid; + $t->json_is('/uuid', $uuid); + + is(Koha::Notice::Messages->search({ + borrowernumber => $patron->borrowernumber, + letter_code => 'PASSWORD_RESET', + message_transport_type => 'email' + })->count, 0, 'Email not enqueued'); + }; + $schema->storage->txn_rollback; }; -- 2.17.1