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] // '';
66 my $is_public = ($namespace eq 'public') ? 1 : 0;
69 and !C4::Context->preference('RESTPublicAPI') )
71 Koha::Exceptions::Authorization->throw(
72 "Configuration prevents the usage of this endpoint by unprivileged users");
75 if ( $c->req->url->to_abs->path eq '/api/v1/oauth/token' ) {
76 # Requesting a token shouldn't go through the API authenticaction chain
80 $status = authenticate_api_request($c, { is_public => $is_public });
84 unless (blessed($_)) {
87 json => { error => 'Something went wrong, check the logs.' }
90 if ($_->isa('Koha::Exceptions::UnderMaintenance')) {
91 return $c->render(status => 503, json => { error => $_->error });
93 elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) {
94 return $c->render(status => 401, json => { error => $_->error });
96 elsif ($_->isa('Koha::Exceptions::Authentication::Required')) {
97 return $c->render(status => 401, json => { error => $_->error });
99 elsif ($_->isa('Koha::Exceptions::Authentication')) {
100 return $c->render(status => 401, json => { error => $_->error });
102 elsif ($_->isa('Koha::Exceptions::BadParameter')) {
103 return $c->render(status => 400, json => $_->error );
105 elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) {
106 return $c->render(status => 403, json => {
108 required_permissions => $_->required_permissions,
111 elsif ($_->isa('Koha::Exceptions::Authorization')) {
112 return $c->render(status => 403, json => { error => $_->error });
114 elsif ($_->isa('Koha::Exceptions')) {
115 return $c->render(status => 500, json => { error => $_->error });
120 json => { error => 'Something went wrong, check the logs.' }
128 =head3 authenticate_api_request
130 Validates authentication and allows access if authorization is not required or
131 if authorization is required and user has required permissions to access.
135 sub authenticate_api_request {
136 my ( $c, $params ) = @_;
140 # The following supports retrieval of spec with Mojolicious::Plugin::OpenAPI@1.17 and later (first one)
141 # and older versions (second one).
142 # TODO: remove the latter 'openapi.op_spec' if minimum version is bumped to at least 1.17.
143 my $spec = $c->openapi->spec || $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
145 $c->stash_embed({ spec => $spec });
147 my $authorization = $spec->{'x-koha-authorization'};
149 my $authorization_header = $c->req->headers->authorization;
151 if ($authorization_header and $authorization_header =~ /^Bearer /) {
152 # attempt to use OAuth2 authentication
153 if ( ! Module::Load::Conditional::can_load(
154 modules => {'Net::OAuth2::AuthorizationServer' => undef} )) {
155 Koha::Exceptions::Authorization::Unauthorized->throw(
156 error => 'Authentication failure.'
160 require Net::OAuth2::AuthorizationServer;
163 my $server = Net::OAuth2::AuthorizationServer->new;
164 my $grant = $server->client_credentials_grant(Koha::OAuth::config);
165 my ($type, $token) = split / /, $authorization_header;
166 my ($valid_token, $error) = $grant->verify_access_token(
167 access_token => $token,
171 my $patron_id = Koha::ApiKeys->find( $valid_token->{client_id} )->patron_id;
172 $user = Koha::Patrons->find($patron_id);
173 C4::Context->interface('api');
176 # If we have "Authorization: Bearer" header and oauth authentication
177 # failed, do not try other authentication means
178 Koha::Exceptions::Authentication::Required->throw(
179 error => 'Authentication failure.'
183 elsif ( $authorization_header and $authorization_header =~ /^Basic / ) {
184 unless ( C4::Context->preference('RESTBasicAuth') ) {
185 Koha::Exceptions::Authentication::Required->throw(
186 error => 'Basic authentication disabled'
189 $user = $c->_basic_auth( $authorization_header );
190 C4::Context->interface('api');
192 # If we have "Authorization: Basic" header and authentication
193 # failed, do not try other authentication means
194 Koha::Exceptions::Authentication::Required->throw(
195 error => 'Authentication failure.'
201 my $cookie = $c->cookie('CGISESSID');
203 # Mojo doesn't use %ENV the way CGI apps do
204 # Manually pass the remote_address to check_auth_cookie
205 my $remote_addr = $c->tx->remote_address;
206 my ($status, $sessionID) = check_cookie_auth(
208 { remote_addr => $remote_addr });
209 if ($status eq "ok") {
210 my $session = get_session($sessionID);
211 $user = Koha::Patrons->find( $session->param('number') )
212 unless $session->param('sessiontype')
213 and $session->param('sessiontype') eq 'anon';
215 elsif ($status eq "maintenance") {
216 Koha::Exceptions::UnderMaintenance->throw(
217 error => 'System is under maintenance.'
220 elsif ($status eq "expired" and $authorization) {
221 Koha::Exceptions::Authentication::SessionExpired->throw(
222 error => 'Session has been expired.'
225 elsif ($status eq "failed" and $authorization) {
226 Koha::Exceptions::Authentication::Required->throw(
227 error => 'Authentication failure.'
230 elsif ($authorization) {
231 Koha::Exceptions::Authentication->throw(
232 error => 'Unexpected authentication status.'
237 $c->stash('koha.user' => $user);
239 if ( !$authorization and
240 ( $params->{is_public} and
241 ( C4::Context->preference('RESTPublicAnonymousRequests') or
243 # We do not need any authorization
244 # Check the parameters
245 validate_query_parameters( $c, $spec );
249 # We are required authorizarion, there needs
250 # to be an identified user
251 Koha::Exceptions::Authentication::Required->throw(
252 error => 'Authentication failure.' )
257 my $permissions = $authorization->{'permissions'};
258 # Check if the user is authorized
259 if ( ( defined($permissions) and haspermission($user->userid, $permissions) )
260 or allow_owner($c, $authorization, $user)
261 or allow_guarantor($c, $authorization, $user) ) {
263 validate_query_parameters( $c, $spec );
269 Koha::Exceptions::Authorization::Unauthorized->throw(
270 error => "Authorization failure. Missing required permission(s).",
271 required_permissions => $permissions,
275 =head3 validate_query_parameters
277 Validates the query parameters against the spec.
281 sub validate_query_parameters {
282 my ( $c, $action_spec ) = @_;
284 # Check for malformed query parameters
286 my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
287 my $existing_params = $c->req->query_params->to_hash;
288 for my $param ( keys %{$existing_params} ) {
289 push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
292 Koha::Exceptions::BadParameter->throw(
300 Allows access to object for its owner.
302 There are endpoints that should allow access for the object owner even if they
303 do not have the required permission, e.g. access an own reserve. This can be
304 achieved by defining the operation as follows:
306 "/holds/{reserve_id}": {
309 "x-koha-authorization": {
321 my ($c, $authorization, $user) = @_;
323 return unless $authorization->{'allow-owner'};
325 return check_object_ownership($c, $user) if $user and $c;
328 =head3 allow_guarantor
330 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
335 sub allow_guarantor {
336 my ($c, $authorization, $user) = @_;
338 if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
342 my $guarantees = $user->guarantee_relationships->guarantees->as_list;
343 foreach my $guarantee (@{$guarantees}) {
344 return 1 if check_object_ownership($c, $guarantee);
348 =head3 check_object_ownership
350 Determines ownership of an object from request parameters.
352 As introducing an endpoint that allows access for object's owner; if the
353 parameter that will be used to determine ownership is not already inside
354 $parameters, add a new subroutine that checks the ownership and extend
355 $parameters to contain a key with parameter_name and a value of a subref to
356 the subroutine that you created.
360 sub check_object_ownership {
363 return if not $c or not $user;
366 accountlines_id => \&_object_ownership_by_accountlines_id,
367 borrowernumber => \&_object_ownership_by_patron_id,
368 patron_id => \&_object_ownership_by_patron_id,
369 checkout_id => \&_object_ownership_by_checkout_id,
370 reserve_id => \&_object_ownership_by_reserve_id,
373 foreach my $param ( keys %{ $parameters } ) {
374 my $check_ownership = $parameters->{$param};
375 if ($c->stash($param)) {
376 return &$check_ownership($c, $user, $c->stash($param));
378 elsif ($c->param($param)) {
379 return &$check_ownership($c, $user, $c->param($param));
381 elsif ($c->match->stack->[-1]->{$param}) {
382 return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
384 elsif ($c->req->json && $c->req->json->{$param}) {
385 return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
390 =head3 _object_ownership_by_accountlines_id
392 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
397 sub _object_ownership_by_accountlines_id {
398 my ($c, $user, $accountlines_id) = @_;
400 my $accountline = Koha::Account::Lines->find($accountlines_id);
401 return $accountline && $user->borrowernumber == $accountline->borrowernumber;
404 =head3 _object_ownership_by_borrowernumber
406 Compares C<$borrowernumber> to currently logged in C<$user>.
410 sub _object_ownership_by_patron_id {
411 my ($c, $user, $patron_id) = @_;
413 return $user->borrowernumber == $patron_id;
416 =head3 _object_ownership_by_checkout_id
418 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
419 compare its borrowernumber to currently logged in C<$user>. However, if an issue
420 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
421 borrowernumber to currently logged in C<$user>.
425 sub _object_ownership_by_checkout_id {
426 my ($c, $user, $issue_id) = @_;
428 my $issue = Koha::Checkouts->find($issue_id);
429 $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
430 return $issue && $issue->borrowernumber
431 && $user->borrowernumber == $issue->borrowernumber;
434 =head3 _object_ownership_by_reserve_id
436 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
439 TODO: Also compare against old_reserves
443 sub _object_ownership_by_reserve_id {
444 my ($c, $user, $reserve_id) = @_;
446 my $reserve = Koha::Holds->find($reserve_id);
447 return $reserve && $user->borrowernumber == $reserve->borrowernumber;
452 Internal method that performs Basic authentication.
457 my ( $c, $authorization_header ) = @_;
459 my ( $type, $credentials ) = split / /, $authorization_header;
461 unless ($credentials) {
462 Koha::Exceptions::Authentication::Required->throw( error => 'Authentication failure.' );
465 my $decoded_credentials = decode_base64( $credentials );
466 my ( $user_id, $password ) = split( /:/, $decoded_credentials, 2 );
468 my $dbh = C4::Context->dbh;
469 unless ( checkpw_internal($dbh, $user_id, $password ) ) {
470 Koha::Exceptions::Authorization::Unauthorized->throw( error => 'Invalid password' );
473 return Koha::Patrons->find({ userid => $user_id });