Browse Source

Bug 20402: Implement OAuth2 authentication for REST API

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>
18.05.x
Julian Maurice 5 years ago
committed by Jonathan Druart
parent
commit
43a4b3c22c
  1. 10
      C4/Installer/PerlDependencies.pm
  2. 69
      Koha/OAuth.pm
  3. 11
      Koha/OAuthAccessToken.pm
  4. 15
      Koha/OAuthAccessTokens.pm
  5. 3
      Koha/REST/V1.pm
  6. 27
      Koha/REST/V1/Auth.pm
  7. 62
      Koha/REST/V1/OAuth.pm
  8. 72
      Koha/Schema/Result/OauthAccessToken.pm
  9. 3
      api/v1/swagger/paths.json
  10. 64
      api/v1/swagger/paths/oauth.json
  11. 14
      etc/koha-conf.xml
  12. 15
      installer/data/mysql/atomicupdate/oauth_tokens.perl
  13. 11
      misc/cronjobs/cleanup_database.pl
  14. 101
      t/db_dependent/api/v1/oauth.t

10
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;

69
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;

11
Koha/OAuthAccessToken.pm

@ -0,0 +1,11 @@
package Koha::OAuthAccessToken;
use Modern::Perl;
use base qw(Koha::Object);
sub _type {
return 'OauthAccessToken';
}
1;

15
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;

3
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'),

27
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

62
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;

72
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<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;

3
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"
},

64
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"
}
}
}
}
}
}

14
etc/koha-conf.xml

@ -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>

15
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";
}

11
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 {

101
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;
};
Loading…
Cancel
Save