Bugzilla – Attachment 72886 Details for
Bug 20402
OAuth2 client credentials grant for REST API
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 20402: Implement OAuth2 authentication for REST API
Bug-20402-Implement-OAuth2-authentication-for-REST.patch (text/plain), 14.72 KB, created by
Julian Maurice
on 2018-03-14 15:56:05 UTC
(
hide
)
Description:
Bug 20402: Implement OAuth2 authentication for REST API
Filename:
MIME Type:
Creator:
Julian Maurice
Created:
2018-03-14 15:56:05 UTC
Size:
14.72 KB
patch
obsolete
>From f116b28c9eb7806b445326c4b5b481d4c1768fdb Mon Sep 17 00:00:00 2001 >From: Julian Maurice <julian.maurice@biblibre.com> >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 <api_client> element under <config>: > <api_client> > <client_id>$CLIENT_ID</client_id> > <client_secret>$CLIENT_SECRET</client_secret> > <scope>patrons.read</scope> > </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 > - 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 <scope> 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/ >--- > 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 0000000000..24546bd9b3 >--- /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 0000000000..91fb7de1d5 >--- /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 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/Auth.pm b/Koha/REST/V1/Auth.pm >index 77864eb26d..23923e6a36 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 0000000000..9fc61ecdac >--- /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 0000000000..ba70bc6d04 >--- /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<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 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</access_token> >+ >+=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 df4f55a999..b866b825f6 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..5f4d16e42b >--- /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 565d20c26e..e253f6ac08 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 0000000000..10d5eae7f5 >--- /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.14.2
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 20402
:
72865
|
72874
|
72886
|
72887
|
72888
|
72892
|
72893
|
72894
|
73086
|
73087
|
74009
|
74018
|
74050
|
74051
|
74069
|
74070
|
74088
|
74365
|
74366
|
74367
|
74368
|
74369
|
74380
|
74381
|
74382
|
74383
|
74384
|
74385
|
74386
|
74600
|
75026
|
75027
|
75028
|
75029
|
75030
|
75031
|
75032
|
75033
|
75189