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

(-)a/Koha/REST/V1.pm (-64 / +7 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 78-145 sub startup { Link Here
78
        print __PACKAGE__."::startup():> No config-file loaded. Define your config-file to the MOJO_CONFIG environmental variable.\n";
77
        print __PACKAGE__."::startup():> No config-file loaded. Define your config-file to the MOJO_CONFIG environmental variable.\n";
79
    }
78
    }
80
79
81
    my $route = $self->routes->under->to(
80
82
        cb => sub {
81
    ##Add the Koha namespace to the plugin engine to find plugins from.
83
            my $c = shift;
82
    my $plugin = $self->plugins();
84
            my $req_username = $c->req->headers->header('X-Koha-Username');
83
    push @{$plugin->namespaces}, 'Koha::REST::V1::Plugins';
85
            my $req_timestamp = $c->req->headers->header('X-Koha-Timestamp');
84
86
            my $req_signature = $c->req->headers->header('X-Koha-Signature');
85
    $self->plugin(KohaliciousSwagtenticator => {
87
            my $req_method = $c->req->method;
88
            my $req_url = '/' . $c->req->url->path_query;
89
90
            # "Anonymous" mode
91
            return 1
92
              unless ( defined($req_username)
93
                or defined($req_timestamp)
94
                or defined($req_signature) );
95
96
            my $borrower = Koha::Borrowers->find({userid => $req_username});
97
98
            my @apikeys = Koha::ApiKeys->search({
99
                borrowernumber => $borrower->borrowernumber,
100
                active => 1,
101
            });
102
103
            my $message = "$req_method $req_url $req_username $req_timestamp";
104
            my $signature = '';
105
            foreach my $apikey (@apikeys) {
106
                $signature = hmac_sha256_hex($message, $apikey->api_key);
107
108
                last if $signature eq $req_signature;
109
            }
110
111
            unless ($signature eq $req_signature) {
112
                $c->res->code(403);
113
                $c->render(json => { error => "Authentication failed" });
114
                return;
115
            }
116
117
            my $api_timestamp = Koha::ApiTimestamps->find($borrower->borrowernumber);
118
            my $timestamp = $api_timestamp ? $api_timestamp->timestamp : 0;
119
            unless ($timestamp < $req_timestamp) {
120
                $c->res->code(403);
121
                $c->render(json => { error => "Bad timestamp" });
122
                return;
123
            }
124
125
            unless ($api_timestamp) {
126
                $api_timestamp = new Koha::ApiTimestamp;
127
                $api_timestamp->borrowernumber($borrower->borrowernumber);
128
            }
129
            $api_timestamp->timestamp($req_timestamp);
130
            $api_timestamp->store;
131
132
            # Authentication succeeded, store authenticated user in stash
133
            $c->stash('user', $borrower);
134
            return 1;
135
        }
136
    );
137
138
    # Force charset=utf8 in Content-Type header for JSON responses
139
    $self->types->type(json => 'application/json; charset=utf8');
140
141
    $self->plugin(Swagger2 => {
142
        route => $route,
143
        url => $self->home->rel_file("api/v1/swagger.json"),
86
        url => $self->home->rel_file("api/v1/swagger.json"),
144
    });
87
    });
145
}
88
}
(-)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
  },
81
  },
Lines 104-108 Link Here
104
      "required": "true",
118
      "required": "true",
105
      "type": "integer"
119
      "type": "integer"
106
    }
120
    }
121
  },
122
  "securityDefinitions": {
123
    "multi_key_auth": {
124
      "type": "custom",
125
      "in": "header",
126
      "keys": {
127
        "ETag": {
128
          "type": "dateTime",
129
          "description": "The current time when the request is created."
130
        },
131
        "x-koha-username": {
132
          "type": "string",
133
          "description": "The username of the API consumer. Not the library card's barcode or borrowernumber!"
134
        },
135
        "x-koha-permission": {
136
          "type": "string",
137
          "description": "The specific permission the user must have. Eg. 'circulation => force_checkout'"
138
        },
139
        "x-koha-signature": {
140
          "type": "string",
141
          "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"
142
        }
143
      }
144
    }
107
  }
145
  }
108
}
146
}
109
- 

Return to bug 13920