Bug 20402: Implement OAuth2 authentication for REST API
authorJulian Maurice <julian.maurice@biblibre.com>
Tue, 13 Mar 2018 12:17:12 +0000 (13:17 +0100)
committerJonathan Druart <jonathan.druart@bugs.koha-community.org>
Tue, 8 May 2018 18:55:42 +0000 (15:55 -0300)
It implements only the "client credentials" flow with no scopes
support. API clients are tied to an existing patron and have the same
permissions as the patron they are tied to.
API Clients are defined in $KOHA_CONF.

Test plan:
0. Install Net::OAuth2::AuthorizationServer 0.16
1. In $KOHA_CONF, add an <api_client> element under <config>:
     <api_client>
       <client_id>$CLIENT_ID</client_id>
       <client_secret>$CLIENT_SECRET</client_secret>
       <patron_id>X</patron_id> <!-- X is an existing borrowernumber -->
     </api_client>
2. Apply patch, run updatedatabase.pl and reload starman
3. Install Firefox extension RESTer [1]
4. In RESTer, go to "Authorization" tab and create a new OAuth2
   configuration:
   - OAuth flow: Client credentials
   - Access Token Request Method: POST
   - Access Token Request Endpoint: http://$KOHA_URL/api/v1/oauth/token
   - Access Token Request Client Authentication: Credentials in request
     body
   - Client ID: $CLIENT_ID
   - Client Secret: $CLIENT_SECRET
5. Click on the newly created configuration to generate a new token
   (which will be valid only for an hour)
6. In RESTer, set HTTP method to GET and url to
   http://$KOHA_URL/api/v1/patrons then click on SEND
   If patron X has permission 'borrowers', it should return 200 OK
   with the list of patrons
   Otherwise it should return 403 with the list of required permissions
   (Please test both cases)
7. Wait an hour (or run the following SQL query:
   UPDATE oauth_access_tokens SET expires = 0) and repeat step 6.
   You should have a 403 Forbidden status, and the token must have been
   removed from the database.
8. Create a bunch of tokens using RESTer, make some of them expires
   using the previous SQL query, and run the following command:
     misc/cronjobs/cleanup_database.pl --oauth-tokens
   Verify that expired tokens were removed, and that the others are
   still there
9. prove t/db_dependent/api/v1/oauth.t

[1] https://addons.mozilla.org/en-US/firefox/addon/rester/

Signed-off-by: Josef Moravec <josef.moravec@gmail.com>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>

14 files changed:
C4/Installer/PerlDependencies.pm
Koha/OAuth.pm [new file with mode: 0644]
Koha/OAuthAccessToken.pm [new file with mode: 0644]
Koha/OAuthAccessTokens.pm [new file with mode: 0644]
Koha/REST/V1.pm
Koha/REST/V1/Auth.pm
Koha/REST/V1/OAuth.pm [new file with mode: 0644]
Koha/Schema/Result/OauthAccessToken.pm [new file with mode: 0644]
api/v1/swagger/paths.json
api/v1/swagger/paths/oauth.json [new file with mode: 0644]
etc/koha-conf.xml
installer/data/mysql/atomicupdate/oauth_tokens.perl [new file with mode: 0644]
misc/cronjobs/cleanup_database.pl
t/db_dependent/api/v1/oauth.t [new file with mode: 0755]

index 2519557..cfd8d68 100644 (file)
@@ -888,6 +888,16 @@ our $PERL_DEPS = {
         required   => 0,
         min_ver    => '0.52',
     },
+    'Net::OAuth2::AuthorizationServer' => {
+        usage    => 'REST API',
+        required => '1',
+        min_ver  => '0.16',
+    },
+    'Mojolicious::Plugin::OAuth2::Server' => {
+        usage    => 'REST API',
+        required => '1',
+        min_ver  => '0.40',
+    }
 };
 
 1;
diff --git a/Koha/OAuth.pm b/Koha/OAuth.pm
new file mode 100644 (file)
index 0000000..6966570
--- /dev/null
@@ -0,0 +1,69 @@
+package Koha::OAuth;
+
+use Modern::Perl;
+use Koha::OAuthAccessTokens;
+use Koha::OAuthAccessToken;
+
+sub config {
+    return {
+        verify_client_cb => \&_verify_client_cb,
+        store_access_token_cb => \&_store_access_token_cb,
+        verify_access_token_cb => \&_verify_access_token_cb
+    };
+}
+
+sub _verify_client_cb {
+    my (%args) = @_;
+
+    my ($client_id, $client_secret)
+        = @args{ qw/ client_id client_secret / };
+
+    return (0, 'unauthorized_client') unless $client_id;
+
+    my $clients = C4::Context->config('api_client');
+    $clients = [ $clients ] unless ref $clients eq 'ARRAY';
+    my ($client) = grep { $_->{client_id} eq $client_id } @$clients;
+    return (0, 'unauthorized_client') unless $client;
+
+    return (0, 'access_denied') unless $client_secret eq $client->{client_secret};
+
+    return (1, undef, []);
+}
+
+sub _store_access_token_cb {
+    my ( %args ) = @_;
+
+    my ( $client_id, $access_token, $expires_in )
+        = @args{ qw/ client_id access_token expires_in / };
+
+    my $at = Koha::OAuthAccessToken->new({
+        access_token  => $access_token,
+        expires       => time + $expires_in,
+        client_id     => $client_id,
+    });
+    $at->store;
+
+    return;
+}
+
+sub _verify_access_token_cb {
+    my (%args) = @_;
+
+    my $access_token = $args{access_token};
+
+    my $at = Koha::OAuthAccessTokens->find($access_token);
+    if ($at) {
+        if ( $at->expires <= time ) {
+            # need to revoke the access token
+            $at->delete;
+
+            return (0, 'invalid_grant')
+        }
+
+        return $at->unblessed;
+    }
+
+    return (0, 'invalid_grant')
+};
+
+1;
diff --git a/Koha/OAuthAccessToken.pm b/Koha/OAuthAccessToken.pm
new file mode 100644 (file)
index 0000000..c322ea6
--- /dev/null
@@ -0,0 +1,11 @@
+package Koha::OAuthAccessToken;
+
+use Modern::Perl;
+
+use base qw(Koha::Object);
+
+sub _type {
+    return 'OauthAccessToken';
+}
+
+1;
diff --git a/Koha/OAuthAccessTokens.pm b/Koha/OAuthAccessTokens.pm
new file mode 100644 (file)
index 0000000..12dbf4a
--- /dev/null
@@ -0,0 +1,15 @@
+package Koha::OAuthAccessTokens;
+
+use Modern::Perl;
+
+use base qw(Koha::Objects);
+
+sub object_class {
+    return 'Koha::OAuthAccessToken';
+}
+
+sub _type {
+    return 'OauthAccessToken';
+}
+
+1;
index 57ad113..9059ea5 100644 (file)
@@ -19,6 +19,8 @@ use Modern::Perl;
 
 use Mojo::Base 'Mojolicious';
 
+use Koha::OAuth;
+
 use C4::Context;
 
 =head1 NAME
@@ -51,6 +53,7 @@ sub startup {
         $self->secrets([$secret_passphrase]);
     }
 
+    $self->plugin('OAuth2::Server' => Koha::OAuth::config);
     $self->plugin(OpenAPI => {
         url => $self->home->rel_file("api/v1/swagger/swagger.json"),
         route => $self->routes->under('/api/v1')->to('Auth#under'),
index c76d26f..3ca43fa 100644 (file)
@@ -22,10 +22,12 @@ use Modern::Perl;
 use Mojo::Base 'Mojolicious::Controller';
 
 use C4::Auth qw( check_cookie_auth get_session haspermission );
+use C4::Context;
 
 use Koha::Account::Lines;
 use Koha::Checkouts;
 use Koha::Holds;
+use Koha::OAuth;
 use Koha::Old::Checkouts;
 use Koha::Patrons;
 
@@ -110,6 +112,31 @@ sub authenticate_api_request {
 
     my $spec = $c->match->endpoint->pattern->defaults->{'openapi.op_spec'};
     my $authorization = $spec->{'x-koha-authorization'};
+
+    if (my $oauth = $c->oauth) {
+        my $clients = C4::Context->config('api_client');
+        $clients = [ $clients ] unless ref $clients eq 'ARRAY';
+        my ($client) = grep { $_->{client_id} eq $oauth->{client_id} } @$clients;
+
+        my $patron = Koha::Patrons->find($client->{patron_id});
+        my $permissions = $authorization->{'permissions'};
+        # Check if the patron is authorized
+        if ( haspermission($patron->userid, $permissions)
+            or allow_owner($c, $authorization, $patron)
+            or allow_guarantor($c, $authorization, $patron) ) {
+
+            validate_query_parameters( $c, $spec );
+
+            # Everything is ok
+            return 1;
+        }
+
+        Koha::Exceptions::Authorization::Unauthorized->throw(
+            error => "Authorization failure. Missing required permission(s).",
+            required_permissions => $permissions,
+        );
+    }
+
     my $cookie = $c->cookie('CGISESSID');
     my ($session, $user);
     # Mojo doesn't use %ENV the way CGI apps do
diff --git a/Koha/REST/V1/OAuth.pm b/Koha/REST/V1/OAuth.pm
new file mode 100644 (file)
index 0000000..7be1a46
--- /dev/null
@@ -0,0 +1,62 @@
+package Koha::REST::V1::OAuth;
+
+use Modern::Perl;
+
+use Mojo::Base 'Mojolicious::Controller';
+
+use Net::OAuth2::AuthorizationServer;
+use Koha::OAuth;
+
+use C4::Context;
+
+sub token {
+    my $c = shift->openapi->valid_input or return;
+
+    my $grant_type = $c->validation->param('grant_type');
+    unless ($grant_type eq 'client_credentials') {
+        return $c->render(status => 400, openapi => {error => 'Unimplemented grant type'});
+    }
+
+    my $client_id = $c->validation->param('client_id');
+    my $client_secret = $c->validation->param('client_secret');
+
+    my $cb = "${grant_type}_grant";
+    my $server = Net::OAuth2::AuthorizationServer->new;
+    my $grant = $server->$cb(Koha::OAuth::config);
+
+    # verify a client against known clients
+    my ( $is_valid, $error ) = $grant->verify_client(
+        client_id     => $client_id,
+        client_secret => $client_secret,
+    );
+
+    unless ($is_valid) {
+        return $c->render(status => 403, openapi => {error => $error});
+    }
+
+    # generate a token
+    my $token = $grant->token(
+        client_id => $client_id,
+        type      => 'access',
+    );
+
+    # store access token
+    my $expires_in = 3600;
+    $grant->store_access_token(
+        client_id    => $client_id,
+        access_token => $token,
+        expires_in   => $expires_in,
+    );
+
+    my $at = Koha::OAuthAccessTokens->search({ access_token => $token })->next;
+
+    my $response = {
+        access_token => $token,
+        token_type => 'Bearer',
+        expires_in => $expires_in,
+    };
+
+    return $c->render(status => 200, openapi => $response);
+}
+
+1;
diff --git a/Koha/Schema/Result/OauthAccessToken.pm b/Koha/Schema/Result/OauthAccessToken.pm
new file mode 100644 (file)
index 0000000..85b8a53
--- /dev/null
@@ -0,0 +1,72 @@
+use utf8;
+package Koha::Schema::Result::OauthAccessToken;
+
+# Created by DBIx::Class::Schema::Loader
+# DO NOT MODIFY THE FIRST PART OF THIS FILE
+
+=head1 NAME
+
+Koha::Schema::Result::OauthAccessToken
+
+=cut
+
+use strict;
+use warnings;
+
+use base 'DBIx::Class::Core';
+
+=head1 TABLE: C<oauth_access_tokens>
+
+=cut
+
+__PACKAGE__->table("oauth_access_tokens");
+
+=head1 ACCESSORS
+
+=head2 access_token
+
+  data_type: 'varchar'
+  is_nullable: 0
+  size: 255
+
+=head2 client_id
+
+  data_type: 'varchar'
+  is_nullable: 0
+  size: 255
+
+=head2 expires
+
+  data_type: 'integer'
+  is_nullable: 0
+
+=cut
+
+__PACKAGE__->add_columns(
+  "access_token",
+  { data_type => "varchar", is_nullable => 0, size => 255 },
+  "client_id",
+  { data_type => "varchar", is_nullable => 0, size => 255 },
+  "expires",
+  { data_type => "integer", is_nullable => 0 },
+);
+
+=head1 PRIMARY KEY
+
+=over 4
+
+=item * L</access_token>
+
+=back
+
+=cut
+
+__PACKAGE__->set_primary_key("access_token");
+
+
+# Created by DBIx::Class::Schema::Loader v0.07046 @ 2018-04-11 17:44:30
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:u2e++Jrwln4Qhi3UPx2CQA
+
+
+# You can replace this text with custom code or comments, and it will be preserved on regeneration
+1;
index f3e843e..6451ec3 100644 (file)
@@ -1,4 +1,7 @@
 {
+  "/oauth/token": {
+    "$ref": "paths/oauth.json#/~1oauth~1token"
+  },
   "/acquisitions/vendors": {
     "$ref": "paths/acquisitions_vendors.json#/~1acquisitions~1vendors"
   },
diff --git a/api/v1/swagger/paths/oauth.json b/api/v1/swagger/paths/oauth.json
new file mode 100644 (file)
index 0000000..4b00db6
--- /dev/null
@@ -0,0 +1,64 @@
+{
+    "/oauth/token": {
+        "post": {
+            "x-mojo-to": "OAuth#token",
+            "operationId": "tokenOAuth",
+            "tags": ["oauth"],
+            "produces": [
+                "application/json"
+            ],
+            "parameters": [
+                {
+                    "name": "grant_type",
+                    "in": "formData",
+                    "description": "grant type (client_credentials)",
+                    "required": true,
+                    "type": "string"
+                },
+                {
+                    "name": "client_id",
+                    "in": "formData",
+                    "description": "client id",
+                    "type": "string"
+                },
+                {
+                    "name": "client_secret",
+                    "in": "formData",
+                    "description": "client secret",
+                    "type": "string"
+                }
+            ],
+            "responses": {
+                "200": {
+                    "description": "OK",
+                    "schema": {
+                        "type": "object",
+                        "properties": {
+                            "access_token": {
+                                "type": "string"
+                            },
+                            "token_type": {
+                                "type": "string"
+                            },
+                            "expires_in": {
+                                "type": "integer"
+                            }
+                        }
+                    }
+                },
+                "400": {
+                    "description": "Bad Request",
+                    "schema": {
+                        "$ref": "../definitions.json#/error"
+                    }
+                },
+                "403": {
+                    "description": "Access forbidden",
+                    "schema": {
+                        "$ref": "../definitions.json#/error"
+                    }
+                }
+            }
+        }
+    }
+}
index 1b50f77..2a0a87a 100644 (file)
@@ -134,6 +134,20 @@ __PAZPAR2_TOGGLE_XML_POST__
      <access_dir></access_dir>
  </access_dirs>
  -->
+ <!-- Uncomment and modify the following to enable OAuth2 authentication for the
+      REST API -->
+ <!--
+ <api_client>
+    <client_id>client1</client_id>
+    <client_secret>secret1</client_secret>
+    <patron_id>1</patron_id>
+ </api_client>
+ <api_client>
+    <client_id>client2</client_id>
+    <client_secret>secret2</client_secret>
+    <patron_id>2</patron_id>
+ </api_client>
+ -->
 
  <!-- true type font mapping accoding to type from $font_types in C4/Creators/Lib.pm -->
  <ttf>
diff --git a/installer/data/mysql/atomicupdate/oauth_tokens.perl b/installer/data/mysql/atomicupdate/oauth_tokens.perl
new file mode 100644 (file)
index 0000000..e22df6f
--- /dev/null
@@ -0,0 +1,15 @@
+$DBversion = 'XXX';
+if (CheckVersion($DBversion)) {
+    $dbh->do(q{DROP TABLE IF EXISTS oauth_access_tokens});
+    $dbh->do(q{
+        CREATE TABLE oauth_access_tokens (
+            access_token VARCHAR(255) NOT NULL,
+            client_id VARCHAR(255) NOT NULL,
+            expires INT NOT NULL,
+            PRIMARY KEY (access_token)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8
+    });
+
+    SetVersion( $DBversion );
+    print "Upgrade to $DBversion done (Bug XXXXX - description)\n";
+}
index 4514b48..b8f550f 100755 (executable)
@@ -81,6 +81,7 @@ Usage: $0 [-h|--help] [--sessions] [--sessdays DAYS] [-v|--verbose] [--zebraqueu
    --temp-uploads     Delete temporary uploads.
    --temp-uploads-days DAYS Override the corresponding preference value.
    --uploads-missing FLAG Delete upload records for missing files when FLAG is true, count them otherwise
+   --oauth-tokens     Delete expired OAuth2 tokens
 USAGE
     exit $_[0];
 }
@@ -106,6 +107,7 @@ my $special_holidays_days;
 my $temp_uploads;
 my $temp_uploads_days;
 my $uploads_missing;
+my $oauth_tokens;
 
 GetOptions(
     'h|help'            => \$help,
@@ -129,6 +131,7 @@ GetOptions(
     'temp-uploads'      => \$temp_uploads,
     'temp-uploads-days:i' => \$temp_uploads_days,
     'uploads-missing:i' => \$uploads_missing,
+    'oauth-tokens'      => \$oauth_tokens,
 ) || usage(1);
 
 # Use default values
@@ -162,6 +165,7 @@ unless ( $sessions
     || $special_holidays_days
     || $temp_uploads
     || defined $uploads_missing
+    || $oauth_tokens
 ) {
     print "You did not specify any cleanup work for the script to do.\n\n";
     usage(1);
@@ -333,6 +337,13 @@ if( defined $uploads_missing ) {
     }
 }
 
+if ($oauth_tokens) {
+    require Koha::OAuthAccessTokens;
+
+    my $count = int Koha::OAuthAccessTokens->search({ expires => { '<=', time } })->delete;
+    say "Removed $count expired OAuth2 tokens";
+}
+
 exit(0);
 
 sub RemoveOldSessions {
diff --git a/t/db_dependent/api/v1/oauth.t b/t/db_dependent/api/v1/oauth.t
new file mode 100755 (executable)
index 0000000..79d9ece
--- /dev/null
@@ -0,0 +1,101 @@
+#!/usr/bin/env perl
+
+# 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 Test::More tests => 1;
+use Test::Mojo;
+
+use Koha::Database;
+
+use t::lib::Mocks;
+use t::lib::TestBuilder;
+
+my $t = Test::Mojo->new('Koha::REST::V1');
+my $schema  = Koha::Database->new->schema;
+my $builder = t::lib::TestBuilder->new();
+
+subtest '/oauth/token tests' => sub {
+    plan tests => 19;
+
+    $schema->storage->txn_begin;
+
+    my $patron = $builder->build({
+        source => 'Borrower',
+        value  => {
+            surname => 'Test OAuth',
+            flags => 0,
+        },
+    });
+
+    # Missing parameter grant_type
+    $t->post_ok('/api/v1/oauth/token')
+        ->status_is(400);
+
+    # Wrong grant type
+    $t->post_ok('/api/v1/oauth/token', form => { grant_type => 'password' })
+        ->status_is(400)
+        ->json_is({error => 'Unimplemented grant type'});
+
+    # No client_id/client_secret
+    $t->post_ok('/api/v1/oauth/token', form => { grant_type => 'client_credentials' })
+        ->status_is(403)
+        ->json_is({error => 'unauthorized_client'});
+
+    my ($client_id, $client_secret) = ('client1', 'secr3t');
+    t::lib::Mocks::mock_config('api_client', {
+        'client_id' => $client_id,
+        'client_secret' => $client_secret,
+        patron_id => $patron->{borrowernumber},
+    });
+
+    my $formData = {
+        grant_type => 'client_credentials',
+        client_id => $client_id,
+        client_secret => $client_secret,
+    };
+    $t->post_ok('/api/v1/oauth/token', form => $formData)
+        ->status_is(200)
+        ->json_is('/expires_in' => 3600)
+        ->json_is('/token_type' => 'Bearer')
+        ->json_has('/access_token');
+
+    my $access_token = $t->tx->res->json->{access_token};
+
+    # Without access token, it returns 401
+    $t->get_ok('/api/v1/patrons')->status_is(401);
+
+    # With access token, but without permissions, it returns 403
+    my $tx = $t->ua->build_tx(GET => '/api/v1/patrons');
+    $tx->req->headers->authorization("Bearer $access_token");
+    $t->request_ok($tx)->status_is(403);
+
+    # With access token and permissions, it returns 200
+    $builder->build({
+        source => 'UserPermission',
+        value  => {
+            borrowernumber => $patron->{borrowernumber},
+            module_bit => 4, # borrowers
+            code => 'edit_borrowers',
+        },
+    });
+    $tx = $t->ua->build_tx(GET => '/api/v1/patrons');
+    $tx->req->headers->authorization("Bearer $access_token");
+    $t->request_ok($tx)->status_is(200);
+
+    $schema->storage->txn_rollback;
+};