From b471a6918a53da21a8c1cce75586387bb4a8b1ef 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 server-side authentication (in Koha/REST/V1.pm) 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 ++++++++++++++++++++++++++++++++++ installer/data/mysql/kohastructure.sql | 15 +++++++ installer/data/mysql/updatedatabase.pl | 21 +++++++++ 6 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 Koha/ApiTimestamp.pm create mode 100644 Koha/ApiTimestamps.pm create mode 100644 Koha/Schema/Result/ApiTimestamp.pm 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/installer/data/mysql/kohastructure.sql b/installer/data/mysql/kohastructure.sql index a34590e..70effc7 100644 --- a/installer/data/mysql/kohastructure.sql +++ b/installer/data/mysql/kohastructure.sql @@ -32,6 +32,21 @@ CREATE TABLE api_keys ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; -- +-- Table structure for table api_timestamps +-- + +DROP TABLE IF EXISTS api_timestamps; +CREATE TABLE api_timestamps ( + borrowernumber int(11) NOT NULL, -- foreign key to the borrowers table + timestamp bigint, -- timestamp of the last request from this user + PRIMARY KEY (borrowernumber), + CONSTRAINT api_timestamps_fk_borrowernumber + FOREIGN KEY (borrowernumber) + REFERENCES borrowers (borrowernumber) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + +-- -- Table structure for table `auth_header` -- diff --git a/installer/data/mysql/updatedatabase.pl b/installer/data/mysql/updatedatabase.pl index 06c67cf..90707eb 100755 --- a/installer/data/mysql/updatedatabase.pl +++ b/installer/data/mysql/updatedatabase.pl @@ -10663,6 +10663,27 @@ if(CheckVersion($DBversion)) { SetVersion($DBversion); } +$DBversion = "XXX"; +if(CheckVersion($DBversion)) { + $dbh->do(q{ + DROP TABLE IF EXISTS api_timestamps; + }); + $dbh->do(q{ + CREATE TABLE api_timestamps ( + borrowernumber int(11) NOT NULL, + timestamp bigint, + PRIMARY KEY (borrowernumber), + CONSTRAINT api_timestamps_fk_borrowernumber + FOREIGN KEY (borrowernumber) + REFERENCES borrowers (borrowernumber) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + }); + + print "Upgrade to $DBversion done (Bug 13920: Add API timestamps table)\n"; + SetVersion($DBversion); +} + # DEVELOPER PROCESS, search for anything to execute in the db_update directory # SEE bug 13068 -- 1.9.1