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