From c6cd16149143dc04d97a706a7f3ed764c74e6801 Mon Sep 17 00:00:00 2001
From: Olli-Antti Kivilahti <olli-antti.kivilahti@jns.fi>
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