Bug 23146: (QA follow-up) Make sure we use the absolute path
[koha-equinox.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 under the
8 # terms of the GNU General Public License as published by the Free Software
9 # Foundation; either version 3 of the License, or (at your option) any later
10 # version.
11 #
12 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License along
17 # with Koha; if not, write to the Free Software Foundation, Inc.,
18 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
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 = shift->openapi->valid_input or return;;
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         if ( $namespace eq 'public'
68             and !C4::Context->preference('RESTPublicAPI') )
69         {
70             Koha::Exceptions::Authorization->throw(
71                 "Configuration prevents the usage of this endpoint by unprivileged users");
72         }
73
74         if ( $c->req->url->to_abs->path eq '/api/v1/oauth/token' ) {
75             # Requesting a token shouldn't go through the API authenticaction chain
76             $status = 1;
77         }
78         else {
79             $status = authenticate_api_request($c);
80         }
81
82     } catch {
83         unless (blessed($_)) {
84             return $c->render(
85                 status => 500,
86                 json => { error => 'Something went wrong, check the logs.' }
87             );
88         }
89         if ($_->isa('Koha::Exceptions::UnderMaintenance')) {
90             return $c->render(status => 503, json => { error => $_->error });
91         }
92         elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) {
93             return $c->render(status => 401, json => { error => $_->error });
94         }
95         elsif ($_->isa('Koha::Exceptions::Authentication::Required')) {
96             return $c->render(status => 401, json => { error => $_->error });
97         }
98         elsif ($_->isa('Koha::Exceptions::Authentication')) {
99             return $c->render(status => 401, json => { error => $_->error });
100         }
101         elsif ($_->isa('Koha::Exceptions::BadParameter')) {
102             return $c->render(status => 400, json => $_->error );
103         }
104         elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) {
105             return $c->render(status => 403, json => {
106                 error => $_->error,
107                 required_permissions => $_->required_permissions,
108             });
109         }
110         elsif ($_->isa('Koha::Exceptions::Authorization')) {
111             return $c->render(status => 403, json => { error => $_->error });
112         }
113         elsif ($_->isa('Koha::Exceptions')) {
114             return $c->render(status => 500, json => { error => $_->error });
115         }
116         else {
117             return $c->render(
118                 status => 500,
119                 json => { error => 'Something went wrong, check the logs.' }
120             );
121         }
122     };
123
124     return $status;
125 }
126
127 =head3 authenticate_api_request
128
129 Validates authentication and allows access if authorization is not required or
130 if authorization is required and user has required permissions to access.
131
132 =cut
133
134 sub authenticate_api_request {
135     my ( $c ) = @_;
136
137     my $user;
138
139     my $spec = $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
140     my $authorization = $spec->{'x-koha-authorization'};
141
142     my $authorization_header = $c->req->headers->authorization;
143
144     if ($authorization_header and $authorization_header =~ /^Bearer /) {
145         # attempt to use OAuth2 authentication
146         if ( ! Module::Load::Conditional::can_load(
147                     modules => {'Net::OAuth2::AuthorizationServer' => undef} )) {
148             Koha::Exceptions::Authorization::Unauthorized->throw(
149                 error => 'Authentication failure.'
150             );
151         }
152         else {
153             require Net::OAuth2::AuthorizationServer;
154         }
155
156         my $server = Net::OAuth2::AuthorizationServer->new;
157         my $grant = $server->client_credentials_grant(Koha::OAuth::config);
158         my ($type, $token) = split / /, $authorization_header;
159         my ($valid_token, $error) = $grant->verify_access_token(
160             access_token => $token,
161         );
162
163         if ($valid_token) {
164             my $patron_id = Koha::ApiKeys->find( $valid_token->{client_id} )->patron_id;
165             $user         = Koha::Patrons->find($patron_id);
166             C4::Context->interface('api');
167         }
168         else {
169             # If we have "Authorization: Bearer" header and oauth authentication
170             # failed, do not try other authentication means
171             Koha::Exceptions::Authentication::Required->throw(
172                 error => 'Authentication failure.'
173             );
174         }
175     }
176     elsif ( $authorization_header and $authorization_header =~ /^Basic / ) {
177         unless ( C4::Context->preference('RESTBasicAuth') ) {
178             Koha::Exceptions::Authentication::Required->throw(
179                 error => 'Basic authentication disabled'
180             );
181         }
182         $user = $c->_basic_auth( $authorization_header );
183         C4::Context->interface('api');
184         unless ( $user ) {
185             # If we have "Authorization: Basic" header and authentication
186             # failed, do not try other authentication means
187             Koha::Exceptions::Authentication::Required->throw(
188                 error => 'Authentication failure.'
189             );
190         }
191     }
192     else {
193
194         my $cookie = $c->cookie('CGISESSID');
195
196         # Mojo doesn't use %ENV the way CGI apps do
197         # Manually pass the remote_address to check_auth_cookie
198         my $remote_addr = $c->tx->remote_address;
199         my ($status, $sessionID) = check_cookie_auth(
200                                                 $cookie, undef,
201                                                 { remote_addr => $remote_addr });
202         if ($status eq "ok") {
203             my $session = get_session($sessionID);
204             $user = Koha::Patrons->find($session->param('number'));
205             # $c->stash('koha.user' => $user);
206         }
207         elsif ($status eq "maintenance") {
208             Koha::Exceptions::UnderMaintenance->throw(
209                 error => 'System is under maintenance.'
210             );
211         }
212         elsif ($status eq "expired" and $authorization) {
213             Koha::Exceptions::Authentication::SessionExpired->throw(
214                 error => 'Session has been expired.'
215             );
216         }
217         elsif ($status eq "failed" and $authorization) {
218             Koha::Exceptions::Authentication::Required->throw(
219                 error => 'Authentication failure.'
220             );
221         }
222         elsif ($authorization) {
223             Koha::Exceptions::Authentication->throw(
224                 error => 'Unexpected authentication status.'
225             );
226         }
227     }
228
229     $c->stash('koha.user' => $user);
230
231     # We do not need any authorization
232     unless ($authorization) {
233         # Check the parameters
234         validate_query_parameters( $c, $spec );
235         return 1;
236     }
237
238     my $permissions = $authorization->{'permissions'};
239     # Check if the user is authorized
240     if ( ( defined($permissions) and haspermission($user->userid, $permissions) )
241         or allow_owner($c, $authorization, $user)
242         or allow_guarantor($c, $authorization, $user) ) {
243
244         validate_query_parameters( $c, $spec );
245
246         # Everything is ok
247         return 1;
248     }
249
250     Koha::Exceptions::Authorization::Unauthorized->throw(
251         error => "Authorization failure. Missing required permission(s).",
252         required_permissions => $permissions,
253     );
254 }
255
256 =head3 validate_query_parameters
257
258 Validates the query parameters against the spec.
259
260 =cut
261
262 sub validate_query_parameters {
263     my ( $c, $action_spec ) = @_;
264
265     # Check for malformed query parameters
266     my @errors;
267     my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
268     my $existing_params = $c->req->query_params->to_hash;
269     for my $param ( keys %{$existing_params} ) {
270         push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
271     }
272
273     Koha::Exceptions::BadParameter->throw(
274         error => \@errors
275     ) if @errors;
276 }
277
278
279 =head3 allow_owner
280
281 Allows access to object for its owner.
282
283 There are endpoints that should allow access for the object owner even if they
284 do not have the required permission, e.g. access an own reserve. This can be
285 achieved by defining the operation as follows:
286
287 "/holds/{reserve_id}": {
288     "get": {
289         ...,
290         "x-koha-authorization": {
291             "allow-owner": true,
292             "permissions": {
293                 "borrowers": "1"
294             }
295         }
296     }
297 }
298
299 =cut
300
301 sub allow_owner {
302     my ($c, $authorization, $user) = @_;
303
304     return unless $authorization->{'allow-owner'};
305
306     return check_object_ownership($c, $user) if $user and $c;
307 }
308
309 =head3 allow_guarantor
310
311 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
312 guarantees.
313
314 =cut
315
316 sub allow_guarantor {
317     my ($c, $authorization, $user) = @_;
318
319     if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
320         return;
321     }
322
323     my $guarantees = $user->guarantees->as_list;
324     foreach my $guarantee (@{$guarantees}) {
325         return 1 if check_object_ownership($c, $guarantee);
326     }
327 }
328
329 =head3 check_object_ownership
330
331 Determines ownership of an object from request parameters.
332
333 As introducing an endpoint that allows access for object's owner; if the
334 parameter that will be used to determine ownership is not already inside
335 $parameters, add a new subroutine that checks the ownership and extend
336 $parameters to contain a key with parameter_name and a value of a subref to
337 the subroutine that you created.
338
339 =cut
340
341 sub check_object_ownership {
342     my ($c, $user) = @_;
343
344     return if not $c or not $user;
345
346     my $parameters = {
347         accountlines_id => \&_object_ownership_by_accountlines_id,
348         borrowernumber  => \&_object_ownership_by_patron_id,
349         patron_id       => \&_object_ownership_by_patron_id,
350         checkout_id     => \&_object_ownership_by_checkout_id,
351         reserve_id      => \&_object_ownership_by_reserve_id,
352     };
353
354     foreach my $param ( keys %{ $parameters } ) {
355         my $check_ownership = $parameters->{$param};
356         if ($c->stash($param)) {
357             return &$check_ownership($c, $user, $c->stash($param));
358         }
359         elsif ($c->param($param)) {
360             return &$check_ownership($c, $user, $c->param($param));
361         }
362         elsif ($c->match->stack->[-1]->{$param}) {
363             return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
364         }
365         elsif ($c->req->json && $c->req->json->{$param}) {
366             return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
367         }
368     }
369 }
370
371 =head3 _object_ownership_by_accountlines_id
372
373 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
374 belongs to C<$user>.
375
376 =cut
377
378 sub _object_ownership_by_accountlines_id {
379     my ($c, $user, $accountlines_id) = @_;
380
381     my $accountline = Koha::Account::Lines->find($accountlines_id);
382     return $accountline && $user->borrowernumber == $accountline->borrowernumber;
383 }
384
385 =head3 _object_ownership_by_borrowernumber
386
387 Compares C<$borrowernumber> to currently logged in C<$user>.
388
389 =cut
390
391 sub _object_ownership_by_patron_id {
392     my ($c, $user, $patron_id) = @_;
393
394     return $user->borrowernumber == $patron_id;
395 }
396
397 =head3 _object_ownership_by_checkout_id
398
399 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
400 compare its borrowernumber to currently logged in C<$user>. However, if an issue
401 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
402 borrowernumber to currently logged in C<$user>.
403
404 =cut
405
406 sub _object_ownership_by_checkout_id {
407     my ($c, $user, $issue_id) = @_;
408
409     my $issue = Koha::Checkouts->find($issue_id);
410     $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
411     return $issue && $issue->borrowernumber
412             && $user->borrowernumber == $issue->borrowernumber;
413 }
414
415 =head3 _object_ownership_by_reserve_id
416
417 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
418 belongs to C<$user>.
419
420 TODO: Also compare against old_reserves
421
422 =cut
423
424 sub _object_ownership_by_reserve_id {
425     my ($c, $user, $reserve_id) = @_;
426
427     my $reserve = Koha::Holds->find($reserve_id);
428     return $reserve && $user->borrowernumber == $reserve->borrowernumber;
429 }
430
431 =head3 _basic_auth
432
433 Internal method that performs Basic authentication.
434
435 =cut
436
437 sub _basic_auth {
438     my ( $c, $authorization_header ) = @_;
439
440     my ( $type, $credentials ) = split / /, $authorization_header;
441
442     unless ($credentials) {
443         Koha::Exceptions::Authentication::Required->throw( error => 'Authentication failure.' );
444     }
445
446     my $decoded_credentials = decode_base64( $credentials );
447     my ( $user_id, $password ) = split( /:/, $decoded_credentials, 2 );
448
449     my $dbh = C4::Context->dbh;
450     unless ( checkpw_internal($dbh, $user_id, $password ) ) {
451         Koha::Exceptions::Authorization::Unauthorized->throw( error => 'Invalid password' );
452     }
453
454     return Koha::Patrons->find({ userid => $user_id });
455 }
456
457 1;