From aeef490685e1faf4e1a7c95735755d75fefff126 Mon Sep 17 00:00:00 2001 From: Julian Maurice Date: Mon, 23 Mar 2015 20:19:12 +0100 Subject: [PATCH] Bug 13920: 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 | 62 ++++++++++++++++++++++++-- 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, 326 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 3becf12..e076ccd 100644 --- a/Koha/REST/V1.pm +++ b/Koha/REST/V1.pm @@ -3,15 +3,71 @@ package Koha::REST::V1; use Modern::Perl; use Mojo::Base 'Mojolicious'; +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; + sub startup { my $self = shift; 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 6fb7cc4..35aab06 100644 --- a/api/v1/doc/index.html +++ b/api/v1/doc/index.html @@ -19,9 +19,10 @@ + - +