@@ -, +, @@ Mojolicious::Plugin::OpenAPI --- Koha/REST/V1.pm | 241 +------------------------------ Koha/REST/V1/Auth.pm | 329 +++++++++++++++++++++++++++++++++++++++++++ t/db_dependent/api/v1/auth.t | 116 +++++++++++++++ 3 files changed, 450 insertions(+), 236 deletions(-) create mode 100644 Koha/REST/V1/Auth.pm create mode 100644 t/db_dependent/api/v1/auth.t --- a/Koha/REST/V1.pm +++ a/Koha/REST/V1.pm @@ -51,244 +51,13 @@ sub startup { $self->secrets([$secret_passphrase]); } - $self->plugin(Swagger2 => { + $self->plugin(OpenAPI => { url => $self->home->rel_file("api/v1/swagger/swagger.json"), - validate => 1, - spec_path => '/spec' + route => $self->routes->under('/api/v1')->to('Auth#under'), + allow_invalid_ref => 1, # required by our spec because $ref directly under + # Paths-, Parameters-, Definitions- & Info-object + # is not allowed by the OpenAPI specification. }); } -=head3 authenticate_api_request - -Validates authentication and allows access if authorization is not required or -if authorization is required and user has required permissions to access. - -This subroutine is called before every request to API. - -=cut - -sub authenticate_api_request { - my ($next, $c, $action_spec) = @_; - - my ($session, $user); - my $cookie = $c->cookie('CGISESSID'); - # Mojo doesn't use %ENV the way CGI apps do - # Manually pass the remote_address to check_auth_cookie - my $remote_addr = $c->tx->remote_address; - my ($status, $sessionID) = check_cookie_auth( - $cookie, undef, - { remote_addr => $remote_addr }); - if ($status eq "ok") { - $session = get_session($sessionID); - $user = Koha::Patrons->find($session->param('number')); - $c->stash('koha.user' => $user); - } - else { - return $c->render_swagger( - { error => "Authentication failure." }, - {}, - 401 - ) if $cookie and $action_spec->{'x-koha-authorization'}; - } - - - # Then check the parameters - my @query_errors = validate_query_parameters( $c, $action_spec ); - - # We do not need any authorization - unless ( $action_spec->{'x-koha-authorization'} ) { - return @query_errors - ? $c->render_swagger({}, \@query_errors, 400) - : $next->($c); - } - - unless ($user) { - return $c->render_swagger({ error => "Authentication required." },{},401); - } - - my $authorization = $action_spec->{'x-koha-authorization'}; - my $permissions = $authorization->{'permissions'}; - - # Check if the user is authorized - if ( C4::Auth::haspermission($user->userid, $permissions) - or allow_owner($c, $authorization, $user) - or allow_guarantor($c, $authorization, $user) ) { - - # Return the query errors if exist - return $c->render_swagger({}, \@query_errors, 400) if @query_errors; - - # Everything is ok - return $next->($c) - } - - return $c->render_swagger( - { error => "Authorization failure. Missing required permission(s).", - required_permissions => $permissions }, - {}, - 403 - ); -} - -sub validate_query_parameters { - my ( $c, $action_spec ) = @_; - - # Check for malformed query parameters - my @errors; - my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} }; - my $existing_params = $c->req->query_params->to_hash; - for my $param ( keys %{$existing_params} ) { - push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param}; - } - return @errors; -} - - -=head3 allow_owner - -Allows access to object for its owner. - -There are endpoints that should allow access for the object owner even if they -do not have the required permission, e.g. access an own reserve. This can be -achieved by defining the operation as follows: - -"/holds/{reserve_id}": { - "get": { - ..., - "x-koha-authorization": { - "allow-owner": true, - "permissions": { - "borrowers": "1" - } - } - } -} - -=cut - -sub allow_owner { - my ($c, $authorization, $user) = @_; - - return unless $authorization->{'allow-owner'}; - - return check_object_ownership($c, $user) if $user and $c; -} - -=head3 allow_guarantor - -Same as "allow_owner", but checks if the object is owned by one of C<$user>'s -guarantees. - -=cut - -sub allow_guarantor { - my ($c, $authorization, $user) = @_; - - if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){ - return; - } - - my $guarantees = $user->guarantees->as_list; - foreach my $guarantee (@{$guarantees}) { - return 1 if check_object_ownership($c, $guarantee); - } -} - -=head3 check_object_ownership - -Determines ownership of an object from request parameters. - -As introducing an endpoint that allows access for object's owner; if the -parameter that will be used to determine ownership is not already inside -$parameters, add a new subroutine that checks the ownership and extend -$parameters to contain a key with parameter_name and a value of a subref to -the subroutine that you created. - -=cut - -sub check_object_ownership { - my ($c, $user) = @_; - - return if not $c or not $user; - - my $parameters = { - accountlines_id => \&_object_ownership_by_accountlines_id, - borrowernumber => \&_object_ownership_by_borrowernumber, - checkout_id => \&_object_ownership_by_checkout_id, - reserve_id => \&_object_ownership_by_reserve_id, - }; - - foreach my $param ( keys %{ $parameters } ) { - my $check_ownership = $parameters->{$param}; - if ($c->stash($param)) { - return &$check_ownership($c, $user, $c->stash($param)); - } - elsif ($c->param($param)) { - return &$check_ownership($c, $user, $c->param($param)); - } - elsif ($c->req->json && $c->req->json->{$param}) { - return 1 if &$check_ownership($c, $user, $c->req->json->{$param}); - } - } -} - -=head3 _object_ownership_by_accountlines_id - -Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it -belongs to C<$user>. - -=cut - -sub _object_ownership_by_accountlines_id { - my ($c, $user, $accountlines_id) = @_; - - my $accountline = Koha::Account::Lines->find($accountlines_id); - return $accountline && $user->borrowernumber == $accountline->borrowernumber; -} - -=head3 _object_ownership_by_borrowernumber - -Compares C<$borrowernumber> to currently logged in C<$user>. - -=cut - -sub _object_ownership_by_borrowernumber { - my ($c, $user, $borrowernumber) = @_; - - return $user->borrowernumber == $borrowernumber; -} - -=head3 _object_ownership_by_checkout_id - -First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one, -compare its borrowernumber to currently logged in C<$user>. However, if an issue -is not found, attempt to find a Koha::Old::Checkout-object instead and compare its -borrowernumber to currently logged in C<$user>. - -=cut - -sub _object_ownership_by_checkout_id { - my ($c, $user, $issue_id) = @_; - - my $issue = Koha::Checkouts->find($issue_id); - $issue = Koha::Old::Checkouts->find($issue_id) unless $issue; - return $issue && $issue->borrowernumber - && $user->borrowernumber == $issue->borrowernumber; -} - -=head3 _object_ownership_by_reserve_id - -Finds a Koha::Hold-object by C<$reserve_id> and checks if it -belongs to C<$user>. - -TODO: Also compare against old_reserves - -=cut - -sub _object_ownership_by_reserve_id { - my ($c, $user, $reserve_id) = @_; - - my $reserve = Koha::Holds->find($reserve_id); - return $reserve && $user->borrowernumber == $reserve->borrowernumber; -} - 1; --- a/Koha/REST/V1/Auth.pm +++ a/Koha/REST/V1/Auth.pm @@ -0,0 +1,329 @@ +package Koha::REST::V1::Auth; + +# Copyright Koha-Suomi Oy 2017 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; + +use C4::Auth qw( check_cookie_auth get_session haspermission ); + +use Koha::Patrons; + +use Koha::Exceptions; +use Koha::Exceptions::Authentication; +use Koha::Exceptions::Authorization; + +use Scalar::Util qw( blessed ); +use Try::Tiny; + +=head3 under + +This subroutine is called before every request to API. + +=cut + +sub under { + my $c = shift->openapi->valid_input or return;; + + my $status = 0; + try { + + $status = authenticate_api_request($c); + + } catch { + unless (blessed($_)) { + return $c->render( + status => 500, + json => { error => 'Something went wrong, check the logs.' } + ); + } + if ($_->isa('Koha::Exceptions::UnderMaintenance')) { + return $c->render(status => 503, json => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::Authentication::SessionExpired')) { + return $c->render(status => 401, json => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::Authentication::Required')) { + return $c->render(status => 401, json => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::Authentication')) { + return $c->render(status => 500, json => { error => $_->error }); + } + elsif ($_->isa('Koha::Exceptions::BadParameter')) { + return $c->render(status => 400, json => $_->error ); + } + elsif ($_->isa('Koha::Exceptions::Authorization::Unauthorized')) { + return $c->render(status => 403, json => { + error => $_->error, + required_permissions => $_->required_permissions, + }); + } + elsif ($_->isa('Koha::Exceptions')) { + return $c->render(status => 500, json => { error => $_->error }); + } + else { + return $c->render( + status => 500, + json => { error => 'Something went wrong, check the logs.' } + ); + } + }; + + return $status; +} + +=head3 authenticate_api_request + +Validates authentication and allows access if authorization is not required or +if authorization is required and user has required permissions to access. + +=cut + +sub authenticate_api_request { + my ( $c ) = @_; + + my $spec = $c->match->endpoint->pattern->defaults->{'openapi.op_spec'}; + my $authorization = $spec->{'x-koha-authorization'}; + my $cookie = $c->cookie('CGISESSID'); + my ($session, $user); + # Mojo doesn't use %ENV the way CGI apps do + # Manually pass the remote_address to check_auth_cookie + my $remote_addr = $c->tx->remote_address; + my ($status, $sessionID) = check_cookie_auth( + $cookie, undef, + { remote_addr => $remote_addr }); + if ($status eq "ok") { + $session = get_session($sessionID); + $user = Koha::Patrons->find($session->param('number')); + $c->stash('koha.user' => $user); + } + elsif ($status eq "maintenance") { + Koha::Exceptions::UnderMaintenance->throw( + error => 'System is under maintenance.' + ); + } + elsif ($status eq "expired" and $authorization) { + Koha::Exceptions::Authentication::SessionExpired->throw( + error => 'Session has been expired.' + ); + } + elsif ($status eq "failed" and $authorization) { + Koha::Exceptions::Authentication::Required->throw( + error => 'Authentication failure.' + ); + } + elsif ($authorization) { + Koha::Exceptions::Authentication->throw( + error => 'Unexpected authentication status.' + ); + } + + # We do not need any authorization + unless ($authorization) { + # Check the parameters + validate_query_parameters( $c, $spec ); + return 1; + } + + my $permissions = $authorization->{'permissions'}; + # Check if the user is authorized + if ( haspermission($user->userid, $permissions) + or allow_owner($c, $authorization, $user) + or allow_guarantor($c, $authorization, $user) ) { + + validate_query_parameters( $c, $spec ); + + # Everything is ok + return 1; + } + + Koha::Exceptions::Authorization::Unauthorized->throw( + error => "Authorization failure. Missing required permission(s).", + required_permissions => $permissions, + ); +} +sub validate_query_parameters { + my ( $c, $action_spec ) = @_; + + # Check for malformed query parameters + my @errors; + my %valid_parameters = map { ( $_->{in} eq 'query' ) ? ( $_->{name} => 1 ) : () } @{ $action_spec->{parameters} }; + my $existing_params = $c->req->query_params->to_hash; + for my $param ( keys %{$existing_params} ) { + push @errors, { path => "/query/" . $param, message => 'Malformed query string' } unless exists $valid_parameters{$param}; + } + + Koha::Exceptions::BadParameter->throw( + error => \@errors + ) if @errors; +} + + +=head3 allow_owner + +Allows access to object for its owner. + +There are endpoints that should allow access for the object owner even if they +do not have the required permission, e.g. access an own reserve. This can be +achieved by defining the operation as follows: + +"/holds/{reserve_id}": { + "get": { + ..., + "x-koha-authorization": { + "allow-owner": true, + "permissions": { + "borrowers": "1" + } + } + } +} + +=cut + +sub allow_owner { + my ($c, $authorization, $user) = @_; + + return unless $authorization->{'allow-owner'}; + + return check_object_ownership($c, $user) if $user and $c; +} + +=head3 allow_guarantor + +Same as "allow_owner", but checks if the object is owned by one of C<$user>'s +guarantees. + +=cut + +sub allow_guarantor { + my ($c, $authorization, $user) = @_; + + if (!$c || !$user || !$authorization || !$authorization->{'allow-guarantor'}){ + return; + } + + my $guarantees = $user->guarantees->as_list; + foreach my $guarantee (@{$guarantees}) { + return 1 if check_object_ownership($c, $guarantee); + } +} + +=head3 check_object_ownership + +Determines ownership of an object from request parameters. + +As introducing an endpoint that allows access for object's owner; if the +parameter that will be used to determine ownership is not already inside +$parameters, add a new subroutine that checks the ownership and extend +$parameters to contain a key with parameter_name and a value of a subref to +the subroutine that you created. + +=cut + +sub check_object_ownership { + my ($c, $user) = @_; + + return if not $c or not $user; + + my $parameters = { + accountlines_id => \&_object_ownership_by_accountlines_id, + borrowernumber => \&_object_ownership_by_borrowernumber, + checkout_id => \&_object_ownership_by_checkout_id, + reserve_id => \&_object_ownership_by_reserve_id, + }; + + foreach my $param ( keys %{ $parameters } ) { + my $check_ownership = $parameters->{$param}; + if ($c->stash($param)) { + return &$check_ownership($c, $user, $c->stash($param)); + } + elsif ($c->param($param)) { + return &$check_ownership($c, $user, $c->param($param)); + } + elsif ($c->match->stack->[-1]->{$param}) { + return &$check_ownership($c, $user, $c->match->stack->[-1]->{$param}); + } + elsif ($c->req->json && $c->req->json->{$param}) { + return 1 if &$check_ownership($c, $user, $c->req->json->{$param}); + } + } +} + +=head3 _object_ownership_by_accountlines_id + +Finds a Koha::Account::Line-object by C<$accountlines_id> and checks if it +belongs to C<$user>. + +=cut + +sub _object_ownership_by_accountlines_id { + my ($c, $user, $accountlines_id) = @_; + + my $accountline = Koha::Account::Lines->find($accountlines_id); + return $accountline && $user->borrowernumber == $accountline->borrowernumber; +} + +=head3 _object_ownership_by_borrowernumber + +Compares C<$borrowernumber> to currently logged in C<$user>. + +=cut + +sub _object_ownership_by_borrowernumber { + my ($c, $user, $borrowernumber) = @_; + + return $user->borrowernumber == $borrowernumber; +} + +=head3 _object_ownership_by_checkout_id + +First, attempts to find a Koha::Checkout-object by C<$issue_id>. If we find one, +compare its borrowernumber to currently logged in C<$user>. However, if an issue +is not found, attempt to find a Koha::Old::Checkout-object instead and compare its +borrowernumber to currently logged in C<$user>. + +=cut + +sub _object_ownership_by_checkout_id { + my ($c, $user, $issue_id) = @_; + + my $issue = Koha::Checkouts->find($issue_id); + $issue = Koha::Old::Checkouts->find($issue_id) unless $issue; + return $issue && $issue->borrowernumber + && $user->borrowernumber == $issue->borrowernumber; +} + +=head3 _object_ownership_by_reserve_id + +Finds a Koha::Hold-object by C<$reserve_id> and checks if it +belongs to C<$user>. + +TODO: Also compare against old_reserves + +=cut + +sub _object_ownership_by_reserve_id { + my ($c, $user, $reserve_id) = @_; + + my $reserve = Koha::Holds->find($reserve_id); + return $reserve && $user->borrowernumber == $reserve->borrowernumber; +} + +1; --- a/t/db_dependent/api/v1/auth.t +++ a/t/db_dependent/api/v1/auth.t @@ -0,0 +1,116 @@ +#!/usr/bin/env perl + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Test::More tests => 1; +use Test::Mojo; +use Test::Warn; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use C4::Auth; +use Koha::Database; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +# FIXME: sessionStorage defaults to mysql, but it seems to break transaction handling +# this affects the other REST api tests +t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); + +my $remote_address = '127.0.0.1'; +my $t = Test::Mojo->new('Koha::REST::V1'); +my $tx; + +subtest 'under() tests' => sub { + plan tests => 15; + + $schema->storage->txn_begin; + + my ($borrowernumber, $session_id) = create_user_and_session(); + + # 401 (no authentication) + $tx = $t->ua->build_tx( GET => "/api/v1/patrons" ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx) + ->status_is(401) + ->json_is('/error', 'Authentication failure.'); + + # 403 (no permission) + $tx = $t->ua->build_tx( GET => "/api/v1/patrons" ); + $tx->req->cookies( + { name => 'CGISESSID', value => $session_id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx) + ->status_is(403) + ->json_is('/error', 'Authorization failure. Missing required permission(s).'); + + # 401 (session expired) + my $session = C4::Auth::get_session($session_id); + $session->delete; + $session->flush; + $tx = $t->ua->build_tx( GET => "/api/v1/patrons" ); + $tx->req->cookies( + { name => 'CGISESSID', value => $session_id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx) + ->status_is(401) + ->json_is('/error', 'Session has been expired.'); + + # 503 (under maintenance & pending update) + t::lib::Mocks::mock_preference('Version', 1); + $tx = $t->ua->build_tx( GET => "/api/v1/patrons" ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx) + ->status_is(503) + ->json_is('/error', 'System is under maintenance.'); + + # 503 (under maintenance & database not installed) + t::lib::Mocks::mock_preference('Version', undef); + $tx = $t->ua->build_tx( GET => "/api/v1/patrons" ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + $t->request_ok($tx) + ->status_is(503) + ->json_is('/error', 'System is under maintenance.'); + + $schema->storage->txn_rollback; +}; + +sub create_user_and_session { + my $user = $builder->build( + { + source => 'Borrower', + value => { + flags => 0 + } + } + ); + + # Create a session for the authorized user + my $session = C4::Auth::get_session(''); + $session->param( 'number', $user->{borrowernumber} ); + $session->param( 'id', $user->{userid} ); + $session->param( 'ip', '127.0.0.1' ); + $session->param( 'lasttime', time() ); + $session->flush; + + return ( $user->{borrowernumber}, $session->id ); +} + +1; --