View | Details | Raw Unified | Return to bug 19133
Collapse All | Expand All

(-)a/Koha/Exceptions/Patron.pm (+3 lines)
Lines 9-14 use Exception::Class ( Link Here
9
    'Koha::Exceptions::Patron::FailedDelete' => {
9
    'Koha::Exceptions::Patron::FailedDelete' => {
10
        description => "Deleting patron failed"
10
        description => "Deleting patron failed"
11
    },
11
    },
12
    'Koha::Exceptions::Patron::NotFound' => {
13
        description => "Deleting patron failed"
14
    },
12
);
15
);
13
16
14
1;
17
1;
(-)a/Koha/REST/V1/Patrons/Password.pm (+121 lines)
Lines 21-26 use Mojo::Base 'Mojolicious::Controller'; Link Here
21
21
22
use C4::Auth qw(checkpw_internal);
22
use C4::Auth qw(checkpw_internal);
23
23
24
use Koha::AuthUtils qw(hash_password);
25
use Koha::Patron::Password::Recovery qw(
26
    SendPasswordRecoveryEmail
27
    ValidateBorrowernumber
28
    CompletePasswordRecovery
29
);
24
use Koha::Patrons;
30
use Koha::Patrons;
25
31
26
use Scalar::Util qw(blessed);
32
use Scalar::Util qw(blessed);
Lines 34-39 Koha::REST::V1::Patrons::Password Link Here
34
40
35
=head2 Methods
41
=head2 Methods
36
42
43
=head3 recovery
44
45
=cut
46
47
sub recovery {
48
    my $c = shift->openapi->valid_input or return;
49
50
    my $patron;
51
    return try {
52
        my $body = $c->req->json;
53
54
        unless (C4::Context->preference('OpacPasswordChange') and
55
                C4::Context->preference('OpacPasswordReset'))
56
        {
57
            return $c->render(status => 403, openapi => {
58
                error => 'Password recovery is disabled.'
59
            });
60
        }
61
62
        unless (defined $body->{userid} or defined $body->{cardnumber}) {
63
            Koha::Exceptions::BadParameter->throw(
64
                error => 'Either userid or cardnumber must be given.'
65
            );
66
        }
67
68
        my $patron = Koha::Patrons->search({
69
            email => $body->{email},
70
            '-or' => {
71
                userid => $body->{userid},
72
                cardnumber => $body->{cardnumber},
73
            }
74
        })->next;
75
76
        unless ($patron) {
77
            Koha::Exceptions::Patron::NotFound->throw(
78
                error => 'Patron not found'
79
            );
80
        }
81
82
        my $resend = ValidateBorrowernumber($patron->borrowernumber);
83
84
        SendPasswordRecoveryEmail($patron, $patron->email, $resend);
85
86
        return $c->render(status => 201, openapi => {
87
            status => 1,
88
            to_address => $patron->email
89
        });
90
    }
91
    catch {
92
        unless ( blessed $_ && $_->can('rethrow') ) {
93
            return $c->render( status => 500, openapi => { error => "$_" } );
94
        }
95
96
        if ($_->isa('Koha::Exceptions::BadParameter')) {
97
            return $c->render(status => 400, openapi => { error => $_->error });
98
        }
99
        elsif ($_->isa('Koha::Exceptions::Patron::NotFound')) {
100
            return $c->render(status => 404, openapi => { error => $_->error });
101
        }
102
        return $c->render(status => 500, openapi => { error => "$_" });
103
    };
104
}
105
106
=head3 complete_recovery
107
108
=cut
109
110
sub complete_recovery {
111
    my $c = shift->openapi->valid_input or return;
112
113
    my $rs = Koha::Database->new->schema->resultset('BorrowerPasswordRecovery');
114
    return try {
115
        my $body = $c->req->json;
116
117
        my $password_recovery = $rs->find({
118
            uuid => $body->{uuid}
119
        });
120
        unless ($password_recovery) {
121
            return $c->render(status => 404, openapi => {
122
                error => 'Password recovery request with given uuid not found.'
123
            });
124
        }
125
126
        my $patron = Koha::Patrons->find($password_recovery->borrowernumber);
127
        my $categorycode = $patron->categorycode;
128
        my $password = $body->{new_password};
129
        $patron->set_password( { password => $password } );
130
        return $c->render(status => 200, openapi => {});
131
    }
132
    catch {
133
        unless ( blessed $_ && $_->can('rethrow') ) {
134
            return $c->render( status => 500, openapi => { error => "$_" } );
135
        }
136
137
        if ($_->isa('Koha::Exceptions::Password::TooShort')) {
138
            return $c->render( status => 400, openapi => {
139
                error => 'Password is too short',
140
                minlength => $_->min_length,
141
                length => $_->length
142
            } );
143
        }
144
        elsif ($_->isa('Koha::Exceptions::Password::TooWeak')) {
145
            return $c->render( status => 400, openapi => {
146
                error => 'Password is too weak',
147
            } );
148
        }
149
        elsif ($_->isa('Koha::Exceptions::Password::WhitespaceCharacters')) {
150
            return $c->render( status => 400, openapi => {
151
                error => 'Password contains trailing whitespace characters',
152
            } );
153
        }
154
        return $c->render(status => 500, openapi => { error => "$_" });
155
    };
156
}
157
37
=head3 set
158
=head3 set
38
159
39
Controller method that sets a patron's password, permission driven
160
Controller method that sets a patron's password, permission driven
(-)a/api/v1/swagger/definitions.json (+3 lines)
Lines 56-61 Link Here
56
  "patron_account_credit": {
56
  "patron_account_credit": {
57
    "$ref": "definitions/patron_account_credit.json"
57
    "$ref": "definitions/patron_account_credit.json"
58
  },
58
  },
59
  "patron_password_recovery": {
60
    "$ref": "definitions/patron_password_recovery.json"
61
  },
59
  "patron_balance": {
62
  "patron_balance": {
60
    "$ref": "definitions/patron_balance.json"
63
    "$ref": "definitions/patron_balance.json"
61
  },
64
  },
(-)a/api/v1/swagger/definitions/patron_password_recovery.json (+13 lines)
Line 0 Link Here
1
{
2
  "type": "object",
3
  "properties": {
4
    "to_address": {
5
      "description": "Patron email address",
6
      "type": ["string", "null"]
7
    },
8
    "status": {
9
      "description": "Status code. 1 = email has been enqueued",
10
      "type": "integer"
11
    }
12
  }
13
}
(-)a/api/v1/swagger/paths.json (+6 lines)
Lines 122-127 Link Here
122
  "/public/patrons/{patron_id}/guarantors/can_see_charges": {
122
  "/public/patrons/{patron_id}/guarantors/can_see_charges": {
123
    "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_charges"
123
    "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_charges"
124
  },
124
  },
125
  "/public/patrons/password/recovery": {
126
    "$ref": "paths/patrons.json#/~1public~1patrons~1password~1recovery"
127
  },
128
  "/public/patrons/password/recovery/complete": {
129
    "$ref": "paths/patrons.json#/~1public~1patrons~1password~1recovery~1complete"
130
  },
125
  "/public/patrons/{patron_id}/guarantors/can_see_checkouts": {
131
  "/public/patrons/{patron_id}/guarantors/can_see_checkouts": {
126
    "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_checkouts"
132
    "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_checkouts"
127
  },
133
  },
(-)a/api/v1/swagger/paths/patrons.json (+128 lines)
Lines 703-707 Link Here
703
        }
703
        }
704
      }
704
      }
705
    }
705
    }
706
  },
707
  "/public/patrons/password/recovery": {
708
    "post": {
709
      "x-mojo-to": "Patrons::Password#recovery",
710
      "operationId": "addPasswordRecoveryRequest",
711
      "description": "Creates a new password recovery request.",
712
      "tags": ["password"],
713
      "parameters": [{
714
        "name": "body",
715
        "in": "body",
716
        "description": "A JSON object containing fields for recovery request",
717
        "required": true,
718
        "schema": {
719
          "type": "object",
720
          "properties": {
721
            "userid": {
722
              "description": "Patron's userid (this field or cardnumber required)",
723
              "type": "string"
724
            },
725
            "cardnumber": {
726
              "description": "Patron's cardnumber (this field or userid required)",
727
              "type": "string"
728
            },
729
            "email": {
730
              "description": "Patron's email (required)",
731
              "type": "string"
732
            }
733
          },
734
          "required": ["email"]
735
        }
736
      }],
737
      "produces": ["application/json"],
738
      "responses": {
739
        "201": {
740
          "description": "Password recovery request created",
741
          "schema": { "$ref": "../definitions.json#/patron_password_recovery" }
742
        },
743
        "400": {
744
          "description": "Bad parameter(s)",
745
          "schema": { "$ref": "../definitions.json#/error" }
746
        },
747
        "403": {
748
          "description": "Password recovery disabled, no access to this endpoint",
749
          "schema": {
750
            "$ref": "../definitions.json#/error"
751
          }
752
        },
753
        "404": {
754
          "description": "One or more of the given parameters not found",
755
          "schema": {
756
            "$ref": "../definitions.json#/error"
757
          }
758
        },
759
        "500": {
760
          "description": "Internal server error",
761
          "schema": {
762
            "$ref": "../definitions.json#/error"
763
          }
764
        },
765
        "503": {
766
          "description": "Under maintenance",
767
          "schema": {
768
            "$ref": "../definitions.json#/error"
769
          }
770
        }
771
      }
772
    }
773
  },
774
  "/public/patrons/password/recovery/complete": {
775
    "post": {
776
      "x-mojo-to": "Patrons::Password#complete_recovery",
777
      "operationId": "completePasswordRecoveryRequest",
778
      "description": "Completes a password recovery request.",
779
      "tags": ["password"],
780
      "parameters": [{
781
        "name": "body",
782
        "in": "body",
783
        "description": "A JSON object containing fields for completing recovery request",
784
        "required": true,
785
        "schema": {
786
          "type": "object",
787
          "properties": {
788
            "uuid": {
789
              "description": "Uuid generated in /patrons/password/recovery",
790
              "type": "string"
791
            },
792
            "new_password": {
793
              "description": "Patron's new password",
794
              "type": "string"
795
            },
796
            "confirm_new_password": {
797
              "description": "Confirm patron's new password",
798
              "type": "string"
799
            }
800
          },
801
          "required": ["uuid", "new_password", "confirm_new_password"]
802
        }
803
      }],
804
      "produces": ["application/json"],
805
      "responses": {
806
        "200": {
807
          "description": "Password recovery completed",
808
          "schema": { "type": "object" }
809
        },
810
        "400": {
811
          "description": "Bad parameter(s)",
812
          "schema": { "$ref": "../definitions.json#/error" }
813
        },
814
        "404": {
815
          "description": "Uuid not found",
816
          "schema": {
817
            "$ref": "../definitions.json#/error"
818
          }
819
        },
820
        "500": {
821
          "description": "Internal server error",
822
          "schema": {
823
            "$ref": "../definitions.json#/error"
824
          }
825
        },
826
        "503": {
827
          "description": "Under maintenance",
828
          "schema": {
829
            "$ref": "../definitions.json#/error"
830
          }
831
        }
832
      }
833
    }
706
  }
834
  }
707
}
835
}
(-)a/t/db_dependent/api/v1/patrons_password_recovery.t (-1 / +316 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/env perl
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it under the
6
# terms of the GNU General Public License as published by the Free Software
7
# Foundation; either version 3 of the License, or (at your option) any later
8
# version.
9
#
10
# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
11
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13
#
14
# You should have received a copy of the GNU General Public License along
15
# with Koha; if not, write to the Free Software Foundation, Inc.,
16
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
18
use Modern::Perl;
19
20
use Test::More tests => 2;
21
use Test::Mojo;
22
use Test::MockModule;
23
24
use t::lib::Mocks;
25
use t::lib::TestBuilder;
26
27
use C4::Auth;
28
use C4::Context;
29
30
use Koha::Database;
31
use Koha::Notice::Messages;
32
33
use Crypt::Eksblowfish::Bcrypt qw(en_base64);
34
35
my $schema  = Koha::Database->new->schema;
36
my $builder = t::lib::TestBuilder->new;
37
38
# FIXME: sessionStorage defaults to mysql, but it seems to break transaction handling
39
# this affects the other REST api tests
40
t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
41
42
my $remote_address = '127.0.0.1';
43
my $t              = Test::Mojo->new('Koha::REST::V1');
44
45
subtest 'recovery() tests' => sub {
46
    plan tests => 29;
47
48
    $schema->storage->txn_begin;
49
50
    my $url = '/api/v1/public/patrons/password/recovery';
51
52
    my ($patron, $session) = create_user_and_session();
53
54
    my $tx = $t->ua->build_tx(POST => $url => json => {});
55
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
56
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
57
    $t->request_ok($tx)
58
      ->status_is(400);
59
60
    t::lib::Mocks::mock_preference('OpacPasswordReset', 0);
61
    t::lib::Mocks::mock_preference('OpacPasswordChange', 0);
62
    $tx = $t->ua->build_tx(POST => $url => json => {
63
        email      => $patron->email,
64
        cardnumber => $patron->cardnumber
65
    });
66
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
67
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
68
    $t->request_ok($tx)
69
      ->status_is(403);
70
71
    t::lib::Mocks::mock_preference('OpacPasswordChange', 1);
72
    $tx = $t->ua->build_tx(POST => $url => json => {
73
        email      => $patron->email,
74
        cardnumber => $patron->cardnumber
75
    });
76
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
77
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
78
    $t->request_ok($tx)
79
      ->status_is(403);
80
81
    t::lib::Mocks::mock_preference('OpacPasswordReset', 1);
82
    t::lib::Mocks::mock_preference('OpacPasswordChange', 0);
83
    $tx = $t->ua->build_tx(POST => $url => json => {
84
        email      => $patron->email,
85
        cardnumber => $patron->cardnumber
86
    });
87
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
88
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
89
    $t->request_ok($tx)
90
      ->status_is(403);
91
92
    t::lib::Mocks::mock_preference('OpacPasswordChange', 1);
93
94
    $tx = $t->ua->build_tx(POST => $url => json => {
95
        email      => 'nonexistent',
96
        cardnumber => $patron->cardnumber
97
    });
98
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
99
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
100
    $t->request_ok($tx)
101
      ->status_is(404);
102
103
    $tx = $t->ua->build_tx(POST => $url => json => {
104
        email      => $patron->email,
105
        cardnumber => 'nonexistent'
106
    });
107
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
108
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
109
    $t->request_ok($tx)
110
      ->status_is(404);
111
112
    $tx = $t->ua->build_tx(POST => $url => json => {
113
        email      => 'nonexistent',
114
        userid     => $patron->userid
115
    });
116
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
117
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
118
    $t->request_ok($tx)
119
      ->status_is(404);
120
121
    $tx = $t->ua->build_tx(POST => $url => json => {
122
        email      => $patron->email,
123
        userid     => 'nonexistent'
124
    });
125
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
126
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
127
    $t->request_ok($tx)
128
      ->status_is(404);
129
130
    $tx = $t->ua->build_tx(POST => $url => json => {
131
        email      => $patron->email,
132
        cardnumber => $patron->cardnumber
133
    });
134
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
135
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
136
    $t->request_ok($tx)
137
      ->status_is(201)
138
      ->json_is('/status' => 1);
139
140
    my $rs = Koha::Database->new->schema->resultset('BorrowerPasswordRecovery');
141
    is(
142
        $rs->search({ borrowernumber => $patron->borrowernumber })->count, 1,
143
        'Password modification request found in database'
144
    );
145
146
    $tx = $t->ua->build_tx(POST => $url => json => {
147
        email      => $patron->email,
148
        userid     => $patron->userid
149
    });
150
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
151
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
152
    $t->request_ok($tx)
153
      ->status_is(201)
154
      ->json_is('/status' => 1);
155
156
    is(
157
        $rs->search({ borrowernumber => $patron->borrowernumber })->count, 1,
158
        'Password modification request found in database'
159
    );
160
161
    $tx = $t->ua->build_tx(POST => $url => json => {
162
        email      => $patron->email,
163
        userid     => $patron->userid,
164
        cardnumber => $patron->cardnumber,
165
    });
166
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
167
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
168
    $t->request_ok($tx)
169
      ->status_is(201)
170
      ->json_is('/status' => 1);
171
172
    is(
173
        $rs->search({ borrowernumber => $patron->borrowernumber })->count, 1,
174
        'Password modification request found in database'
175
    );
176
177
    my $notice = Koha::Notice::Messages->search({
178
        borrowernumber => $patron->borrowernumber,
179
        letter_code => 'PASSWORD_RESET',
180
        message_transport_type => 'email'
181
    })->count;
182
    is($notice, 3, 'Found password reset letters in message queue.');
183
184
    $schema->storage->txn_rollback;
185
};
186
187
subtest 'complete_recovery() tests' => sub {
188
    plan tests => 16;
189
190
    $schema->storage->txn_begin;
191
192
    my $rs = Koha::Database->new->schema->resultset('BorrowerPasswordRecovery');
193
194
    my ($patron, $session) = create_user_and_session();
195
    my $uuid_str;
196
    do {
197
        $uuid_str = '$2a$08$'.en_base64(Koha::AuthUtils::generate_salt('weak', 16));
198
    } while ( substr ( $uuid_str, -1, 1 ) eq '.' );
199
    my $recovery = $builder->build({
200
        source => 'BorrowerPasswordRecovery',
201
        value  => {
202
            borrowernumber => $patron->borrowernumber,
203
            uuid => $uuid_str
204
        }
205
    });
206
207
    my $url = '/api/v1/public/patrons/password/recovery/complete';
208
209
    my $tx = $t->ua->build_tx(POST => $url => json => {});
210
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
211
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
212
    $t->request_ok($tx)
213
      ->status_is(400);
214
215
    $tx = $t->ua->build_tx(POST => $url.'notfound' => json => {
216
        uuid                 => $uuid_str,
217
        new_password         => 'test',
218
        confirm_new_password => 'test',
219
    });
220
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
221
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
222
    $t->request_ok($tx)
223
      ->status_is(404);
224
225
    t::lib::Mocks::mock_preference('minPasswordLength', 6);
226
    my $new_pass = 'short';
227
    $tx = $t->ua->build_tx(POST => $url => json => {
228
        uuid                 => $uuid_str,
229
        new_password         => $new_pass,
230
        confirm_new_password => $new_pass,
231
    });
232
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
233
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
234
    $t->request_ok($tx)
235
      ->status_is(400)
236
      ->json_like('/error', qr/too short/);
237
238
    $new_pass = ' wh1te Space! ';
239
    $tx = $t->ua->build_tx(POST => $url => json => {
240
        uuid                 => $uuid_str,
241
        new_password         => $new_pass,
242
        confirm_new_password => $new_pass,
243
    });
244
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
245
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
246
    $t->request_ok($tx)
247
      ->status_is(400)
248
      ->json_like('/error', qr/trailing/);
249
250
    $new_pass = 'tooweak';
251
    $tx = $t->ua->build_tx(POST => $url => json => {
252
        uuid                 => $uuid_str,
253
        new_password         => $new_pass,
254
        confirm_new_password => $new_pass,
255
    });
256
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
257
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
258
    $t->request_ok($tx)
259
      ->status_is(400)
260
      ->json_like('/error', qr/too weak/);
261
262
    t::lib::Mocks::mock_preference('minPasswordLength', 4);
263
    $new_pass = 'STR 0NG ENOugh P@@ssword';
264
    $tx = $t->ua->build_tx(POST => $url => json => {
265
        uuid                 => $uuid_str,
266
        new_password         => $new_pass,
267
        confirm_new_password => $new_pass,
268
    });
269
    $tx->req->cookies({name => 'CGISESSID', value => $session->id});
270
    $tx->req->env({REMOTE_ADDR => '127.0.0.1'});
271
    $t->request_ok($tx)
272
      ->status_is(200);
273
274
    my $stored_pw = Koha::Patrons->find($patron->borrowernumber)->password;
275
    is(
276
      $stored_pw,
277
       Koha::AuthUtils::hash_password($new_pass, $stored_pw), 'Password changed'
278
    );
279
280
    $schema->storage->txn_rollback;
281
};
282
283
sub create_user_and_session {
284
    my $args = shift;
285
286
    my $flags = ( $args->{authorized} ) ? $args->{authorized} : 0;
287
    my $categorycode = $builder->build({ source => 'Category' })->{categorycode};
288
    my $branchcode = $builder->build({ source => 'Branch' })->{ branchcode };
289
    my $dbh   = C4::Context->dbh;
290
291
    my $borrower = $builder->build({
292
        source => 'Borrower',
293
        value => {
294
            branchcode   => $branchcode,
295
            categorycode => $categorycode,
296
            lost         => 0,
297
            flags        => $flags,
298
        }
299
    });
300
301
    my $session = C4::Auth::get_session('');
302
    $session->param('number', $borrower->{ borrowernumber });
303
    $session->param('id', $borrower->{ userid });
304
    $session->param('ip', '127.0.0.1');
305
    $session->param('lasttime', time());
306
    $session->flush;
307
    my $patron = Koha::Patrons->find($borrower->{borrowernumber});
308
    if ( $args->{authorized} ) {
309
        $dbh->do( "
310
            INSERT INTO user_permissions (borrowernumber,module_bit,code)
311
            VALUES (?,4,'get_password_reset_uuid')", undef,
312
            $borrower->{borrowernumber} );
313
    }
314
315
    return ($patron, $session);
316
}

Return to bug 19133