View | Details | Raw Unified | Return to bug 13920
Collapse All | Expand All

(-)a/Koha/REST/V1.pm (-152 / +6 lines)
Lines 3-10 package Koha::REST::V1; Link Here
3
use Modern::Perl;
3
use Modern::Perl;
4
use Mojo::Base 'Mojolicious';
4
use Mojo::Base 'Mojolicious';
5
use Mojo::Log;
5
use Mojo::Log;
6
6
use Mojolicious::Plugins; #Extend the plugin system
7
use Digest::SHA qw(hmac_sha256_hex);
8
7
9
use Koha::Borrower;
8
use Koha::Borrower;
10
use Koha::Borrowers;
9
use Koha::Borrowers;
Lines 77-235 sub startup { Link Here
77
        print __PACKAGE__."::startup():> No config-file loaded. Define your config-file to the MOJO_CONFIG environmental variable.\n";
76
        print __PACKAGE__."::startup():> No config-file loaded. Define your config-file to the MOJO_CONFIG environmental variable.\n";
78
    }
77
    }
79
78
80
    my $route = $self->routes->under->to(
81
        cb => sub {
82
            my $c = shift;
83
            my $auth_ok = check_key_auth($c);
84
            return 1 if $auth_ok;
85
        }
86
    );
87
79
88
    $self->plugin(Swagger2 => {
80
    ##Add the Koha namespace to the plugin engine to find plugins from.
89
        route => $route,
81
    my $plugin = $self->plugins();
82
    push @{$plugin->namespaces}, 'Koha::REST::V1::Plugins';
83
84
    $self->plugin(KohaliciousSwagtenticator => {
90
        url => $self->home->rel_file("api/v1/swagger.json"),
85
        url => $self->home->rel_file("api/v1/swagger.json"),
91
    });
86
    });
92
}
87
}
93
88
94
=head check_key_auth
95
96
    my $auth_ok = check_key_auth($c);
97
98
For authentication to succeed, the client have to send 3 custom HTTP
99
headers:
100
 - X-Koha-Username: userid of borrower
101
 - X-Koha-Timestamp: timestamp of the request
102
 - X-Koha-Signature: signature of the request
103
104
The signature is a HMAC-SHA256 hash of several elements of the request,
105
separated by spaces:
106
 - HTTP method (uppercase)
107
 - URL path and query string
108
 - username
109
 - timestamp of the request
110
111
The server then tries to rebuild the signature with each user's API key.
112
If one matches the received X-Koha-Signature, then authentication is
113
almost OK.
114
115
To avoid requests to be replayed, the last request's timestamp is stored
116
in database and the authentication succeeds only if the stored timestamp
117
is lesser than X-Koha-Timestamp.
118
119
120
There is also an "anonymous" mode if X-Koha-* headers are not set.
121
Anonymous mode differ from authenticated mode in one thing: if user is
122
authenticated, the corresponding Koha::Borrower object is stored in
123
Mojolicious stash, so it can easily be retrieved by controllers.
124
Controllers then have the responsibility of what to do if user is
125
authenticated or not.
126
127
@PARAM1, Mojolicious::Controller
128
@RETURNS, Boolean, 2, anonymous authentication
129
                   1, authenticated properly
130
                   undef, authentication failed.
131
=cut
132
133
sub check_key_auth {
134
    my ($c) = @_;
135
    my $req_username = $c->req->headers->header('X-Koha-Username');
136
    my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp');
137
    my $req_signature = $c->req->headers->header('X-Koha-Signature');
138
    my $req_method = $c->req->method;
139
    my $req_url = '/' . $c->req->url->path_query;
140
141
142
    # "Anonymous" mode
143
    if (_check_key_auth_anonymous($c)) {
144
        return 2;
145
    }
146
147
    my $borrower = Koha::Borrowers->find({userid => $req_username});
148
149
    unless (_check_key_auth_api_key($c, $borrower)) {
150
        return;
151
    }
152
153
    # Authentication succeeded, store authenticated user in stash
154
    $c->stash('user', $borrower);
155
    return 1;
156
}
157
158
=head _check_key_auth_anonymous
159
160
@PARAM1, Mojolicious::Controller
161
@RETURNS, Int, 2, if anonymous authentication
162
               undef, if no anonymous authentication.
163
=cut
164
165
sub _check_key_auth_anonymous {
166
    my ($c) = @_;
167
    my $req_username = $c->req->headers->header('X-Koha-Username');
168
    my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp');
169
    my $req_signature = $c->req->headers->header('X-Koha-Signature');
170
171
    return 2
172
      unless ( defined($req_username)
173
        or defined($req_timestamp)
174
        or defined($req_signature) );
175
    return undef;
176
}
177
178
=head _check_key_auth_api_key
179
180
    unless (_check_key_auth_api_key($c, $borrower)) {
181
        return;
182
    }
183
184
@PARAM1, Mojolicious::Controller
185
@PARAM2, Koha::Borrower
186
@RETURNS, Int, 1, if api authentication succeeded
187
               undef, authentication failed
188
189
=cut
190
191
sub _check_key_auth_api_key {
192
    my ($c, $borrower) = @_;
193
    my $req_username = $c->req->headers->header('X-Koha-Username');
194
    my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp');
195
    my $req_signature = $c->req->headers->header('X-Koha-Signature');
196
    my $req_method = $c->req->method;
197
    my $req_url = '/' . $c->req->url->path_query;
198
199
    my @apikeys = Koha::ApiKeys->search({
200
        borrowernumber => $borrower->borrowernumber,
201
        active => 1,
202
    });
203
204
    my $message = "$req_method $req_url $req_username $req_timestamp";
205
    my $signature = '';
206
    foreach my $apikey (@apikeys) {
207
        $signature = hmac_sha256_hex($message, $apikey->api_key);
208
209
        last if $signature eq $req_signature;
210
    }
211
212
    unless ($signature eq $req_signature) {
213
        $c->res->code(403);
214
        $c->render(json => { error => "Authentication failed" });
215
        return;
216
    }
217
218
    my $api_timestamp = Koha::ApiTimestamps->find($borrower->borrowernumber);
219
    my $timestamp = $api_timestamp ? $api_timestamp->timestamp : 0;
220
    unless ($timestamp < $req_timestamp) {
221
        $c->res->code(403);
222
        $c->render(json => { error => "Bad timestamp" });
223
        return;
224
    }
225
226
    unless ($api_timestamp) {
227
        $api_timestamp = new Koha::ApiTimestamp;
228
        $api_timestamp->borrowernumber($borrower->borrowernumber);
229
    }
230
    $api_timestamp->timestamp($req_timestamp);
231
    $api_timestamp->store;
232
233
    return 1;
234
}
235
1;
89
1;
(-)a/Koha/REST/V1/Plugins/KohaliciousSwagtenticator.pm (+350 lines)
Line 0 Link Here
1
package Koha::REST::V1::Plugins::KohaliciousSwagtenticator;
2
3
use Modern::Perl;
4
5
use base qw(Mojolicious::Plugin::Swagger2);
6
7
use Digest::SHA qw(hmac_sha256_hex);
8
use Try::Tiny;
9
use Scalar::Util qw(blessed);
10
11
use C4::Auth;
12
13
use Koha::Exception::BadAuthenticationToken;
14
use Koha::Exception::UnknownProgramState;
15
use Koha::Exception::NoPermission;
16
17
use constant DEBUG => $ENV{SWAGGER2_DEBUG} || 0;
18
19
20
21
################################################################################
22
######################  STARTING OVERLOADING SUBROUTINES  ######################
23
################################################################################
24
25
26
27
=head _generate_request_handler
28
@OVERLOADS Mojolicious::Plugin::Swagger2::_generate_request_handler()
29
This is just a copy-paste of the parent function with a small incision to inject the Koha-authentication mechanism.
30
Keep code changes minimal for upstream compatibility, so when problems arise, copy-pasting fixes them!
31
32
=cut
33
34
sub _generate_request_handler {
35
  my ($self, $method, $config) = @_;
36
  my $controller = $config->{'x-mojo-controller'} || $self->{controller};    # back compat
37
38
  return sub {
39
    my $c = shift;
40
    my $method_ref;
41
42
    unless (eval "require $controller;1") {
43
      $c->app->log->error($@);
44
      return $c->render_swagger($self->_not_implemented('Controller not implemented.'), {}, 501);
45
    }
46
    unless ($method_ref = $controller->can($method)) {
47
      $method_ref = $controller->can(sprintf '%s_%s', $method, lc $c->req->method)
48
        and warn "HTTP method name is not used in method name lookup anymore!";
49
    }
50
    unless ($method_ref) {
51
      $c->app->log->error(
52
        qq(Can't locate object method "$method" via package "$controller. (Something is wrong in @{[$self->url]})"));
53
      return $c->render_swagger($self->_not_implemented('Method not implemented.'), {}, 501);
54
    }
55
    ### Koha-overload starts here ###
56
    ## Check for user api-key authentication and permissions.
57
    my ($error, $data, $statusCode) = _koha_authenticate($c, $config);
58
    return $c->render_swagger($error, $data, $statusCode) if $error;
59
    ### END OF Koha-overload      ###
60
61
    bless $c, $controller;    # ugly hack?
62
63
    $c->delay(
64
      sub {
65
        my ($delay) = @_;
66
        my ($v, $input) = $self->_validate_input($c, $config);
67
68
        return $c->render_swagger($v, {}, 400) unless $v->{valid};
69
        return $c->$method_ref($input, $delay->begin);
70
      },
71
      sub {
72
        my $delay  = shift;
73
        my $data   = shift;
74
        my $status = shift || 200;
75
        my $format = $config->{responses}{$status} || $config->{responses}{default} || {};
76
        my @err    = $self->_validator->validate($data, $format->{schema});
77
78
        return $c->render_swagger({errors => \@err, valid => Mojo::JSON->false}, $data, 500) if @err;
79
        return $c->render_swagger({}, $data, $status);
80
      },
81
    );
82
  };
83
}
84
85
86
87
=head _validate_input
88
@OVERLOADS Mojolicious::Plugin::Swagger2::_validate_input()
89
90
Validates the parameters from the "Operation Object" from Swagger2 specification.
91
Overloading to allow OPTIONAL parameters.
92
=cut
93
94
sub _validate_input {
95
  my ($self, $c, $config) = @_;
96
  my $headers = $c->req->headers;
97
  my $query   = $c->req->url->query;
98
  my (%input, %v);
99
  warn "[Swagtenticator] Successful subclassing _validate_input()!\n";
100
101
  for my $p (@{$config->{parameters} || []}) {
102
    my ($in, $name) = @$p{qw( in name )};
103
    my ($value, @e);
104
105
    $value
106
      = $in eq 'query'    ? $query->param($name)
107
      : $in eq 'path'     ? $c->stash($name)
108
      : $in eq 'header'   ? $headers->header($name)
109
      : $in eq 'body'     ? $c->req->json
110
      : $in eq 'formData' ? $c->req->body_params->to_hash
111
      :                     "Invalid 'in' for parameter $name in schema definition";
112
113
    if (defined $value or $p->{required}) {
114
      my $type = $p->{type} || 'object';
115
      $value += 0 if $type =~ /^(?:integer|number)/ and $value =~ /^\d/;
116
      $value = ($value eq 'false' or !$value) ? Mojo::JSON->false : Mojo::JSON->true if $type eq 'boolean';
117
118
      if ($in eq 'body' or $in eq 'formData') {
119
        warn "[Swagger2] Validate $in @{[$c->req->body]}\n";
120
        push @e, map { $_->{path} = "/$name$_->{path}"; $_; } $self->_validator->validate($value, $p->{schema});
121
      }
122
      else {
123
        warn "[Swagger2] Validate $in $name=$value\n";
124
        push @e, $self->_validator->validate({$name => $value}, {properties => {$name => $p}});
125
      }
126
    }
127
128
    $input{$name} = $value unless @e;
129
    push @{$v{errors}}, @e;
130
  }
131
132
  $v{valid} = @{$v{errors} || []} ? Mojo::JSON->false : Mojo::JSON->true;
133
  return \%v, \%input;
134
}
135
136
137
138
################################################################################
139
#########  END OF OVERLOADED SUBROUTINES, STARTING EXTENDED FEATURES  ##########
140
################################################################################
141
142
143
144
=head _koha_authenticate
145
146
    _koha_authenticate($c, $config);
147
148
Checks all authentications in Koha, and prepares the data for a
149
Mojolicious::Plugin::Swagger2->render_swagger($errors, $data, $statusCode) -response
150
if authentication failed for some reason.
151
152
@PARAM1 Mojolicious::Controller or a subclass
153
@PARAM2 Reference to HASH, the "Operation Object" from Swagger2.0 specification,
154
                            matching the given "Path Item Object"'s HTTP Verb.
155
@RETURNS List of: HASH Ref, errors encountered
156
                  HASH Ref, data to be sent
157
                  String, status code from the Koha::REST::V1::check_key_auth()
158
=cut
159
160
sub _koha_authenticate {
161
    my ($c, $config) = @_;
162
    my ($error, $data, $statusCode);
163
164
    try {
165
        check_key_auth($c, $config);
166
    } catch {
167
      if (blessed($_)) {
168
        if ($_->isa('Koha::Exception::BadAuthenticationToken') ||
169
            $_->isa('Koha::Exception::NoPermission')    ) {
170
          $error = {valid => Mojo::JSON->false, errors => [{message => $_->error, path => $c->req->url->path_query}]};
171
          $data = {header => {"WWW-Authenticate" => "Where is Koha API authentication publicly instructed?"}};
172
          $statusCode = 401;
173
        }
174
        elsif ($_->isa('Koha::Exception::UnknownProgramState')){
175
          $error = {valid => Mojo::JSON->false, errors => [{message => $_->error, path => $c->req->url->path_query}]};
176
          $data = {};
177
          $statusCode = 500;
178
        }
179
        
180
      }
181
182
    };
183
    return ($error, $data, $statusCode);
184
}
185
186
=head check_key_auth
187
188
    my $auth_ok = check_key_auth($c, $opObj);
189
190
For authentication to succeed, the client have to send 3 custom HTTP
191
headers:
192
 - X-Koha-Username: userid of borrower
193
 - X-Koha-Timestamp: timestamp of the request
194
 - X-Koha-Signature: signature of the request
195
196
The signature is a HMAC-SHA256 hash of several elements of the request,
197
separated by spaces:
198
 - HTTP method (uppercase)
199
 - URL path and query string
200
 - username
201
 - timestamp of the request
202
203
The server then tries to rebuild the signature with each user's API key.
204
If one matches the received X-Koha-Signature, then authentication is
205
almost OK.
206
207
To avoid requests to be replayed, the last request's timestamp is stored
208
in database and the authentication succeeds only if the stored timestamp
209
is lesser than X-Koha-Timestamp.
210
211
212
There is also an "anonymous" mode if X-Koha-* headers are not set.
213
Anonymous mode differ from authenticated mode in one thing: if user is
214
authenticated, the corresponding Koha::Borrower object is stored in
215
Mojolicious stash, so it can easily be retrieved by controllers.
216
Controllers then have the responsibility of what to do if user is
217
authenticated or not.
218
219
@PARAM1 Mojolicious::Controller
220
@PARAM2 Reference to HASH, the "Operation Object" from Swagger2.0 specification,
221
                            matching the given "Path Item Object"'s HTTP Verb.
222
@RETURNS Integer, 1 if authentication succeeded, otherwise throws exceptions.
223
@THROWS Koha::Exception::BadAuthenticationToken from _check_key_auth_api_key()
224
@THROWS Koha::Exception::NoPermission if borrower has no Koha permission to access the resource
225
@THROWS Koha::Exception::UnknownProgramState if authentication system malfunctions;
226
=cut
227
228
sub check_key_auth {
229
    my ($c, $opObj) = @_;
230
    my $req_username = $c->req->headers->header('X-Koha-Username');
231
    my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp');
232
    my $req_signature = $c->req->headers->header('X-Koha-Signature');
233
    my $req_method = $c->req->method;
234
    my $req_url = '/' . $c->req->url->path_query;
235
236
    my $borrower = Koha::Borrowers->find({userid => $req_username});
237
238
    #If the resource requires specific permissions, a strong authentication must be given.
239
    if ($opObj->{"x-koha-permission"}) {
240
        #Does key authentication fail?
241
        _check_key_auth_api_key($c, $borrower);
242
243
        #Strong auth OK, Are there enough permissions?
244
        my ($failedPermission, $borrowerPermissions) = C4::Auth::haspermission($borrower, $opObj->{"x-koha-permission"});
245
        unless ($borrowerPermissions) { #Permissions are lacking.
246
            my @permTokens = %$failedPermission if (ref $failedPermission eq 'HASH');
247
            my $failedPermissionString = (@permTokens) ? $permTokens[0].' => '.$permTokens[1] : "Permission unknown";
248
            Koha::Exception::NoPermission->throw(error => "No Koha permission to access this resource. Permission '$failedPermissionString' required.");
249
        }
250
251
        # Authentication succeeded, store authenticated user in stash
252
        $c->stash('user', $borrower);
253
        return 1;
254
    }
255
    #No special permissions needed, try anon auth, and then strong auth.
256
    else {
257
        # "Anonymous" mode
258
        if (_check_key_auth_anonymous($c)) {
259
            return 'ANON';
260
        }
261
        _check_key_auth_api_key($c, $borrower);
262
263
        # Authentication succeeded, store authenticated user in stash
264
        $c->stash('user', $borrower);
265
        return 1;
266
    }
267
268
    #If we reach this point there is something wrong. NEVER default to auth OK
269
    Koha::Exception::UnknownProgramState->throw(error => "Failure when authenticating. Unknown authentication state.");
270
}
271
272
=head _check_key_auth_anonymous
273
274
@PARAM1, Mojolicious::Controller
275
@RETURNS, Int, 2, if anonymous authentication
276
               undef, if no anonymous authentication.
277
=cut
278
279
sub _check_key_auth_anonymous {
280
    my ($c) = @_;
281
    my $req_username = $c->req->headers->header('X-Koha-Username');
282
    my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp');
283
    my $req_signature = $c->req->headers->header('X-Koha-Signature');
284
285
    return 2
286
      unless ( defined($req_username)
287
        or defined($req_timestamp)
288
        or defined($req_signature) );
289
    return undef;
290
}
291
292
=head _check_key_auth_api_key
293
294
    unless (_check_key_auth_api_key($c, $borrower)) {
295
        return;
296
    }
297
298
@PARAM1 Mojolicious::Controller
299
@PARAM2 Koha::Borrower
300
@RETURNS Int, 1, if api authentication succeeded
301
               undef, authentication failed
302
@THROWS Koha::Exception::BadAuthenticationToken if borrower:
303
                has no API keys,
304
                signatures do not match,
305
                given timestamp is stale
306
=cut
307
308
sub _check_key_auth_api_key {
309
    my ($c, $borrower) = @_;
310
    my $req_username = $c->req->headers->header('X-Koha-Username');
311
    my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp');
312
    my $req_signature = $c->req->headers->header('X-Koha-Signature');
313
    my $req_method = $c->req->method;
314
    my $req_url = '/' . $c->req->url->path_query;
315
316
    my @apikeys = Koha::ApiKeys->search({
317
        borrowernumber => $borrower->borrowernumber,
318
        active => 1,
319
    });
320
    Koha::Exception::BadAuthenticationToken->throw(error => "User has no API keys") unless @apikeys;
321
322
    my $message = "$req_method $req_url $req_username $req_timestamp";
323
    my $signature = '';
324
    foreach my $apikey (@apikeys) {
325
        $signature = hmac_sha256_hex($message, $apikey->api_key);
326
327
        last if $signature eq $req_signature;
328
    }
329
330
    unless ($signature eq $req_signature) {
331
        Koha::Exception::BadAuthenticationToken->throw(error => "API key authentication failed");
332
    }
333
334
    my $api_timestamp = Koha::ApiTimestamps->find($borrower->borrowernumber);
335
    my $timestamp = $api_timestamp ? $api_timestamp->timestamp : 0;
336
    unless ($timestamp < $req_timestamp) {
337
        Koha::Exception::BadAuthenticationToken->throw(error => "Bad X-Koha-Timestamp, expected '$timestamp'");
338
    }
339
340
    unless ($api_timestamp) {
341
        $api_timestamp = new Koha::ApiTimestamp;
342
        $api_timestamp->borrowernumber($borrower->borrowernumber);
343
    }
344
    $api_timestamp->timestamp($req_timestamp);
345
    $api_timestamp->store;
346
347
    return 1;
348
}
349
350
return 1;
(-)a/api/v1/swagger.json (-3 / +40 lines)
Lines 17-24 Link Here
17
    "/borrowers": {
17
    "/borrowers": {
18
      "get": {
18
      "get": {
19
        "x-mojo-controller": "Koha::REST::V1::Borrowers",
19
        "x-mojo-controller": "Koha::REST::V1::Borrowers",
20
        "x-koha-permission": {
21
          "borrowers": "*"
22
        },
20
        "operationId": "listBorrowers",
23
        "operationId": "listBorrowers",
21
        "tags": ["borrowers"],
24
        "tags": ["borrowers"],
25
        "summary": "just a summary",
26
        "description": "long description",
22
        "produces": [
27
        "produces": [
23
          "application/json"
28
          "application/json"
24
        ],
29
        ],
Lines 32-43 Link Here
32
              }
37
              }
33
            }
38
            }
34
          }
39
          }
35
        }
40
        },
41
        "security": [
42
          { "multi_key_auth": [] }
43
        ]
36
      }
44
      }
37
    },
45
    },
38
    "/borrowers/{borrowernumber}": {
46
    "/borrowers/{borrowernumber}": {
39
      "get": {
47
      "get": {
40
        "x-mojo-controller": "Koha::REST::V1::Borrowers",
48
        "x-mojo-controller": "Koha::REST::V1::Borrowers",
49
        "x-koha-permission": {
50
          "borrowers": "*"
51
        },
41
        "operationId": "getBorrower",
52
        "operationId": "getBorrower",
42
        "tags": ["borrowers"],
53
        "tags": ["borrowers"],
43
        "parameters": [
54
        "parameters": [
Lines 61-67 Link Here
61
              "$ref": "#/definitions/error"
72
              "$ref": "#/definitions/error"
62
            }
73
            }
63
          }
74
          }
64
        }
75
        },
76
        "security": [
77
          { "multi_key_auth": [] }
78
        ]
65
      }
79
      }
66
    },
80
    },
67
    "/borrowers/{borrowernumber}/issues": {
81
    "/borrowers/{borrowernumber}/issues": {
Lines 466-470 Link Here
466
      "required": "true",
480
      "required": "true",
467
      "type": "integer"
481
      "type": "integer"
468
    }
482
    }
483
  },
484
  "securityDefinitions": {
485
    "multi_key_auth": {
486
      "type": "custom",
487
      "in": "header",
488
      "keys": {
489
        "ETag": {
490
          "type": "dateTime",
491
          "description": "The current time when the request is created."
492
        },
493
        "x-koha-username": {
494
          "type": "string",
495
          "description": "The username of the API consumer. Not the library card's barcode or borrowernumber!"
496
        },
497
        "x-koha-permission": {
498
          "type": "string",
499
          "description": "The specific permission the user must have. Eg. 'circulation => force_checkout'"
500
        },
501
        "x-koha-signature": {
502
          "type": "string",
503
          "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"
504
        }
505
      }
506
    }
469
  }
507
  }
470
}
508
}
471
- 

Return to bug 13920