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.
58 my $c = shift->openapi->valid_input or return;;
65 my $namespace = $c->req->url->to_abs->path->[2];
67 if ( $namespace eq 'public'
68 and !C4::Context->preference('RESTPublicAPI') )
70 Koha::Exceptions::Authorization->throw(
71 "Configuration prevents the usage of this endpoint by unprivileged users");
74 if ( $c->req->url->to_abs->path eq '/api/v1/oauth/token' ) {
75 # Requesting a token shouldn't go through the API authenticaction chain
79 $status = authenticate_api_request($c);
83 unless (blessed($_)) {
86 json => { error => 'Something went wrong, check the logs.' }
89 if ($_->isa('Koha::Exceptions::UnderMaintenance')) {
90 return $c->render(status => 503, json => { error => $_->error });
92 elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) {
93 return $c->render(status => 401, json => { error => $_->error });
95 elsif ($_->isa('Koha::Exceptions::Authentication::Required')) {
96 return $c->render(status => 401, json => { error => $_->error });
98 elsif ($_->isa('Koha::Exceptions::Authentication')) {
99 return $c->render(status => 401, json => { error => $_->error });
101 elsif ($_->isa('Koha::Exceptions::BadParameter')) {
102 return $c->render(status => 400, json => $_->error );
104 elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) {
105 return $c->render(status => 403, json => {
107 required_permissions => $_->required_permissions,
110 elsif ($_->isa('Koha::Exceptions::Authorization')) {
111 return $c->render(status => 403, json => { error => $_->error });
113 elsif ($_->isa('Koha::Exceptions')) {
114 return $c->render(status => 500, json => { error => $_->error });
119 json => { error => 'Something went wrong, check the logs.' }
127 =head3 authenticate_api_request
129 Validates authentication and allows access if authorization is not required or
130 if authorization is required and user has required permissions to access.
134 sub authenticate_api_request {
139 my $spec = $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
141 $c->stash_embed({ spec => $spec });
143 my $authorization = $spec->{'x-koha-authorization'};
145 my $authorization_header = $c->req->headers->authorization;
147 if ($authorization_header and $authorization_header =~ /^Bearer /) {
148 # attempt to use OAuth2 authentication
149 if ( ! Module::Load::Conditional::can_load(
150 modules => {'Net::OAuth2::AuthorizationServer' => undef} )) {
151 Koha::Exceptions::Authorization::Unauthorized->throw(
152 error => 'Authentication failure.'
156 require Net::OAuth2::AuthorizationServer;
159 my $server = Net::OAuth2::AuthorizationServer->new;
160 my $grant = $server->client_credentials_grant(Koha::OAuth::config);
161 my ($type, $token) = split / /, $authorization_header;
162 my ($valid_token, $error) = $grant->verify_access_token(
163 access_token => $token,
167 my $patron_id = Koha::ApiKeys->find( $valid_token->{client_id} )->patron_id;
168 $user = Koha::Patrons->find($patron_id);
169 C4::Context->interface('api');
172 # If we have "Authorization: Bearer" header and oauth authentication
173 # failed, do not try other authentication means
174 Koha::Exceptions::Authentication::Required->throw(
175 error => 'Authentication failure.'
179 elsif ( $authorization_header and $authorization_header =~ /^Basic / ) {
180 unless ( C4::Context->preference('RESTBasicAuth') ) {
181 Koha::Exceptions::Authentication::Required->throw(
182 error => 'Basic authentication disabled'
185 $user = $c->_basic_auth( $authorization_header );
186 C4::Context->interface('api');
188 # If we have "Authorization: Basic" header and authentication
189 # failed, do not try other authentication means
190 Koha::Exceptions::Authentication::Required->throw(
191 error => 'Authentication failure.'
197 my $cookie = $c->cookie('CGISESSID');
199 # Mojo doesn't use %ENV the way CGI apps do
200 # Manually pass the remote_address to check_auth_cookie
201 my $remote_addr = $c->tx->remote_address;
202 my ($status, $sessionID) = check_cookie_auth(
204 { remote_addr => $remote_addr });
205 if ($status eq "ok") {
206 my $session = get_session($sessionID);
207 $user = Koha::Patrons->find($session->param('number'));
208 # $c->stash('koha.user' => $user);
210 elsif ($status eq "maintenance") {
211 Koha::Exceptions::UnderMaintenance->throw(
212 error => 'System is under maintenance.'
215 elsif ($status eq "expired" and $authorization) {
216 Koha::Exceptions::Authentication::SessionExpired->throw(
217 error => 'Session has been expired.'
220 elsif ($status eq "failed" and $authorization) {
221 Koha::Exceptions::Authentication::Required->throw(
222 error => 'Authentication failure.'
225 elsif ($authorization) {
226 Koha::Exceptions::Authentication->throw(
227 error => 'Unexpected authentication status.'
232 $c->stash('koha.user' => $user);
234 # We do not need any authorization
235 unless ($authorization) {
236 # Check the parameters
237 validate_query_parameters( $c, $spec );
241 my $permissions = $authorization->{'permissions'};
242 # Check if the user is authorized
243 if ( ( defined($permissions) and haspermission($user->userid, $permissions) )
244 or allow_owner($c, $authorization, $user)
245 or allow_guarantor($c, $authorization, $user) ) {
247 validate_query_parameters( $c, $spec );
253 Koha::Exceptions::Authorization::Unauthorized->throw(
254 error => "Authorization failure. Missing required permission(s).",
255 required_permissions => $permissions,
259 =head3 validate_query_parameters
261 Validates the query parameters against the spec.
265 sub validate_query_parameters {
266 my ( $c, $action_spec ) = @_;
268 # Check for malformed query parameters
270 my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} };
271 my $existing_params = $c->req->query_params->to_hash;
272 for my $param ( keys %{$existing_params} ) {
273 push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param};
276 Koha::Exceptions::BadParameter->throw(
284 Allows access to object for its owner.
286 There are endpoints that should allow access for the object owner even if they
287 do not have the required permission, e.g. access an own reserve. This can be
288 achieved by defining the operation as follows:
290 "/holds/{reserve_id}": {
293 "x-koha-authorization": {
305 my ($c, $authorization, $user) = @_;
307 return unless $authorization->{'allow-owner'};
309 return check_object_ownership($c, $user) if $user and $c;
312 =head3 allow_guarantor
314 Same as "allow_owner", but checks if the object is owned by one of C<$user>'s
319 sub allow_guarantor {
320 my ($c, $authorization, $user) = @_;
322 if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){
326 my $guarantees = $user->guarantee_relationships->guarantees->as_list;
327 foreach my $guarantee (@{$guarantees}) {
328 return 1 if check_object_ownership($c, $guarantee);
332 =head3 check_object_ownership
334 Determines ownership of an object from request parameters.
336 As introducing an endpoint that allows access for object's owner; if the
337 parameter that will be used to determine ownership is not already inside
338 $parameters, add a new subroutine that checks the ownership and extend
339 $parameters to contain a key with parameter_name and a value of a subref to
340 the subroutine that you created.
344 sub check_object_ownership {
347 return if not $c or not $user;
350 accountlines_id => \&_object_ownership_by_accountlines_id,
351 borrowernumber => \&_object_ownership_by_patron_id,
352 patron_id => \&_object_ownership_by_patron_id,
353 checkout_id => \&_object_ownership_by_checkout_id,
354 reserve_id => \&_object_ownership_by_reserve_id,
357 foreach my $param ( keys %{ $parameters } ) {
358 my $check_ownership = $parameters->{$param};
359 if ($c->stash($param)) {
360 return &$check_ownership($c, $user, $c->stash($param));
362 elsif ($c->param($param)) {
363 return &$check_ownership($c, $user, $c->param($param));
365 elsif ($c->match->stack->[-1]->{$param}) {
366 return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param});
368 elsif ($c->req->json && $c->req->json->{$param}) {
369 return 1 if &$check_ownership($c, $user, $c->req->json->{$param});
374 =head3 _object_ownership_by_accountlines_id
376 Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it
381 sub _object_ownership_by_accountlines_id {
382 my ($c, $user, $accountlines_id) = @_;
384 my $accountline = Koha::Account::Lines->find($accountlines_id);
385 return $accountline && $user->borrowernumber == $accountline->borrowernumber;
388 =head3 _object_ownership_by_borrowernumber
390 Compares C<$borrowernumber> to currently logged in C<$user>.
394 sub _object_ownership_by_patron_id {
395 my ($c, $user, $patron_id) = @_;
397 return $user->borrowernumber == $patron_id;
400 =head3 _object_ownership_by_checkout_id
402 First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one,
403 compare its borrowernumber to currently logged in C<$user>. However, if an issue
404 is not found, attempt to find a Koha::Old::Checkout-object instead and compare its
405 borrowernumber to currently logged in C<$user>.
409 sub _object_ownership_by_checkout_id {
410 my ($c, $user, $issue_id) = @_;
412 my $issue = Koha::Checkouts->find($issue_id);
413 $issue = Koha::Old::Checkouts->find($issue_id) unless $issue;
414 return $issue && $issue->borrowernumber
415 && $user->borrowernumber == $issue->borrowernumber;
418 =head3 _object_ownership_by_reserve_id
420 Finds a Koha::Hold-object by C<$reserve_id> and checks if it
423 TODO: Also compare against old_reserves
427 sub _object_ownership_by_reserve_id {
428 my ($c, $user, $reserve_id) = @_;
430 my $reserve = Koha::Holds->find($reserve_id);
431 return $reserve && $user->borrowernumber == $reserve->borrowernumber;
436 Internal method that performs Basic authentication.
441 my ( $c, $authorization_header ) = @_;
443 my ( $type, $credentials ) = split / /, $authorization_header;
445 unless ($credentials) {
446 Koha::Exceptions::Authentication::Required->throw( error => 'Authentication failure.' );
449 my $decoded_credentials = decode_base64( $credentials );
450 my ( $user_id, $password ) = split( /:/, $decoded_credentials, 2 );
452 my $dbh = C4::Context->dbh;
453 unless ( checkpw_internal($dbh, $user_id, $password ) ) {
454 Koha::Exceptions::Authorization::Unauthorized->throw( error => 'Invalid password' );
457 return Koha::Patrons->find({ userid => $user_id });