@@ -, +, @@
$CLIENT_ID
$CLIENT_SECRET
patrons.read
configuration:
- OAuth flow: Client credentials
- Access Token Request Method: POST
- Access Token Request Endpoint: http://koha/api/v1/oauth/token
- Access Token Request Client Authentication: Credentials in request
body
- Client ID: $CLIENT_ID
- Client Secret: $CLIENT_SECRET
- Scopes: patrons.read
(which will be valid only for an hour)
It should return 200 OK with the list of patrons
memcached) and see that you cannot generate a new token.
Then reset the scope to its initial value
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.
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.
---
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
--- a/Koha/OAuth.pm
+++ a/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;
--- a/Koha/OAuthAccessToken.pm
+++ a/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;
--- 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/Auth.pm
+++ a/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);
--- a/Koha/REST/V1/OAuth.pm
+++ a/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;
--- a/Koha/Schema/Result/OauthAccessToken.pm
+++ a/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;
--- 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,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"
+ }
+ }
+ }
+ }
+ }
+}
--- a/api/v1/swagger/paths/patrons.json
+++ a/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"
+ ]
}
}
}
--- a/installer/data/mysql/atomicupdate/oauth_tokens.perl
+++ a/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";
+}
--