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