Bug 27342: Remove dbh from C4::Auth
[koha.git] / Koha / REST / V1 / Auth.pm
1 package Koha::REST::V1::Auth;
2
3 # Copyright Koha-Suomi Oy 2017
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21
22 use Mojo::Base 'Mojolicious::Controller';
23
24 use C4::Auth qw( check_cookie_auth checkpw_internal get_session haspermission );
25 use C4::Context;
26
27 use Koha::ApiKeys;
28 use Koha::Account::Lines;
29 use Koha::Checkouts;
30 use Koha::Holds;
31 use Koha::Libraries;
32 use Koha::OAuth;
33 use Koha::OAuthAccessTokens;
34 use Koha::Old::Checkouts;
35 use Koha::Patrons;
36
37 use Koha::Exceptions;
38 use Koha::Exceptions::Authentication;
39 use Koha::Exceptions::Authorization;
40
41 use MIME::Base64 qw( decode_base64 );
42 use Module::Load::Conditional;
43 use Scalar::Util qw( blessed );
44 use Try::Tiny qw( catch try );
45
46 =head1 NAME
47
48 Koha::REST::V1::Auth
49
50 =head2 Operations
51
52 =head3 under
53
54 This subroutine is called before every request to API.
55
56 =cut
57
58 sub under {
59     my ( $c ) = @_;
60
61     my $status = 0;
62
63     try {
64
65         # /api/v1/{namespace}
66         my $namespace = $c->req->url->to_abs->path->[2] // '';
67
68         my $is_public = 0; # By default routes are not public
69         my $is_plugin = 0;
70
71         if ( $namespace eq 'public' ) {
72             $is_public = 1;
73         } elsif ( $namespace eq 'contrib' ) {
74             $is_plugin = 1;
75         }
76
77         if ( $is_public
78             and !C4::Context->preference('RESTPublicAPI') )
79         {
80             Koha::Exceptions::Authorization->throw(
81                 "Configuration prevents the usage of this endpoint by unprivileged users");
82         }
83
84         if ( $c->req->url->to_abs->path eq '/api/v1/oauth/token' ) {
85             # Requesting a token shouldn't go through the API authenticaction chain
86             $status = 1;
87         }
88         elsif ( $namespace eq '' or $namespace eq '.html' ) {
89             $status = 1;
90         }
91         else {
92             $status = authenticate_api_request($c, { is_public => $is_public, is_plugin => $is_plugin });
93         }
94
95     } catch {
96         unless (blessed($_)) {
97             return $c->render(
98                 status => 500,
99                 json => { error => 'Something went wrong, check the logs.' }
100             );
101         }
102         if ($_->isa('Koha::Exceptions::UnderMaintenance')) {
103             return $c->render(status => 503, json => { error => $_->error });
104         }
105         elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) {
106             return $c->render(status => 401, json => { error => $_->error });
107         }
108         elsif ($_->isa('Koha::Exceptions::Authentication::Required')) {
109             return $c->render(status => 401, json => { error => $_->error });
110         }
111         elsif ($_->isa('Koha::Exceptions::Authentication')) {
112             return $c->render(status => 401, json => { error => $_->error });
113         }
114         elsif ($_->isa('Koha::Exceptions::BadParameter')) {
115             return $c->render(status => 400, json => $_->error );
116         }
117         elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) {
118             return $c->render(status => 403, json => {
119                 error => $_->error,
120                 required_permissions => $_->required_permissions,
121             });
122         }
123         elsif ($_->isa('Koha::Exceptions::Authorization')) {
124             return $c->render(status => 403, json => { error => $_->error });
125         }
126         elsif ($_->isa('Koha::Exceptions')) {
127             return $c->render(status => 500, json => { error => $_->error });
128         }
129         else {
130             return $c->render(
131                 status => 500,
132                 json => { error => 'Something went wrong, check the logs.' }
133             );
134         }
135     };
136
137     return $status;
138 }
139
140 =head3 authenticate_api_request
141
142 Validates authentication and allows access if authorization is not required or
143 if authorization is required and user has required permissions to access.
144
145 =cut
146
147 sub authenticate_api_request {
148     my ( $c, $params ) = @_;
149
150     my $user;
151
152     $c->stash( 'is_public' => 1 )
153         if $params->{is_public};
154
155     # The following supports retrieval of spec with Mojolicious::Plugin::OpenAPI@1.17 and later (first one)
156     # and older versions (second one).
157     # TODO: remove the latter 'openapi.op_spec' if minimum version is bumped to at least 1.17.
158     my $spec = $c->openapi->spec || $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
159
160     $c->stash_embed();
161     $c->stash_overrides();
162
163     my $cookie_auth = 0;
164
165     my $authorization = $spec->{'x-koha-authorization'};
166
167     my $authorization_header = $c->req->headers->authorization;
168
169     if ($authorization_header and $authorization_header =~ /^Bearer /) {
170         # attempt to use OAuth2 authentication
171         if ( ! Module::Load::Conditional::can_load(
172                     modules => {'Net::OAuth2::AuthorizationServer' => undef} )) {
173             Koha::Exceptions::Authorization::Unauthorized->throw(
174                 error => 'Authentication failure.'
175             );
176         }
177         else {
178             require Net::OAuth2::AuthorizationServer;
179         }
180
181         my $server = Net::OAuth2::AuthorizationServer->new;
182         my $grant = $server->client_credentials_grant(Koha::OAuth::config);
183         my ($type, $token) = split / /, $authorization_header;
184         my ($valid_token, $error) = $grant->verify_access_token(
185             access_token => $token,
186         );
187
188         if ($valid_token) {
189             my $patron_id = Koha::ApiKeys->find( $valid_token->{client_id} )->patron_id;
190             $user         = Koha::Patrons->find($patron_id);
191         }
192         else {
193             # If we have "Authorization: Bearer" header and oauth authentication
194             # failed, do not try other authentication means
195             Koha::Exceptions::Authentication::Required->throw(
196                 error => 'Authentication failure.'
197             );
198         }
199     }
200     elsif ( $authorization_header and $authorization_header =~ /^Basic / ) {
201         unless ( C4::Context->preference('RESTBasicAuth') ) {
202             Koha::Exceptions::Authentication::Required->throw(
203                 error => 'Basic authentication disabled'
204             );
205         }
206         $user = $c->_basic_auth( $authorization_header );
207         unless ( $user ) {
208             # If we have "Authorization: Basic" header and authentication
209             # failed, do not try other authentication means
210             Koha::Exceptions::Authentication::Required->throw(
211                 error => 'Authentication failure.'
212             );
213         }
214     }
215     else {
216
217         my $cookie = $c->cookie('CGISESSID');
218
219         # Mojo doesn't use %ENV the way CGI apps do
220         # Manually pass the remote_address to check_auth_cookie
221         my $remote_addr = $c->tx->remote_address;
222         my ($status, $session) = check_cookie_auth(
223                                                 $cookie, undef,
224                                                 { remote_addr => $remote_addr });
225         if ($status eq "ok") {
226             $user = Koha::Patrons->find( $session->param('number') );
227             $cookie_auth = 1;
228         }
229         elsif ($status eq "anon") {
230             $cookie_auth = 1;
231         }
232         elsif ($status eq "maintenance") {
233             Koha::Exceptions::UnderMaintenance->throw(
234                 error => 'System is under maintenance.'
235             );
236         }
237         elsif ($status eq "expired" and $authorization) {
238             Koha::Exceptions::Authentication::SessionExpired->throw(
239                 error => 'Session has been expired.'
240             );
241         }
242         elsif ($status eq "failed" and $authorization) {
243             Koha::Exceptions::Authentication::Required->throw(
244                 error => 'Authentication failure.'
245             );
246         }
247         elsif ($authorization) {
248             Koha::Exceptions::Authentication->throw(
249                 error => 'Unexpected authentication status.'
250             );
251         }
252     }
253
254     $c->stash('koha.user' => $user);
255     C4::Context->interface('api');
256
257     if ( $user and !$cookie_auth ) { # cookie-auth sets this and more, don't mess with that
258         $c->_set_userenv( $user );
259     }
260
261     if ( !$authorization and
262          ( $params->{is_public} and
263           ( C4::Context->preference('RESTPublicAnonymousRequests') or
264             $user) or $params->{is_plugin} ) ) {
265         # We do not need any authorization
266         # Check the parameters
267         validate_query_parameters( $c, $spec );
268         return 1;
269     }
270     else {
271         # We are required authorizarion, there needs
272         # to be an identified user
273         Koha::Exceptions::Authentication::Required->throw(
274             error => 'Authentication failure.' )
275           unless $user;
276     }
277
278
279     my $permissions = $authorization->{'permissions'};
280     # Check if the user is authorized
281     if ( ( defined($permissions) and haspermission($user->userid, $permissions) )
282         or allow_owner($c, $authorization, $user)
283         or allow_guarantor($c, $authorization, $user) ) {
284
285         validate_query_parameters( $c, $spec );
286
287         # Everything is ok
288         return 1;
289     }
290
291     Koha::Exceptions::Authorization::Unauthorized->throw(
292         error => "Authorization failure. Missing required permission(s).",
293         required_permissions => $permissions,
294     );
295 }
296
297 =head3 validate_query_parameters
298
299 Validates the query parameters against the spec.
300
301 =cut
302
303 sub validate_query_parameters {
304     my ( $c, $action_spec ) = @_;
305
306     # Check for malformed query parameters
307     my @errors;
308     my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
309     my $existing_params = $c->req->query_params->to_hash;
310     for my $param ( keys %{$existing_params} ) {
311         push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
312     }
313
314     Koha::Exceptions::BadParameter->throw(
315         error => \@errors
316     ) if @errors;
317 }
318
319 =head3 allow_owner
320
321 Allows access to object for its owner.
322
323 There are endpoints that should allow access for the object owner even if they
324 do not have the required permission, e.g. access an own reserve. This can be
325 achieved by defining the operation as follows:
326
327 "/holds/{reserve_id}": {
328     "get": {
329         ...,
330         "x-koha-authorization": {
331             "allow-owner": true,
332             "permissions": {
333                 "borrowers": "1"
334             }
335         }
336     }
337 }
338
339 =cut
340
341 sub allow_owner {
342     my ($c, $authorization, $user) = @_;
343
344     return unless $authorization->{'allow-owner'};
345
346     return check_object_ownership($c, $user) if $user and $c;
347 }
348
349 =head3 allow_guarantor
350
351 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
352 guarantees.
353
354 =cut
355
356 sub allow_guarantor {
357     my ($c, $authorization, $user) = @_;
358
359     if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
360         return;
361     }
362
363     my $guarantees = $user->guarantee_relationships->guarantees->as_list;
364     foreach my $guarantee (@{$guarantees}) {
365         return 1 if check_object_ownership($c, $guarantee);
366     }
367 }
368
369 =head3 check_object_ownership
370
371 Determines ownership of an object from request parameters.
372
373 As introducing an endpoint that allows access for object's owner; if the
374 parameter that will be used to determine ownership is not already inside
375 $parameters, add a new subroutine that checks the ownership and extend
376 $parameters to contain a key with parameter_name and a value of a subref to
377 the subroutine that you created.
378
379 =cut
380
381 sub check_object_ownership {
382     my ($c, $user) = @_;
383
384     return if not $c or not $user;
385
386     my $parameters = {
387         accountlines_id => \&_object_ownership_by_accountlines_id,
388         borrowernumber  => \&_object_ownership_by_patron_id,
389         patron_id       => \&_object_ownership_by_patron_id,
390         checkout_id     => \&_object_ownership_by_checkout_id,
391         reserve_id      => \&_object_ownership_by_reserve_id,
392     };
393
394     foreach my $param ( keys %{ $parameters } ) {
395         my $check_ownership = $parameters->{$param};
396         if ($c->stash($param)) {
397             return &$check_ownership($c, $user, $c->stash($param));
398         }
399         elsif ($c->param($param)) {
400             return &$check_ownership($c, $user, $c->param($param));
401         }
402         elsif ($c->match->stack->[-1]->{$param}) {
403             return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
404         }
405         elsif ($c->req->json && $c->req->json->{$param}) {
406             return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
407         }
408     }
409 }
410
411 =head3 _object_ownership_by_accountlines_id
412
413 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
414 belongs to C<$user>.
415
416 =cut
417
418 sub _object_ownership_by_accountlines_id {
419     my ($c, $user, $accountlines_id) = @_;
420
421     my $accountline = Koha::Account::Lines->find($accountlines_id);
422     return $accountline && $user->borrowernumber == $accountline->borrowernumber;
423 }
424
425 =head3 _object_ownership_by_borrowernumber
426
427 Compares C<$borrowernumber> to currently logged in C<$user>.
428
429 =cut
430
431 sub _object_ownership_by_patron_id {
432     my ($c, $user, $patron_id) = @_;
433
434     return $user->borrowernumber == $patron_id;
435 }
436
437 =head3 _object_ownership_by_checkout_id
438
439 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
440 compare its borrowernumber to currently logged in C<$user>. However, if an issue
441 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
442 borrowernumber to currently logged in C<$user>.
443
444 =cut
445
446 sub _object_ownership_by_checkout_id {
447     my ($c, $user, $issue_id) = @_;
448
449     my $issue = Koha::Checkouts->find($issue_id);
450     $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
451     return $issue && $issue->borrowernumber
452             && $user->borrowernumber == $issue->borrowernumber;
453 }
454
455 =head3 _object_ownership_by_reserve_id
456
457 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
458 belongs to C<$user>.
459
460 TODO: Also compare against old_reserves
461
462 =cut
463
464 sub _object_ownership_by_reserve_id {
465     my ($c, $user, $reserve_id) = @_;
466
467     my $reserve = Koha::Holds->find($reserve_id);
468     return $reserve && $user->borrowernumber == $reserve->borrowernumber;
469 }
470
471 =head3 _basic_auth
472
473 Internal method that performs Basic authentication.
474
475 =cut
476
477 sub _basic_auth {
478     my ( $c, $authorization_header ) = @_;
479
480     my ( $type, $credentials ) = split / /, $authorization_header;
481
482     unless ($credentials) {
483         Koha::Exceptions::Authentication::Required->throw( error => 'Authentication failure.' );
484     }
485
486     my $decoded_credentials = decode_base64( $credentials );
487     my ( $user_id, $password ) = split( /:/, $decoded_credentials, 2 );
488
489     unless ( checkpw_internal($user_id, $password ) ) {
490         Koha::Exceptions::Authorization::Unauthorized->throw( error => 'Invalid password' );
491     }
492
493     my $patron = Koha::Patrons->find({ userid => $user_id });
494     if ( $patron->password_expired ) {
495         Koha::Exceptions::Authorization::Unauthorized->throw( error => 'Password has expired' );
496     }
497
498     return $patron;
499 }
500
501 =head3 _set_userenv
502
503     $c->_set_userenv( $patron );
504
505 Internal method that sets C4::Context->userenv
506
507 =cut
508
509 sub _set_userenv {
510     my ( $c, $patron ) = @_;
511
512     my $passed_library_id = $c->req->headers->header('x-koha-library');
513     my $THE_library;
514
515     if ( $passed_library_id ) {
516         $THE_library = Koha::Libraries->find( $passed_library_id );
517         Koha::Exceptions::Authorization::Unauthorized->throw(
518             "Unauthorized attempt to set library to $passed_library_id"
519         ) unless $THE_library and $patron->can_log_into($THE_library);
520     }
521     else {
522         $THE_library = $patron->library;
523     }
524
525     C4::Context->_new_userenv( $patron->borrowernumber );
526     C4::Context->set_userenv(
527         $patron->borrowernumber,  # number,
528         $patron->userid,          # userid,
529         $patron->cardnumber,      # cardnumber
530         $patron->firstname,       # firstname
531         $patron->surname,         # surname
532         $THE_library->branchcode, # branch
533         $THE_library->branchname, # branchname
534         $patron->flags,           # flags,
535         $patron->email,           # emailaddress
536     );
537
538     return $c;
539 }
540
541 1;