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 32-37 Link Here
32
  "patron_account_credit": {
32
  "patron_account_credit": {
33
    "$ref": "definitions/patron_account_credit.json"
33
    "$ref": "definitions/patron_account_credit.json"
34
  },
34
  },
35
  "patron_password_recovery": {
36
    "$ref": "definitions/patron_password_recovery.json"
37
  },
35
  "patron_balance": {
38
  "patron_balance": {
36
    "$ref": "definitions/patron_balance.json"
39
    "$ref": "definitions/patron_balance.json"
37
  },
40
  },
(-)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 83-88 Link Here
83
  "/public/patrons/{patron_id}/guarantors/can_see_charges": {
83
  "/public/patrons/{patron_id}/guarantors/can_see_charges": {
84
    "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_charges"
84
    "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_charges"
85
  },
85
  },
86
  "/public/patrons/password/recovery": {
87
    "$ref": "paths/patrons.json#/~1public~1patrons~1password~1recovery"
88
  },
89
  "/public/patrons/password/recovery/complete": {
90
    "$ref": "paths/patrons.json#/~1public~1patrons~1password~1recovery~1complete"
91
  },
86
  "/public/patrons/{patron_id}/guarantors/can_see_checkouts": {
92
  "/public/patrons/{patron_id}/guarantors/can_see_checkouts": {
87
    "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_checkouts"
93
    "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_checkouts"
88
  }
94
  }
(-)a/api/v1/swagger/paths/patrons.json (+128 lines)
Lines 700-704 Link Here
700
        }
700
        }
701
      }
701
      }
702
    }
702
    }
703
  },
704
  "/public/patrons/password/recovery": {
705
    "post": {
706
      "x-mojo-to": "Patrons::Password#recovery",
707
      "operationId": "addPasswordRecoveryRequest",
708
      "description": "Creates a new password recovery request.",
709
      "tags": ["password"],
710
      "parameters": [{
711
        "name": "body",
712
        "in": "body",
713
        "description": "A JSON object containing fields for recovery request",
714
        "required": true,
715
        "schema": {
716
          "type": "object",
717
          "properties": {
718
            "userid": {
719
              "description": "Patron's userid (this field or cardnumber required)",
720
              "type": "string"
721
            },
722
            "cardnumber": {
723
              "description": "Patron's cardnumber (this field or userid required)",
724
              "type": "string"
725
            },
726
            "email": {
727
              "description": "Patron's email (required)",
728
              "type": "string"
729
            }
730
          },
731
          "required": ["email"]
732
        }
733
      }],
734
      "produces": ["application/json"],
735
      "responses": {
736
        "201": {
737
          "description": "Password recovery request created",
738
          "schema": { "$ref": "../definitions.json#/patron_password_recovery" }
739
        },
740
        "400": {
741
          "description": "Bad parameter(s)",
742
          "schema": { "$ref": "../definitions.json#/error" }
743
        },
744
        "403": {
745
          "description": "Password recovery disabled, no access to this endpoint",
746
          "schema": {
747
            "$ref": "../definitions.json#/error"
748
          }
749
        },
750
        "404": {
751
          "description": "One or more of the given parameters not found",
752
          "schema": {
753
            "$ref": "../definitions.json#/error"
754
          }
755
        },
756
        "500": {
757
          "description": "Internal server error",
758
          "schema": {
759
            "$ref": "../definitions.json#/error"
760
          }
761
        },
762
        "503": {
763
          "description": "Under maintenance",
764
          "schema": {
765
            "$ref": "../definitions.json#/error"
766
          }
767
        }
768
      }
769
    }
770
  },
771
  "/public/patrons/password/recovery/complete": {
772
    "post": {
773
      "x-mojo-to": "Patrons::Password#complete_recovery",
774
      "operationId": "completePasswordRecoveryRequest",
775
      "description": "Completes a password recovery request.",
776
      "tags": ["password"],
777
      "parameters": [{
778
        "name": "body",
779
        "in": "body",
780
        "description": "A JSON object containing fields for completing recovery request",
781
        "required": true,
782
        "schema": {
783
          "type": "object",
784
          "properties": {
785
            "uuid": {
786
              "description": "Uuid generated in /patrons/password/recovery",
787
              "type": "string"
788
            },
789
            "new_password": {
790
              "description": "Patron's new password",
791
              "type": "string"
792
            },
793
            "confirm_new_password": {
794
              "description": "Confirm patron's new password",
795
              "type": "string"
796
            }
797
          },
798
          "required": ["uuid", "new_password", "confirm_new_password"]
799
        }
800
      }],
801
      "produces": ["application/json"],
802
      "responses": {
803
        "200": {
804
          "description": "Password recovery completed",
805
          "schema": { "type": "object" }
806
        },
807
        "400": {
808
          "description": "Bad parameter(s)",
809
          "schema": { "$ref": "../definitions.json#/error" }
810
        },
811
        "404": {
812
          "description": "Uuid not found",
813
          "schema": {
814
            "$ref": "../definitions.json#/error"
815
          }
816
        },
817
        "500": {
818
          "description": "Internal server error",
819
          "schema": {
820
            "$ref": "../definitions.json#/error"
821
          }
822
        },
823
        "503": {
824
          "description": "Under maintenance",
825
          "schema": {
826
            "$ref": "../definitions.json#/error"
827
          }
828
        }
829
      }
830
    }
703
  }
831
  }
704
}
832
}
(-)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