@@ -, +, @@ Mojolicious::Plugin::OAuth2::Server 0.40 $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 'edit_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; +}; --