From 0246af9967e0805fff6bdcc9f8ed20a24f86b55a 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 basic scopes support (only one is defined, "patrons.read"). API Clients are defined in $KOHA_CONF. Test plan: 0. Install Net::OAuth2::AuthorizationServer 0.16 1. In $KOHA_CONF, add an element under : $CLIENT_ID $CLIENT_SECRET patrons.read 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 - Scopes: patrons.read 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 It should return 200 OK with the list of patrons 7. Remove or change the from $KOHA_CONF (reload starman & memcached) and see that you cannot generate a new token. Then reset the scope to its initial value 8. Edit api/v1/swagger/paths/patrons.json, locate 'x-koha-scopes' (2 occurences) and change the values to something else. Reload starman. Repeat step 6 and see that you receive a 403 Forbidden status Undo your changes in api/v1/swagger/paths/patrons.json and reload starman again. 9. 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. [1] https://addons.mozilla.org/en-US/firefox/addon/rester/ Signed-off-by: Mark Tompsett --- Koha/OAuth.pm | 87 ++++++++++++++++++++++ Koha/OAuthAccessToken.pm | 19 +++++ Koha/OAuthAccessTokens.pm | 15 ++++ Koha/REST/V1/Auth.pm | 21 ++++++ Koha/REST/V1/OAuth.pm | 57 ++++++++++++++ Koha/Schema/Result/OauthAccessToken.pm | 79 ++++++++++++++++++++ api/v1/swagger/paths.json | 3 + api/v1/swagger/paths/oauth.json | 58 +++++++++++++++ api/v1/swagger/paths/patrons.json | 10 ++- .../data/mysql/atomicupdate/oauth_tokens.perl | 16 ++++ 10 files changed, 363 insertions(+), 2 deletions(-) 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 diff --git a/Koha/OAuth.pm b/Koha/OAuth.pm new file mode 100644 index 0000000..24546bd --- /dev/null +++ b/Koha/OAuth.pm @@ -0,0 +1,87 @@ +package Koha::OAuth; + +use Modern::Perl; +use Net::OAuth2::AuthorizationServer::ClientCredentialsGrant; +use Koha::OAuthAccessTokens; +use Koha::OAuthAccessToken; + +sub grant { + my $grant = Net::OAuth2::AuthorizationServer::ClientCredentialsGrant->new( + verify_client_cb => \&_verify_client_cb, + store_access_token_cb => \&_store_access_token_cb, + verify_access_token_cb => \&_verify_access_token_cb + ); + + return $grant; +} + +sub _verify_client_cb { + my (%args) = @_; + + my ($client_id, $scopes_ref, $client_secret) + = @args{ qw/ client_id scopes client_secret / }; + + 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}; + + $client->{scope} //= []; + $client->{scope} = [ $client->{scope} ] if ref $client->{scope} ne 'ARRAY'; + my $client_scopes = []; + foreach my $scope ( @{ $scopes_ref // [] } ) { + if (!grep { $_ eq $scope } @{ $client->{scope} }) { + return (0, 'invalid_scope'); + } + push @$client_scopes, $scope; + } + + return (1, undef, $client_scopes); +} + +sub _store_access_token_cb { + my ( %args ) = @_; + + my ( $client_id, $access_token, $expires_in, $scopes_ref ) + = @args{ qw/ client_id access_token expires_in scopes / }; + + my $at = Koha::OAuthAccessToken->new({ + access_token => $access_token, + scope => join (' ', @$scopes_ref), + expires => time + $expires_in, + client_id => $client_id, + }); + $at->store; + + return; +} + +sub _verify_access_token_cb { + my (%args) = @_; + + my ($access_token, $scopes_ref) = @args{qw(access_token scopes)}; + + 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') + } elsif ( $scopes_ref ) { + foreach my $scope ( @{ $scopes_ref // [] } ) { + unless ($at->has_scope($scope)) { + 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 0000000..91fb7de --- /dev/null +++ b/Koha/OAuthAccessToken.pm @@ -0,0 +1,19 @@ +package Koha::OAuthAccessToken; + +use Modern::Perl; + +use base qw(Koha::Object); + +sub has_scope { + my ($self, $scope) = @_; + + my @scopes = split / /, $self->scope; + + return scalar grep { $_ eq $scope } @scopes; +} + +sub _type { + return 'OauthAccessToken'; +} + +1; diff --git a/Koha/OAuthAccessTokens.pm b/Koha/OAuthAccessTokens.pm new file mode 100644 index 0000000..12dbf4a --- /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/Auth.pm b/Koha/REST/V1/Auth.pm index 77864eb..23923e6 100644 --- a/Koha/REST/V1/Auth.pm +++ b/Koha/REST/V1/Auth.pm @@ -26,6 +26,7 @@ use C4::Auth qw( check_cookie_auth get_session haspermission ); use Koha::Account::Lines; use Koha::Checkouts; use Koha::Holds; +use Koha::OAuth; use Koha::Old::Checkouts; use Koha::Patrons; @@ -109,6 +110,26 @@ sub authenticate_api_request { my ( $c ) = @_; my $spec = $c->match->endpoint->pattern->defaults->{'openapi.op_spec'}; + + my $authorization_header = $c->req->headers->authorization; + if ($authorization_header) { + my $grant = Koha::OAuth->grant; + my ($type, $token) = split / /, $authorization_header; + my ($is_valid, $error) = $grant->verify_access_token( + access_token => $token, + scopes => $spec->{'x-koha-scopes'} // [], + ); + + if (!$is_valid) { + Koha::Exceptions::Authorization::Unauthorized->throw( + error => $error, + required_permissions => $spec->{'x-koha-scopes'}, + ); + } + + return 1; + } + my $authorization = $spec->{'x-koha-authorization'}; my $cookie = $c->cookie('CGISESSID'); my ($session, $user); diff --git a/Koha/REST/V1/OAuth.pm b/Koha/REST/V1/OAuth.pm new file mode 100644 index 0000000..9fc61ec --- /dev/null +++ b/Koha/REST/V1/OAuth.pm @@ -0,0 +1,57 @@ +package Koha::REST::V1::OAuth; + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; +use Koha::OAuth; + +use C4::Context; + +sub token { + my $c = shift->openapi->valid_input or return; + + my $grant = Koha::OAuth->grant; + my $client_id = $c->validation->param('client_id'); + my $client_secret = $c->validation->param('client_secret'); + my $scope = [ split / /, $c->validation->param('scope') ]; + + # verify a client against known clients + my ( $is_valid, $error, $scopes ) = $grant->verify_client( + client_id => $client_id, + client_secret => $client_secret, + scopes => $scope, + ); + + unless ($is_valid) { + return $c->render(status => 403, openapi => {error => $error}); + } + + # generate a token + my $token = $grant->token( + client_id => $client_id, + scopes => $scopes, + ); + + # store access token + my $expires_in = 3600; + $grant->store_access_token( + client_id => $client_id, + access_token => $token, + expires_in => $expires_in, + scopes => $scopes, + ); + + 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 0000000..ba70bc6 --- /dev/null +++ b/Koha/Schema/Result/OauthAccessToken.pm @@ -0,0 +1,79 @@ +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 scope + + data_type: 'text' + is_nullable: 1 + +=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 }, + "scope", + { data_type => "text", is_nullable => 1 }, + "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-03-14 12:13:59 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:VgSA5BIbeUR31WV1YwbfEQ + + +# 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 df4f55a..b866b82 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 0000000..5f4d16e --- /dev/null +++ b/api/v1/swagger/paths/oauth.json @@ -0,0 +1,58 @@ +{ + "/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" + }, + { + "name": "scope", + "in": "formData", + "description": "space-separated list of scopes", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + } + } + } + }, + "403": { + "description": "Access forbidden", + "schema": { + "$ref": "../definitions.json#/error" + } + } + } + } + } +} diff --git a/api/v1/swagger/paths/patrons.json b/api/v1/swagger/paths/patrons.json index 565d20c..e253f6a 100644 --- a/api/v1/swagger/paths/patrons.json +++ b/api/v1/swagger/paths/patrons.json @@ -46,7 +46,10 @@ "permissions": { "borrowers": "edit_borrowers" } - } + }, + "x-koha-scopes": [ + "patrons.read" + ] } }, "/patrons/{borrowernumber}": { @@ -105,7 +108,10 @@ "permissions": { "borrowers": "edit_borrowers" } - } + }, + "x-koha-scopes": [ + "patrons.read" + ] } } } diff --git a/installer/data/mysql/atomicupdate/oauth_tokens.perl b/installer/data/mysql/atomicupdate/oauth_tokens.perl new file mode 100644 index 0000000..10d5eae --- /dev/null +++ b/installer/data/mysql/atomicupdate/oauth_tokens.perl @@ -0,0 +1,16 @@ +$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, + scope TEXT, + expires INT NOT NULL, + PRIMARY KEY (access_token) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 + }); + + SetVersion( $DBversion ); + print "Upgrade to $DBversion done (Bug XXXXX - description)\n"; +} -- 2.1.4