From 11f4fb64c55cc1aad1ec69523c185ab6188c41e4 Mon Sep 17 00:00:00 2001 From: Julian Maurice Date: Mon, 23 Mar 2015 20:19:12 +0100 Subject: [PATCH] Bug 13920: 8. API Authentication, part 2: implement authentication in API 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. This patch implements both server-side authentication (in Koha/REST/V1.pm) and client-side authentication in Swagger UI (api/v1/doc/index.html). 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 responsability of what to do if user is authenticated or not. --- Koha/ApiTimestamp.pm | 46 ++++++++++++++++++ Koha/ApiTimestamps.pm | 58 +++++++++++++++++++++++ Koha/REST/V1.pm | 63 +++++++++++++++++++++++-- Koha/Schema/Result/ApiTimestamp.pm | 81 ++++++++++++++++++++++++++++++++ api/v1/doc/css/screen.css | 5 +- api/v1/doc/index.html | 41 +++++++++------- api/v1/doc/lib/hmac-sha256.js | 18 +++++++ installer/data/mysql/kohastructure.sql | 15 ++++++ installer/data/mysql/updatedatabase.pl | 21 +++++++++ 9 files changed, 327 insertions(+), 21 deletions(-) create mode 100644 Koha/ApiTimestamp.pm create mode 100644 Koha/ApiTimestamps.pm create mode 100644 Koha/Schema/Result/ApiTimestamp.pm create mode 100644 api/v1/doc/lib/hmac-sha256.js diff --git a/Koha/ApiTimestamp.pm b/Koha/ApiTimestamp.pm new file mode 100644 index 0000000..ed7d355 --- /dev/null +++ b/Koha/ApiTimestamp.pm @@ -0,0 +1,46 @@ +package Koha::ApiTimestamp; + +# Copyright BibLibre 2015 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use base qw(Koha::Object); + +=head1 NAME + +Koha::ApiTimestamp - Koha API Timestamp Object class + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub type { + return 'ApiTimestamp'; +} + +1; diff --git a/Koha/ApiTimestamps.pm b/Koha/ApiTimestamps.pm new file mode 100644 index 0000000..f5ba5dc --- /dev/null +++ b/Koha/ApiTimestamps.pm @@ -0,0 +1,58 @@ +package Koha::ApiTimestamps; + +# Copyright BibLibre 2015 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use Koha::Borrower; + +use base qw(Koha::Objects); + +=head1 NAME + +Koha::ApiTimestamps - Koha API Timestamps Object class + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub type { + return 'ApiTimestamp'; +} + +sub object_class { + return 'Koha::ApiTimestamp'; +} + +=head1 AUTHOR + +Kyle M Hall + +=cut + +1; diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm index ea1810f..33a7ad9 100644 --- a/Koha/REST/V1.pm +++ b/Koha/REST/V1.pm @@ -4,6 +4,16 @@ use Modern::Perl; use Mojo::Base 'Mojolicious'; use Mojo::Log; +use Digest::SHA qw(hmac_sha256_hex); + +use Koha::Borrower; +use Koha::Borrowers; +use Koha::ApiKey; +use Koha::ApiKeys; +use Koha::ApiTimestamp; +use Koha::ApiTimestamps; + + =head startup Starts the Mojolicious application using whatever server is configured. @@ -71,9 +81,56 @@ sub startup { 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); + 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; } ); diff --git a/Koha/Schema/Result/ApiTimestamp.pm b/Koha/Schema/Result/ApiTimestamp.pm new file mode 100644 index 0000000..000ff5c --- /dev/null +++ b/Koha/Schema/Result/ApiTimestamp.pm @@ -0,0 +1,81 @@ +use utf8; +package Koha::Schema::Result::ApiTimestamp; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Koha::Schema::Result::ApiTimestamp + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("api_timestamps"); + +=head1 ACCESSORS + +=head2 borrowernumber + + data_type: 'integer' + is_foreign_key: 1 + is_nullable: 0 + +=head2 timestamp + + data_type: 'bigint' + is_nullable: 1 + +=cut + +__PACKAGE__->add_columns( + "borrowernumber", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "timestamp", + { data_type => "bigint", is_nullable => 1 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("borrowernumber"); + +=head1 RELATIONS + +=head2 borrowernumber + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "borrowernumber", + "Koha::Schema::Result::Borrower", + { borrowernumber => "borrowernumber" }, + { is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07025 @ 2015-03-24 08:27:29 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:FfKz14uY/UgVOWcF4T5J8A + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/api/v1/doc/css/screen.css b/api/v1/doc/css/screen.css index 32b199b..818252a 100644 --- a/api/v1/doc/css/screen.css +++ b/api/v1/doc/css/screen.css @@ -1216,7 +1216,10 @@ margin: 0 10px 0 0; } .swagger-section #header form#api_selector .input input#input_apiKey { - width: 200px; + width: 100px; +} +.swagger-section #header form#api_selector .input input#input_username { + width: 100px; } .swagger-section #header form#api_selector .input input#input_baseUrl { width: 400px; diff --git a/api/v1/doc/index.html b/api/v1/doc/index.html index c06ab62..b430114 100644 --- a/api/v1/doc/index.html +++ b/api/v1/doc/index.html @@ -20,9 +20,10 @@ + - +