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