@@ -, +, @@
$CLIENT_ID
$CLIENT_SECRET
X
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
(which will be valid only for an hour)
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)
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.
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
---
C4/Installer/PerlDependencies.pm | 10 ++
Koha/OAuth.pm | 69 ++++++++++++
Koha/OAuthAccessToken.pm | 11 ++
Koha/OAuthAccessTokens.pm | 15 +++
Koha/REST/V1.pm | 3 +
Koha/REST/V1/Auth.pm | 27 +++++
Koha/REST/V1/OAuth.pm | 62 +++++++++++
Koha/Schema/Result/OauthAccessToken.pm | 72 +++++++++++++
api/v1/swagger/paths.json | 3 +
api/v1/swagger/paths/oauth.json | 64 +++++++++++
etc/koha-conf.xml | 15 +++
.../data/mysql/atomicupdate/oauth_tokens.perl | 15 +++
misc/cronjobs/cleanup_database.pl | 11 ++
t/db_dependent/api/v1/oauth.t | 101 ++++++++++++++++++
14 files changed, 478 insertions(+)
create mode 100644 Koha/OAuth.pm
create mode 100644 Koha/OAuthAccessToken.pm
create mode 100644 Koha/OAuthAccessTokens.pm
create mode 100644 Koha/REST/V1/OAuth.pm
create mode 100644 Koha/Schema/Result/OauthAccessToken.pm
create mode 100644 api/v1/swagger/paths/oauth.json
create mode 100644 installer/data/mysql/atomicupdate/oauth_tokens.perl
create mode 100755 t/db_dependent/api/v1/oauth.t
--- a/C4/Installer/PerlDependencies.pm
+++ a/C4/Installer/PerlDependencies.pm
@@ -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;
--- a/Koha/OAuth.pm
+++ a/Koha/OAuth.pm
@@ -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;
--- a/Koha/OAuthAccessToken.pm
+++ a/Koha/OAuthAccessToken.pm
@@ -0,0 +1,11 @@
+package Koha::OAuthAccessToken;
+
+use Modern::Perl;
+
+use base qw(Koha::Object);
+
+sub _type {
+ return 'OauthAccessToken';
+}
+
+1;
--- a/Koha/OAuthAccessTokens.pm
+++ a/Koha/OAuthAccessTokens.pm
@@ -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;
--- a/Koha/REST/V1.pm
+++ a/Koha/REST/V1.pm
@@ -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'),
--- a/Koha/REST/V1/Auth.pm
+++ a/Koha/REST/V1/Auth.pm
@@ -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
--- a/Koha/REST/V1/OAuth.pm
+++ a/Koha/REST/V1/OAuth.pm
@@ -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;
--- a/Koha/Schema/Result/OauthAccessToken.pm
+++ a/Koha/Schema/Result/OauthAccessToken.pm
@@ -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
+
+=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
+
+=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;
--- a/api/v1/swagger/paths.json
+++ a/api/v1/swagger/paths.json
@@ -1,4 +1,7 @@
{
+ "/oauth/token": {
+ "$ref": "paths/oauth.json#/~1oauth~1token"
+ },
"/acquisitions/vendors": {
"$ref": "paths/acquisitions_vendors.json#/~1acquisitions~1vendors"
},
--- a/api/v1/swagger/paths/oauth.json
+++ a/api/v1/swagger/paths/oauth.json
@@ -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"
+ }
+ }
+ }
+ }
+ }
+}
--- a/etc/koha-conf.xml
+++ a/etc/koha-conf.xml
@@ -127,6 +127,21 @@ __PAZPAR2_TOGGLE_XML_POST__
CHANGEME
+
+
+
__FONT_DIR__/DejaVuSerif.ttf
--- a/installer/data/mysql/atomicupdate/oauth_tokens.perl
+++ a/installer/data/mysql/atomicupdate/oauth_tokens.perl
@@ -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";
+}
--- a/misc/cronjobs/cleanup_database.pl
+++ a/misc/cronjobs/cleanup_database.pl
@@ -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 {
--- a/t/db_dependent/api/v1/oauth.t
+++ a/t/db_dependent/api/v1/oauth.t
@@ -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;
+};
--