Merge "Extended Guided Tour"
diff --git a/Changes b/Changes
index ee8858a..ef8de58 100755
--- a/Changes
+++ b/Changes
@@ -1,9 +1,16 @@
-0.36 2019-07-25
+0.36 2019-08-29
- Rename all cookies to be independent
for different instance (#94).
- Enable https only via
configuration option 'https_only'.
- Make VC replaceable via KorAP.vc.fromJson().
+ - Emit 'after_render' in proxy responses
+ to make it accessible to post processing
+ (such as the Piwik plugin).
+ - Fix treatment of legacy "collection" parameter.
+ - Fix pagination by not repeating page value in URL.
+ - Added auto-refresh of OAuth tokens.
+ - Added token revocation on logout.
WARNING: This requires relogin for all users!
diff --git a/Makefile.PL b/Makefile.PL
index 92c8b68..19284c1 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -14,7 +14,7 @@
},
LICENSE => 'freebsd',
PREREQ_PM => {
- 'Mojolicious' => '8.18',
+ 'Mojolicious' => '8.22',
'Mojolicious::Plugin::TagHelpers::Pagination' => 0.07,
'Mojolicious::Plugin::TagHelpers::MailToChiffre' => 0.10,
'Mojolicious::Plugin::ClosedRedirect' => 0.14,
@@ -26,6 +26,8 @@
'Cache::FastMmap' => 1.47,
'Data::Serializer' => 0.60,
'Mojo::JWT' => 0.05,
+ 'Test::Mojo::Session' => 1.05,
+ 'Test::Mojo::WithRoles' => 0.02,
# Required for Data::Serializer at the moment
'JSON' => 4.02,
diff --git a/lib/Kalamar.pm b/lib/Kalamar.pm
index a72ead5..54fbe09 100644
--- a/lib/Kalamar.pm
+++ b/lib/Kalamar.pm
@@ -185,7 +185,9 @@
$self->plugin('MailException' => $self->config('MailException'));
};
- # Load further plugins
+ # Load further plugins,
+ # that can override core functions,
+ # therefore order may be of importance
if (exists $conf->{'plugins'}) {
foreach (@{$conf->{'plugins'}}) {
$self->plugin('Kalamar::Plugin::' . $_);
diff --git a/lib/Kalamar/Controller/Proxy.pm b/lib/Kalamar/Controller/Proxy.pm
index 4bb4116..aff9d8a 100644
--- a/lib/Kalamar/Controller/Proxy.pm
+++ b/lib/Kalamar/Controller/Proxy.pm
@@ -49,6 +49,8 @@
# another proxy, e.g. Apache, manages multiple
# connections
$headers->connection('close');
+
+ $c->app->plugins->emit_hook(after_render => $c);
}
);
};
diff --git a/lib/Kalamar/Controller/Search.pm b/lib/Kalamar/Controller/Search.pm
index 331f78d..e69f8f0 100644
--- a/lib/Kalamar/Controller/Search.pm
+++ b/lib/Kalamar/Controller/Search.pm
@@ -9,7 +9,10 @@
# TODO:
# Support server timing API
-
+#
+# TODO:
+# use "token" instead of "user"
+#
# TODO:
# Add match_info template for HTML
#
@@ -38,14 +41,16 @@
# $v->optional('action'); # action 'inspect' is no longer valid
# $v->optional('snippet');
- my $cutoff = 0;
+ my $cutoff;
if ($v->param('cutoff') && $v->param('cutoff') =~ /^1|true$/i) {
- $cutoff = 1;
+ $cutoff = 'true';
};
# Deal with legacy collection
- if ($v->param('collection')) {
+ my $cq = $v->param('cq');
+ if ($v->param('collection') && !defined $cq) {
$c->param(cq => $v->param('collection'));
+ $cq = $v->param('collection');
};
# No query (Check ignoring validation)
@@ -75,9 +80,9 @@
$query{count} = $v->param('count') // $c->items_per_page;
- $query{cq} = $v->param('cq');
- $query{cutoff} = $v->param('cutoff');
+ $query{cq} = $cq;
+ $query{cutoff} = $cutoff;
# Before: 'base/s:p'/'paragraph'
$query{context} = $v->param('context') // '40-t,40-t';
@@ -152,6 +157,8 @@
sub {
my $json = shift;
+ $c->app->log->debug("Receiving cached promised results");
+
#######################
# Cache total results #
#######################
@@ -163,6 +170,7 @@
# There are results to remember
if (!$cutoff &&
+ !$c->stash('no_cache') &&
$json->{meta}->{totalResults} >= 0) {
# Remove cutoff requirement again
@@ -246,10 +254,12 @@
# Only raised in case of connection errors
if ($err_msg) {
- $c->stash('err_msg' => 'backendNotAvailable');
+ # $c->stash('err_msg' => 'backendNotAvailable');
$c->notify(error => { src => 'Backend' } => $err_msg)
};
+ $c->app->log->debug("Receiving cached promised failure");
+
# $c->_notify_on_errors(shift);
return $c->render(
template => 'failure'
diff --git a/lib/Kalamar/Plugin/Auth.pm b/lib/Kalamar/Plugin/Auth.pm
index fc2cd8b..7072f9f 100644
--- a/lib/Kalamar/Plugin/Auth.pm
+++ b/lib/Kalamar/Plugin/Auth.pm
@@ -2,9 +2,25 @@
use Mojo::Base 'Mojolicious::Plugin';
use Mojo::ByteStream 'b';
+# This is a plugin to deal with the Kustvakt OAuth server.
+# It establishes both the JWT as well as the OAuth password
+# flow for login.
+# All tokens are stored in the session. Access tokens are short-lived,
+# which limits the effects of misuse.
+# Refresh tokens are bound to client id and client secret,
+# which again limits the effects of misuse.
+
+# TODO:
+# Establish a plugin 'OAuth' that works independent of 'Auth'.
+
# TODO:
# CSRF-protect logout!
+# TODO:
+# Remove the Bearer prefix from auth.
+
+# In case no expiration time is returned by the server,
+# take this time.
our $EXPECTED_EXPIRATION_IN = 259200;
# Register the plugin
@@ -31,9 +47,6 @@
$app->log->error('client_id or client_secret not defined');
};
- # TODO:
- # Define user CHI cache
-
$app->plugin('Localize' => {
dict => {
Auth => {
@@ -45,7 +58,10 @@
logoutFail => 'Abmeldung fehlgeschlagen',
csrfFail => 'Fehlerhafter CSRF Token',
openRedirectFail => 'Weiterleitungsfehler',
- refreshFail => 'Fehlerhafter Refresh-Token'
+ tokenExpired => 'Zugriffstoken abgelaufen',
+ tokenInvalid => 'Zugriffstoken ungültig',
+ refreshFail => 'Fehlerhafter Refresh-Token',
+ responseError => 'Unbekannter Autorisierungsfehler'
},
-en => {
loginSuccess => 'Login successful',
@@ -54,7 +70,10 @@
logoutFail => 'Logout failed',
csrfFail => 'Bad CSRF token',
openRedirectFail => 'Redirect failure',
- refreshFail => 'Bad refresh token'
+ tokenExpired => 'Access token expired',
+ tokenInvalid => 'Access token invalid',
+ refreshFail => 'Bad refresh token',
+ responseError => 'Unknown authorization error'
}
}
}
@@ -76,54 +95,31 @@
}
);
- # Inject authorization to all korap requests
- $app->hook(
- before_korap_request => sub {
- my ($c, $tx) = @_;
- my $h = $tx->req->headers;
-
- # If the request already has an Authorization
- # header, respect it
- unless ($h->authorization) {
- my $auth_token = $c->auth->token or return;
- $h->authorization($auth_token);
-
- }
-
- # TODO:
- # When a request fails because the access token timed out,
- # rerequest with the refresh token.
-
- # TODO:
- # Check if the auth_token is timed out
-
- }
- );
-
# Get or set the user token necessary for authorization
$app->helper(
'auth.token' => sub {
- my ($c, $token) = @_;
+ my ($c, $token, $expires_in) = @_;
- unless ($token) {
- # Get token from stash
- $token = $c->stash('auth');
-
- return $token if $token;
-
- # Get auth from session
- $token = $c->session('auth') or return;
-
- # Set token to stash
+ if ($token) {
+ # Set auth token
$c->stash(auth => $token);
-
- return $token;
+ $c->session(auth => $token);
+ $c->session(auth_exp => time + $expires_in);
+ return 1;
};
- # Set auth token
- $c->stash('auth' => $token);
- $c->session('auth' => $token);
+ # Get token from stash
+ $token = $c->stash('auth');
+
+ return $token if $token;
+
+ # Get auth from session
+ $token = $c->session('auth') or return;
+ $c->stash(auth => $token);
+
+ # Return stashed value
+ return $token;
}
);
@@ -136,38 +132,281 @@
my $client_id = $param->{client_id};
my $client_secret = $param->{client_secret};
- # This refreshes an oauth2 token and
- # returns a promise
+
+ # Sets a requested token and returns
+ # an error, if it didn't work
$app->helper(
- 'auth.refresh_token' => sub {
+ 'auth.set_tokens_p' => sub {
+ my ($c, $json) = @_;
+ my $promise = Mojo::Promise->new;
+
+ # No json object
+ unless ($json) {
+ return $promise->reject({
+ message => 'Response is no valid JSON object (remote)'
+ });
+ };
+
+ # There is an error here
+ # Dealing with errors here
+ if ($json->{error} && ref $json->{error} ne 'ARRAY') {
+ return $promise->reject(
+ {
+ message => $json->{error} . ($json->{error_description} ? ': ' . $json->{error_description} : '')
+ }
+ );
+ }
+
+ # There is an array of errors
+ elsif (my $error = $json->{errors} // $json->{error}) {
+ if (ref $error eq 'ARRAY') {
+ my @errors = ();
+ foreach (@{$error}) {
+ if ($_->[1]) {
+ push @errors, { code => $_->[0], message => $_->[1]}
+ }
+ }
+ return $promise->reject(@errors);
+ }
+
+ return $promise->reject({message => $error});
+ };
+
+ # Everything is fine
+ my $access_token = $json->{access_token};
+ my $token_type = $json->{token_type};
+ my $refresh_token = $json->{refresh_token};
+ my $expires_in = $json->{"expires_in"} // $EXPECTED_EXPIRATION_IN;
+ my $auth = $token_type . ' ' . $access_token;
+ # my $scope = $json->{scope};
+
+ # Set session info
+ $c->session(auth => $auth);
+
+ # Expiration of the token minus tolerance
+ $c->session(auth_exp => time + $expires_in - 60);
+
+ # Set session info for refresh token
+ # This can be stored in the session, as it is useless
+ # unless the client secret is stolen
+ $c->session(auth_r => $refresh_token) if $refresh_token;
+
+ # Set stash info
+ $c->stash(auth => $auth);
+
+ return $promise->resolve;
+ }
+ );
+
+
+ # Refresh tokens and return a promise
+ $app->helper(
+ 'auth.refresh_p' => sub {
my $c = shift;
my $refresh_token = shift;
- unless ($refresh_token) {
- return Mojo::Promise->reject({message => 'Missing refresh token'})
- };
-
# Get OAuth access token
- my $url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
+ state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
- return $c->korap_request('POST', $url, {} => form => {
+ $c->app->log->debug("Refresh at $r_url");
+
+ return $c->kalamar_ua->post_p($r_url, {} => form => {
grant_type => 'refresh_token',
client_id => $client_id,
client_secret => $client_secret,
refresh_token => $refresh_token
})->then(
sub {
- # Set the tokens and return a promise
- return $plugin->set_tokens(
- $c,
- shift->result->json
- )
+ my $tx = shift;
+ my $json = $tx->result->json;
+
+ # Response is fine
+ if ($tx->res->is_success) {
+
+ $c->app->log->info("Refresh was successful");
+
+ # Set the tokens and return a promise
+ return $c->auth->set_tokens_p($json);
+ };
+
+ # There is a client error - refresh fails
+ if ($tx->res->is_client_error && $json) {
+
+ $c->stash(auth => undef);
+ $c->stash(auth_exp => undef);
+ delete $c->session->{user};
+ delete $c->session->{auth};
+ delete $c->session->{auth_r};
+ delete $c->session->{auth_exp};
+
+ # Response is 400
+ return Mojo::Promise->reject(
+ $json->{error_description} // $c->loc('Auth_refreshFail')
+ );
+ };
+
+ $c->notify(error => $c->loc('Auth_responseError'));
+ return Mojo::Promise->reject;
+ }
+ )
+ }
+ );
+
+
+ # Issue a korap request with "oauth"orization
+ # This will override the core request helper
+ $app->helper(
+ korap_request => sub {
+ my $c = shift;
+ my $method = shift;
+ my $path = shift;
+ my @param = @_;
+
+ # TODO:
+ # Check if $tx is not leaked!
+
+ # Get plugin user agent
+ my $ua = $c->kalamar_ua;
+
+ my $url = Mojo::URL->new($path);
+ my $tx = $ua->build_tx(uc($method), $url->clone, @param);
+
+ # Set X-Forwarded for
+ $tx->req->headers->header(
+ 'X-Forwarded-For' => $c->client_ip
+ );
+
+ # Emit Hook to alter request
+ $c->app->plugins->emit_hook(
+ before_korap_request => ($c, $tx)
+ );
+
+ my $h = $tx->req->headers;
+
+ # If the request already has an Authorization
+ # header, respect it!
+ if ($h->authorization) {
+ return $ua->start_p($tx);
+ };
+
+ # Get auth token
+ if (my $auth_token = $c->auth->token) {
+
+ # The token is already expired!
+ my $exp = $c->session('auth_exp');
+ if (defined $exp && $exp < time) {
+
+ # Remove auth ...
+ $c->stash(auth => undef);
+
+ # And get refresh token from session
+ if (my $refresh_token = $c->session('auth_r')) {
+
+ $c->app->log->debug("Refresh is required");
+
+ # Refresh
+ return $c->auth->refresh_p($refresh_token)->then(
+ sub {
+ $c->app->log->debug("Search with refreshed tokens");
+
+ # Tokens were set - now send the request the first time!
+ $tx->req->headers->authorization($c->stash('auth'));
+ return $ua->start_p($tx);
+ }
+ );
+ }
+
+ # The token is expired and no refresh token is
+ # available - issue an unauthorized request!
+ else {
+ $c->stash(auth => undef);
+ $c->stash(auth_exp => undef);
+ delete $c->session->{user};
+ delete $c->session->{auth};
+ delete $c->session->{auth_r};
+ delete $c->session->{auth_exp};
+
+ # Warn on Error!
+ $c->notify(warn => $c->loc('Auth_tokenExpired'));
+ return $ua->start_p($tx);
+ };
+ }
+
+ # Auth token is fine
+ else {
+
+ # Set auth
+ $h->authorization($auth_token);
+ }
+ }
+
+ # No token set
+ else {
+
+ # Return unauthorized request
+ return $ua->start_p($tx);
+ };
+
+ # Issue an authorized request and automatically
+ # refresh the token on expiration!
+ return $ua->start_p($tx)->then(
+ sub {
+ my $tx = shift;
+
+ # Response is fine
+ if ($tx->res->is_success) {
+ return Mojo::Promise->resolve($tx);
+ }
+
+ # There is a client error - maybe refresh!
+ elsif ($tx->res->is_client_error) {
+
+ # Check the error
+ my $json = $tx->res->json('/errors/0/1');
+ if ($json && ($json =~ /expired|invalid/)) {
+ $c->stash(auth => undef);
+ $c->stash(auth_exp => undef);
+ delete $c->session->{user};
+ delete $c->session->{auth};
+
+ # And get refresh token from session
+ if (my $refresh_token = $c->session('auth_r')) {
+
+ # Refresh
+ return $c->auth->refresh_p($refresh_token)->then(
+ sub {
+ $c->app->log->debug("Search with refreshed tokens");
+
+ my $tx = $ua->build_tx(uc($method), $url->clone, @param);
+
+ # Set X-Forwarded for
+ $tx->req->headers->header(
+ 'X-Forwarded-For' => $c->client_ip
+ );
+
+ # Tokens were set - now send the request the first time!
+ $tx->req->headers->authorization($c->stash('auth'));
+ return $ua->start_p($tx);
+ }
+ )
+ };
+
+ # Reject the invalid token
+ $c->notify(error => $c->loc('Auth_tokenInvalid'));
+ return Mojo::Promise->reject;
+ };
+
+ return Mojo::Promise->resolve($tx);
+ };
+
+ $c->notify(error => $c->loc('Auth_responseError'));
+ return Mojo::Promise->reject;
}
);
}
);
- # Password flow
+ # Password flow for OAuth
$r->post('/user/login')->to(
cb => sub {
my $c = shift;
@@ -201,7 +440,7 @@
my $pwd = $v->param('pwd');
- $c->app->log->debug("Login from user $user:XXXX");
+ $c->app->log->debug("Login from user $user");
# <specific>
@@ -218,10 +457,7 @@
})->then(
sub {
# Set the tokens and return a promise
- return $plugin->set_tokens(
- $c,
- shift->result->json
- )
+ return $c->auth->set_tokens_p(shift->result->json)
}
)->catch(
sub {
@@ -271,10 +507,101 @@
return 1;
}
)->name('login');
+
+
+ # Log out of the session
+ $r->get('/user/logout')->to(
+ cb => sub {
+ my $c = shift;
+
+ # TODO: csrf-protection!
+
+ my $refresh_token = $c->session('auth_r');
+
+ # Revoke the token
+ state $url = Mojo::URL->new($c->korap->api)->path('oauth2/revoke');
+
+ $c->kalamar_ua->post_p($url => {} => form => {
+ client_id => $client_id,
+ client_secret => $client_secret,
+ token => $refresh_token,
+ token_type => 'refresh_token'
+ })->then(
+ sub {
+ my $tx = shift;
+ my $json = $tx->result->json;
+
+ my $promise;
+
+ # Response is fine
+ if ($tx->res->is_success) {
+ $c->app->log->info("Revocation was successful");
+ $c->notify(success => $c->loc('Auth_logoutSuccess'));
+
+ $c->stash(auth => undef);
+ $c->stash(auth_exp => undef);
+ $c->flash(handle_or_email => delete $c->session->{user});
+ delete $c->session->{auth};
+ delete $c->session->{auth_r};
+ delete $c->session->{auth_exp};
+ return Mojo::Promise->resolve;
+ }
+
+ # Token may be invalid
+ $c->notify('error', $c->loc('Auth_logoutFail'));
+
+ # There is a client error - refresh fails
+ if ($tx->res->is_client_error && $json) {
+
+ return Mojo::Promise->reject(
+ $json->{error_description}
+ );
+ };
+
+ # Resource may not be found (404)
+ return Mojo::Promise->reject
+
+ }
+ )->catch(
+ sub {
+ my $err = shift;
+
+ # Server may be irresponsible
+ $c->notify('error', $c->loc('Auth_logoutFail'));
+ return Mojo::Promise->reject($err);
+ }
+ )->finally(
+ sub {
+ return $c->redirect_to('index');
+ }
+ )->wait;
+ }
+ )->name('logout');
}
+
# Use JWT login
+ # (should be deprecated)
else {
+ # Inject authorization to all korap requests
+ $app->hook(
+ before_korap_request => sub {
+ my ($c, $tx) = @_;
+ my $h = $tx->req->headers;
+
+ # If the request already has an Authorization
+ # header, respect it
+ unless ($h->authorization) {
+
+ # Get valid auth token and set as header
+ if (my $auth_token = $c->auth->token) {
+ $h->authorization($auth_token);
+ };
+ };
+ }
+ );
+
+ # Password flow with JWT
$r->post('/user/login')->to(
cb => sub {
my $c = shift;
@@ -308,7 +635,7 @@
my $pwd = $v->param('pwd');
- $c->app->log->debug("Login from user $user:XXXX");
+ $c->app->log->debug("Login from user $user");
my $url = Mojo::URL->new($c->korap->api)->path('auth/apiToken');
@@ -353,7 +680,7 @@
# TODO: Deal with user return values.
my $auth = $jwt->{token_type} . ' ' . $jwt->{token};
- $c->app->log->debug(qq!Login successful: "$user" with "$auth"!);
+ $c->app->log->debug(qq!Login successful: "$user"!);
$user = $jwt->{username} ? $jwt->{username} : $user;
@@ -365,8 +692,6 @@
$c->stash(user => $user);
$c->stash(auth => $auth);
- # Set cache
- $c->chi('user')->set($auth => $user);
$c->notify(success => $c->loc('Auth_loginSuccess'));
}
)->catch(
@@ -403,122 +728,63 @@
return 1;
}
)->name('login');
+
+
+ # Log out of the session
+ $r->get('/user/logout')->to(
+ cb => sub {
+ my $c = shift;
+
+ # TODO: csrf-protection!
+
+ # Log out of the system
+ my $url = Mojo::URL->new($c->korap->api)->path('auth/logout');
+
+ $c->korap_request(
+ 'get', $url
+ )->then(
+ # Logged out
+ sub {
+ my $tx = shift;
+ # Clear cache
+ # ?? Necesseary
+ # $c->chi('user')->remove($c->auth->token);
+
+ # TODO:
+ # Revoke refresh token!
+ # based on auth token!
+ # my $refresh_token = $c->chi('user')->get('refr_' . $c->auth->token);
+ # $c->auth->revoke_token($refresh_token)
+
+ # Expire session
+ $c->session(user => undef);
+ $c->session(auth => undef);
+ $c->notify(success => $c->loc('Auth_logoutSuccess'));
+ }
+
+ )->catch(
+ # Something went wrong
+ sub {
+ # my $err_msg = shift;
+ $c->notify('error', $c->loc('Auth_logoutFail'));
+ }
+
+ )->finally(
+ # Redirect
+ sub {
+ return $c->redirect_to('index');
+ }
+ )
+
+ # Start IOLoop
+ ->wait;
+
+ return 1;
+ }
+ )->name('logout');
};
-
-
- # Log out of the session
- $r->get('/user/logout')->to(
- cb => sub {
- my $c = shift;
-
- # TODO: csrf-protection!
-
- # TODO:
- # Revoke refresh token!
-
- # Log out of the system
- my $url = Mojo::URL->new($c->korap->api)->path('auth/logout');
-
- $c->korap_request(
- 'get', $url
- )->then(
- # Logged out
- sub {
- my $tx = shift;
- # Clear cache
- # ?? Necesseary
- # $c->chi('user')->remove($c->auth->token);
-
- # TODO:
- # Revoke refresh token!
- # based on auth token!
- # my $refresh_token = $c->chi('user')->get('refr_' . $c->auth->token);
- # $c->auth->revoke_token($refresh_token)
-
- # Expire session
- $c->session(user => undef);
- $c->session(auth => undef);
- $c->notify(success => $c->loc('Auth_logoutSuccess'));
- }
-
- )->catch(
- # Something went wrong
- sub {
- # my $err_msg = shift;
- $c->notify('error', $c->loc('Auth_logoutFail'));
- }
-
- )->finally(
- # Redirect
- sub {
- return $c->redirect_to('index');
- }
- )
-
- # Start IOLoop
- ->wait;
-
- return 1;
- }
- )->name('logout');
};
-# Sets a requested token and returns
-# an error, if it didn't work
-sub set_tokens {
- my ($plugin, $c, $json) = @_;
-
- my $promise = Mojo::Promise->new;
-
- # No json object
- unless ($json) {
- return $promise->reject({message => 'Response is no valid Json object (remote)'});
- };
-
- # There is an error here
- # Dealing with errors here
- if ($json->{error} && ref $json->{error} ne 'ARRAY') {
- return $promise->reject(
- {
- message => $json->{error} . ($json->{error_description} ? ': ' . $json->{error_description} : '')
- }
- );
- } elsif (my $error = $json->{errors} // $json->{error}) {
- if (ref $error eq 'ARRAY') {
- my @errors = ();
- foreach (@{$error}) {
- if ($_->[1]) {
- push @errors, { code => $_->[0], message => $_->[1]}
- }
- }
- return $promise->reject(@errors);
- }
-
- return $promise->reject({message => $error});
- };
-
- my $access_token = $json->{access_token};
- my $token_type = $json->{token_type};
- my $refresh_token = $json->{refresh_token};
- # my $scope = $json->{scope};
- my $expires_in = $json->{"expires_in"} // $EXPECTED_EXPIRATION_IN;
- my $auth = $token_type . ' ' . $access_token;
-
- # Set session info
- $c->session(auth => $auth);
-
- # Set stash info
- $c->stash(auth => $auth);
-
- # Remember refresh token in cache
- $c->chi('user')->set(
- "refr_" . $auth => $refresh_token,
- $expires_in
- );
-
- return $promise->resolve;
-}
-
1;
__DATA__
diff --git a/lib/Kalamar/Plugin/KalamarHelpers.pm b/lib/Kalamar/Plugin/KalamarHelpers.pm
index f97f453..4731e4d 100644
--- a/lib/Kalamar/Plugin/KalamarHelpers.pm
+++ b/lib/Kalamar/Plugin/KalamarHelpers.pm
@@ -338,7 +338,7 @@
my $tx = shift;
# Catch errors and warnings
return ($c->catch_errors_and_warnings($tx) ||
- Mojo::Promise->new->reject);
+ Mojo::Promise->reject);
}
);
};
@@ -380,6 +380,9 @@
# Cache on success
sub {
my $json = shift;
+
+ $c->app->log->debug("Receiving promised results");
+
$c->chi->set($cache_str => $json);
return $json;
}
diff --git a/lib/Kalamar/Plugin/KalamarUser.pm b/lib/Kalamar/Plugin/KalamarUser.pm
index c1a6b0c..fbda902 100644
--- a/lib/Kalamar/Plugin/KalamarUser.pm
+++ b/lib/Kalamar/Plugin/KalamarUser.pm
@@ -8,10 +8,6 @@
has 'ua';
# TODO:
-# This Plugin will be removed in favour of
-# Kalamar::Plugin::Auth!
-
-# TODO:
# Replace plugin-api with korap->api!
sub register {
diff --git a/t/plugin/auth-oauth.t b/t/plugin/auth-oauth.t
index 17c3cda..f03f61e 100644
--- a/t/plugin/auth-oauth.t
+++ b/t/plugin/auth-oauth.t
@@ -1,6 +1,6 @@
use Mojo::Base -strict;
use Test::More;
-use Test::Mojo;
+use Test::Mojo::WithRoles 'Session';
use Mojo::File qw/path/;
use Data::Dumper;
@@ -11,7 +11,7 @@
my $mount_point = '/realapi/';
$ENV{KALAMAR_API} = $mount_point;
-my $t = Test::Mojo->new('Kalamar' => {
+my $t = Test::Mojo::WithRoles->new('Kalamar' => {
Kalamar => {
plugins => ['Auth']
},
@@ -32,7 +32,70 @@
}
);
# Configure fake backend
-$fake_backend->pattern->defaults->{app}->log($t->app->log);
+my $fake_backend_app = $fake_backend->pattern->defaults->{app};
+
+# Set general app logger for simplicity
+$fake_backend_app->log($t->app->log);
+
+my $access_token = $fake_backend_app->get_token('access_token');
+my $refresh_token = $fake_backend_app->get_token('refresh_token');
+my $access_token_2 = $fake_backend_app->get_token('access_token_2');
+my $refresh_token_2 = $fake_backend_app->get_token('refresh_token_2');
+
+# Some routes to modify the session
+# This expires the session
+$t->app->routes->get('/x/expire')->to(
+ cb => sub {
+ my $c = shift;
+ $c->session(auth_exp => 0);
+ return $c->render(text => 'okay')
+ }
+);
+
+# This expires the session and removes the refresh token
+$t->app->routes->get('/x/expire-no-refresh')->to(
+ cb => sub {
+ my $c = shift;
+ $c->session(auth_exp => 0);
+ delete $c->session->{auth_r};
+ return $c->render(text => 'okay')
+ }
+);
+
+# This sets an invalid token
+$t->app->routes->get('/x/invalid')->to(
+ cb => sub {
+ my $c = shift;
+ $c->session(auth_exp => time + 1000);
+ $c->session(auth_r => $refresh_token_2);
+ $c->session(auth => 'Bearer inv4lid');
+ return $c->render(text => 'okay')
+ }
+);
+
+
+# This sets an invalid token
+$t->app->routes->get('/x/invalid-no-refresh')->to(
+ cb => sub {
+ my $c = shift;
+ $c->session(auth_exp => time + 1000);
+ delete $c->session->{auth_r};
+ $c->session(auth => 'Bearer inv4lid');
+ return $c->render(text => 'okay')
+ }
+);
+
+# This sets an invalid refresh token
+$t->app->routes->get('/x/expired-with-wrong-refresh')->to(
+ cb => sub {
+ my $c = shift;
+ $c->session(auth_exp => 0);
+ $c->session(auth => 'Bearer inv4lid');
+ $c->session(auth_r => 'inv4lid');
+ return $c->render(text => 'okay')
+ }
+);
+
$t->get_ok('/realapi/v1.0')
->status_is(200)
@@ -137,6 +200,10 @@
# search with authorization
$t->get_ok('/?q=Baum')
->status_is(200)
+ ->session_has('/auth')
+ ->session_is('/auth', 'Bearer ' . $access_token)
+ ->session_is('/auth_r', $refresh_token)
+ ->session_is('/user', 'test')
->text_like('h1 span', qr/KorAP: Find .Baum./i)
->text_like('#total-results', qr/\d+$/)
->element_exists_not('div.notify-error')
@@ -148,6 +215,9 @@
# Logout
$t->get_ok('/user/logout')
->status_is(302)
+ ->session_hasnt('/auth')
+ ->session_hasnt('/auth_r')
+ ->session_hasnt('/user')
->header_is('Location' => '/');
$t->get_ok('/')
@@ -155,6 +225,8 @@
->element_exists_not('div.notify-error')
->element_exists('div.notify-success')
->text_is('div.notify-success', 'Logout successful')
+ ->element_exists("input[name=handle_or_email]")
+ ->element_exists("input[name=handle_or_email][value=test]")
;
$t->get_ok('/?q=Baum')
@@ -203,91 +275,113 @@
->element_exists_not('div.notify-error')
->element_exists('div.notify-success')
->text_is('div.notify-success', 'Login successful')
+ ->session_has('/auth')
+ ->session_is('/auth', 'Bearer ' . $access_token)
+ ->session_is('/auth_r', $refresh_token)
+ ->header_isnt('X-Kalamar-Cache', 'true')
;
-$t->app->routes->get(
- '/user/refresh' => sub {
- my $c = shift;
-
- my $old_auth = $c->auth->token;
- my $refresh = $c->chi('user')->get("refr_$old_auth");
-
- $c->auth->refresh_token($refresh)->then(
- sub {
- my $new_auth = $c->auth->token;
- $c->notify(success => $new_auth . ' vs. ' . $old_auth);
- }
- )->catch(
- sub {
-
- # Notify the user on login failure
- unless (@_) {
- $c->notify(error => $c->loc('Auth_refreshFail'));
- }
-
- # There are known errors
- foreach (@_) {
- if (ref $_ eq 'HASH') {
- my $err = ($_->{code} ? $_->{code} . ': ' : '') .
- $_->{message};
- $c->notify(error => $err);
- }
- else {
- $c->notify(error => $_);
- }
- };
- }
- )->finally(
- sub {
- return $c->redirect_to('index');
- }
- )->wait;
- }
-);
-
-$t->get_ok('/user/refresh')
- ->status_is(302)
- ->header_is('Location' => '/');
-
-$t->get_ok('/')
+# Expire the session
+# (makes the token be marked as expired - though it isn't serverside)
+$t->get_ok('/x/expire')
->status_is(200)
- ->element_exists_not('div.notify-error')
- ->element_exists('div.notify-success')
- ->text_like('div.notify-success', qr!Bearer abcde vs\. Bearer .{6,}!)
+ ->content_is('okay')
+ ;
+
+## It may be a problem, but the cache is still valid
+$t->get_ok('/?q=Baum')
+ ->status_is(200)
+ ->text_like('h1 span', qr/KorAP: Find .Baum./i)
+ ->text_like('#total-results', qr/\d+$/)
+ ->content_like(qr/\"authorized\"\:\"yes\"/)
+ ->header_is('X-Kalamar-Cache', 'true')
+ ;
+
+# Query without partial cache (unfortunately) (but no total results)
+$t->get_ok('/?q=baum&cutoff=true')
+ ->status_is(200)
+ ->session_is('/auth', 'Bearer ' . $access_token_2)
+ ->session_is('/auth_r', $refresh_token_2)
+ ->text_is('#error','')
+ ->text_is('title', 'KorAP: Find »baum« with Poliqarp')
+ ->element_exists('meta[name="DC.title"][content="KorAP: Find »baum« with Poliqarp"]')
+ ->element_exists('body[itemscope][itemtype="http://schema.org/SearchResultsPage"]')
+ ->content_like(qr/\"authorized\"\:\"yes\"/)
+ ->header_isnt('X-Kalamar-Cache', 'true')
+ ->content_like(qr!\"cutOff":true!)
+ ->element_exists_not('#total-results')
+ ;
+
+# Expire the session and remove the refresh option
+$t->get_ok('/x/expire-no-refresh')
+ ->status_is(200)
+ ->content_is('okay')
+ ;
+
+$t->app->defaults(no_cache => 1);
+
+
+$t->get_ok('/x/invalid-no-refresh')
+ ->status_is(200)
+ ->content_is('okay')
+ ;
+
+# Query without cache
+# The token is invalid and can't be refreshed!
+$t->get_ok('/?q=baum&cutoff=true')
+ ->status_is(200)
+ ->session_hasnt('/auth')
+ ->session_hasnt('/auth_r')
+ ->text_is('#error','')
+ ->text_is('div.notify-error','Access token invalid')
+ ->text_is('title', 'KorAP: Find »baum« with Poliqarp')
+ ->element_exists('meta[name="DC.title"][content="KorAP: Find »baum« with Poliqarp"]')
+ ->element_exists('body[itemscope][itemtype="http://schema.org/SearchResultsPage"]')
+ ->content_unlike(qr/\"authorized\"\:\"yes\"/)
+ ->header_isnt('X-Kalamar-Cache', 'true')
+ ->element_exists('p.no-results')
+ ;
+
+$t->get_ok('/x/invalid')
+ ->status_is(200)
+ ->content_is('okay')
+ ;
+
+# Query without cache
+# The token is invalid and can't be refreshed!
+$t->get_ok('/?q=baum&cutoff=true')
+ ->status_is(200)
+ ->session_is('/auth', 'Bearer ' . $access_token_2)
+ ->session_is('/auth_r', $refresh_token_2)
+ ->text_is('#error','')
+ ->element_exists_not('div.notify-error','Access token invalid')
+ ->text_is('title', 'KorAP: Find »baum« with Poliqarp')
+ ->element_exists('meta[name="DC.title"][content="KorAP: Find »baum« with Poliqarp"]')
+ ->element_exists('body[itemscope][itemtype="http://schema.org/SearchResultsPage"]')
+ ->content_like(qr/\"authorized\"\:\"yes\"/)
+ ->header_isnt('X-Kalamar-Cache', 'true')
+ ->element_exists_not('p.no-results')
;
-# Test before_korap_request_hook
-my $app = $t->app;
-my $c = $app->build_controller;
-my $tx = $app->build_tx('GET', 'https://korap.ids-mannheim.de/');
+$t->get_ok('/x/expired-with-wrong-refresh')
+ ->status_is(200)
+ ->content_is('okay')
+ ;
-# Emit Hook to alter request
-$app->plugins->emit_hook(
- before_korap_request => ($c, $tx)
-);
-ok(!$tx->req->headers->authorization, 'No authorization');
+# The token is invalid and can't be refreshed!
+$t->get_ok('/?q=baum&cutoff=true')
+ ->status_is(200)
+ ->session_hasnt('/auth')
+ ->session_hasnt('/auth_r')
+ ->text_is('#error','')
+ ->text_is('div.notify-error','Refresh token is expired')
+ ->text_is('title', 'KorAP: Find »baum« with Poliqarp')
+ ->content_unlike(qr/\"authorized\"\:\"yes\"/)
+ ->element_exists('p.no-results')
+ ;
-# Set token
-$c->auth->token('abcd');
-
-# Emit Hook to alter request
-$app->plugins->emit_hook(
- before_korap_request => ($c, $tx)
-);
-
-is($tx->req->headers->authorization, 'abcd', 'authorization');
-
-# Override authorization in header
-$tx->req->headers->authorization('xyz');
-
-# Emit Hook to alter request
-$app->plugins->emit_hook(
- before_korap_request => ($c, $tx)
-);
-
-is($tx->req->headers->authorization, 'xyz', 'authorization');
done_testing;
__END__
diff --git a/t/proxy.t b/t/proxy.t
index f30e049..2e2f922 100644
--- a/t/proxy.t
+++ b/t/proxy.t
@@ -33,22 +33,44 @@
# Globally set server
$t->app->ua->server->app($t->app);
+my $rendered = 0;
+$t->app->hook(
+ after_render => sub {
+ $rendered++;
+ }
+);
+
+$t->get_ok('/doc')
+ ->status_is(200)
+ ->text_like('title', qr!KorAP!)
+ ;
+
+is($rendered, 1);
+
$t->get_ok('/realapi/v1.0')
->status_is(200)
->content_is('Fake server available')
;
+is($rendered, 1);
+
$t->get_ok('/api/v1.0/')
->status_is(200)
->content_is('Fake server available')
;
+# Proxy renders
+is($rendered, 2);
+
$t->get_ok('/api/v1.0/search?ql=cosmas3')
->status_is(400)
->json_is('/errors/0/0','307')
->header_is('connection', 'close')
;
+# Proxy renders
+is($rendered, 3);
+
$t->post_ok('/api/v1.0/oauth2/token' => {} => form => {
username => 'test',
password => 'pass'
diff --git a/t/query.t b/t/query.t
index 4532167..3252bac 100644
--- a/t/query.t
+++ b/t/query.t
@@ -161,6 +161,12 @@
->content_unlike(qr!\"cutOff":true!)
;
+# Check pagination repetion of page
+my $next_href = $t->get_ok('/?q=der&p=1&count=2')
+ ->tx->res->dom->at('#pagination a[rel=next]')->attr('href');
+like($next_href, qr/p=2/);
+unlike($next_href, qr/p=1/);
+
# Query with page information - next page
$t->get_ok('/?q=der&p=2&count=2')
->status_is(200)
@@ -225,10 +231,13 @@
->text_is('#total-results', '> 4,274,841');
;
+$t->app->defaults(no_cache => 1);
+
# Query with collection
$t->get_ok('/?q=baum&collection=availability+%3D+%2FCC-BY.*%2F')
->status_is(200)
->element_exists("input#cq[value='availability = /CC-BY.*/']")
+ ->content_like(qr!\"availability\"!)
->text_is('#error','')
;
@@ -236,8 +245,10 @@
$t->get_ok('/?q=baum&cq=availability+%3D+%2FCC-BY.*%2F')
->status_is(200)
->element_exists("input#cq[value='availability = /CC-BY.*/']")
+ ->content_like(qr!\"availability\"!)
->text_is('#error','')
;
+
done_testing;
__END__
diff --git a/t/server/mock.pl b/t/server/mock.pl
index 961c6ea..2e14035 100644
--- a/t/server/mock.pl
+++ b/t/server/mock.pl
@@ -14,6 +14,18 @@
my $secret = 's3cr3t';
my $fixture_path = path(Mojo::File->new(__FILE__)->dirname)->child('..', 'fixtures');
+our %tokens = (
+ "access_token" => "4dcf8784ccfd26fac9bdb82778fe60e2",
+ "refresh_token" => "hlWci75xb8atDiq3924NUSvOdtAh7Nlf9z",
+ "access_token_2" => "abcde",
+ "refresh_token_2" => "fghijk"
+);
+
+helper get_token => sub {
+ my ($c, $token) = @_;
+ return $tokens{$token}
+};
+
# Legacy:
helper jwt_encode => sub {
shift;
@@ -32,6 +44,24 @@
return Mojo::JWT->new(secret => $secret)->decode($auth);
};
+# Expiration helper
+helper expired => sub {
+ my ($c, $auth, $set) = @_;
+
+
+ $auth =~ s/^[^ ]+? //;
+ if ($set) {
+ $c->app->log->debug("Set $auth for expiration");
+ $c->app->defaults('auth_' . $auth => 1);
+ return 1;
+ };
+
+ $c->app->log->debug("Check $auth for expiration: " . (
+ $c->app->defaults('auth_' . $auth) // '0'
+ ));
+
+ return $c->app->defaults('auth_' . $auth);
+};
# Load fixture responses
helper 'load_response' => sub {
@@ -111,14 +141,40 @@
# Check authentification
if (my $auth = $c->req->headers->header('Authorization')) {
+ $c->app->log->debug("There is an authorization header $auth");
my $jwt;
if ($auth =~ /^Bearer/) {
# Username unknown in OAuth2
$response->{json}->{meta}->{authorized} = 'yes';
}
- elsif ($jwt = $c->jwt_decode($auth)) {
+ elsif ($auth =~ /^api_token/ && ($jwt = $c->jwt_decode($auth))) {
$response->{json}->{meta}->{authorized} = $jwt->{username} if $jwt->{username};
};
+
+ # Code is expired
+ if ($c->expired($auth)) {
+
+ $c->app->log->debug("The access token has expired");
+
+ return $c->render(
+ status => 401,
+ json => {
+ errors => [[2003, 'Access token is expired']]
+ }
+ );
+ }
+
+ # Auth token is invalid
+ if ($auth =~ /^Bearer inv4lid/) {
+ $c->app->log->debug("The access token is invalid");
+
+ return $c->render(
+ status => 401,
+ json => {
+ errors => [[2011, 'Access token is invalid']]
+ }
+ );
+ }
};
# Set page parameter
@@ -199,7 +255,13 @@
my $c = shift;
if (my $auth = $c->req->headers->header('Authorization')) {
- if (my $jwt = $c->jwt_decode($auth)) {
+
+ if ($auth =~ /^Bearer/) {
+ $c->app->log->debug('Server-Logout: ' . $auth);
+ return $c->render(json => { msg => [[0, 'Fine!']]});
+ }
+
+ elsif (my $jwt = $c->jwt_decode($auth)) {
my $user = $jwt->{username} if $jwt->{username};
$c->app->log->debug('Server-Logout: ' . $user);
@@ -341,8 +403,8 @@
# Return fine access
return $c->render(
json => {
- "access_token" => "4dcf8784ccfd26fac9bdb82778fe60e2",
- "refresh_token" => "hlWci75xb8atDiq3924NUSvOdtAh7Nlf9z",
+ "access_token" => $c->get_token('access_token'),
+ "refresh_token" => $c->get_token('refresh_token'),
"scope" => "all",
"token_type" => "Bearer",
"expires_in" => 86400
@@ -351,11 +413,24 @@
# Refresh token
elsif ($grant_type eq 'refresh_token') {
+
+ if ($c->param('refresh_token') eq 'inv4lid') {
+ return $c->render(
+ status => 400,
+ json => {
+ "error_description" => "Refresh token is expired",
+ "error" => "invalid_grant"
+ }
+ );
+ };
+
+ $c->app->log->debug("Refresh the token in the mock server!");
+
return $c->render(
status => 200,
json => {
- "access_token" => "abcde",
- "refresh_token" => "fghijk",
+ "access_token" => $c->get_token("access_token_2"),
+ "refresh_token" => $c->get_token("refresh_token_2"),
"token_type" => "Bearer",
"expires_in" => 86400
}
@@ -377,6 +452,26 @@
}
};
+# Revoke API token
+post '/v1.0/oauth2/revoke' => sub {
+ my $c = shift;
+
+ my $refresh_token = $c->param('token');
+
+ if ($c->param('client_secret') ne 'k414m4r-s3cr3t') {
+ return $c->render(
+ json => {
+ "error_description" => "Invalid client credentials",
+ "error" => "invalid_client"
+ },
+ status => 401
+ );
+ };
+
+ return $c->render(
+ text => ''
+ )
+};
app->start;
diff --git a/templates/failure.html.ep b/templates/failure.html.ep
index 5cb98a1..9155c31 100644
--- a/templates/failure.html.ep
+++ b/templates/failure.html.ep
@@ -8,5 +8,5 @@
<p class="no-results"><%= loc('notIssued') %></p>
% if (stash('err_msg')) {
-<p class="no-results"><%= loc(stash('err_msg'),stash('err_msg')) %></p>
+<p class="no-results"><%== loc(stash('err_msg'),stash('err_msg')) %></p>
% }
diff --git a/templates/search.html.ep b/templates/search.html.ep
index be95990..a349401 100644
--- a/templates/search.html.ep
+++ b/templates/search.html.ep
@@ -1,7 +1,7 @@
% layout 'main', title => loc('searchtitle', q => stash('q'), ql => stash('ql')), schematype => 'SearchResultsPage';
<div id="resultinfo" <% if (stash('results')->size) { %> class="found"<%} %>>
- <div id="pagination"><%= pagination(stash('start_page'), stash('total_pages'), url_with->query(['p' => '{page}'])) =%></div>
+ <div id="pagination"><%= pagination(stash('start_page'), stash('total_pages'), url_with->query({'p' => '{page}'})) =%></div>
% my $found = stash('total_results') // 0;
<p class="found">\
% if ($found != -1) {