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