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