@@ -, +, @@ endpoints above for required parameters. to make sure your password recovery was enqueued --- Koha/REST/V1/Patron/Password.pm | 127 +++++++++++ api/v1/swagger/definitions.json | 3 + api/v1/swagger/definitions/passwordRecovery.json | 13 ++ api/v1/swagger/paths.json | 6 + api/v1/swagger/paths/patrons.json | 128 +++++++++++ t/db_dependent/api/v1/passwordrecovery.t | 273 +++++++++++++++++++++++ 6 files changed, 550 insertions(+) create mode 100644 Koha/REST/V1/Patron/Password.pm create mode 100644 api/v1/swagger/definitions/passwordRecovery.json create mode 100644 t/db_dependent/api/v1/passwordrecovery.t --- a/Koha/REST/V1/Patron/Password.pm +++ a/Koha/REST/V1/Patron/Password.pm @@ -0,0 +1,127 @@ +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 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 $resend = ValidateBorrowernumber($patron->borrowernumber); + + SendPasswordRecoveryEmail($patron, $patron->email, $resend); + + 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 }); + } + 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; --- a/api/v1/swagger/definitions.json +++ a/api/v1/swagger/definitions.json @@ -23,6 +23,9 @@ "CPUinvoiceReport": { "$ref": "definitions/CPUinvoiceReport.json" }, + "passwordRecovery": { + "$ref": "definitions/passwordRecovery.json" + }, "patron": { "$ref": "definitions/patron.json" }, --- a/api/v1/swagger/definitions/passwordRecovery.json +++ a/api/v1/swagger/definitions/passwordRecovery.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "to_address": { + "description": "Patron email address", + "type": ["string", "null"] + }, + "status": { + "description": "Status code. 1 = email has been enqueued", + "type": "integer" + } + } +} --- a/api/v1/swagger/paths.json +++ a/api/v1/swagger/paths.json @@ -128,6 +128,12 @@ "/patrons/{borrowernumber}/status": { "$ref": "paths/patrons.json#/~1patrons~1{borrowernumber}~1status" }, + "/patrons/password/recovery": { + "$ref": "paths/patrons.json#/~1patrons~1password~1recovery" + }, + "/patrons/password/recovery/complete": { + "$ref": "paths/patrons.json#/~1patrons~1password~1recovery~1complete" + }, "/payments/transaction/{invoicenumber}": { "$ref": "paths/payments/transactions.json#/~1payments~1transactions~1{invoicenumber}" }, --- a/api/v1/swagger/paths/patrons.json +++ a/api/v1/swagger/paths/patrons.json @@ -1147,5 +1147,133 @@ } } } + }, + "/patrons/password/recovery": { + "post": { + "x-mojo-to": "Patron::Password#recovery", + "operationId": "addPasswordRecoveryRequest", + "description": "Creates a new password recovery request.", + "tags": ["password"], + "parameters": [{ + "name": "body", + "in": "body", + "description": "A JSON object containing fields for recovery request", + "required": true, + "schema": { + "type": "object", + "properties": { + "userid": { + "description": "Patron's userid (this field or cardnumber required)", + "type": "string" + }, + "cardnumber": { + "description": "Patron's cardnumber (this field or userid required)", + "type": "string" + }, + "email": { + "description": "Patron's email (required)", + "type": "string" + } + }, + "required": ["email"] + } + }], + "produces": ["application/json"], + "responses": { + "201": { + "description": "Password recovery request created", + "schema": { "$ref": "../definitions.json#/passwordRecovery" } + }, + "400": { + "description": "Bad parameter(s)", + "schema": { "$ref": "../definitions.json#/error" } + }, + "403": { + "description": "Password recovery disabled, no access to this endpoint", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "404": { + "description": "One or more of the given parameters not found", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "503": { + "description": "Under maintenance", + "schema": { + "$ref": "../definitions.json#/error" + } + } + } + } + }, + "/patrons/password/recovery/complete": { + "post": { + "x-mojo-to": "Patron::Password#complete_recovery", + "operationId": "completePasswordRecoveryRequest", + "description": "Completes a password recovery request.", + "tags": ["password"], + "parameters": [{ + "name": "body", + "in": "body", + "description": "A JSON object containing fields for completing recovery request", + "required": true, + "schema": { + "type": "object", + "properties": { + "uuid": { + "description": "Uuid generated in /patrons/password/recovery", + "type": "string" + }, + "new_password": { + "description": "Patron's new password", + "type": "string" + }, + "confirm_new_password": { + "description": "Confirm patron's new password", + "type": "string" + } + }, + "required": ["uuid", "new_password", "confirm_new_password"] + } + }], + "produces": ["application/json"], + "responses": { + "200": { + "description": "Password recovery completed", + "schema": { "type": "object" } + }, + "400": { + "description": "Bad parameter(s)", + "schema": { "$ref": "../definitions.json#/error" } + }, + "404": { + "description": "Uuid not found", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "503": { + "description": "Under maintenance", + "schema": { + "$ref": "../definitions.json#/error" + } + } + } + } } } --- a/t/db_dependent/api/v1/passwordrecovery.t +++ a/t/db_dependent/api/v1/passwordrecovery.t @@ -0,0 +1,273 @@ +#!/usr/bin/env perl + +# 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 Test::More tests => 2; +use Test::Mojo; +use Test::MockModule; + +use t::lib::Mocks; +use t::lib::TestBuilder; + +use C4::Auth; +use C4::Context; + +use Koha::Database; +use Koha::Notice::Messages; + +use Crypt::Eksblowfish::Bcrypt qw(en_base64); + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +# FIXME: sessionStorage defaults to mysql, but it seems to break transaction handling +# this affects the other REST api tests +t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); + +my $remote_address = '127.0.0.1'; +my $t = Test::Mojo->new('Koha::REST::V1'); + +subtest 'recovery() tests' => sub { + plan tests => 29; + + $schema->storage->txn_begin; + + my $url = '/api/v1/patrons/password/recovery'; + + my ($patron, $session) = create_user_and_session(); + + my $tx = $t->ua->build_tx(POST => $url => json => {}); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(400); + + t::lib::Mocks::mock_preference('OpacPasswordReset', 0); + t::lib::Mocks::mock_preference('OpacPasswordChange', 0); + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + cardnumber => $patron->cardnumber + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(403); + + t::lib::Mocks::mock_preference('OpacPasswordChange', 1); + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + cardnumber => $patron->cardnumber + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(403); + + t::lib::Mocks::mock_preference('OpacPasswordReset', 1); + t::lib::Mocks::mock_preference('OpacPasswordChange', 0); + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + cardnumber => $patron->cardnumber + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(403); + + t::lib::Mocks::mock_preference('OpacPasswordChange', 1); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => 'nonexistent', + cardnumber => $patron->cardnumber + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(404); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + cardnumber => 'nonexistent' + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(404); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => 'nonexistent', + userid => $patron->userid + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(404); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + userid => 'nonexistent' + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(404); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + cardnumber => $patron->cardnumber + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(201) + ->json_is('/status' => 1); + + my $rs = Koha::Database->new->schema->resultset('BorrowerPasswordRecovery'); + is( + $rs->search({ borrowernumber => $patron->borrowernumber })->count, 1, + 'Password modification request found in database' + ); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + userid => $patron->userid + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(201) + ->json_is('/status' => 1); + + is( + $rs->search({ borrowernumber => $patron->borrowernumber })->count, 1, + 'Password modification request found in database' + ); + + $tx = $t->ua->build_tx(POST => $url => json => { + email => $patron->email, + userid => $patron->userid, + cardnumber => $patron->cardnumber, + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(201) + ->json_is('/status' => 1); + + is( + $rs->search({ borrowernumber => $patron->borrowernumber })->count, 1, + 'Password modification request found in database' + ); + + my $notice = Koha::Notice::Messages->search({ + borrowernumber => $patron->borrowernumber, + letter_code => 'PASSWORD_RESET', + message_transport_type => 'email' + })->count; + is($notice, 3, 'Found password reset letters in message queue.'); + + $schema->storage->txn_rollback; +}; + +subtest 'complete_recovery() tests' => sub { + plan tests => 7; + + $schema->storage->txn_begin; + + my $rs = Koha::Database->new->schema->resultset('BorrowerPasswordRecovery'); + + my ($patron, $session) = create_user_and_session(); + my $uuid_str; + do { + $uuid_str = '$2a$08$'.en_base64(Koha::AuthUtils::generate_salt('weak', 16)); + } while ( substr ( $uuid_str, -1, 1 ) eq '.' ); + my $recovery = $builder->build({ + source => 'BorrowerPasswordRecovery', + value => { + borrowernumber => $patron->borrowernumber, + uuid => $uuid_str + } + }); + + my $url = '/api/v1/patrons/password/recovery/complete'; + + my $tx = $t->ua->build_tx(POST => $url => json => {}); + $tx->req->cookies({name => 'CGISESSID', value => $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.'notfound' => json => { + uuid => $uuid_str, + new_password => 'test', + confirm_new_password => 'test', + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(404); + + t::lib::Mocks::mock_preference('minPasswordLength', 4); + $tx = $t->ua->build_tx(POST => $url => json => { + uuid => $uuid_str, + new_password => '1234', + confirm_new_password => '1234', + }); + $tx->req->cookies({name => 'CGISESSID', value => $session->id}); + $tx->req->env({REMOTE_ADDR => '127.0.0.1'}); + $t->request_ok($tx) + ->status_is(200); + + my $stored_pw = Koha::Patrons->find($patron->borrowernumber)->password; + is( + $stored_pw, + Koha::AuthUtils::hash_password('1234', $stored_pw), 'Password changed' + ); + + $schema->storage->txn_rollback; +}; + +sub create_user_and_session { + my ($flags) = @_; + + my $categorycode = $builder->build({ source => 'Category' })->{categorycode}; + my $branchcode = $builder->build({ source => 'Branch' })->{ branchcode }; + + my $borrower = $builder->build({ + source => 'Borrower', + value => { + branchcode => $branchcode, + categorycode => $categorycode, + lost => 0, + } + }); + + my $session = C4::Auth::get_session(''); + $session->param('number', $borrower->{ borrowernumber }); + $session->param('id', $borrower->{ userid }); + $session->param('ip', '127.0.0.1'); + $session->param('lasttime', time()); + $session->flush; + my $patron = Koha::Patrons->find($borrower->{borrowernumber}); + if ( $flags ) { + Koha::Auth::PermissionManager->grantPermissions($patron, $flags); + } + + return ($patron, $session); +} --