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