From 37c4c54512c56c375615424b41aa8909f9e5a824 Mon Sep 17 00:00:00 2001 From: Olli-Antti Kivilahti Date: Wed, 22 Jul 2015 14:06:44 +0000 Subject: [PATCH] Bug 13920: API authentication system - Swagtenticator authentication Reads the Swagger2 definitions and defines the API routes and controllers for Mojolicious. Authentiates the API consumer using Koha::Auth::Challenge::RESTV1 with all the necessary details inferred from Swagger2, like permissions. Validates all input to match the Swagger2 definition. Authentication is based on the permissions defined in the Swagger2 definition. Add x-koha-permission to the Operation Object to define needed Koha permissions to access the resource. Eg. "/borrowers/{borrowernumber}": { "get": { "x-mojo-controller": "Koha::REST::V1::Borrowers", "x-koha-permission": { "borrowers": "*" }, "operationId": "getBorrower", "tags": ["borrowers"], --- C4/Installer/PerlDependencies.pm | 10 ++ Koha/REST/V1.pm | 28 ++-- Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm | 185 ++++++++++++++++++++++ api/v1/swagger.json | 41 ++++- 4 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm diff --git a/C4/Installer/PerlDependencies.pm b/C4/Installer/PerlDependencies.pm index 42400e7..4524d59 100644 --- a/C4/Installer/PerlDependencies.pm +++ b/C4/Installer/PerlDependencies.pm @@ -49,6 +49,11 @@ our $PERL_DEPS = { 'required' => '1', 'min_ver' => '0.21' }, + 'Data::Walk' => { + 'usage' => 'Core', + 'required' => '1', + 'min_ver' => '1.00' + }, 'DBI' => { 'usage' => 'Core', 'required' => '1', @@ -542,6 +547,11 @@ our $PERL_DEPS = { 'required' => '1', 'min_ver' => '0.09', }, + 'DateTime::Format::HTTP' => { + 'usage' => 'REST API', + 'required' => '1', + 'min_ver' => '0.42', + }, 'Template::Plugin::HtmlToText' => { 'usage' => 'Core', 'required' => '1', diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm index 746910a..5558245 100644 --- a/Koha/REST/V1.pm +++ b/Koha/REST/V1.pm @@ -3,6 +3,13 @@ package Koha::REST::V1; use Modern::Perl; use Mojo::Base 'Mojolicious'; use Mojo::Log; +use Mojolicious::Plugins; #Extend the plugin system + +use Koha::Borrower; +use Koha::Borrowers; +use Koha::ApiKey; +use Koha::ApiKeys; + =head startup @@ -58,21 +65,12 @@ sub startup { $self->setKohaParamLogging(); $self->setKohaParamConfig(); - my $route = $self->routes->under->to( - cb => sub { - my $c = shift; - my $user = $c->param('user'); - # Do the authentication stuff here... - $c->stash('user', $user); - return 1; - } - ); - - # Force charset=utf8 in Content-Type header for JSON responses - $self->types->type(json => 'application/json; charset=utf8'); - - $self->plugin(Swagger2 => { - route => $route, + + ##Add the Koha namespace to the plugin engine to find plugins from. + my $plugin = $self->plugins(); + push @{$plugin->namespaces}, 'Koha::REST::V1::Plugins'; + + $self->plugin(KohaliciousSwagtenticator => { url => $self->home->rel_file("api/v1/swagger.json"), }); } diff --git a/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm b/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm new file mode 100644 index 0000000..0304c50 --- /dev/null +++ b/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm @@ -0,0 +1,185 @@ +package Koha::REST::V1::Plugins::KohaliciousSwagtenticator; + +use Modern::Perl; + +use base qw(Mojolicious::Plugin::Swagger2); + +use Digest::SHA qw(hmac_sha256_hex); +use Try::Tiny; +use Scalar::Util qw(blessed); +use Data::Walk; + +use Koha::Auth; + +use Koha::Exception::BadAuthenticationToken; +use Koha::Exception::UnknownProgramState; +use Koha::Exception::NoPermission; + +use constant DEBUG => $ENV{SWAGGER2_DEBUG} || 0; + + + +################################################################################ +###################### STARTING OVERLOADING SUBROUTINES ###################### +################################################################################ + + + +=head _generate_request_handler +@OVERLOADS Mojolicious::Plugin::Swagger2::_generate_request_handler() +This is just a copy-paste of the parent function with a small incision to inject the Koha-authentication mechanism. +Keep code changes minimal for upstream compatibility, so when problems arise, copy-pasting fixes them! + +=cut + +sub _generate_request_handler { + my ($self, $method, $config) = @_; + my $controller = $config->{'x-mojo-controller'} || $self->{controller}; # back compat + + return sub { + my $c = shift; + my $method_ref; + + unless (eval "require $controller;1") { + $c->app->log->error($@); + return $c->render_swagger($self->_not_implemented('Controller not implemented.'), {}, 501); + } + unless ($method_ref = $controller->can($method)) { + $method_ref = $controller->can(sprintf '%s_%s', $method, lc $c->req->method) + and warn "HTTP method name is not used in method name lookup anymore!"; + } + unless ($method_ref) { + $c->app->log->error( + qq(Can't locate object method "$method" via package "$controller. (Something is wrong in @{[$self->url]})")); + return $c->render_swagger($self->_not_implemented('Method not implemented.'), {}, 501); + } + ######################################### + ####### Koha-overload starts here ####### + ## Check for user api-key authentication and permissions. + my ($error, $data, $statusCode) = _koha_authenticate($c, $config); + return $c->render_swagger($error, $data, $statusCode) if $error; + ### END OF Koha-overload ### + ######################################### + + bless $c, $controller; # ugly hack? + + $c->delay( + sub { + my ($delay) = @_; + my ($v, $input) = $self->_validate_input($c, $config); + + return $c->render_swagger($v, {}, 400) unless $v->{valid}; + return $c->$method_ref($input, $delay->begin); + }, + sub { + my $delay = shift; + my $data = shift; + my $status = shift || 200; + my $format = $config->{responses}{$status} || $config->{responses}{default} || {}; + my @err = $self->_validator->validate($data, $format->{schema}); + + return $c->render_swagger({errors => \@err, valid => Mojo::JSON->false}, $data, 500) if @err; + return $c->render_swagger({}, $data, $status); + }, + ); + }; +} + + + +################################################################################ +######### END OF OVERLOADED SUBROUTINES, STARTING EXTENDED FEATURES ########## +################################################################################ + + + +=head _koha_authenticate + + _koha_authenticate($c, $config); + +Checks all authentications in Koha, and prepares the data for a +Mojolicious::Plugin::Swagger2->render_swagger($errors, $data, $statusCode) -response +if authentication failed for some reason. + +@PARAM1 Mojolicious::Controller or a subclass +@PARAM2 Reference to HASH, the "Operation Object" from Swagger2.0 specification, + matching the given "Path Item Object"'s HTTP Verb. +@RETURNS List of: HASH Ref, errors encountered + HASH Ref, data to be sent + String, status code from the Koha::REST::V1::check_key_auth() +=cut + +sub _koha_authenticate { + my ($c, $opObj) = @_; + my ($error, $data, $statusCode); #define return values + + try { + + my $authParams = {}; + $authParams->{authnotrequired} = 1 unless $opObj->{"x-koha-permission"}; + Koha::Auth::authenticate($c, $opObj->{"x-koha-permission"}, $authParams); + + } catch { + my $e = $_; + if (blessed($e)) { + my $swagger2DocumentationUrl = findConfigurationParameterFromAnyConfigurationFile($c->app->config(), 'swagger2DocumentationUrl') || ''; + + if ($e->isa('Koha::Exception::NoPermission') || + $e->isa('Koha::Exception::LoginFailed') || + $e->isa('Koha::Exception::UnknownObject') + ) { + $error = {valid => Mojo::JSON->false, errors => [{message => $e->error, path => $c->req->url->path_query}, + {message => "See '$swagger2DocumentationUrl' for how to properly authenticate to Koha"},]}; + $data = {header => {"WWW-Authenticate" => "Koha $swagger2DocumentationUrl"}}; + $statusCode = 401; #Throw Unauthorized with instructions on how to properly authorize. + } + elsif ($e->isa('Koha::Exception::BadParameter')) { + $error = {valid => Mojo::JSON->false, errors => [{message => $e->error, path => $c->req->url->path_query}]}; + $data = {}; + $statusCode = 400; #Throw a Bad Request + } + elsif ($e->isa('Koha::Exception::VersionMismatch') || + $e->isa('Koha::Exception::BadSystemPreference') || + $e->isa('Koha::Exception::ServiceTemporarilyUnavailable') + ){ + $error = {valid => Mojo::JSON->false, errors => [{message => $e->error, path => $c->req->url->path_query}]}; + $data = {}; + $statusCode = 503; #Throw Service Unavailable, but will be available later. + } + else { + die $e; + } + } + else { + die $e; + } + }; + return ($error, $data, $statusCode); +} + +=head findConfigurationParameterFromAnyConfigurationFile + +Because we can use this REST API with CGI, or Plack, or Hypnotoad, or Morbo, ... +We cannot know which configuration file we are currently using. +$conf = {hypnotoad => {#conf params}, + plack => {#conf params}, + ... + } +So find the needed markers from any configuration file. +=cut + +sub findConfigurationParameterFromAnyConfigurationFile { + my ($conf, $paramLookingFor) = @_; + + my $found; + my $wanted = sub { + if ($_ eq $paramLookingFor) { + $found = $Data::Walk::container->{$_}; + return (); + } + }; + Data::Walk::walk( $wanted, $conf); + return $found; +} + +return 1; \ No newline at end of file diff --git a/api/v1/swagger.json b/api/v1/swagger.json index 9672f15..cfd7356 100644 --- a/api/v1/swagger.json +++ b/api/v1/swagger.json @@ -17,8 +17,13 @@ "/borrowers": { "get": { "x-mojo-controller": "Koha::REST::V1::Borrowers", + "x-koha-permission": { + "borrowers": "*" + }, "operationId": "listBorrowers", "tags": ["borrowers"], + "summary": "just a summary", + "description": "long description", "produces": [ "application/json" ], @@ -32,12 +37,18 @@ } } } - } + }, + "security": [ + { "multi_key_auth": [] } + ] } }, "/borrowers/{borrowernumber}": { "get": { "x-mojo-controller": "Koha::REST::V1::Borrowers", + "x-koha-permission": { + "borrowers": "*" + }, "operationId": "getBorrower", "tags": ["borrowers"], "parameters": [ @@ -61,7 +72,10 @@ "$ref": "#/definitions/error" } } - } + }, + "security": [ + { "multi_key_auth": [] } + ] } } }, @@ -104,5 +118,28 @@ "required": "true", "type": "integer" } + }, + "securityDefinitions": { + "multi_key_auth": { + "type": "custom", + "description": "Example: 'Authorization: Koha 1:0f049b5ba2f04da7e719b7166dd9e1b0efacf23747798f19efe51eb6e437f84c'\n\nConstructing the Authorization header\n\n-You brand the authorization header with 'Koha'\n-Then you give the userid/cardnumber of the user authenticating.\n-Then the hashed signature.\n\nThe signature is a HMAC-SHA256-HEX hash of several elements of the request,\nseparated by spaces:\n - HTTP method (uppercase)\n - userid/cardnumber\n - X-Koha-Date-header\nSigned with the Borrowers API key\n\n\nPseudocode example:\n\nSignature = HMAC-SHA256-HEX('HTTPS' + ' ' +\n '/api/v1/borrowers/12?howdoyoudo=voodoo' + ' ' +\n 'admin69' + ' ' +\n '760818212' + ' ' +\n 'frJIUN8DYpKDtOLCwo//yllqDzg='\n );\n", + "keys": { + "X-Koha_Date": { + "type": "dateTime", + "in": "header", + "description": "The current time when the request is created. The standard HTTP Date header complying to RFC 1123" + }, + "Authorization": { + "type": "string", + "in": "header", + "description": "Starts with identifier 'Koha', then you give the userid/cardnumber of the user authenticating and finally the hashed signature." + }, + "x-koha-permission": { + "type": "object", + "in": "not part of the request", + "description": "The specific permission the user must have. Eg. 'circulation => force_checkout'. Only we can grant these permissions." + } + } + } } } -- 1.9.1