Bug 28948: Make is_public stashed on public routes
[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({ spec => $spec });
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               unless $session->param('sessiontype')
228                  and $session->param('sessiontype') eq 'anon';
229             $cookie_auth = 1;
230         }
231         elsif ($status eq "maintenance") {
232             Koha::Exceptions::UnderMaintenance->throw(
233                 error => 'System is under maintenance.'
234             );
235         }
236         elsif ($status eq "expired" and $authorization) {
237             Koha::Exceptions::Authentication::SessionExpired->throw(
238                 error => 'Session has been expired.'
239             );
240         }
241         elsif ($status eq "failed" and $authorization) {
242             Koha::Exceptions::Authentication::Required->throw(
243                 error => 'Authentication failure.'
244             );
245         }
246         elsif ($authorization) {
247             Koha::Exceptions::Authentication->throw(
248                 error => 'Unexpected authentication status.'
249             );
250         }
251     }
252
253     $c->stash('koha.user' => $user);
254     C4::Context->interface('api');
255
256     if ( $user and !$cookie_auth ) { # cookie-auth sets this and more, don't mess with that
257         $c->_set_userenv( $user );
258     }
259
260     if ( !$authorization and
261          ( $params->{is_public} and
262           ( C4::Context->preference('RESTPublicAnonymousRequests') or
263             $user) or $params->{is_plugin} ) ) {
264         # We do not need any authorization
265         # Check the parameters
266         validate_query_parameters( $c, $spec );
267         return 1;
268     }
269     else {
270         # We are required authorizarion, there needs
271         # to be an identified user
272         Koha::Exceptions::Authentication::Required->throw(
273             error => 'Authentication failure.' )
274           unless $user;
275     }
276
277
278     my $permissions = $authorization->{'permissions'};
279     # Check if the user is authorized
280     if ( ( defined($permissions) and haspermission($user->userid, $permissions) )
281         or allow_owner($c, $authorization, $user)
282         or allow_guarantor($c, $authorization, $user) ) {
283
284         validate_query_parameters( $c, $spec );
285
286         # Everything is ok
287         return 1;
288     }
289
290     Koha::Exceptions::Authorization::Unauthorized->throw(
291         error => "Authorization failure. Missing required permission(s).",
292         required_permissions => $permissions,
293     );
294 }
295
296 =head3 validate_query_parameters
297
298 Validates the query parameters against the spec.
299
300 =cut
301
302 sub validate_query_parameters {
303     my ( $c, $action_spec ) = @_;
304
305     # Check for malformed query parameters
306     my @errors;
307     my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
308     my $existing_params = $c->req->query_params->to_hash;
309     for my $param ( keys %{$existing_params} ) {
310         push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
311     }
312
313     Koha::Exceptions::BadParameter->throw(
314         error => \@errors
315     ) if @errors;
316 }
317
318 =head3 allow_owner
319
320 Allows access to object for its owner.
321
322 There are endpoints that should allow access for the object owner even if they
323 do not have the required permission, e.g. access an own reserve. This can be
324 achieved by defining the operation as follows:
325
326 "/holds/{reserve_id}": {
327     "get": {
328         ...,
329         "x-koha-authorization": {
330             "allow-owner": true,
331             "permissions": {
332                 "borrowers": "1"
333             }
334         }
335     }
336 }
337
338 =cut
339
340 sub allow_owner {
341     my ($c, $authorization, $user) = @_;
342
343     return unless $authorization->{'allow-owner'};
344
345     return check_object_ownership($c, $user) if $user and $c;
346 }
347
348 =head3 allow_guarantor
349
350 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
351 guarantees.
352
353 =cut
354
355 sub allow_guarantor {
356     my ($c, $authorization, $user) = @_;
357
358     if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
359         return;
360     }
361
362     my $guarantees = $user->guarantee_relationships->guarantees->as_list;
363     foreach my $guarantee (@{$guarantees}) {
364         return 1 if check_object_ownership($c, $guarantee);
365     }
366 }
367
368 =head3 check_object_ownership
369
370 Determines ownership of an object from request parameters.
371
372 As introducing an endpoint that allows access for object's owner; if the
373 parameter that will be used to determine ownership is not already inside
374 $parameters, add a new subroutine that checks the ownership and extend
375 $parameters to contain a key with parameter_name and a value of a subref to
376 the subroutine that you created.
377
378 =cut
379
380 sub check_object_ownership {
381     my ($c, $user) = @_;
382
383     return if not $c or not $user;
384
385     my $parameters = {
386         accountlines_id => \&_object_ownership_by_accountlines_id,
387         borrowernumber  => \&_object_ownership_by_patron_id,
388         patron_id       => \&_object_ownership_by_patron_id,
389         checkout_id     => \&_object_ownership_by_checkout_id,
390         reserve_id      => \&_object_ownership_by_reserve_id,
391     };
392
393     foreach my $param ( keys %{ $parameters } ) {
394         my $check_ownership = $parameters->{$param};
395         if ($c->stash($param)) {
396             return &$check_ownership($c, $user, $c->stash($param));
397         }
398         elsif ($c->param($param)) {
399             return &$check_ownership($c, $user, $c->param($param));
400         }
401         elsif ($c->match->stack->[-1]->{$param}) {
402             return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
403         }
404         elsif ($c->req->json && $c->req->json->{$param}) {
405             return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
406         }
407     }
408 }
409
410 =head3 _object_ownership_by_accountlines_id
411
412 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
413 belongs to C<$user>.
414
415 =cut
416
417 sub _object_ownership_by_accountlines_id {
418     my ($c, $user, $accountlines_id) = @_;
419
420     my $accountline = Koha::Account::Lines->find($accountlines_id);
421     return $accountline && $user->borrowernumber == $accountline->borrowernumber;
422 }
423
424 =head3 _object_ownership_by_borrowernumber
425
426 Compares C<$borrowernumber> to currently logged in C<$user>.
427
428 =cut
429
430 sub _object_ownership_by_patron_id {
431     my ($c, $user, $patron_id) = @_;
432
433     return $user->borrowernumber == $patron_id;
434 }
435
436 =head3 _object_ownership_by_checkout_id
437
438 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
439 compare its borrowernumber to currently logged in C<$user>. However, if an issue
440 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
441 borrowernumber to currently logged in C<$user>.
442
443 =cut
444
445 sub _object_ownership_by_checkout_id {
446     my ($c, $user, $issue_id) = @_;
447
448     my $issue = Koha::Checkouts->find($issue_id);
449     $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
450     return $issue && $issue->borrowernumber
451             && $user->borrowernumber == $issue->borrowernumber;
452 }
453
454 =head3 _object_ownership_by_reserve_id
455
456 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
457 belongs to C<$user>.
458
459 TODO: Also compare against old_reserves
460
461 =cut
462
463 sub _object_ownership_by_reserve_id {
464     my ($c, $user, $reserve_id) = @_;
465
466     my $reserve = Koha::Holds->find($reserve_id);
467     return $reserve && $user->borrowernumber == $reserve->borrowernumber;
468 }
469
470 =head3 _basic_auth
471
472 Internal method that performs Basic authentication.
473
474 =cut
475
476 sub _basic_auth {
477     my ( $c, $authorization_header ) = @_;
478
479     my ( $type, $credentials ) = split / /, $authorization_header;
480
481     unless ($credentials) {
482         Koha::Exceptions::Authentication::Required->throw( error => 'Authentication failure.' );
483     }
484
485     my $decoded_credentials = decode_base64( $credentials );
486     my ( $user_id, $password ) = split( /:/, $decoded_credentials, 2 );
487
488     my $dbh = C4::Context->dbh;
489     unless ( checkpw_internal($dbh, $user_id, $password ) ) {
490         Koha::Exceptions::Authorization::Unauthorized->throw( error => 'Invalid password' );
491     }
492
493     return Koha::Patrons->find({ userid => $user_id });
494 }
495
496 =head3 _set_userenv
497
498     $c->_set_userenv( $patron );
499
500 Internal method that sets C4::Context->userenv
501
502 =cut
503
504 sub _set_userenv {
505     my ( $c, $patron ) = @_;
506
507     my $passed_library_id = $c->req->headers->header('x-koha-library');
508     my $THE_library;
509
510     if ( $passed_library_id ) {
511         $THE_library = Koha::Libraries->find( $passed_library_id );
512         Koha::Exceptions::Authorization::Unauthorized->throw(
513             "Unauthorized attempt to set library to $passed_library_id"
514         ) unless $THE_library and $patron->can_log_into($THE_library);
515     }
516     else {
517         $THE_library = $patron->library;
518     }
519
520     C4::Context->_new_userenv( $patron->borrowernumber );
521     C4::Context->set_userenv(
522         $patron->borrowernumber,  # number,
523         $patron->userid,          # userid,
524         $patron->cardnumber,      # cardnumber
525         $patron->firstname,       # firstname
526         $patron->surname,         # surname
527         $THE_library->branchcode, # branch
528         $THE_library->branchname, # branchname
529         $patron->flags,           # flags,
530         $patron->email,           # emailaddress
531     );
532
533     return $c;
534 }
535
536 1;