From 3a74a9aa8c36f8df93b5196b8199480f1542e664 Mon Sep 17 00:00:00 2001 From: Julian Maurice Date: Tue, 13 Mar 2018 13:17:12 +0100 Subject: [PATCH] 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 and Mojolicious::Plugin::OAuth2::Server 0.40 1. In $KOHA_CONF, add an element under : $CLIENT_ID $CLIENT_SECRET X 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/ --- 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 diff --git a/C4/Installer/PerlDependencies.pm b/C4/Installer/PerlDependencies.pm index 9913d77d8c..84a02b4940 100644 --- a/C4/Installer/PerlDependencies.pm +++ b/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; diff --git a/Koha/OAuth.pm b/Koha/OAuth.pm new file mode 100644 index 0000000000..6966570502 --- /dev/null +++ b/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; diff --git a/Koha/OAuthAccessToken.pm b/Koha/OAuthAccessToken.pm new file mode 100644 index 0000000000..c322ea645a --- /dev/null +++ b/Koha/OAuthAccessToken.pm @@ -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 index 0000000000..12dbf4ab23 --- /dev/null +++ b/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; diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm index 57ad113291..9059ea5069 100644 --- a/Koha/REST/V1.pm +++ b/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'), diff --git a/Koha/REST/V1/Auth.pm b/Koha/REST/V1/Auth.pm index c76d26fe36..3ca43faadd 100644 --- a/Koha/REST/V1/Auth.pm +++ b/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 diff --git a/Koha/REST/V1/OAuth.pm b/Koha/REST/V1/OAuth.pm new file mode 100644 index 0000000000..7be1a46e27 --- /dev/null +++ b/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; diff --git a/Koha/Schema/Result/OauthAccessToken.pm b/Koha/Schema/Result/OauthAccessToken.pm new file mode 100644 index 0000000000..85b8a53fe1 --- /dev/null +++ b/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; diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json index f3e843e6c3..6451ec3a37 100644 --- a/api/v1/swagger/paths.json +++ b/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" }, diff --git a/api/v1/swagger/paths/oauth.json b/api/v1/swagger/paths/oauth.json new file mode 100644 index 0000000000..4b00db605a --- /dev/null +++ b/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" + } + } + } + } + } +} diff --git a/etc/koha-conf.xml b/etc/koha-conf.xml index 5bcd521caf..9cb903c2f5 100644 --- a/etc/koha-conf.xml +++ b/etc/koha-conf.xml @@ -127,6 +127,21 @@ __PAZPAR2_TOGGLE_XML_POST__ CHANGEME + + + __FONT_DIR__/DejaVuSerif.ttf diff --git a/installer/data/mysql/atomicupdate/oauth_tokens.perl b/installer/data/mysql/atomicupdate/oauth_tokens.perl new file mode 100644 index 0000000000..e22df6f9bd --- /dev/null +++ b/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"; +} diff --git a/misc/cronjobs/cleanup_database.pl b/misc/cronjobs/cleanup_database.pl index 4514b48b5d..b8f550fb5e 100755 --- a/misc/cronjobs/cleanup_database.pl +++ b/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 { diff --git a/t/db_dependent/api/v1/oauth.t b/t/db_dependent/api/v1/oauth.t new file mode 100755 index 0000000000..79d9eceb02 --- /dev/null +++ b/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; +}; -- 2.14.2