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