1 package Koha::REST::V1::Auth;
3 # Copyright Koha-Suomi Oy 2017
5 # This file is part of Koha.
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
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.
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.
22 use Mojo::Base 'Mojolicious::Controller';
24 use C4::Auth qw( check_cookie_auth checkpw_internal get_session haspermission );
28 use Koha::Account::Lines;
32 use Koha::OAuthAccessTokens;
33 use Koha::Old::Checkouts;
37 use Koha::Exceptions::Authentication;
38 use Koha::Exceptions::Authorization;
41 use Module::Load::Conditional;
42 use Scalar::Util qw( blessed );
53 This subroutine is called before every request to API.
65 my $namespace = $c->req->url->to_abs->path->[2] // '';
67 my $is_public = 0; # By default routes are not public
70 if ( $namespace eq 'public' ) {
72 } elsif ( $namespace eq 'contrib' ) {
77 and !C4::Context->preference('RESTPublicAPI') )
79 Koha::Exceptions::Authorization->throw(
80 "Configuration prevents the usage of this endpoint by unprivileged users");
83 if ( $c->req->url->to_abs->path eq '/api/v1/oauth/token' ) {
84 # Requesting a token shouldn't go through the API authenticaction chain
88 $status = authenticate_api_request($c, { is_public => $is_public, is_plugin => $is_plugin });
92 unless (blessed($_)) {
95 json => { error => 'Something went wrong, check the logs.' }
98 if ($_->isa('Koha::Exceptions::UnderMaintenance')) {
99 return $c->render(status => 503, json => { error => $_->error });
101 elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) {
102 return $c->render(status => 401, json => { error => $_->error });
104 elsif ($_->isa('Koha::Exceptions::Authentication::Required')) {
105 return $c->render(status => 401, json => { error => $_->error });
107 elsif ($_->isa('Koha::Exceptions::Authentication')) {
108 return $c->render(status => 401, json => { error => $_->error });
110 elsif ($_->isa('Koha::Exceptions::BadParameter')) {
111 return $c->render(status => 400, json => $_->error );
113 elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) {
114 return $c->render(status => 403, json => {
116 required_permissions => $_->required_permissions,
119 elsif ($_->isa('Koha::Exceptions::Authorization')) {
120 return $c->render(status => 403, json => { error => $_->error });
122 elsif ($_->isa('Koha::Exceptions')) {
123 return $c->render(status => 500, json => { error => $_->error });
128 json => { error => 'Something went wrong, check the logs.' }
136 =head3 authenticate_api_request
138 Validates authentication and allows access if authorization is not required or
139 if authorization is required and user has required permissions to access.
143 sub authenticate_api_request {
144 my ( $c, $params ) = @_;
148 # The following supports retrieval of spec with Mojolicious::Plugin::OpenAPI@1.17 and later (first one)
149 # and older versions (second one).
150 # TODO: remove the latter 'openapi.op_spec' if minimum version is bumped to at least 1.17.
151 my $spec = $c->openapi->spec || $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
155 my $authorization = $spec->{'x-koha-authorization'};
157 my $authorization_header = $c->req->headers->authorization;
159 if ($authorization_header and $authorization_header =~ /^Bearer /) {
160 # attempt to use OAuth2 authentication
161 if ( ! Module::Load::Conditional::can_load(
162 modules => {'Net::OAuth2::AuthorizationServer' => undef} )) {
163 Koha::Exceptions::Authorization::Unauthorized->throw(
164 error => 'Authentication failure.'
168 require Net::OAuth2::AuthorizationServer;
171 my $server = Net::OAuth2::AuthorizationServer->new;
172 my $grant = $server->client_credentials_grant(Koha::OAuth::config);
173 my ($type, $token) = split / /, $authorization_header;
174 my ($valid_token, $error) = $grant->verify_access_token(
175 access_token => $token,
179 my $patron_id = Koha::ApiKeys->find( $valid_token->{client_id} )->patron_id;
180 $user = Koha::Patrons->find($patron_id);
181 C4::Context->interface('api');
184 # If we have "Authorization: Bearer" header and oauth authentication
185 # failed, do not try other authentication means
186 Koha::Exceptions::Authentication::Required->throw(
187 error => 'Authentication failure.'
191 elsif ( $authorization_header and $authorization_header =~ /^Basic / ) {
192 unless ( C4::Context->preference('RESTBasicAuth') ) {
193 Koha::Exceptions::Authentication::Required->throw(
194 error => 'Basic authentication disabled'
197 $user = $c->_basic_auth( $authorization_header );
198 C4::Context->interface('api');
200 # If we have "Authorization: Basic" header and authentication
201 # failed, do not try other authentication means
202 Koha::Exceptions::Authentication::Required->throw(
203 error => 'Authentication failure.'
209 my $cookie = $c->cookie('CGISESSID');
211 # Mojo doesn't use %ENV the way CGI apps do
212 # Manually pass the remote_address to check_auth_cookie
213 my $remote_addr = $c->tx->remote_address;
214 my ($status, $sessionID) = check_cookie_auth(
216 { remote_addr => $remote_addr });
217 if ($status eq "ok") {
218 my $session = get_session($sessionID);
219 $user = Koha::Patrons->find( $session->param('number') )
220 unless $session->param('sessiontype')
221 and $session->param('sessiontype') eq 'anon';
224 elsif ($status eq "maintenance") {
225 Koha::Exceptions::UnderMaintenance->throw(
226 error => 'System is under maintenance.'
229 elsif ($status eq "expired" and $authorization) {
230 Koha::Exceptions::Authentication::SessionExpired->throw(
231 error => 'Session has been expired.'
234 elsif ($status eq "failed" and $authorization) {
235 Koha::Exceptions::Authentication::Required->throw(
236 error => 'Authentication failure.'
239 elsif ($authorization) {
240 Koha::Exceptions::Authentication->throw(
241 error => 'Unexpected authentication status.'
246 $c->stash('koha.user' => $user);
248 if ( $user and !$cookie_auth ) { # cookie-auth sets this and more, don't mess with that
249 C4::Context->_new_userenv( $user->borrowernumber );
250 C4::Context->set_userenv( $user->borrowernumber );
253 if ( !$authorization and
254 ( $params->{is_public} and
255 ( C4::Context->preference('RESTPublicAnonymousRequests') or
256 $user) ) or $params->{is_plugin} ) {
257 # We do not need any authorization
258 # Check the parameters
259 validate_query_parameters( $c, $spec );
263 # We are required authorizarion, there needs
264 # to be an identified user
265 Koha::Exceptions::Authentication::Required->throw(
266 error => 'Authentication failure.' )
271 my $permissions = $authorization->{'permissions'};
272 # Check if the user is authorized
273 if ( ( defined($permissions) and haspermission($user->userid, $permissions) )
274 or allow_owner($c, $authorization, $user)
275 or allow_guarantor($c, $authorization, $user) ) {
277 validate_query_parameters( $c, $spec );
283 Koha::Exceptions::Authorization::Unauthorized->throw(
284 error => "Authorization failure. Missing required permission(s).",
285 required_permissions => $permissions,
289 =head3 validate_query_parameters
291 Validates the query parameters against the spec.
295 sub validate_query_parameters {
296 my ( $c, $action_spec ) = @_;
298 # Check for malformed query parameters
300 my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
301 my $existing_params = $c->req->query_params->to_hash;
302 for my $param ( keys %{$existing_params} ) {
303 push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
306 Koha::Exceptions::BadParameter->throw(
314 Allows access to object for its owner.
316 There are endpoints that should allow access for the object owner even if they
317 do not have the required permission, e.g. access an own reserve. This can be
318 achieved by defining the operation as follows:
320 "/holds/{reserve_id}": {
323 "x-koha-authorization": {
335 my ($c, $authorization, $user) = @_;
337 return unless $authorization->{'allow-owner'};
339 return check_object_ownership($c, $user) if $user and $c;
342 =head3 allow_guarantor
344 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
349 sub allow_guarantor {
350 my ($c, $authorization, $user) = @_;
352 if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
356 my $guarantees = $user->guarantee_relationships->guarantees->as_list;
357 foreach my $guarantee (@{$guarantees}) {
358 return 1 if check_object_ownership($c, $guarantee);
362 =head3 check_object_ownership
364 Determines ownership of an object from request parameters.
366 As introducing an endpoint that allows access for object's owner; if the
367 parameter that will be used to determine ownership is not already inside
368 $parameters, add a new subroutine that checks the ownership and extend
369 $parameters to contain a key with parameter_name and a value of a subref to
370 the subroutine that you created.
374 sub check_object_ownership {
377 return if not $c or not $user;
380 accountlines_id => \&_object_ownership_by_accountlines_id,
381 borrowernumber => \&_object_ownership_by_patron_id,
382 patron_id => \&_object_ownership_by_patron_id,
383 checkout_id => \&_object_ownership_by_checkout_id,
384 reserve_id => \&_object_ownership_by_reserve_id,
387 foreach my $param ( keys %{ $parameters } ) {
388 my $check_ownership = $parameters->{$param};
389 if ($c->stash($param)) {
390 return &$check_ownership($c, $user, $c->stash($param));
392 elsif ($c->param($param)) {
393 return &$check_ownership($c, $user, $c->param($param));
395 elsif ($c->match->stack->[-1]->{$param}) {
396 return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
398 elsif ($c->req->json && $c->req->json->{$param}) {
399 return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
404 =head3 _object_ownership_by_accountlines_id
406 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
411 sub _object_ownership_by_accountlines_id {
412 my ($c, $user, $accountlines_id) = @_;
414 my $accountline = Koha::Account::Lines->find($accountlines_id);
415 return $accountline && $user->borrowernumber == $accountline->borrowernumber;
418 =head3 _object_ownership_by_borrowernumber
420 Compares C<$borrowernumber> to currently logged in C<$user>.
424 sub _object_ownership_by_patron_id {
425 my ($c, $user, $patron_id) = @_;
427 return $user->borrowernumber == $patron_id;
430 =head3 _object_ownership_by_checkout_id
432 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
433 compare its borrowernumber to currently logged in C<$user>. However, if an issue
434 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
435 borrowernumber to currently logged in C<$user>.
439 sub _object_ownership_by_checkout_id {
440 my ($c, $user, $issue_id) = @_;
442 my $issue = Koha::Checkouts->find($issue_id);
443 $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
444 return $issue && $issue->borrowernumber
445 && $user->borrowernumber == $issue->borrowernumber;
448 =head3 _object_ownership_by_reserve_id
450 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
453 TODO: Also compare against old_reserves
457 sub _object_ownership_by_reserve_id {
458 my ($c, $user, $reserve_id) = @_;
460 my $reserve = Koha::Holds->find($reserve_id);
461 return $reserve && $user->borrowernumber == $reserve->borrowernumber;
466 Internal method that performs Basic authentication.
471 my ( $c, $authorization_header ) = @_;
473 my ( $type, $credentials ) = split / /, $authorization_header;
475 unless ($credentials) {
476 Koha::Exceptions::Authentication::Required->throw( error => 'Authentication failure.' );
479 my $decoded_credentials = decode_base64( $credentials );
480 my ( $user_id, $password ) = split( /:/, $decoded_credentials, 2 );
482 my $dbh = C4::Context->dbh;
483 unless ( checkpw_internal($dbh, $user_id, $password ) ) {
484 Koha::Exceptions::Authorization::Unauthorized->throw( error => 'Invalid password' );
487 return Koha::Patrons->find({ userid => $user_id });