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