Bugzilla – Attachment 40583 Details for
Bug 13920
API authentication system - proposal
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 13920 - API authentication system - Swagtenticator authentication - WIP
Bug-13920---API-authentication-system---Swagtentic.patch (text/plain), 23.08 KB, created by
Olli-Antti Kivilahti
on 2015-06-24 14:13:34 UTC
(
hide
)
Description:
Bug 13920 - API authentication system - Swagtenticator authentication - WIP
Filename:
MIME Type:
Creator:
Olli-Antti Kivilahti
Created:
2015-06-24 14:13:34 UTC
Size:
23.08 KB
patch
obsolete
>From 711e3cec851e5898bb4da3700d5c4509919d424a Mon Sep 17 00:00:00 2001 >From: Olli-Antti Kivilahti <olli-antti.kivilahti@jns.fi> >Date: Wed, 24 Jun 2015 11:32:40 +0300 >Subject: [PATCH] Bug 13920 - API authentication system - Swagtenticator > authentication - WIP > >This feature implements REST API-key authentication and Koha permission validation >in the Swagger2-plugin extension. >This is basically a Mojolicious to Koha authentication using Swagger2 RESTful API >definition to autodocument and check for proper user permissions, aka. > "KohaliciousSwagtenticator". > >With this feature the API provider doesn't need to code anything in the Controller >to support Koha permissions. Simply by defining a custom Swagger2 parameter > "x-koha-parameters": {} >the Swagtenticator knows to check the user for proper Koha permissions. > >Example (require any borrowers-permission): >... > "paths": { > "/borrowers": { > "get": { > "x-mojo-controller": "Koha::REST::V1::Borrowers", > "x-koha-permission": { > "borrowers": "*" > }, > "operationId": "listBorrowers", >... > >This x-koha-permission definition is turned to a HASH and given to the >C4::Auth::haspermission() for verification by the Swagger2-based plugin. > >Bug dependencies: > Buugg 13995 - Proper Exception handling, > which helps a lot in dealing with all the various ways authentication can fail. > Buugg 14437 - Refactor C4::Auth::haspermission() to Koha::Object and return better > errors. > Which returns the failing permission so we can create a more helpful API which > tells which permissions are missing > (also helps admins in giving the right permissions) > >This feature is implemented by inheriting Mojolicious::Plugin::Swagger2 in >Koha::REST::V1::Plugins::KohaliciousSwagtenticator and overloading the necessary >subroutines. > >TEST PLAN: > >1. Add the given example (up) to any "Operation Object *". >2. Call the "Operation object" (eg. /v1/borrowers/10) with user credetials not > having any borrower-permissions. >3. Fail because of myriad of reasons. > (see. KohaliciousSwagtenticator::check_key_auth()) >4. Add some borrowers-permissions to the same user. >5. Succeed in your operation. > >* from Swagger2.0 specification >--- > Koha/REST/V1.pm | 158 +--------- > Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm | 350 +++++++++++++++++++++ > api/v1/swagger.json | 42 ++- > 3 files changed, 396 insertions(+), 154 deletions(-) > create mode 100644 Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm > >diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm >index 80acba9..e697891 100644 >--- a/Koha/REST/V1.pm >+++ b/Koha/REST/V1.pm >@@ -3,8 +3,7 @@ package Koha::REST::V1; > use Modern::Perl; > use Mojo::Base 'Mojolicious'; > use Mojo::Log; >- >-use Digest::SHA qw(hmac_sha256_hex); >+use Mojolicious::Plugins; #Extend the plugin system > > use Koha::Borrower; > use Koha::Borrowers; >@@ -77,159 +76,14 @@ sub startup { > print __PACKAGE__."::startup():> No config-file loaded. Define your config-file to the MOJO_CONFIG environmental variable.\n"; > } > >- my $route = $self->routes->under->to( >- cb => sub { >- my $c = shift; >- my $auth_ok = check_key_auth($c); >- return 1 if $auth_ok; >- } >- ); > >- $self->plugin(Swagger2 => { >- route => $route, >+ ##Add the Koha namespace to the plugin engine to find plugins from. >+ my $plugin = $self->plugins(); >+ push @{$plugin->namespaces}, 'Koha::REST::V1::Plugins'; >+ >+ $self->plugin(KohaliciousSwagtenticator => { > url => $self->home->rel_file("api/v1/swagger.json"), > }); > } > >-=head check_key_auth >- >- my $auth_ok = check_key_auth($c); >- >-For authentication to succeed, the client have to send 3 custom HTTP >-headers: >- - X-Koha-Username: userid of borrower >- - X-Koha-Timestamp: timestamp of the request >- - X-Koha-Signature: signature of the request >- >-The signature is a HMAC-SHA256 hash of several elements of the request, >-separated by spaces: >- - HTTP method (uppercase) >- - URL path and query string >- - username >- - timestamp of the request >- >-The server then tries to rebuild the signature with each user's API key. >-If one matches the received X-Koha-Signature, then authentication is >-almost OK. >- >-To avoid requests to be replayed, the last request's timestamp is stored >-in database and the authentication succeeds only if the stored timestamp >-is lesser than X-Koha-Timestamp. >- >- >-There is also an "anonymous" mode if X-Koha-* headers are not set. >-Anonymous mode differ from authenticated mode in one thing: if user is >-authenticated, the corresponding Koha::Borrower object is stored in >-Mojolicious stash, so it can easily be retrieved by controllers. >-Controllers then have the responsibility of what to do if user is >-authenticated or not. >- >-@PARAM1, Mojolicious::Controller >-@RETURNS, Boolean, 2, anonymous authentication >- 1, authenticated properly >- undef, authentication failed. >-=cut >- >-sub check_key_auth { >- my ($c) = @_; >- my $req_username = $c->req->headers->header('X-Koha-Username'); >- my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); >- my $req_signature = $c->req->headers->header('X-Koha-Signature'); >- my $req_method = $c->req->method; >- my $req_url = '/' . $c->req->url->path_query; >- >- >- # "Anonymous" mode >- if (_check_key_auth_anonymous($c)) { >- return 2; >- } >- >- my $borrower = Koha::Borrowers->find({userid => $req_username}); >- >- unless (_check_key_auth_api_key($c, $borrower)) { >- return; >- } >- >- # Authentication succeeded, store authenticated user in stash >- $c->stash('user', $borrower); >- return 1; >-} >- >-=head _check_key_auth_anonymous >- >-@PARAM1, Mojolicious::Controller >-@RETURNS, Int, 2, if anonymous authentication >- undef, if no anonymous authentication. >-=cut >- >-sub _check_key_auth_anonymous { >- my ($c) = @_; >- my $req_username = $c->req->headers->header('X-Koha-Username'); >- my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); >- my $req_signature = $c->req->headers->header('X-Koha-Signature'); >- >- return 2 >- unless ( defined($req_username) >- or defined($req_timestamp) >- or defined($req_signature) ); >- return undef; >-} >- >-=head _check_key_auth_api_key >- >- unless (_check_key_auth_api_key($c, $borrower)) { >- return; >- } >- >-@PARAM1, Mojolicious::Controller >-@PARAM2, Koha::Borrower >-@RETURNS, Int, 1, if api authentication succeeded >- undef, authentication failed >- >-=cut >- >-sub _check_key_auth_api_key { >- my ($c, $borrower) = @_; >- my $req_username = $c->req->headers->header('X-Koha-Username'); >- my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); >- my $req_signature = $c->req->headers->header('X-Koha-Signature'); >- my $req_method = $c->req->method; >- my $req_url = '/' . $c->req->url->path_query; >- >- my @apikeys = Koha::ApiKeys->search({ >- borrowernumber => $borrower->borrowernumber, >- active => 1, >- }); >- >- my $message = "$req_method $req_url $req_username $req_timestamp"; >- my $signature = ''; >- foreach my $apikey (@apikeys) { >- $signature = hmac_sha256_hex($message, $apikey->api_key); >- >- last if $signature eq $req_signature; >- } >- >- unless ($signature eq $req_signature) { >- $c->res->code(403); >- $c->render(json => { error => "Authentication failed" }); >- return; >- } >- >- my $api_timestamp = Koha::ApiTimestamps->find($borrower->borrowernumber); >- my $timestamp = $api_timestamp ? $api_timestamp->timestamp : 0; >- unless ($timestamp < $req_timestamp) { >- $c->res->code(403); >- $c->render(json => { error => "Bad timestamp" }); >- return; >- } >- >- unless ($api_timestamp) { >- $api_timestamp = new Koha::ApiTimestamp; >- $api_timestamp->borrowernumber($borrower->borrowernumber); >- } >- $api_timestamp->timestamp($req_timestamp); >- $api_timestamp->store; >- >- return 1; >-} > 1; >diff --git a/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm b/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm >new file mode 100644 >index 0000000..3cccb88 >--- /dev/null >+++ b/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm >@@ -0,0 +1,350 @@ >+package Koha::REST::V1::Plugins::KohaliciousSwagtenticator; >+ >+use Modern::Perl; >+ >+use base qw(Mojolicious::Plugin::Swagger2); >+ >+use Digest::SHA qw(hmac_sha256_hex); >+use Try::Tiny; >+use Scalar::Util qw(blessed); >+ >+use C4::Auth; >+ >+use Koha::Exception::BadAuthenticationToken; >+use Koha::Exception::UnknownProgramState; >+use Koha::Exception::NoPermission; >+ >+use constant DEBUG => $ENV{SWAGGER2_DEBUG} || 0; >+ >+ >+ >+################################################################################ >+###################### STARTING OVERLOADING SUBROUTINES ###################### >+################################################################################ >+ >+ >+ >+=head _generate_request_handler >+@OVERLOADS Mojolicious::Plugin::Swagger2::_generate_request_handler() >+This is just a copy-paste of the parent function with a small incision to inject the Koha-authentication mechanism. >+Keep code changes minimal for upstream compatibility, so when problems arise, copy-pasting fixes them! >+ >+=cut >+ >+sub _generate_request_handler { >+ my ($self, $method, $config) = @_; >+ my $controller = $config->{'x-mojo-controller'} || $self->{controller}; # back compat >+ >+ return sub { >+ my $c = shift; >+ my $method_ref; >+ >+ unless (eval "require $controller;1") { >+ $c->app->log->error($@); >+ return $c->render_swagger($self->_not_implemented('Controller not implemented.'), {}, 501); >+ } >+ unless ($method_ref = $controller->can($method)) { >+ $method_ref = $controller->can(sprintf '%s_%s', $method, lc $c->req->method) >+ and warn "HTTP method name is not used in method name lookup anymore!"; >+ } >+ unless ($method_ref) { >+ $c->app->log->error( >+ qq(Can't locate object method "$method" via package "$controller. (Something is wrong in @{[$self->url]})")); >+ return $c->render_swagger($self->_not_implemented('Method not implemented.'), {}, 501); >+ } >+ ### Koha-overload starts here ### >+ ## Check for user api-key authentication and permissions. >+ my ($error, $data, $statusCode) = _koha_authenticate($c, $config); >+ return $c->render_swagger($error, $data, $statusCode) if $error; >+ ### END OF Koha-overload ### >+ >+ bless $c, $controller; # ugly hack? >+ >+ $c->delay( >+ sub { >+ my ($delay) = @_; >+ my ($v, $input) = $self->_validate_input($c, $config); >+ >+ return $c->render_swagger($v, {}, 400) unless $v->{valid}; >+ return $c->$method_ref($input, $delay->begin); >+ }, >+ sub { >+ my $delay = shift; >+ my $data = shift; >+ my $status = shift || 200; >+ my $format = $config->{responses}{$status} || $config->{responses}{default} || {}; >+ my @err = $self->_validator->validate($data, $format->{schema}); >+ >+ return $c->render_swagger({errors => \@err, valid => Mojo::JSON->false}, $data, 500) if @err; >+ return $c->render_swagger({}, $data, $status); >+ }, >+ ); >+ }; >+} >+ >+ >+ >+=head _validate_input >+@OVERLOADS Mojolicious::Plugin::Swagger2::_validate_input() >+ >+Validates the parameters from the "Operation Object" from Swagger2 specification. >+Overloading to allow OPTIONAL parameters. >+=cut >+ >+sub _validate_input { >+ my ($self, $c, $config) = @_; >+ my $headers = $c->req->headers; >+ my $query = $c->req->url->query; >+ my (%input, %v); >+ warn "[Swagtenticator] Successful subclassing _validate_input()!\n"; >+ >+ for my $p (@{$config->{parameters} || []}) { >+ my ($in, $name) = @$p{qw( in name )}; >+ my ($value, @e); >+ >+ $value >+ = $in eq 'query' ? $query->param($name) >+ : $in eq 'path' ? $c->stash($name) >+ : $in eq 'header' ? $headers->header($name) >+ : $in eq 'body' ? $c->req->json >+ : $in eq 'formData' ? $c->req->body_params->to_hash >+ : "Invalid 'in' for parameter $name in schema definition"; >+ >+ if (defined $value or $p->{required}) { >+ my $type = $p->{type} || 'object'; >+ $value += 0 if $type =~ /^(?:integer|number)/ and $value =~ /^\d/; >+ $value = ($value eq 'false' or !$value) ? Mojo::JSON->false : Mojo::JSON->true if $type eq 'boolean'; >+ >+ if ($in eq 'body' or $in eq 'formData') { >+ warn "[Swagger2] Validate $in @{[$c->req->body]}\n"; >+ push @e, map { $_->{path} = "/$name$_->{path}"; $_; } $self->_validator->validate($value, $p->{schema}); >+ } >+ else { >+ warn "[Swagger2] Validate $in $name=$value\n"; >+ push @e, $self->_validator->validate({$name => $value}, {properties => {$name => $p}}); >+ } >+ } >+ >+ $input{$name} = $value unless @e; >+ push @{$v{errors}}, @e; >+ } >+ >+ $v{valid} = @{$v{errors} || []} ? Mojo::JSON->false : Mojo::JSON->true; >+ return \%v, \%input; >+} >+ >+ >+ >+################################################################################ >+######### END OF OVERLOADED SUBROUTINES, STARTING EXTENDED FEATURES ########## >+################################################################################ >+ >+ >+ >+=head _koha_authenticate >+ >+ _koha_authenticate($c, $config); >+ >+Checks all authentications in Koha, and prepares the data for a >+Mojolicious::Plugin::Swagger2->render_swagger($errors, $data, $statusCode) -response >+if authentication failed for some reason. >+ >+@PARAM1 Mojolicious::Controller or a subclass >+@PARAM2 Reference to HASH, the "Operation Object" from Swagger2.0 specification, >+ matching the given "Path Item Object"'s HTTP Verb. >+@RETURNS List of: HASH Ref, errors encountered >+ HASH Ref, data to be sent >+ String, status code from the Koha::REST::V1::check_key_auth() >+=cut >+ >+sub _koha_authenticate { >+ my ($c, $config) = @_; >+ my ($error, $data, $statusCode); >+ >+ try { >+ check_key_auth($c, $config); >+ } catch { >+ if (blessed($_)) { >+ if ($_->isa('Koha::Exception::BadAuthenticationToken') || >+ $_->isa('Koha::Exception::NoPermission') ) { >+ $error = {valid => Mojo::JSON->false, errors => [{message => $_->error, path => $c->req->url->path_query}]}; >+ $data = {header => {"WWW-Authenticate" => "Where is Koha API authentication publicly instructed?"}}; >+ $statusCode = 401; >+ } >+ elsif ($_->isa('Koha::Exception::UnknownProgramState')){ >+ $error = {valid => Mojo::JSON->false, errors => [{message => $_->error, path => $c->req->url->path_query}]}; >+ $data = {}; >+ $statusCode = 500; >+ } >+ >+ } >+ >+ }; >+ return ($error, $data, $statusCode); >+} >+ >+=head check_key_auth >+ >+ my $auth_ok = check_key_auth($c, $opObj); >+ >+For authentication to succeed, the client have to send 3 custom HTTP >+headers: >+ - X-Koha-Username: userid of borrower >+ - X-Koha-Timestamp: timestamp of the request >+ - X-Koha-Signature: signature of the request >+ >+The signature is a HMAC-SHA256 hash of several elements of the request, >+separated by spaces: >+ - HTTP method (uppercase) >+ - URL path and query string >+ - username >+ - timestamp of the request >+ >+The server then tries to rebuild the signature with each user's API key. >+If one matches the received X-Koha-Signature, then authentication is >+almost OK. >+ >+To avoid requests to be replayed, the last request's timestamp is stored >+in database and the authentication succeeds only if the stored timestamp >+is lesser than X-Koha-Timestamp. >+ >+ >+There is also an "anonymous" mode if X-Koha-* headers are not set. >+Anonymous mode differ from authenticated mode in one thing: if user is >+authenticated, the corresponding Koha::Borrower object is stored in >+Mojolicious stash, so it can easily be retrieved by controllers. >+Controllers then have the responsibility of what to do if user is >+authenticated or not. >+ >+@PARAM1 Mojolicious::Controller >+@PARAM2 Reference to HASH, the "Operation Object" from Swagger2.0 specification, >+ matching the given "Path Item Object"'s HTTP Verb. >+@RETURNS Integer, 1 if authentication succeeded, otherwise throws exceptions. >+@THROWS Koha::Exception::BadAuthenticationToken from _check_key_auth_api_key() >+@THROWS Koha::Exception::NoPermission if borrower has no Koha permission to access the resource >+@THROWS Koha::Exception::UnknownProgramState if authentication system malfunctions; >+=cut >+ >+sub check_key_auth { >+ my ($c, $opObj) = @_; >+ my $req_username = $c->req->headers->header('X-Koha-Username'); >+ my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); >+ my $req_signature = $c->req->headers->header('X-Koha-Signature'); >+ my $req_method = $c->req->method; >+ my $req_url = '/' . $c->req->url->path_query; >+ >+ my $borrower = Koha::Borrowers->find({userid => $req_username}); >+ >+ #If the resource requires specific permissions, a strong authentication must be given. >+ if ($opObj->{"x-koha-permission"}) { >+ #Does key authentication fail? >+ _check_key_auth_api_key($c, $borrower); >+ >+ #Strong auth OK, Are there enough permissions? >+ my ($failedPermission, $borrowerPermissions) = C4::Auth::haspermission($borrower, $opObj->{"x-koha-permission"}); >+ unless ($borrowerPermissions) { #Permissions are lacking. >+ my @permTokens = %$failedPermission if (ref $failedPermission eq 'HASH'); >+ my $failedPermissionString = (@permTokens) ? $permTokens[0].' => '.$permTokens[1] : "Permission unknown"; >+ Koha::Exception::NoPermission->throw(error => "No Koha permission to access this resource. Permission '$failedPermissionString' required."); >+ } >+ >+ # Authentication succeeded, store authenticated user in stash >+ $c->stash('user', $borrower); >+ return 1; >+ } >+ #No special permissions needed, try anon auth, and then strong auth. >+ else { >+ # "Anonymous" mode >+ if (_check_key_auth_anonymous($c)) { >+ return 'ANON'; >+ } >+ _check_key_auth_api_key($c, $borrower); >+ >+ # Authentication succeeded, store authenticated user in stash >+ $c->stash('user', $borrower); >+ return 1; >+ } >+ >+ #If we reach this point there is something wrong. NEVER default to auth OK >+ Koha::Exception::UnknownProgramState->throw(error => "Failure when authenticating. Unknown authentication state."); >+} >+ >+=head _check_key_auth_anonymous >+ >+@PARAM1, Mojolicious::Controller >+@RETURNS, Int, 2, if anonymous authentication >+ undef, if no anonymous authentication. >+=cut >+ >+sub _check_key_auth_anonymous { >+ my ($c) = @_; >+ my $req_username = $c->req->headers->header('X-Koha-Username'); >+ my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); >+ my $req_signature = $c->req->headers->header('X-Koha-Signature'); >+ >+ return 2 >+ unless ( defined($req_username) >+ or defined($req_timestamp) >+ or defined($req_signature) ); >+ return undef; >+} >+ >+=head _check_key_auth_api_key >+ >+ unless (_check_key_auth_api_key($c, $borrower)) { >+ return; >+ } >+ >+@PARAM1 Mojolicious::Controller >+@PARAM2 Koha::Borrower >+@RETURNS Int, 1, if api authentication succeeded >+ undef, authentication failed >+@THROWS Koha::Exception::BadAuthenticationToken if borrower: >+ has no API keys, >+ signatures do not match, >+ given timestamp is stale >+=cut >+ >+sub _check_key_auth_api_key { >+ my ($c, $borrower) = @_; >+ my $req_username = $c->req->headers->header('X-Koha-Username'); >+ my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp'); >+ my $req_signature = $c->req->headers->header('X-Koha-Signature'); >+ my $req_method = $c->req->method; >+ my $req_url = '/' . $c->req->url->path_query; >+ >+ my @apikeys = Koha::ApiKeys->search({ >+ borrowernumber => $borrower->borrowernumber, >+ active => 1, >+ }); >+ Koha::Exception::BadAuthenticationToken->throw(error => "User has no API keys") unless @apikeys; >+ >+ my $message = "$req_method $req_url $req_username $req_timestamp"; >+ my $signature = ''; >+ foreach my $apikey (@apikeys) { >+ $signature = hmac_sha256_hex($message, $apikey->api_key); >+ >+ last if $signature eq $req_signature; >+ } >+ >+ unless ($signature eq $req_signature) { >+ Koha::Exception::BadAuthenticationToken->throw(error => "API key authentication failed"); >+ } >+ >+ my $api_timestamp = Koha::ApiTimestamps->find($borrower->borrowernumber); >+ my $timestamp = $api_timestamp ? $api_timestamp->timestamp : 0; >+ unless ($timestamp < $req_timestamp) { >+ Koha::Exception::BadAuthenticationToken->throw(error => "Bad X-Koha-Timestamp, expected '$timestamp'"); >+ } >+ >+ unless ($api_timestamp) { >+ $api_timestamp = new Koha::ApiTimestamp; >+ $api_timestamp->borrowernumber($borrower->borrowernumber); >+ } >+ $api_timestamp->timestamp($req_timestamp); >+ $api_timestamp->store; >+ >+ return 1; >+} >+ >+return 1; >\ No newline at end of file >diff --git a/api/v1/swagger.json b/api/v1/swagger.json >index afec513..78a7db9 100644 >--- a/api/v1/swagger.json >+++ b/api/v1/swagger.json >@@ -17,8 +17,13 @@ > "/borrowers": { > "get": { > "x-mojo-controller": "Koha::REST::V1::Borrowers", >+ "x-koha-permission": { >+ "borrowers": "*" >+ }, > "operationId": "listBorrowers", > "tags": ["borrowers"], >+ "summary": "just a summary", >+ "description": "long description", > "produces": [ > "application/json" > ], >@@ -32,12 +37,18 @@ > } > } > } >- } >+ }, >+ "security": [ >+ { "multi_key_auth": [] } >+ ] > } > }, > "/borrowers/{borrowernumber}": { > "get": { > "x-mojo-controller": "Koha::REST::V1::Borrowers", >+ "x-koha-permission": { >+ "borrowers": "*" >+ }, > "operationId": "getBorrower", > "tags": ["borrowers"], > "parameters": [ >@@ -61,7 +72,10 @@ > "$ref": "#/definitions/error" > } > } >- } >+ }, >+ "security": [ >+ { "multi_key_auth": [] } >+ ] > } > }, > "/borrowers/{borrowernumber}/issues": { >@@ -466,5 +480,29 @@ > "required": "true", > "type": "integer" > } >+ }, >+ "securityDefinitions": { >+ "multi_key_auth": { >+ "type": "custom", >+ "in": "header", >+ "keys": { >+ "ETag": { >+ "type": "dateTime", >+ "description": "The current time when the request is created." >+ }, >+ "x-koha-username": { >+ "type": "string", >+ "description": "The username of the API consumer. Not the library card's barcode or borrowernumber!" >+ }, >+ "x-koha-permission": { >+ "type": "string", >+ "description": "The specific permission the user must have. Eg. 'circulation => force_checkout'" >+ }, >+ "x-koha-signature": { >+ "type": "string", >+ "description": "The signature is a HMAC-SHA256 hash of several elements of the request, separated by spaces: 1. HTTP method (uppercase) 2. URL path and query string 3. Value of x-koha-username -header 4. Value of the ETag-header" >+ } >+ } >+ } > } > } >-- >1.7.9.5
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 13920
:
37286
|
37287
|
37288
|
40583
|
40590
|
40591
|
40592
|
40623
|
40624
|
40625
|
41165
|
41166
|
41195
|
41196
|
41257
|
41260
|
42041
|
42042
|
42555