From 3b0ea5d5e70d4be39ed5b4d2ee6ee22fd5e85cc3 Mon Sep 17 00:00:00 2001 From: Olli-Antti Kivilahti Date: Wed, 24 Jun 2015 11:32:40 +0300 Subject: [PATCH] Bug 13920 - 9. API authentication system - Swagtenticator authentication - WIP This feature implements REST API-key authentication and Koha permission validation in the Swagger2-plugin extension. This is basically a Mojolicious to Koha authentication using Swagger2 RESTful API definition to autodocument and check for proper user permissions, aka. "KohaliciousSwagtenticator". With this feature the API provider doesn't need to code anything in the Controller to support Koha permissions. Simply by defining a custom Swagger2 parameter "x-koha-parameters": {} the Swagtenticator knows to check the user for proper Koha permissions. Example (require any borrowers-permission): ... "paths": { "/borrowers": { "get": { "x-mojo-controller": "Koha::REST::V1::Borrowers", "x-koha-permission": { "borrowers": "*" }, "operationId": "listBorrowers", ... This x-koha-permission definition is turned to a HASH and given to the C4::Auth::haspermission() for verification by the Swagger2-based plugin. Bug dependencies: Buugg 13995 - Proper Exception handling, which helps a lot in dealing with all the various ways authentication can fail. Buugg 14437 - Refactor C4::Auth::haspermission() to Koha::Object and return better errors. Which returns the failing permission so we can create a more helpful API which tells which permissions are missing (also helps admins in giving the right permissions) This feature is implemented by inheriting Mojolicious::Plugin::Swagger2 in Koha::REST::V1::Plugins::KohaliciousSwagtenticator and overloading the necessary subroutines. TEST PLAN: 1. Add the given example (up) to any "Operation Object *". 2. Call the "Operation object" (eg. /v1/borrowers/10) with user credetials not having any borrower-permissions. 3. Fail because of myriad of reasons. (see. KohaliciousSwagtenticator::check_key_auth()) 4. Add some borrowers-permissions to the same user. 5. Succeed in your operation. * from Swagger2.0 specification --- Koha/REST/V1.pm | 71 +---- Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm | 350 +++++++++++++++++++++ api/v1/swagger.json | 42 ++- 3 files changed, 397 insertions(+), 66 deletions(-) create mode 100644 Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm index 33a7ad9..7c4b75e 100644 --- a/Koha/REST/V1.pm +++ b/Koha/REST/V1.pm @@ -3,8 +3,7 @@ package Koha::REST::V1; use Modern::Perl; use Mojo::Base 'Mojolicious'; use Mojo::Log; - -use Digest::SHA qw(hmac_sha256_hex); +use Mojolicious::Plugins; #Extend the plugin system use Koha::Borrower; use Koha::Borrowers; @@ -78,68 +77,12 @@ sub startup { print __PACKAGE__."::startup():> No config-file loaded. Define your config-file to the MOJO_CONFIG environmental variable.\n"; } - my $route = $self->routes->under->to( - cb => sub { - my $c = shift; - my $req_username = $c->req->headers->header('X-Koha-Username'); - my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); - my $req_signature = $c->req->headers->header('X-Koha-Signature'); - my $req_method = $c->req->method; - my $req_url = '/' . $c->req->url->path_query; - - # "Anonymous" mode - return 1 - unless ( defined($req_username) - or defined($req_timestamp) - or defined($req_signature) ); - - my $borrower = Koha::Borrowers->find({userid => $req_username}); - - my @apikeys = Koha::ApiKeys->search({ - borrowernumber => $borrower->borrowernumber, - active => 1, - }); - - my $message = "$req_method $req_url $req_username $req_timestamp"; - my $signature = ''; - foreach my $apikey (@apikeys) { - $signature = hmac_sha256_hex($message, $apikey->api_key); - - last if $signature eq $req_signature; - } - - unless ($signature eq $req_signature) { - $c->res->code(403); - $c->render(json => { error => "Authentication failed" }); - return; - } - - my $api_timestamp = Koha::ApiTimestamps->find($borrower->borrowernumber); - my $timestamp = $api_timestamp ? $api_timestamp->timestamp : 0; - unless ($timestamp < $req_timestamp) { - $c->res->code(403); - $c->render(json => { error => "Bad timestamp" }); - return; - } - - unless ($api_timestamp) { - $api_timestamp = new Koha::ApiTimestamp; - $api_timestamp->borrowernumber($borrower->borrowernumber); - } - $api_timestamp->timestamp($req_timestamp); - $api_timestamp->store; - - # Authentication succeeded, store authenticated user in stash - $c->stash('user', $borrower); - 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..0f4e902 --- /dev/null +++ b/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm @@ -0,0 +1,350 @@ +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 C4::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); + warn "[Swagtenticator] Successful subclassing _validate_input()!\n"; + + 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, $config) = @_; + my ($error, $data, $statusCode); + + try { + check_key_auth($c, $config); + } catch { + if (blessed($_)) { + if ($_->isa('Koha::Exception::BadAuthenticationToken') || + $_->isa('Koha::Exception::NoPermission') ) { + $error = {valid => Mojo::JSON->false, errors => [{message => $_->error, path => $c->req->url->path_query}]}; + $data = {header => {"WWW-Authenticate" => "Where is Koha API authentication publicly instructed?"}}; + $statusCode = 401; + } + elsif ($_->isa('Koha::Exception::UnknownProgramState')){ + $error = {valid => Mojo::JSON->false, errors => [{message => $_->error, path => $c->req->url->path_query}]}; + $data = {}; + $statusCode = 500; + } + + } + + }; + return ($error, $data, $statusCode); +} + +=head check_key_auth + + my $auth_ok = check_key_auth($c, $opObj); + +For authentication to succeed, the client have to send 3 custom HTTP +headers: + - X-Koha-Username: userid of borrower + - X-Koha-Timestamp: timestamp of the request + - X-Koha-Signature: signature of the request + +The signature is a HMAC-SHA256 hash of several elements of the request, +separated by spaces: + - HTTP method (uppercase) + - URL path and query string + - username + - timestamp of the request + +The server then tries to rebuild the signature with each user's API key. +If one matches the received X-Koha-Signature, then authentication is +almost OK. + +To avoid requests to be replayed, the last request's timestamp is stored +in database and the authentication succeeds only if the stored timestamp +is lesser than X-Koha-Timestamp. + + +There is also an "anonymous" mode if X-Koha-* headers are not set. +Anonymous mode differ from authenticated mode in one thing: if user is +authenticated, the corresponding Koha::Borrower object is stored in +Mojolicious stash, so it can easily be retrieved by controllers. +Controllers then have the responsibility of what to do if user is +authenticated or not. + +@PARAM1 Mojolicious::Controller +@PARAM2 Reference to HASH, the "Operation Object" from Swagger2.0 specification, + matching the given "Path Item Object"'s HTTP Verb. +@RETURNS Integer, 1 if authentication succeeded, otherwise throws exceptions. +@THROWS Koha::Exception::BadAuthenticationToken from _check_key_auth_api_key() +@THROWS Koha::Exception::NoPermission if borrower has no Koha permission to access the resource +@THROWS Koha::Exception::UnknownProgramState if authentication system malfunctions; +=cut + +sub check_key_auth { + my ($c, $opObj) = @_; + my $req_username = $c->req->headers->header('X-Koha-Username'); + my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); + my $req_signature = $c->req->headers->header('X-Koha-Signature'); + my $req_method = $c->req->method; + my $req_url = '/' . $c->req->url->path_query; + + my $borrower = Koha::Borrowers->find({userid => $req_username}); + + #If the resource requires specific permissions, a strong authentication must be given. + if ($opObj->{"x-koha-permission"}) { + #Does key authentication fail? + _check_key_auth_api_key($c, $borrower); + + #Strong auth OK, Are there enough permissions? + my ($failedPermission, $borrowerPermissions) = C4::Auth::haspermission($borrower, $opObj->{"x-koha-permission"}); + unless ($borrowerPermissions) { #Permissions are lacking. + my @permTokens = %$failedPermission if (ref $failedPermission eq 'HASH'); + my $failedPermissionString = (@permTokens) ? $permTokens[0].' => '.$permTokens[1] : "Permission unknown"; + Koha::Exception::NoPermission->throw(error => "No Koha permission to access this resource. Permission '$failedPermissionString' required."); + } + + # Authentication succeeded, store authenticated user in stash + $c->stash('user', $borrower); + return 1; + } + #No special permissions needed, try anon auth, and then strong auth. + else { + # "Anonymous" mode + if (_check_key_auth_anonymous($c)) { + return 'ANON'; + } + _check_key_auth_api_key($c, $borrower); + + # Authentication succeeded, store authenticated user in stash + $c->stash('user', $borrower); + return 1; + } + + #If we reach this point there is something wrong. NEVER default to auth OK + Koha::Exception::UnknownProgramState->throw(error => "Failure when authenticating. Unknown authentication state."); +} + +=head _check_key_auth_anonymous + +@PARAM1, Mojolicious::Controller +@RETURNS, Int, 2, if anonymous authentication + undef, if no anonymous authentication. +=cut + +sub _check_key_auth_anonymous { + my ($c) = @_; + my $req_username = $c->req->headers->header('X-Koha-Username'); + my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); + my $req_signature = $c->req->headers->header('X-Koha-Signature'); + + return 2 + unless ( defined($req_username) + or defined($req_timestamp) + or defined($req_signature) ); + return undef; +} + +=head _check_key_auth_api_key + + unless (_check_key_auth_api_key($c, $borrower)) { + return; + } + +@PARAM1 Mojolicious::Controller +@PARAM2 Koha::Borrower +@RETURNS Int, 1, if api authentication succeeded + undef, authentication failed +@THROWS Koha::Exception::BadAuthenticationToken if borrower: + has no API keys, + signatures do not match, + given timestamp is stale +=cut + +sub _check_key_auth_api_key { + my ($c, $borrower) = @_; + my $req_username = $c->req->headers->header('X-Koha-Username'); + my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); + my $req_signature = $c->req->headers->header('X-Koha-Signature'); + my $req_method = $c->req->method; + my $req_url = '/' . $c->req->url->path_query; + + my @apikeys = Koha::ApiKeys->search({ + borrowernumber => $borrower->borrowernumber, + active => 1, + }); + Koha::Exception::BadAuthenticationToken->throw(error => "User has no API keys") unless @apikeys; + + my $message = "$req_method $req_url $req_username $req_timestamp"; + my $signature = ''; + foreach my $apikey (@apikeys) { + $signature = hmac_sha256_hex($message, $apikey->api_key); + + last if $signature eq $req_signature; + } + + unless ($signature eq $req_signature) { + Koha::Exception::BadAuthenticationToken->throw(error => "API key authentication failed"); + } + + my $api_timestamp = Koha::ApiTimestamps->find($borrower->borrowernumber); + my $timestamp = $api_timestamp ? $api_timestamp->timestamp : 0; + unless ($timestamp < $req_timestamp) { + Koha::Exception::BadAuthenticationToken->throw(error => "Bad X-Koha-Timestamp, expected '$timestamp'"); + } + + unless ($api_timestamp) { + $api_timestamp = new Koha::ApiTimestamp; + $api_timestamp->borrowernumber($borrower->borrowernumber); + } + $api_timestamp->timestamp($req_timestamp); + $api_timestamp->store; + + return 1; +} + +return 1; \ No newline at end of file diff --git a/api/v1/swagger.json b/api/v1/swagger.json index afec513..78a7db9 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,29 @@ "required": "true", "type": "integer" } + }, + "securityDefinitions": { + "multi_key_auth": { + "type": "custom", + "in": "header", + "keys": { + "ETag": { + "type": "dateTime", + "description": "The current time when the request is created." + }, + "x-koha-username": { + "type": "string", + "description": "The username of the API consumer. Not the library card's barcode or borrowernumber!" + }, + "x-koha-permission": { + "type": "string", + "description": "The specific permission the user must have. Eg. 'circulation => force_checkout'" + }, + "x-koha-signature": { + "type": "string", + "description": "The signature is a HMAC-SHA256 hash of several elements of the request, separated by spaces: 1. HTTP method (uppercase) 2. URL path and query string 3. Value of x-koha-username -header 4. Value of the ETag-header" + } + } + } } } -- 1.7.9.5