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
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
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' ) {
74 if ( $namespace eq 'contrib' ) {
79 and !C4::Context->preference('RESTPublicAPI') )
81 Koha::Exceptions::Authorization->throw(
82 "Configuration prevents the usage of this endpoint by unprivileged users");
85 if ( $c->req->url->to_abs->path eq '/api/v1/oauth/token' ) {
86 # Requesting a token shouldn't go through the API authenticaction chain
89 elsif ( $namespace eq '' or $namespace eq '.html' ) {
93 $status = authenticate_api_request($c, { is_public => $is_public, is_plugin => $is_plugin });
97 unless (blessed($_)) {
100 json => { error => 'Something went wrong, check the logs.' }
103 if ($_->isa('Koha::Exceptions::UnderMaintenance')) {
104 return $c->render(status => 503, json => { error => $_->error });
106 elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) {
107 return $c->render(status => 401, json => { error => $_->error });
109 elsif ($_->isa('Koha::Exceptions::Authentication::Required')) {
110 return $c->render(status => 401, json => { error => $_->error });
112 elsif ($_->isa('Koha::Exceptions::Authentication')) {
113 return $c->render(status => 401, json => { error => $_->error });
115 elsif ($_->isa('Koha::Exceptions::BadParameter')) {
116 return $c->render(status => 400, json => $_->error );
118 elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) {
119 return $c->render(status => 403, json => {
121 required_permissions => $_->required_permissions,
124 elsif ($_->isa('Koha::Exceptions::Authorization')) {
125 return $c->render(status => 403, json => { error => $_->error });
127 elsif ($_->isa('Koha::Exceptions')) {
128 return $c->render(status => 500, json => { error => $_->error });
133 json => { error => 'Something went wrong, check the logs.' }
141 =head3 authenticate_api_request
143 Validates authentication and allows access if authorization is not required or
144 if authorization is required and user has required permissions to access.
148 sub authenticate_api_request {
149 my ( $c, $params ) = @_;
153 # The following supports retrieval of spec with Mojolicious::Plugin::OpenAPI@1.17 and later (first one)
154 # and older versions (second one).
155 # TODO: remove the latter 'openapi.op_spec' if minimum version is bumped to at least 1.17.
156 my $spec = $c->openapi->spec || $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
158 $c->stash_embed({ spec => $spec });
160 my $authorization = $spec->{'x-koha-authorization'};
162 my $authorization_header = $c->req->headers->authorization;
164 if ($authorization_header and $authorization_header =~ /^Bearer /) {
165 # attempt to use OAuth2 authentication
166 if ( ! Module::Load::Conditional::can_load(
167 modules => {'Net::OAuth2::AuthorizationServer' => undef} )) {
168 Koha::Exceptions::Authorization::Unauthorized->throw(
169 error => 'Authentication failure.'
173 require Net::OAuth2::AuthorizationServer;
176 my $server = Net::OAuth2::AuthorizationServer->new;
177 my $grant = $server->client_credentials_grant(Koha::OAuth::config);
178 my ($type, $token) = split / /, $authorization_header;
179 my ($valid_token, $error) = $grant->verify_access_token(
180 access_token => $token,
184 my $patron_id = Koha::ApiKeys->find( $valid_token->{client_id} )->patron_id;
185 $user = Koha::Patrons->find($patron_id);
186 C4::Context->interface('api');
189 # If we have "Authorization: Bearer" header and oauth authentication
190 # failed, do not try other authentication means
191 Koha::Exceptions::Authentication::Required->throw(
192 error => 'Authentication failure.'
196 elsif ( $authorization_header and $authorization_header =~ /^Basic / ) {
197 unless ( C4::Context->preference('RESTBasicAuth') ) {
198 Koha::Exceptions::Authentication::Required->throw(
199 error => 'Basic authentication disabled'
202 $user = $c->_basic_auth( $authorization_header );
203 C4::Context->interface('api');
205 # If we have "Authorization: Basic" header and authentication
206 # failed, do not try other authentication means
207 Koha::Exceptions::Authentication::Required->throw(
208 error => 'Authentication failure.'
214 my $cookie = $c->cookie('CGISESSID');
216 # Mojo doesn't use %ENV the way CGI apps do
217 # Manually pass the remote_address to check_auth_cookie
218 my $remote_addr = $c->tx->remote_address;
219 my ($status, $sessionID) = check_cookie_auth(
221 { remote_addr => $remote_addr });
222 if ($status eq "ok") {
223 my $session = get_session($sessionID);
224 $user = Koha::Patrons->find( $session->param('number') )
225 unless $session->param('sessiontype')
226 and $session->param('sessiontype') eq 'anon';
228 elsif ($status eq "maintenance") {
229 Koha::Exceptions::UnderMaintenance->throw(
230 error => 'System is under maintenance.'
233 elsif ($status eq "expired" and $authorization) {
234 Koha::Exceptions::Authentication::SessionExpired->throw(
235 error => 'Session has been expired.'
238 elsif ($status eq "failed" and $authorization) {
239 Koha::Exceptions::Authentication::Required->throw(
240 error => 'Authentication failure.'
243 elsif ($authorization) {
244 Koha::Exceptions::Authentication->throw(
245 error => 'Unexpected authentication status.'
250 $c->stash('koha.user' => $user);
252 if ( !$authorization and
253 ( $params->{is_public} and
254 ( C4::Context->preference('RESTPublicAnonymousRequests') or
255 $user) ) or $params->{is_plugin} ) {
256 # We do not need any authorization
257 # Check the parameters
258 validate_query_parameters( $c, $spec );
262 # We are required authorizarion, there needs
263 # to be an identified user
264 Koha::Exceptions::Authentication::Required->throw(
265 error => 'Authentication failure.' )
270 my $permissions = $authorization->{'permissions'};
271 # Check if the user is authorized
272 if ( ( defined($permissions) and haspermission($user->userid, $permissions) )
273 or allow_owner($c, $authorization, $user)
274 or allow_guarantor($c, $authorization, $user) ) {
276 validate_query_parameters( $c, $spec );
282 Koha::Exceptions::Authorization::Unauthorized->throw(
283 error => "Authorization failure. Missing required permission(s).",
284 required_permissions => $permissions,
288 =head3 validate_query_parameters
290 Validates the query parameters against the spec.
294 sub validate_query_parameters {
295 my ( $c, $action_spec ) = @_;
297 # Check for malformed query parameters
299 my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
300 my $existing_params = $c->req->query_params->to_hash;
301 for my $param ( keys %{$existing_params} ) {
302 push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
305 Koha::Exceptions::BadParameter->throw(
313 Allows access to object for its owner.
315 There are endpoints that should allow access for the object owner even if they
316 do not have the required permission, e.g. access an own reserve. This can be
317 achieved by defining the operation as follows:
319 "/holds/{reserve_id}": {
322 "x-koha-authorization": {
334 my ($c, $authorization, $user) = @_;
336 return unless $authorization->{'allow-owner'};
338 return check_object_ownership($c, $user) if $user and $c;
341 =head3 allow_guarantor
343 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
348 sub allow_guarantor {
349 my ($c, $authorization, $user) = @_;
351 if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
355 my $guarantees = $user->guarantee_relationships->guarantees->as_list;
356 foreach my $guarantee (@{$guarantees}) {
357 return 1 if check_object_ownership($c, $guarantee);
361 =head3 check_object_ownership
363 Determines ownership of an object from request parameters.
365 As introducing an endpoint that allows access for object's owner; if the
366 parameter that will be used to determine ownership is not already inside
367 $parameters, add a new subroutine that checks the ownership and extend
368 $parameters to contain a key with parameter_name and a value of a subref to
369 the subroutine that you created.
373 sub check_object_ownership {
376 return if not $c or not $user;
379 accountlines_id => \&_object_ownership_by_accountlines_id,
380 borrowernumber => \&_object_ownership_by_patron_id,
381 patron_id => \&_object_ownership_by_patron_id,
382 checkout_id => \&_object_ownership_by_checkout_id,
383 reserve_id => \&_object_ownership_by_reserve_id,
386 foreach my $param ( keys %{ $parameters } ) {
387 my $check_ownership = $parameters->{$param};
388 if ($c->stash($param)) {
389 return &$check_ownership($c, $user, $c->stash($param));
391 elsif ($c->param($param)) {
392 return &$check_ownership($c, $user, $c->param($param));
394 elsif ($c->match->stack->[-1]->{$param}) {
395 return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
397 elsif ($c->req->json && $c->req->json->{$param}) {
398 return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
403 =head3 _object_ownership_by_accountlines_id
405 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
410 sub _object_ownership_by_accountlines_id {
411 my ($c, $user, $accountlines_id) = @_;
413 my $accountline = Koha::Account::Lines->find($accountlines_id);
414 return $accountline && $user->borrowernumber == $accountline->borrowernumber;
417 =head3 _object_ownership_by_borrowernumber
419 Compares C<$borrowernumber> to currently logged in C<$user>.
423 sub _object_ownership_by_patron_id {
424 my ($c, $user, $patron_id) = @_;
426 return $user->borrowernumber == $patron_id;
429 =head3 _object_ownership_by_checkout_id
431 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
432 compare its borrowernumber to currently logged in C<$user>. However, if an issue
433 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
434 borrowernumber to currently logged in C<$user>.
438 sub _object_ownership_by_checkout_id {
439 my ($c, $user, $issue_id) = @_;
441 my $issue = Koha::Checkouts->find($issue_id);
442 $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
443 return $issue && $issue->borrowernumber
444 && $user->borrowernumber == $issue->borrowernumber;
447 =head3 _object_ownership_by_reserve_id
449 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
452 TODO: Also compare against old_reserves
456 sub _object_ownership_by_reserve_id {
457 my ($c, $user, $reserve_id) = @_;
459 my $reserve = Koha::Holds->find($reserve_id);
460 return $reserve && $user->borrowernumber == $reserve->borrowernumber;
465 Internal method that performs Basic authentication.
470 my ( $c, $authorization_header ) = @_;
472 my ( $type, $credentials ) = split / /, $authorization_header;
474 unless ($credentials) {
475 Koha::Exceptions::Authentication::Required->throw( error => 'Authentication failure.' );
478 my $decoded_credentials = decode_base64( $credentials );
479 my ( $user_id, $password ) = split( /:/, $decoded_credentials, 2 );
481 my $dbh = C4::Context->dbh;
482 unless ( checkpw_internal($dbh, $user_id, $password ) ) {
483 Koha::Exceptions::Authorization::Unauthorized->throw( error => 'Invalid password' );
486 return Koha::Patrons->find({ userid => $user_id });