@@ -, +, @@ authentication - WIP "KohaliciousSwagtenticator". "x-koha-parameters": {} "paths": { "/borrowers": { "get": { "x-mojo-controller": "Koha::REST::V1::Borrowers", "x-koha-permission": { "borrowers": "*" }, "operationId": "listBorrowers", 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) having any borrower-permissions. (see. KohaliciousSwagtenticator::check_key_auth()) --- 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 --- a/Koha/REST/V1.pm +++ a/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"), }); } --- a/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm +++ a/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; --- a/api/v1/swagger.json +++ a/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" + } + } + } } } --