Bug 20624: (QA follow-up) Handle missing deps gracefuly
[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 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 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 Module::Load::Conditional;
41 use Scalar::Util qw( blessed );
42 use Try::Tiny;
43
44 =head1 NAME
45
46 Koha::REST::V1::Auth
47
48 =head2 Operations
49
50 =head3 under
51
52 This subroutine is called before every request to API.
53
54 =cut
55
56 sub under {
57     my $c = shift->openapi->valid_input or return;;
58
59     my $status = 0;
60     try {
61
62         $status = authenticate_api_request($c);
63
64     } catch {
65         unless (blessed($_)) {
66             return $c->render(
67                 status => 500,
68                 json => { error => 'Something went wrong, check the logs.' }
69             );
70         }
71         if ($_->isa('Koha::Exceptions::UnderMaintenance')) {
72             return $c->render(status => 503, json => { error => $_->error });
73         }
74         elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) {
75             return $c->render(status => 401, json => { error => $_->error });
76         }
77         elsif ($_->isa('Koha::Exceptions::Authentication::Required')) {
78             return $c->render(status => 401, json => { error => $_->error });
79         }
80         elsif ($_->isa('Koha::Exceptions::Authentication')) {
81             return $c->render(status => 500, json => { error => $_->error });
82         }
83         elsif ($_->isa('Koha::Exceptions::BadParameter')) {
84             return $c->render(status => 400, json => $_->error );
85         }
86         elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) {
87             return $c->render(status => 403, json => {
88                 error => $_->error,
89                 required_permissions => $_->required_permissions,
90             });
91         }
92         elsif ($_->isa('Koha::Exceptions')) {
93             return $c->render(status => 500, json => { error => $_->error });
94         }
95         else {
96             return $c->render(
97                 status => 500,
98                 json => { error => 'Something went wrong, check the logs.' }
99             );
100         }
101     };
102
103     return $status;
104 }
105
106 =head3 authenticate_api_request
107
108 Validates authentication and allows access if authorization is not required or
109 if authorization is required and user has required permissions to access.
110
111 =cut
112
113 sub authenticate_api_request {
114     my ( $c ) = @_;
115
116     my $spec = $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
117     my $authorization = $spec->{'x-koha-authorization'};
118
119     my $authorization_header = $c->req->headers->authorization;
120
121     if ($authorization_header and $authorization_header =~ /^Bearer /) {
122         # attempt to use OAuth2 authentication
123         if ( ! Module::Load::Conditional::can_load('Net::OAuth2::AuthorizationServer') ) {
124             Koha::Exceptions::Authorization::Unauthorized->throw(
125                 error => 'Authentication failure.'
126             );
127         }
128         else {
129             require Net::OAuth2::AuthorizationServer;
130         }
131
132         my $server = Net::OAuth2::AuthorizationServer->new;
133         my $grant = $server->client_credentials_grant(Koha::OAuth::config);
134         my ($type, $token) = split / /, $authorization_header;
135         my ($valid_token, $error) = $grant->verify_access_token(
136             access_token => $token,
137         );
138
139         if ($valid_token) {
140             my $patron_id   = Koha::ApiKeys->find( $valid_token->{client_id} )->patron_id;
141             my $patron      = Koha::Patrons->find($patron_id);
142             my $permissions = $authorization->{'permissions'};
143             # Check if the patron is authorized
144             if ( haspermission($patron->userid, $permissions)
145                 or allow_owner($c, $authorization, $patron)
146                 or allow_guarantor($c, $authorization, $patron) ) {
147
148                 validate_query_parameters( $c, $spec );
149
150                 # Everything is ok
151                 return 1;
152             }
153
154             Koha::Exceptions::Authorization::Unauthorized->throw(
155                 error => "Authorization failure. Missing required permission(s).",
156                 required_permissions => $permissions,
157             );
158         }
159
160         # If we have "Authorization: Bearer" header and oauth authentication
161         # failed, do not try other authentication means
162         Koha::Exceptions::Authentication::Required->throw(
163             error => 'Authentication failure.'
164         );
165     }
166
167     my $cookie = $c->cookie('CGISESSID');
168     my ($session, $user);
169     # Mojo doesn't use %ENV the way CGI apps do
170     # Manually pass the remote_address to check_auth_cookie
171     my $remote_addr = $c->tx->remote_address;
172     my ($status, $sessionID) = check_cookie_auth(
173                                             $cookie, undef,
174                                             { remote_addr => $remote_addr });
175     if ($status eq "ok") {
176         $session = get_session($sessionID);
177         $user = Koha::Patrons->find($session->param('number'));
178         $c->stash('koha.user' => $user);
179     }
180     elsif ($status eq "maintenance") {
181         Koha::Exceptions::UnderMaintenance->throw(
182             error => 'System is under maintenance.'
183         );
184     }
185     elsif ($status eq "expired" and $authorization) {
186         Koha::Exceptions::Authentication::SessionExpired->throw(
187             error => 'Session has been expired.'
188         );
189     }
190     elsif ($status eq "failed" and $authorization) {
191         Koha::Exceptions::Authentication::Required->throw(
192             error => 'Authentication failure.'
193         );
194     }
195     elsif ($authorization) {
196         Koha::Exceptions::Authentication->throw(
197             error => 'Unexpected authentication status.'
198         );
199     }
200
201     # We do not need any authorization
202     unless ($authorization) {
203         # Check the parameters
204         validate_query_parameters( $c, $spec );
205         return 1;
206     }
207
208     my $permissions = $authorization->{'permissions'};
209     # Check if the user is authorized
210     if ( haspermission($user->userid, $permissions)
211         or allow_owner($c, $authorization, $user)
212         or allow_guarantor($c, $authorization, $user) ) {
213
214         validate_query_parameters( $c, $spec );
215
216         # Everything is ok
217         return 1;
218     }
219
220     Koha::Exceptions::Authorization::Unauthorized->throw(
221         error => "Authorization failure. Missing required permission(s).",
222         required_permissions => $permissions,
223     );
224 }
225 sub validate_query_parameters {
226     my ( $c, $action_spec ) = @_;
227
228     # Check for malformed query parameters
229     my @errors;
230     my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
231     my $existing_params = $c->req->query_params->to_hash;
232     for my $param ( keys %{$existing_params} ) {
233         push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
234     }
235
236     Koha::Exceptions::BadParameter->throw(
237         error => \@errors
238     ) if @errors;
239 }
240
241
242 =head3 allow_owner
243
244 Allows access to object for its owner.
245
246 There are endpoints that should allow access for the object owner even if they
247 do not have the required permission, e.g. access an own reserve. This can be
248 achieved by defining the operation as follows:
249
250 "/holds/{reserve_id}": {
251     "get": {
252         ...,
253         "x-koha-authorization": {
254             "allow-owner": true,
255             "permissions": {
256                 "borrowers": "1"
257             }
258         }
259     }
260 }
261
262 =cut
263
264 sub allow_owner {
265     my ($c, $authorization, $user) = @_;
266
267     return unless $authorization->{'allow-owner'};
268
269     return check_object_ownership($c, $user) if $user and $c;
270 }
271
272 =head3 allow_guarantor
273
274 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
275 guarantees.
276
277 =cut
278
279 sub allow_guarantor {
280     my ($c, $authorization, $user) = @_;
281
282     if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
283         return;
284     }
285
286     my $guarantees = $user->guarantees->as_list;
287     foreach my $guarantee (@{$guarantees}) {
288         return 1 if check_object_ownership($c, $guarantee);
289     }
290 }
291
292 =head3 check_object_ownership
293
294 Determines ownership of an object from request parameters.
295
296 As introducing an endpoint that allows access for object's owner; if the
297 parameter that will be used to determine ownership is not already inside
298 $parameters, add a new subroutine that checks the ownership and extend
299 $parameters to contain a key with parameter_name and a value of a subref to
300 the subroutine that you created.
301
302 =cut
303
304 sub check_object_ownership {
305     my ($c, $user) = @_;
306
307     return if not $c or not $user;
308
309     my $parameters = {
310         accountlines_id => \&_object_ownership_by_accountlines_id,
311         borrowernumber  => \&_object_ownership_by_patron_id,
312         patron_id       => \&_object_ownership_by_patron_id,
313         checkout_id     => \&_object_ownership_by_checkout_id,
314         reserve_id      => \&_object_ownership_by_reserve_id,
315     };
316
317     foreach my $param ( keys %{ $parameters } ) {
318         my $check_ownership = $parameters->{$param};
319         if ($c->stash($param)) {
320             return &$check_ownership($c, $user, $c->stash($param));
321         }
322         elsif ($c->param($param)) {
323             return &$check_ownership($c, $user, $c->param($param));
324         }
325         elsif ($c->match->stack->[-1]->{$param}) {
326             return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
327         }
328         elsif ($c->req->json && $c->req->json->{$param}) {
329             return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
330         }
331     }
332 }
333
334 =head3 _object_ownership_by_accountlines_id
335
336 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
337 belongs to C<$user>.
338
339 =cut
340
341 sub _object_ownership_by_accountlines_id {
342     my ($c, $user, $accountlines_id) = @_;
343
344     my $accountline = Koha::Account::Lines->find($accountlines_id);
345     return $accountline && $user->borrowernumber == $accountline->borrowernumber;
346 }
347
348 =head3 _object_ownership_by_borrowernumber
349
350 Compares C<$borrowernumber> to currently logged in C<$user>.
351
352 =cut
353
354 sub _object_ownership_by_patron_id {
355     my ($c, $user, $patron_id) = @_;
356
357     return $user->borrowernumber == $patron_id;
358 }
359
360 =head3 _object_ownership_by_checkout_id
361
362 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
363 compare its borrowernumber to currently logged in C<$user>. However, if an issue
364 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
365 borrowernumber to currently logged in C<$user>.
366
367 =cut
368
369 sub _object_ownership_by_checkout_id {
370     my ($c, $user, $issue_id) = @_;
371
372     my $issue = Koha::Checkouts->find($issue_id);
373     $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
374     return $issue && $issue->borrowernumber
375             && $user->borrowernumber == $issue->borrowernumber;
376 }
377
378 =head3 _object_ownership_by_reserve_id
379
380 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
381 belongs to C<$user>.
382
383 TODO: Also compare against old_reserves
384
385 =cut
386
387 sub _object_ownership_by_reserve_id {
388     my ($c, $user, $reserve_id) = @_;
389
390     my $reserve = Koha::Holds->find($reserve_id);
391     return $reserve && $user->borrowernumber == $reserve->borrowernumber;
392 }
393
394 1;