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