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