Initial token management
Change-Id: I6177b46961b7a0e53b9fa1fa9430a4d5562ae2da
diff --git a/Changes b/Changes
index 45365b1..7267d4d 100755
--- a/Changes
+++ b/Changes
@@ -2,6 +2,7 @@
- Support X-Forwarded-Host name for proxy.
- Document API URI.
- Improve redirect handling in proxy.
+ - Added support for OAuth2 client registration.
0.37 2020-01-16
- Removed deprecated 'kalamar_test_port' helper.
diff --git a/dev/scss/base.scss b/dev/scss/base.scss
index f6dc152..2d71b4d 100644
--- a/dev/scss/base.scss
+++ b/dev/scss/base.scss
@@ -3,6 +3,7 @@
@import "base/flextable";
@import "base/fragment";
@import "base/load";
+@import "base/form";
/**
* Basic global CSS rules for Kalamar
@@ -84,6 +85,10 @@
}
}
+button {
+ cursor: pointer;
+}
+
button[type=submit] {
font-weight: normal;
@include choose-item;
diff --git a/dev/scss/base/form.scss b/dev/scss/base/form.scss
new file mode 100644
index 0000000..b2ae781
--- /dev/null
+++ b/dev/scss/base/form.scss
@@ -0,0 +1,87 @@
+// This class defines form views
+
+.form-table {
+ display: block;
+ padding: 0;
+
+ fieldset {
+ border-width: 0;
+ padding: 0;
+ margin: 0;
+ // margin-left: 5em;
+ }
+
+ fieldset > div {
+ white-space: nowrap;
+ }
+
+ legend {
+ background-color: transparent;
+ margin-left: 0;
+ font-weight: bold;
+ border-radius: $standard-border-radius;
+ }
+
+ label, input[type=radio] {
+ font-size: 80%;
+ }
+
+ label[for] {
+ display: block;
+ text-align: left;
+ }
+
+ label[for], input[type=submit] {
+ margin-top: 2em;
+ }
+
+ input, textarea, button {
+ border-radius: $standard-border-radius;
+ }
+
+ input, textarea {
+ border-color: $ids-grey-2;
+ background-color: $nearly-white;
+ border-style: solid;
+ }
+
+ input, textarea, select {
+ display: inline-block;
+ width: 20%;
+ min-width: 20em;
+ // margin: 0 20pt 0 20pt;
+ padding: $base-padding;
+ }
+
+ input[type=radio] {
+ display: inline;
+ text-aline: right;
+ background-color: red;
+ width: auto;
+ min-width: auto;
+ }
+
+ input[readonly=readonly],textarea[readonly] {
+ background-color: $light-orange; // rgba(0,0,0,0.5);
+ }
+
+ .field-with-error {
+ border-color: $ids-pink-1;
+ }
+
+ input:not([type=radio]), button {
+ height: 3em;
+ }
+
+ input[type=submit], button {
+ display: inline-block;
+ text-align: center;
+ background-color: $middle-green;
+ border-color: $dark-green;
+ }
+
+ label.field-required::after {
+ color: $ids-blue-1;
+ content:'*';
+ }
+}
\ No newline at end of file
diff --git a/lib/Kalamar.pm b/lib/Kalamar.pm
index 2ef30df..2f1632b 100644
--- a/lib/Kalamar.pm
+++ b/lib/Kalamar.pm
@@ -272,9 +272,18 @@
$r->get('/doc')->to('documentation#page', page => 'korap')->name('doc_start');
$r->get('/doc/:scope/:page')->to('documentation#page', scope => undef)->name('doc');
- # Settings routes (deactivated)
- # $r->get('/settings')->to(cb => sub { shift->render('settings') })->name('settings_start');
- # $r->get('/settings/:scope/:page')->to(scope => undef, page => undef)->name('settings');
+ # Settings routes
+ if ($self->navi->exists('settings')) {
+ $r->get('/settings')->to(
+ cb => sub {
+ return shift->render('settings')
+ }
+ )->name('settings_start');
+ $r->get('/settings/:scope/:page')->to(
+ scope => undef,
+ page => undef
+ )->name('settings');
+ };
# Contact route
$r->get('/contact')->to('documentation#contact');
@@ -318,7 +327,7 @@
=head2 COPYRIGHT AND LICENSE
-Copyright (C) 2015-2019, L<IDS Mannheim|http://www.ids-mannheim.de/>
+Copyright (C) 2015-2020, L<IDS Mannheim|http://www.ids-mannheim.de/>
Author: L<Nils Diewald|http://nils-diewald.de/>
Kalamar is developed as part of the L<KorAP|http://korap.ids-mannheim.de/>
diff --git a/lib/Kalamar/Plugin/Auth.pm b/lib/Kalamar/Plugin/Auth.pm
index 7072f9f..920eb46 100644
--- a/lib/Kalamar/Plugin/Auth.pm
+++ b/lib/Kalamar/Plugin/Auth.pm
@@ -1,5 +1,7 @@
package Kalamar::Plugin::Auth;
use Mojo::Base 'Mojolicious::Plugin';
+use File::Basename 'dirname';
+use File::Spec::Functions qw/catdir/;
use Mojo::ByteStream 'b';
# This is a plugin to deal with the Kustvakt OAuth server.
@@ -47,6 +49,7 @@
$app->log->error('client_id or client_secret not defined');
};
+ # Load localize
$app->plugin('Localize' => {
dict => {
Auth => {
@@ -61,7 +64,20 @@
tokenExpired => 'Zugriffstoken abgelaufen',
tokenInvalid => 'Zugriffstoken ungültig',
refreshFail => 'Fehlerhafter Refresh-Token',
- responseError => 'Unbekannter Autorisierungsfehler'
+ responseError => 'Unbekannter Autorisierungsfehler',
+ paramError => 'Einige Eingaben sind fehlerhaft',
+ redirectUri => 'Weiterleitungs-Adresse',
+ homepage => 'Webseite',
+ desc => 'Kurzbeschreibung',
+ clientCredentials => 'Client Daten',
+ clientType => 'Art der Client-Applikation',
+ clientName => 'Name der Client-Applikation',
+ clientID => 'ID der Client-Applikation',
+ clientSecret => 'Client-Secret',
+ clientRegister => 'Neue Client-Applikation registrieren',
+ registerSuccess => 'Registrierung erfolgreich',
+ registerFail => 'Registrierung fehlgeschlagen',
+ oauthSettings => 'OAuth',
},
-en => {
loginSuccess => 'Login successful',
@@ -73,7 +89,20 @@
tokenExpired => 'Access token expired',
tokenInvalid => 'Access token invalid',
refreshFail => 'Bad refresh token',
- responseError => 'Unknown authorization error'
+ responseError => 'Unknown authorization error',
+ paramError => 'Some fields are invalid',
+ redirectUri => 'Redirect URI',
+ homepage => 'Homepage',
+ desc => 'Short description',
+ clientCredentials => 'Client Credentials',
+ clientType => 'Type of the client application',
+ clientName => 'Name of the client application',
+ clientID => 'ID of the client application',
+ clientSecret => 'Client secret',
+ clientRegister => 'Register new client application',
+ registerSuccess => 'Registration successful',
+ registerFail => 'Registration denied',
+ oauthSettings => 'OAuth',
}
}
}
@@ -95,6 +124,11 @@
}
);
+ # The plugin path
+ my $path = catdir(dirname(__FILE__), 'Auth');
+
+ # Append "templates"
+ push @{$app->renderer->paths}, catdir($path, 'templates');
# Get or set the user token necessary for authorization
$app->helper(
@@ -577,6 +611,110 @@
)->wait;
}
)->name('logout');
+
+ # If "experimental_registration" is set, open
+ # OAuth registration dialogues.
+ if ($param->{experimental_client_registration}) {
+
+ # Add settings
+ $app->navi->add(settings => (
+ $app->loc('Auth_oauthSettings'), 'oauth'
+ ));
+
+ # Route to oauth settings
+ $r->get('/settings/oauth')->to(
+ cb => sub {
+ return shift->render(template => 'auth/tokens')
+ }
+ );
+
+ # Route to oauth client registration
+ $r->post('/settings/oauth/register')->to(
+ cb => sub {
+ my $c = shift;
+ my $v = $c->validation;
+
+ unless ($c->auth->token) {
+
+ # TODO: not allowed
+ return $c->reply->not_found;
+ };
+
+ $v->csrf_protect;
+ $v->required('name', 'trim')->size(3, 255);
+ $v->required('type')->in('PUBLIC', 'CONFIDENTIAL');
+ $v->required('desc', 'trim')->size(3, 255);
+ $v->optional('url', 'trim')->like(qr/^(http|$)/i);
+ $v->optional('redirectUri', 'trim')->like(qr/^(http|$)/i);
+
+ # Render with error
+ if ($v->has_error) {
+ if ($v->has_error('csrf_token')) {
+ $c->notify(error => $c->loc('Auth_csrfFail'));
+ }
+ else {
+ $c->notify(warn => $c->loc('Auth_paramError'));
+ };
+ return $c->render(template => 'auth/tokens')
+ };
+
+ # Wait for async result
+ $c->render_later;
+
+ # Register on server
+ state $url = Mojo::URL->new($c->korap->api)->path('oauth2/client/register');
+ $c->korap_request('POST', $url => {} => json => {
+ name => $v->param('name'),
+ type => $v->param('type'),
+ description => $v->param('desc'),
+ url => $v->param('url'),
+ redirectURI => $v->param('redirectURI')
+ })->then(
+ sub {
+ my $tx = shift;
+ my $result = $tx->result;
+
+ if ($result->is_error) {
+ return Mojo::Promise->reject;
+ };
+
+ my $json = $result->json;
+
+ # TODO:
+ # Respond in template
+ my $client_id = $json->{client_id};
+ my $client_secret = $json->{client_secret};
+
+ $c->stash('client_name' => $v->param('name'));
+ $c->stash('client_desc' => $v->param('desc'));
+ $c->stash('client_type' => $v->param('type'));
+ $c->stash('client_url' => $v->param('url'));
+ $c->stash('client_redirect_uri' => $v->param('redirectURI'));
+ $c->stash('client_id' => $client_id);
+
+ if ($client_secret) {
+ $c->stash('client_secret' => $client_secret);
+ };
+
+ $c->notify(success => $c->loc('Auth_en_registerSuccess'));
+
+ return $c->render(template => 'auth/register-success');
+ }
+ )->catch(
+ sub {
+ # Server may be irresponsible
+ my $err = shift;
+ $c->notify('error' => $c->loc('Auth_en_registerFail'));
+ return Mojo::Promise->reject($err);
+ }
+ )->finally(
+ sub {
+ return $c->redirect_to('settings' => { scope => 'oauth' });
+ }
+ );
+ }
+ )->name('oauth-register');
+ };
}
# Use JWT login
@@ -783,6 +921,8 @@
}
)->name('logout');
};
+
+ $app->log->info('Successfully registered Auth plugin');
};
1;
@@ -821,3 +961,68 @@
__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+Kalamar::Plugin::Auth - OAuth-2.0-based authorization plugin
+
+=head1 DESCRIPTION
+
+L<Kalamar::Plugin::Auth> is an OAuth-2.0-based authorization
+plugin for L<Kalamar>. It requires a C<Kustvakt> full server
+with OAuth 2.0 capabilities.
+It is activated by loading C<Auth> as a plugin in the C<Kalamar.plugins>
+parameter in the Kalamar configuration.
+
+=head1 CONFIGURATION
+
+L<Kalamar::Plugin::Auth> supports the following parameter for the
+C<Kalamar-Auth> configuration section in the Kalamar configuration:
+
+=over 2
+
+=item B<client_id>
+
+The client identifier of Kalamar to be send with every OAuth 2.0
+management request.
+
+=item B<client_secret>
+
+The client secret of Kalamar to be send with every OAuth 2.0
+management request.
+
+=item B<oauth2>
+
+Initially L<Kalamar-Plugin-Auth> was based on JWT. This parameter
+is historically used to switch between oauth2 and jwt. It is expected
+to be deprecated in the future, but for the moment it is required
+to be set to a true value.
+
+=item B<experimental_client_registration>
+
+Activates the oauth client registration flow.
+
+=back
+
+=head2 COPYRIGHT AND LICENSE
+
+Copyright (C) 2015-2020, L<IDS Mannheim|http://www.ids-mannheim.de/>
+Author: L<Nils Diewald|http://nils-diewald.de/>
+
+Kalamar is developed as part of the L<KorAP|http://korap.ids-mannheim.de/>
+Corpus Analysis Platform at the
+L<Leibniz Institute for the German Language (IDS)|http://ids-mannheim.de/>,
+member of the
+L<Leibniz-Gemeinschaft|http://www.leibniz-gemeinschaft.de>
+and supported by the L<KobRA|http://www.kobra.tu-dortmund.de> project,
+funded by the
+L<Federal Ministry of Education and Research (BMBF)|http://www.bmbf.de/en/>.
+
+Kalamar is free software published under the
+L<BSD-2 License|https://raw.githubusercontent.com/KorAP/Kalamar/master/LICENSE>.
+
+=cut
diff --git a/lib/Kalamar/Plugin/Auth/templates/auth/register-success.html.ep b/lib/Kalamar/Plugin/Auth/templates/auth/register-success.html.ep
new file mode 100644
index 0000000..2218236
--- /dev/null
+++ b/lib/Kalamar/Plugin/Auth/templates/auth/register-success.html.ep
@@ -0,0 +1,24 @@
+% extends 'settings', title => 'KorAP: '.loc('Auth_oauthSettings'), page => 'oauth';
+
+%= page_title
+
+<form class="form-table">
+ <fieldset>
+ <legend><%= loc 'Auth_clientCredentials' %></legend>
+ <p><strong><%= stash 'client_name' %></strong></p>
+ % if (stash('client_desc')) {
+ <p><%= stash 'client_desc' %></p>
+ % };
+ <p><%= loc 'Auth_clientType' %>: <%= stash 'client_type' %></p>
+ <div>
+ %= label_for 'client_id' => loc('Auth_clientID')
+ %= text_field 'client_id', stash('client_id'), readonly => 'readonly'
+ </div>
+ % if (stash('client_type') ne 'PUBLIC') {
+ <div>
+ %= label_for 'client_secret' => loc('Auth_clientSecret')
+ %= password_field 'client_secret', value => stash('client_secret'), readonly => 'readonly'
+ </div>
+ % };
+ </fieldset>
+</form>
diff --git a/lib/Kalamar/Plugin/Auth/templates/auth/tokens.html.ep b/lib/Kalamar/Plugin/Auth/templates/auth/tokens.html.ep
new file mode 100644
index 0000000..d5fe095
--- /dev/null
+++ b/lib/Kalamar/Plugin/Auth/templates/auth/tokens.html.ep
@@ -0,0 +1,41 @@
+% extends 'settings', title => 'KorAP: '.loc('Auth_oauthSettings'), page => 'oauth';
+
+%= page_title
+
+%= form_for 'oauth-register', class => 'form-table oauth-register', begin
+ <fieldset>
+ %= csrf_field
+ <legend><%= loc('Auth_clientRegister') %></legend>
+
+ <div>
+ %= label_for name => loc('Auth_clientName'), class => 'field-required', maxlength => 255
+ %= text_field 'name'
+ </div>
+
+ <div>
+ %= label_for type => loc('Auth_clientType'), class => 'field-required'
+ <%= radio_button type => 'PUBLIC', checked => 'checked' %>
+ <label>Public</label>
+ <br />
+ <%= radio_button type => 'CONFIDENTIAL' %>
+ <label>Confidential</label>
+ </div>
+
+ <div>
+ %= label_for 'desc' => loc('Auth_desc'), class => 'field-required'
+ %= text_field 'desc'
+ </div>
+
+ <div>
+ %= label_for name => loc('Auth_homepage')
+ %= url_field 'url', placeholder => 'https://...'
+ </div>
+
+ <div>
+ %= label_for name => loc('Auth_redirectUri')
+ %= url_field 'redirectURI', placeholder => 'https://...'
+ </div>
+
+ %= submit_button loc('Auth_clientRegister')
+ </fieldset>
+% end
diff --git a/lib/Kalamar/Plugin/KalamarPages.pm b/lib/Kalamar/Plugin/KalamarPages.pm
index c7694e4..eba3dbd 100644
--- a/lib/Kalamar/Plugin/KalamarPages.pm
+++ b/lib/Kalamar/Plugin/KalamarPages.pm
@@ -152,6 +152,14 @@
# Take items from central list
unless ($items) {
$items = $navi->{$realm};
+
+ # Realm has no entries
+ return '' unless $items;
+ }
+
+ # Set realm
+ else {
+ $navi->{$realm} = $items;
};
# Create unordered list
@@ -270,6 +278,7 @@
}
);
+ # Add an item to the realm
$mojo->helper(
'navi.add' => sub {
my $c = shift;
@@ -284,6 +293,19 @@
}
}
);
+
+ # Check for existence
+ $mojo->helper(
+ 'navi.exists' => sub {
+ my $c = shift;
+ my $realm = shift;
+ unless (exists $navi->{$realm}) {
+ return 0 ;
+ };
+ return 0 unless ref $navi->{$realm} && @{$navi->{$realm}} > 0;
+ return 1;
+ }
+ );
}
1;
diff --git a/t/intro.t b/t/intro.t
index ed8181a..0069713 100644
--- a/t/intro.t
+++ b/t/intro.t
@@ -16,6 +16,10 @@
->text_is('div.intro > p > strong', 'KorAP')
;
+# Only routed when existing
+$t->get_ok('/settings')
+ ->status_is(404);
+
push @{$t->app->renderer->paths}, path(path(__FILE__)->dirname);
$t->app->plugin(Localize => {
diff --git a/t/navigation.t b/t/navigation.t
index f9518e8..b1a9f8e 100644
--- a/t/navigation.t
+++ b/t/navigation.t
@@ -141,6 +141,10 @@
'Path matches doc/ql/poliqarp-plus#complex');
like($render, qr!/doc/faq!, 'Path matches doc/faq');
+ok($app->navi->exists('doc'));
+ok(!$app->navi->exists('xy'));
+is($app->navigation('xy'), '');;
+
my $c = $app->build_controller;
$c->stash(page => 'korap');
diff --git a/t/plugin/auth-oauth.t b/t/plugin/auth-oauth.t
index f03f61e..c9c4207 100644
--- a/t/plugin/auth-oauth.t
+++ b/t/plugin/auth-oauth.t
@@ -18,7 +18,8 @@
'Kalamar-Auth' => {
client_id => 2,
client_secret => 'k414m4r-s3cr3t',
- oauth2 => 1
+ oauth2 => 1,
+ experimental_client_registration => 1
}
});
@@ -371,7 +372,7 @@
# The token is invalid and can't be refreshed!
-$t->get_ok('/?q=baum&cutoff=true')
+$csrf = $t->get_ok('/?q=baum&cutoff=true')
->status_is(200)
->session_hasnt('/auth')
->session_hasnt('/auth_r')
@@ -380,8 +381,57 @@
->text_is('title', 'KorAP: Find »baum« with Poliqarp')
->content_unlike(qr/\"authorized\"\:\"yes\"/)
->element_exists('p.no-results')
+ ->tx->res->dom->at('input[name="csrf_token"]')
+ ->attr('value')
;
+# Login:
+$t->post_ok('/user/login' => form => {
+ handle_or_email => 'test',
+ pwd => 'pass',
+ csrf_token => $csrf
+})
+ ->status_is(302)
+ ->content_is('')
+ ->header_is('Location' => '/');
+
+$t->get_ok('/')
+ ->status_is(200)
+ ->element_exists_not('div.notify-error')
+ ->element_exists('div.notify-success')
+ ->text_is('div.notify-success', 'Login successful')
+ ->element_exists('aside.off')
+ ->element_exists_not('aside.active')
+ ;
+
+$t->get_ok('/settings/oauth')
+ ->text_is('form.form-table legend', 'Register new client application')
+ ->attr_is('form.oauth-register','action', '/settings/oauth/register')
+ ;
+
+$csrf = $t->post_ok('/settings/oauth/register' => form => {
+ name => 'MyApp',
+ type => 'PUBLIC',
+ desc => 'This is my application'
+})
+ ->text_is('div.notify-error', 'Bad CSRF token')
+ ->tx->res->dom->at('input[name="csrf_token"]')
+ ->attr('value')
+ ;
+
+$t->post_ok('/settings/oauth/register' => form => {
+ name => 'MyApp',
+ type => 'CONFIDENTIAL',
+ desc => 'This is my application',
+ csrf_token => $csrf
+})
+ ->status_is(200)
+ ->element_exists('div.notify-success')
+ ->text_is('legend', 'Client Credentials')
+ ->text_is('label[for=client_id]', 'ID of the client application')
+ ->element_exists('input[name=client_id][readonly][value]')
+ ->element_exists('input[name=client_secret][readonly][value]')
+ ;
done_testing;
__END__
diff --git a/t/server/mock.pl b/t/server/mock.pl
index a1f0596..21192f0 100644
--- a/t/server/mock.pl
+++ b/t/server/mock.pl
@@ -15,10 +15,12 @@
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"
+ 'access_token' => "4dcf8784ccfd26fac9bdb82778fe60e2",
+ 'refresh_token' => "hlWci75xb8atDiq3924NUSvOdtAh7Nlf9z",
+ 'access_token_2' => "abcde",
+ 'refresh_token_2' => "fghijk",
+ 'new_client_id' => 'fCBbQkA2NDA3MzM1Yw==',
+ 'new_client_secret' => 'KUMaFxs6R1WGud4HM22w3HbmYKHMnNHIiLJ2ihaWtB4N5JxGzZgyqs5GTLutrORj',
);
helper get_token => sub {
@@ -487,6 +489,31 @@
)
};
+# Register a client
+post '/v1.0/oauth2/client/register' => sub {
+ my $c = shift;
+ my $json = $c->req->json;
+
+ my $name = $json->{name};
+ my $desc = $json->{desc};
+ my $type = $json->{type};
+ my $url = $json->{url};
+ my $redirect_url = $json->{redirectURI};
+
+ # Confidential server application
+ if ($type eq 'CONFIDENTIAL') {
+ return $c->render(json => {
+ client_id => $tokens{new_client_id},
+ client_secret => $tokens{new_client_secret}
+ });
+ };
+
+ # Desktop application
+ return $c->render(json => {
+ client_id => $tokens{new_client_id}
+ });
+};
+
app->start;
diff --git a/t/settings.t b/t/settings.t
index 9620504..9eb0655 100644
--- a/t/settings.t
+++ b/t/settings.t
@@ -31,21 +31,15 @@
}
});
-my $app = $t->app;
-
-$app->routes->get('/settings')->to(cb => sub { shift->render('settings') })->name('settings_start');
-$app->routes->get('/settings/:scope/:page')->to(scope => undef, page => undef)->name('settings');
-
-
$t->get_ok('/settings')
->text_is('a[href~/settings/oauth]','OAuth Token Management')
- ->text_is('h2#page-top', 'Settings')
+ ->text_is('h1 span', 'Settings')
;
$t->get_ok('/settings/oauth')
->text_is('a[href~/settings/oauth]','OAuth Token Management')
- ->text_is('h2#page-top', 'Settings')
- ->text_is('#abc', 'My Settings')
+ ->text_is('h1 span', 'Settings')
+ ->text_is('p#abc', 'My Settings')
;
done_testing;
diff --git a/templates/settings.html.ep b/templates/settings.html.ep
index bd92eb6..17f1be5 100644
--- a/templates/settings.html.ep
+++ b/templates/settings.html.ep
@@ -12,6 +12,4 @@
% layout 'main', sidebar_active => 1, main_class => 'page settings';
-%= page_title
-
%= content 'settings'