From c6cd16149143dc04d97a706a7f3ed764c74e6801 Mon Sep 17 00:00:00 2001 From: Olli-Antti Kivilahti Date: Wed, 22 Jul 2015 14:06:44 +0000 Subject: [PATCH] Bug 13920: 8. API authentication system - Swagtenticator authentication --- C4/Installer/PerlDependencies.pm | 10 + Koha/REST/V1.pm | 28 ++- Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm | 236 ++++++++++++++++++++++ api/v1/swagger.json | 41 +++- 4 files changed, 298 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 90cfbc8..f1e5944 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..06bff3b --- /dev/null +++ b/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm @@ -0,0 +1,236 @@ +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); + }, + ); + }; +} + + + +=head _validate_input +@OVERLOADS Mojolicious::Plugin::Swagger2::_validate_input() + +Validates the parameters from the "Operation Object" from Swagger2 specification. +Overloading to allow OPTIONAL parameters. +=cut + +sub _validate_input { + my ($self, $c, $config) = @_; + my $headers = $c->req->headers; + my $query = $c->req->url->query; + my (%input, %v); + + for my $p (@{$config->{parameters} || []}) { + my ($in, $name) = @$p{qw( in name )}; + my ($value, @e); + + $value + = $in eq 'query' ? $query->param($name) + : $in eq 'path' ? $c->stash($name) + : $in eq 'header' ? $headers->header($name) + : $in eq 'body' ? $c->req->json + : $in eq 'formData' ? $c->req->body_params->to_hash + : "Invalid 'in' for parameter $name in schema definition"; + + if (defined $value or $p->{required}) { + my $type = $p->{type} || 'object'; + $value += 0 if $type =~ /^(?:integer|number)/ and $value =~ /^\d/; + $value = ($value eq 'false' or !$value) ? Mojo::JSON->false : Mojo::JSON->true if $type eq 'boolean'; + + if ($in eq 'body' or $in eq 'formData') { + warn "[Swagger2] Validate $in @{[$c->req->body]}\n"; + push @e, map { $_->{path} = "/$name$_->{path}"; $_; } $self->_validator->validate($value, $p->{schema}); + } + else { + warn "[Swagger2] Validate $in $name=$value\n"; + push @e, $self->_validator->validate({$name => $value}, {properties => {$name => $p}}); + } + } + + $input{$name} = $value unless @e; + push @{$v{errors}}, @e; + } + + $v{valid} = @{$v{errors} || []} ? Mojo::JSON->false : Mojo::JSON->true; + return \%v, \%input; +} + + + +################################################################################ +######### 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'); + + #my $apiSpecificationUrl = $c-> + 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. + } + if ($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 afec513..47d8a94 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": [] } + ] } }, "/borrowers/{borrowernumber}/issues": { @@ -466,5 +480,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