Bug 22071: (follow-up) Simplify code
[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 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 Module::Load::Conditional;
41 use Scalar::Util qw( blessed );
42 use Try::Tiny;
43
44 =head1 NAME
45
46 Koha::REST::V1::Auth
47
48 =head2 Operations
49
50 =head3 under
51
52 This subroutine is called before every request to API.
53
54 =cut
55
56 sub under {
57     my $c = shift->openapi->valid_input or return;;
58
59     my $status = 0;
60     try {
61
62         $status = authenticate_api_request($c);
63
64     } catch {
65         unless (blessed($_)) {
66             return $c->render(
67                 status => 500,
68                 json => { error => 'Something went wrong, check the logs.' }
69             );
70         }
71         if ($_->isa('Koha::Exceptions::UnderMaintenance')) {
72             return $c->render(status => 503, json => { error => $_->error });
73         }
74         elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) {
75             return $c->render(status => 401, json => { error => $_->error });
76         }
77         elsif ($_->isa('Koha::Exceptions::Authentication::Required')) {
78             return $c->render(status => 401, json => { error => $_->error });
79         }
80         elsif ($_->isa('Koha::Exceptions::Authentication')) {
81             return $c->render(status => 500, json => { error => $_->error });
82         }
83         elsif ($_->isa('Koha::Exceptions::BadParameter')) {
84             return $c->render(status => 400, json => $_->error );
85         }
86         elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) {
87             return $c->render(status => 403, json => {
88                 error => $_->error,
89                 required_permissions => $_->required_permissions,
90             });
91         }
92         elsif ($_->isa('Koha::Exceptions')) {
93             return $c->render(status => 500, json => { error => $_->error });
94         }
95         else {
96             return $c->render(
97                 status => 500,
98                 json => { error => 'Something went wrong, check the logs.' }
99             );
100         }
101     };
102
103     return $status;
104 }
105
106 =head3 authenticate_api_request
107
108 Validates authentication and allows access if authorization is not required or
109 if authorization is required and user has required permissions to access.
110
111 =cut
112
113 sub authenticate_api_request {
114     my ( $c ) = @_;
115
116     my $user;
117
118     my $spec = $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
119     my $authorization = $spec->{'x-koha-authorization'};
120
121     my $authorization_header = $c->req->headers->authorization;
122
123     if ($authorization_header and $authorization_header =~ /^Bearer /) {
124         # attempt to use OAuth2 authentication
125         if ( ! Module::Load::Conditional::can_load(
126                     modules => {'Net::OAuth2::AuthorizationServer' => undef} )) {
127             Koha::Exceptions::Authorization::Unauthorized->throw(
128                 error => 'Authentication failure.'
129             );
130         }
131         else {
132             require Net::OAuth2::AuthorizationServer;
133         }
134
135         my $server = Net::OAuth2::AuthorizationServer->new;
136         my $grant = $server->client_credentials_grant(Koha::OAuth::config);
137         my ($type, $token) = split / /, $authorization_header;
138         my ($valid_token, $error) = $grant->verify_access_token(
139             access_token => $token,
140         );
141
142         if ($valid_token) {
143             my $patron_id = Koha::ApiKeys->find( $valid_token->{client_id} )->patron_id;
144             $user         = Koha::Patrons->find($patron_id);
145         }
146         else {
147             # If we have "Authorization: Bearer" header and oauth authentication
148             # failed, do not try other authentication means
149             Koha::Exceptions::Authentication::Required->throw(
150                 error => 'Authentication failure.'
151             );
152         }
153     }
154     else {
155
156         my $cookie = $c->cookie('CGISESSID');
157
158         # Mojo doesn't use %ENV the way CGI apps do
159         # Manually pass the remote_address to check_auth_cookie
160         my $remote_addr = $c->tx->remote_address;
161         my ($status, $sessionID) = check_cookie_auth(
162                                                 $cookie, undef,
163                                                 { remote_addr => $remote_addr });
164         if ($status eq "ok") {
165             my $session = get_session($sessionID);
166             $user = Koha::Patrons->find($session->param('number'));
167             # $c->stash('koha.user' => $user);
168         }
169         elsif ($status eq "maintenance") {
170             Koha::Exceptions::UnderMaintenance->throw(
171                 error => 'System is under maintenance.'
172             );
173         }
174         elsif ($status eq "expired" and $authorization) {
175             Koha::Exceptions::Authentication::SessionExpired->throw(
176                 error => 'Session has been expired.'
177             );
178         }
179         elsif ($status eq "failed" and $authorization) {
180             Koha::Exceptions::Authentication::Required->throw(
181                 error => 'Authentication failure.'
182             );
183         }
184         elsif ($authorization) {
185             Koha::Exceptions::Authentication->throw(
186                 error => 'Unexpected authentication status.'
187             );
188         }
189     }
190
191     $c->stash('koha.user' => $user);
192
193     # We do not need any authorization
194     unless ($authorization) {
195         # Check the parameters
196         validate_query_parameters( $c, $spec );
197         return 1;
198     }
199
200     my $permissions = $authorization->{'permissions'};
201     # Check if the user is authorized
202     if ( haspermission($user->userid, $permissions)
203         or allow_owner($c, $authorization, $user)
204         or allow_guarantor($c, $authorization, $user) ) {
205
206         validate_query_parameters( $c, $spec );
207
208         # Everything is ok
209         return 1;
210     }
211
212     Koha::Exceptions::Authorization::Unauthorized->throw(
213         error => "Authorization failure. Missing required permission(s).",
214         required_permissions => $permissions,
215     );
216 }
217
218 sub validate_query_parameters {
219     my ( $c, $action_spec ) = @_;
220
221     # Check for malformed query parameters
222     my @errors;
223     my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
224     my $existing_params = $c->req->query_params->to_hash;
225     for my $param ( keys %{$existing_params} ) {
226         push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
227     }
228
229     Koha::Exceptions::BadParameter->throw(
230         error => \@errors
231     ) if @errors;
232 }
233
234
235 =head3 allow_owner
236
237 Allows access to object for its owner.
238
239 There are endpoints that should allow access for the object owner even if they
240 do not have the required permission, e.g. access an own reserve. This can be
241 achieved by defining the operation as follows:
242
243 "/holds/{reserve_id}": {
244     "get": {
245         ...,
246         "x-koha-authorization": {
247             "allow-owner": true,
248             "permissions": {
249                 "borrowers": "1"
250             }
251         }
252     }
253 }
254
255 =cut
256
257 sub allow_owner {
258     my ($c, $authorization, $user) = @_;
259
260     return unless $authorization->{'allow-owner'};
261
262     return check_object_ownership($c, $user) if $user and $c;
263 }
264
265 =head3 allow_guarantor
266
267 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
268 guarantees.
269
270 =cut
271
272 sub allow_guarantor {
273     my ($c, $authorization, $user) = @_;
274
275     if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
276         return;
277     }
278
279     my $guarantees = $user->guarantees->as_list;
280     foreach my $guarantee (@{$guarantees}) {
281         return 1 if check_object_ownership($c, $guarantee);
282     }
283 }
284
285 =head3 check_object_ownership
286
287 Determines ownership of an object from request parameters.
288
289 As introducing an endpoint that allows access for object's owner; if the
290 parameter that will be used to determine ownership is not already inside
291 $parameters, add a new subroutine that checks the ownership and extend
292 $parameters to contain a key with parameter_name and a value of a subref to
293 the subroutine that you created.
294
295 =cut
296
297 sub check_object_ownership {
298     my ($c, $user) = @_;
299
300     return if not $c or not $user;
301
302     my $parameters = {
303         accountlines_id => \&_object_ownership_by_accountlines_id,
304         borrowernumber  => \&_object_ownership_by_patron_id,
305         patron_id       => \&_object_ownership_by_patron_id,
306         checkout_id     => \&_object_ownership_by_checkout_id,
307         reserve_id      => \&_object_ownership_by_reserve_id,
308     };
309
310     foreach my $param ( keys %{ $parameters } ) {
311         my $check_ownership = $parameters->{$param};
312         if ($c->stash($param)) {
313             return &$check_ownership($c, $user, $c->stash($param));
314         }
315         elsif ($c->param($param)) {
316             return &$check_ownership($c, $user, $c->param($param));
317         }
318         elsif ($c->match->stack->[-1]->{$param}) {
319             return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
320         }
321         elsif ($c->req->json && $c->req->json->{$param}) {
322             return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
323         }
324     }
325 }
326
327 =head3 _object_ownership_by_accountlines_id
328
329 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
330 belongs to C<$user>.
331
332 =cut
333
334 sub _object_ownership_by_accountlines_id {
335     my ($c, $user, $accountlines_id) = @_;
336
337     my $accountline = Koha::Account::Lines->find($accountlines_id);
338     return $accountline && $user->borrowernumber == $accountline->borrowernumber;
339 }
340
341 =head3 _object_ownership_by_borrowernumber
342
343 Compares C<$borrowernumber> to currently logged in C<$user>.
344
345 =cut
346
347 sub _object_ownership_by_patron_id {
348     my ($c, $user, $patron_id) = @_;
349
350     return $user->borrowernumber == $patron_id;
351 }
352
353 =head3 _object_ownership_by_checkout_id
354
355 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
356 compare its borrowernumber to currently logged in C<$user>. However, if an issue
357 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
358 borrowernumber to currently logged in C<$user>.
359
360 =cut
361
362 sub _object_ownership_by_checkout_id {
363     my ($c, $user, $issue_id) = @_;
364
365     my $issue = Koha::Checkouts->find($issue_id);
366     $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
367     return $issue && $issue->borrowernumber
368             && $user->borrowernumber == $issue->borrowernumber;
369 }
370
371 =head3 _object_ownership_by_reserve_id
372
373 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
374 belongs to C<$user>.
375
376 TODO: Also compare against old_reserves
377
378 =cut
379
380 sub _object_ownership_by_reserve_id {
381     my ($c, $user, $reserve_id) = @_;
382
383     my $reserve = Koha::Holds->find($reserve_id);
384     return $reserve && $user->borrowernumber == $reserve->borrowernumber;
385 }
386
387 1;