blob: 5081ef850d9a42d47e7bfa3ca49a79bb11aa3a7e [file] [log] [blame]
Akron864c2932018-11-16 17:18:55 +01001package Kalamar::Plugin::Auth;
2use Mojo::Base 'Mojolicious::Plugin';
Akron59992122019-10-29 11:28:45 +01003use File::Basename 'dirname';
4use File::Spec::Functions qw/catdir/;
Akron864c2932018-11-16 17:18:55 +01005use Mojo::ByteStream 'b';
Akron66ef3b52022-11-22 14:25:15 +01006use Mojo::File qw!path!;
Akrond91a1ca2022-05-20 16:45:01 +02007use Mojo::Util qw!b64_encode encode!;
Akron6b75d122022-05-12 17:39:05 +02008use Mojo::JSON qw'decode_json encode_json';
Akron6a228db2021-10-14 15:57:00 +02009use Encode 'is_utf8';
Akron864c2932018-11-16 17:18:55 +010010
Akroncdfd9d52019-07-23 11:35:00 +020011# This is a plugin to deal with the Kustvakt OAuth server.
Akroncdfd9d52019-07-23 11:35:00 +020012# All tokens are stored in the session. Access tokens are short-lived,
13# which limits the effects of misuse.
14# Refresh tokens are bound to client id and client secret,
15# which again limits the effects of misuse.
16
17# TODO:
18# Establish a plugin 'OAuth' that works independent of 'Auth'.
19
Akron864c2932018-11-16 17:18:55 +010020# TODO:
Akronc82b1bc2018-11-18 18:06:14 +010021# CSRF-protect logout!
Akron864c2932018-11-16 17:18:55 +010022
Akroncdfd9d52019-07-23 11:35:00 +020023# TODO:
24# Remove the Bearer prefix from auth.
25
26# In case no expiration time is returned by the server,
27# take this time.
Akron8bbbecf2019-07-01 18:57:30 +020028our $EXPECTED_EXPIRATION_IN = 259200;
29
Akron864c2932018-11-16 17:18:55 +010030# Register the plugin
31sub register {
32 my ($plugin, $app, $param) = @_;
33
Akron864c2932018-11-16 17:18:55 +010034 # Load parameter from config file
35 if (my $config_param = $app->config('Kalamar-Auth')) {
36 $param = { %$param, %$config_param };
37 };
38
Akrond91a1ca2022-05-20 16:45:01 +020039 if ($param->{jwt}) {
40 $app->log->error('JWT flow is no longer supported');
41 return;
42 };
43
Akron864c2932018-11-16 17:18:55 +010044 # Load 'notifications' plugin
45 unless (exists $app->renderer->helpers->{notify}) {
46 $app->plugin(Notifications => {
47 HTML => 1
48 });
49 };
50
Akron86276092022-05-20 15:36:05 +020051 # Set session default timeout
52 for ($app->sessions) {
53 $_->default_expiration(60*60*24*3); # Session expires after 3 days of non-use
54 };
55
Akron66ef3b52022-11-22 14:25:15 +010056 # Get client_id and client_secret from client file
Akron53a171e2022-12-05 18:22:58 +010057 if ($param->{client_file} || $main::ENV{KALAMAR_CLIENT_FILE}) {
58 $param->{client_file} ||= $main::ENV{KALAMAR_CLIENT_FILE};
Akron66ef3b52022-11-22 14:25:15 +010059 my $client_json = decode_json(path($param->{client_file})->slurp);
60 $param->{client_id} //= $client_json->{client_id};
61 $param->{client_secret} //= $client_json->{client_secret};
62 };
63
Akron33f5c672019-06-24 19:40:47 +020064 # Get the client id and the client_secret as a requirement
65 unless ($param->{client_id} && $param->{client_secret}) {
66 $app->log->error('client_id or client_secret not defined');
67 };
Akron864c2932018-11-16 17:18:55 +010068
Akron59992122019-10-29 11:28:45 +010069 # Load localize
Akron864c2932018-11-16 17:18:55 +010070 $app->plugin('Localize' => {
71 dict => {
Akrone997bb52021-06-11 16:44:06 +020072 de => {
73 abort => 'Abbrechen'
74 },
75 -en => {
76 abort => 'Abort'
77 },
Akron864c2932018-11-16 17:18:55 +010078 Auth => {
79 _ => sub { $_->locale },
80 de => {
Akrona8efaa92022-04-09 14:45:43 +020081 loginPlease => 'Bitte melden Sie sich an!',
Akron864c2932018-11-16 17:18:55 +010082 loginSuccess => 'Anmeldung erfolgreich',
83 loginFail => 'Anmeldung fehlgeschlagen',
84 logoutSuccess => 'Abmeldung erfolgreich',
85 logoutFail => 'Abmeldung fehlgeschlagen',
Akronff088112021-06-15 15:26:04 +020086 authenticationFail => 'Nicht authentifiziert',
Akron864c2932018-11-16 17:18:55 +010087 csrfFail => 'Fehlerhafter CSRF Token',
Akron6a228db2021-10-14 15:57:00 +020088 invalidChar => 'Ungültiges Zeichen in Anfrage',
Akron8bbbecf2019-07-01 18:57:30 +020089 openRedirectFail => 'Weiterleitungsfehler',
Akroncdfd9d52019-07-23 11:35:00 +020090 tokenExpired => 'Zugriffstoken abgelaufen',
91 tokenInvalid => 'Zugriffstoken ungültig',
92 refreshFail => 'Fehlerhafter Refresh-Token',
Akron59992122019-10-29 11:28:45 +010093 responseError => 'Unbekannter Autorisierungsfehler',
Akroncce055c2021-07-02 12:18:03 +020094 serverError => 'Unbekannter Serverfehler',
Akronc1aaf932021-06-09 12:19:15 +020095 revokeFail => 'Der Token kann nicht widerrufen werden',
96 revokeSuccess => 'Der Token wurde erfolgreich widerrufen',
Akron59992122019-10-29 11:28:45 +010097 paramError => 'Einige Eingaben sind fehlerhaft',
98 redirectUri => 'Weiterleitungs-Adresse',
Akron9f2ad342022-05-04 16:16:40 +020099 pluginSrc => 'Beschreibung des Plugins (*.json-Datei)',
Akron59992122019-10-29 11:28:45 +0100100 homepage => 'Webseite',
101 desc => 'Kurzbeschreibung',
Akronc1aaf932021-06-09 12:19:15 +0200102 revoke => 'Widerrufen',
Akron59992122019-10-29 11:28:45 +0100103 clientCredentials => 'Client Daten',
104 clientType => 'Art der Client-Applikation',
105 clientName => 'Name der Client-Applikation',
106 clientID => 'ID der Client-Applikation',
107 clientSecret => 'Client-Secret',
108 clientRegister => 'Neue Client-Applikation registrieren',
109 registerSuccess => 'Registrierung erfolgreich',
110 registerFail => 'Registrierung fehlgeschlagen',
111 oauthSettings => 'OAuth',
Akrone997bb52021-06-11 16:44:06 +0200112 oauthUnregister => {
113 -long => 'Möchten sie <span class="client-name"><%= $client_name %></span> wirklich löschen?',
114 short => 'Löschen'
115 },
Akron9f2ad342022-05-04 16:16:40 +0200116 oauthHint => 'Die folgende Registrierung (und alle Angaben) für API-Clients folgen der <a href="https://oauth.net/" class="external">OAuth-2.0-Spezifikation</a>.',
Akron83209f72021-01-29 17:54:15 +0100117 loginHint => 'Möglicherweise müssen sie sich zunächst einloggen.',
Akrone997bb52021-06-11 16:44:06 +0200118 oauthIssueToken => {
119 -long => 'Stelle einen neuen Token für <span class="client-name"><%= $client_name %></span> aus',
120 short => 'Neuen Token ausstellen'
121 },
Akron9ffb4a32021-06-08 16:11:21 +0200122 accessToken => 'Access Token',
Akrone997bb52021-06-11 16:44:06 +0200123 oauthRevokeToken => {
124 -long => 'Widerrufe einen Token für <span class="client-name"><%= $client_name %></span>',
125 short => 'Widerrufe'
126 },
Akrona8efaa92022-04-09 14:45:43 +0200127 oauthGrantScope => {
128 -long => '<span class="client-name"><%= $client_name %></span> möchte Zugriffsrechte',
129 short => 'Zugriffsrechte erteilen'
130 },
Akron408bc7c2022-04-28 15:46:43 +0200131 oauthGrantPublicWarn => 'Achtung - dies ist ein öffentlicher Client!',
Akrone997bb52021-06-11 16:44:06 +0200132 createdAt => 'Erstellt am <time datetime="<%= stash("date") %>"><%= stash("date") %></date>.',
Akron9f2ad342022-05-04 16:16:40 +0200133 expiresIn => 'Läuft in <%= stash("seconds") %> Sekunden ab.',
134 fileSizeExceeded => 'Dateigröße überschritten'
Akron864c2932018-11-16 17:18:55 +0100135 },
136 -en => {
Akrona8efaa92022-04-09 14:45:43 +0200137 loginPlease => 'Please log in!',
Akron864c2932018-11-16 17:18:55 +0100138 loginSuccess => 'Login successful',
139 loginFail => 'Access denied',
140 logoutSuccess => 'Logout successful',
141 logoutFail => 'Logout failed',
Akronff088112021-06-15 15:26:04 +0200142 authenticationFail => 'Not authenticated',
Akron864c2932018-11-16 17:18:55 +0100143 csrfFail => 'Bad CSRF token',
Akron6a228db2021-10-14 15:57:00 +0200144 invalidChar => 'Invalid character in request',
Akron8bbbecf2019-07-01 18:57:30 +0200145 openRedirectFail => 'Redirect failure',
Akroncdfd9d52019-07-23 11:35:00 +0200146 tokenExpired => 'Access token expired',
147 tokenInvalid => 'Access token invalid',
148 refreshFail => 'Bad refresh token',
Akron59992122019-10-29 11:28:45 +0100149 responseError => 'Unknown authorization error',
Akroncce055c2021-07-02 12:18:03 +0200150 serverError => 'Unknown server error',
Akronc1aaf932021-06-09 12:19:15 +0200151 revokeFail => 'Token can\'t be revoked',
152 revokeSuccess => 'Token was revoked successfully',
Akron59992122019-10-29 11:28:45 +0100153 paramError => 'Some fields are invalid',
154 redirectUri => 'Redirect URI',
Akron9f2ad342022-05-04 16:16:40 +0200155 pluginSrc => 'Declaration of the plugin (*.json file)',
Akron59992122019-10-29 11:28:45 +0100156 homepage => 'Homepage',
157 desc => 'Short description',
Akronc1aaf932021-06-09 12:19:15 +0200158 revoke => 'Revoke',
Akron59992122019-10-29 11:28:45 +0100159 clientCredentials => 'Client Credentials',
160 clientType => 'Type of the client application',
161 clientName => 'Name of the client application',
162 clientID => 'ID of the client application',
163 clientSecret => 'Client secret',
164 clientRegister => 'Register new client application',
165 registerSuccess => 'Registration successful',
166 registerFail => 'Registration denied',
167 oauthSettings => 'OAuth',
Akrone997bb52021-06-11 16:44:06 +0200168 oauthUnregister => {
169 -long => 'Do you really want to unregister <span class="client-name"><%= $client_name %></span>?',
170 short => 'Unregister'
171 },
Akron9f2ad342022-05-04 16:16:40 +0200172 oauthHint => 'The following registration of API clients follows the <a href="https://oauth.net/" class="external">OAuth 2.0 specification</a>.',
Akron83209f72021-01-29 17:54:15 +0100173 loginHint => 'Maybe you need to log in first?',
Akrone997bb52021-06-11 16:44:06 +0200174 oauthIssueToken => {
175 -long => 'Issue a new token for <span class="client-name"><%= $client_name %></span>',
176 short => 'Issue new token'
177 },
Akron9ffb4a32021-06-08 16:11:21 +0200178 accessToken => 'Access Token',
Akrone997bb52021-06-11 16:44:06 +0200179 oauthRevokeToken => {
180 -long => 'Revoke a token for <span class="client-name"><%= $client_name %></span>',
181 short => 'Revoke'
182 },
Akrona8efaa92022-04-09 14:45:43 +0200183 oauthGrantScope => {
184 -long => '<span class="client-name"><%= $client_name %></span> wants to have access',
185 short => 'Grant access'
186 },
Akron408bc7c2022-04-28 15:46:43 +0200187 oauthGrantPublicWarn => 'Warning - this is a public client!',
Akrone997bb52021-06-11 16:44:06 +0200188 createdAt => 'Created at <time datetime="<%= stash("date") %>"><%= stash("date") %></date>.',
Akron9f2ad342022-05-04 16:16:40 +0200189 expiresIn => 'Expires in <%= stash("seconds") %> seconds.',
190 fileSizeExceeded => 'File size exceeded',
191 confidentialRequired => 'Plugins need to be confidential',
192 jsonRequired => 'Plugin declarations need to be json files',
Akron864c2932018-11-16 17:18:55 +0100193 }
194 }
195 }
196 });
197
198
Akrona9c8b0e2018-11-16 20:20:28 +0100199 # Add login frame to sidebar
200 $app->content_block(
201 sidebar => {
202 template => 'partial/auth/login'
203 }
204 );
205
206
Akronc82b1bc2018-11-18 18:06:14 +0100207 # Add logout button to header button list
208 $app->content_block(
209 headerButtonGroup => {
210 template => 'partial/auth/logout'
211 }
212 );
213
Akron27031aa2020-04-28 14:57:10 +0200214
215 # Add hook after search
216 $app->hook(
217 after_search => sub {
218 my $c = shift;
219
220 # User is not logged in
221 if ($c->stash('results')->size == 0 && !$c->auth->token) {
222 $c->content_for(
223 'after_search_results' =>
224 $c->render_to_string(
225 inline => '<p class="hint"><%= loc "Auth_loginHint" %></p>'
226 )
227 );
228 };
229 }
230 );
231
Akron59992122019-10-29 11:28:45 +0100232 # The plugin path
233 my $path = catdir(dirname(__FILE__), 'Auth');
234
235 # Append "templates"
236 push @{$app->renderer->paths}, catdir($path, 'templates');
Akron864c2932018-11-16 17:18:55 +0100237
Akron4796e002019-07-05 10:13:15 +0200238 # Get or set the user token necessary for authorization
Akron864c2932018-11-16 17:18:55 +0100239 $app->helper(
240 'auth.token' => sub {
Akroncdfd9d52019-07-23 11:35:00 +0200241 my ($c, $token, $expires_in) = @_;
Akron864c2932018-11-16 17:18:55 +0100242
Akroncdfd9d52019-07-23 11:35:00 +0200243 if ($token) {
244 # Set auth token
Akron4796e002019-07-05 10:13:15 +0200245 $c->stash(auth => $token);
Akroncdfd9d52019-07-23 11:35:00 +0200246 $c->session(auth => $token);
247 $c->session(auth_exp => time + $expires_in);
248 return 1;
Akron4796e002019-07-05 10:13:15 +0200249 };
250
Akroncdfd9d52019-07-23 11:35:00 +0200251 # Get token from stash
252 $token = $c->stash('auth');
253
254 return $token if $token;
255
256 # Get auth from session
257 $token = $c->session('auth') or return;
258 $c->stash(auth => $token);
259
260 # Return stashed value
261 return $token;
Akron864c2932018-11-16 17:18:55 +0100262 }
263 );
264
Akron864c2932018-11-16 17:18:55 +0100265 # Log in to the system
266 my $r = $app->routes;
Akron864c2932018-11-16 17:18:55 +0100267
Akrond91a1ca2022-05-20 16:45:01 +0200268 my $client_id = $param->{client_id};
269 my $client_secret = $param->{client_secret};
Akron8bbbecf2019-07-01 18:57:30 +0200270
Akroncdfd9d52019-07-23 11:35:00 +0200271
Akrond91a1ca2022-05-20 16:45:01 +0200272 # Sets a requested token and returns
273 # an error, if it didn't work
274 $app->helper(
275 'auth.set_tokens_p' => sub {
276 my ($c, $json) = @_;
277 my $promise = Mojo::Promise->new;
Akroncdfd9d52019-07-23 11:35:00 +0200278
Akrond91a1ca2022-05-20 16:45:01 +0200279 # No json object
280 unless ($json) {
281 return $promise->reject({
282 message => 'Response is no valid JSON object (remote)'
283 });
284 };
Akroncdfd9d52019-07-23 11:35:00 +0200285
Akrond91a1ca2022-05-20 16:45:01 +0200286 # There is an error here
287 # Dealing with errors here
288 if ($json->{error} && ref $json->{error} ne 'ARRAY') {
289 return $promise->reject(
290 {
291 message => $json->{error} . ($json->{error_description} ? ': ' . $json->{error_description} : '')
Akron0f1b93b2020-03-17 11:37:19 +0100292 }
293 );
294 }
Akroncdfd9d52019-07-23 11:35:00 +0200295
Akrond91a1ca2022-05-20 16:45:01 +0200296 # There is an array of errors
297 elsif (my $error = $json->{errors} // $json->{error}) {
298 if (ref $error eq 'ARRAY') {
299 my @errors = ();
300 foreach (@{$error}) {
301 if ($_->[1]) {
302 push @errors, { code => $_->[0], message => $_->[1]}
Akronbc94a9c2021-04-15 00:07:35 +0200303 };
Akrond91a1ca2022-05-20 16:45:01 +0200304 };
305 return $promise->reject(@errors);
Akroncdfd9d52019-07-23 11:35:00 +0200306 };
307
Akrond91a1ca2022-05-20 16:45:01 +0200308 return $promise->reject({message => $error});
309 };
Akroncdfd9d52019-07-23 11:35:00 +0200310
Akrond91a1ca2022-05-20 16:45:01 +0200311 # Everything is fine
312 my $access_token = $json->{access_token};
313 my $token_type = $json->{token_type};
314 my $refresh_token = $json->{refresh_token};
315 my $expires_in = $json->{"expires_in"} // $EXPECTED_EXPIRATION_IN;
316 my $auth = $token_type . ' ' . $access_token;
317 # my $scope = $json->{scope};
Akroncdfd9d52019-07-23 11:35:00 +0200318
Akrond91a1ca2022-05-20 16:45:01 +0200319 # Set session info
320 $c->session(auth => $auth);
321
322 # Expiration of the token minus tolerance
323 $c->session(auth_exp => time + $expires_in - 60);
324
325 # Set session info for refresh token
326 # This can be stored in the session, as it is useless
327 # unless the client secret is stolen
328 $c->session(auth_r => $refresh_token) if $refresh_token;
329
330 # Set stash info
331 $c->stash(auth => $auth);
332
333 return $promise->resolve;
334 }
335 );
336
337
338 # Refresh tokens and return a promise
339 $app->helper(
340 'auth.refresh_p' => sub {
341 my $c = shift;
342 my $refresh_token = shift;
343
344 # Get OAuth access token
345 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
346
347 $c->app->log->debug("Refresh at $r_url");
348
349 return $c->kalamar_ua->post_p($r_url, {} => form => {
350 grant_type => 'refresh_token',
351 client_id => $client_id,
352 client_secret => $client_secret,
353 refresh_token => $refresh_token
354 })->then(
355 sub {
356 my $tx = shift;
357 my $json = $tx->result->json;
358
359 # Response is fine
360 if ($tx->res->is_success) {
361
362 $c->app->log->info("Refresh was successful");
363
364 # Set the tokens and return a promise
365 return $c->auth->set_tokens_p($json);
366 };
367
368 # There is a client error - refresh fails
369 if ($tx->res->is_client_error && $json) {
370
Akroncdfd9d52019-07-23 11:35:00 +0200371 $c->stash(auth => undef);
Akrond91a1ca2022-05-20 16:45:01 +0200372 $c->stash(auth_exp => undef);
373 delete $c->session->{user};
374 delete $c->session->{auth};
375 delete $c->session->{auth_r};
376 delete $c->session->{auth_exp};
Akroncdfd9d52019-07-23 11:35:00 +0200377
Akrond91a1ca2022-05-20 16:45:01 +0200378 # Response is 400
379 return Mojo::Promise->reject(
380 $json->{error_description} // $c->loc('Auth_refreshFail')
381 );
382 };
Akroncdfd9d52019-07-23 11:35:00 +0200383
Akrond91a1ca2022-05-20 16:45:01 +0200384 if ($tx->res->is_server_error) {
385 return Mojo::Promise->reject(
386 '600'
387 )
388 };
Akroncdfd9d52019-07-23 11:35:00 +0200389
Akrond91a1ca2022-05-20 16:45:01 +0200390 $c->notify(error => $c->loc('Auth_responseError'));
391 return Mojo::Promise->reject;
392 }
393 )
394 }
395 );
Akroncdfd9d52019-07-23 11:35:00 +0200396
Akrond91a1ca2022-05-20 16:45:01 +0200397 # Get a list of registered clients
398 $app->helper(
399 'auth.client_list_p' => sub {
400 my $c = shift;
Akroncdfd9d52019-07-23 11:35:00 +0200401
Akrond91a1ca2022-05-20 16:45:01 +0200402 # Get list of registered clients
403 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/client/list');
Akrona8efaa92022-04-09 14:45:43 +0200404
Akrond91a1ca2022-05-20 16:45:01 +0200405 # Get the list of all clients
406 return $c->korap_request(post => $r_url, {} => form => {
407 super_client_id => $client_id,
408 super_client_secret => $client_secret,
409 authorized_only => 'no'
410 })->then(
411 sub {
412 my $tx = shift;
413 my $json = $tx->result->json;
Akroncdfd9d52019-07-23 11:35:00 +0200414
Akrond91a1ca2022-05-20 16:45:01 +0200415 # Response is fine
416 if ($tx->res->is_success) {
417 return Mojo::Promise->resolve($json);
418 };
419
420 $c->log->error($c->dumper($tx->res->to_string));
421
422 # Failure
423 $c->notify(error => $c->loc('Auth_responseError'));
424 return Mojo::Promise->reject($json // 'No response');
425 }
426 );
427 }
428 );
429
430
431 # Get a list of registered clients
432 $app->helper(
433 'auth.token_list_p' => sub {
434 my $c = shift;
435 my $user_client_id = shift;
436
437 # Revoke the token
438 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/token/list');
439
440 my $form = {
441 super_client_id => $client_id,
442 super_client_secret => $client_secret,
443 token_type => 'access_token',
444 };
445
446 if ($user_client_id) {
447 $form->{client_id} = $user_client_id;
448 };
449
450 # Get the list of all clients
451 return $c->korap_request(post => $r_url, {} => form => $form)->then(
452 sub {
453 my $tx = shift;
454 my $json = $tx->result->json;
455
456 # Response is fine
457 if ($tx->res->is_success) {
458 return Mojo::Promise->resolve($json);
459 };
460
461 $c->log->error($c->dumper($tx->res->to_string));
462
463 # Failure
464 $c->notify(error => $c->loc('Auth_responseError'));
465 return Mojo::Promise->reject($json // 'No response');
466 }
467 );
468 }
469 );
470
471
472 # Issue a korap request with "oauth"orization
473 # This will override the core request helper
474 $app->helper(
475 korap_request => sub {
476 my $c = shift;
477 my $method = shift;
478 my $path = shift;
479 my @param = @_;
480
481 # TODO:
482 # Check if $tx is not leaked!
483
484 # Get plugin user agent
485 my $ua = $c->kalamar_ua;
486
487 my $url = Mojo::URL->new($path);
488 my $tx = $ua->build_tx(uc($method), $url->clone, @param);
489
490 # Set X-Forwarded for
491 $tx->req->headers->header(
492 'X-Forwarded-For' => $c->client_ip
493 );
494
495 # Emit Hook to alter request
496 $c->app->plugins->emit_hook(
497 before_korap_request => ($c, $tx)
498 );
499
500 my $h = $tx->req->headers;
501
502 # If the request already has an Authorization
503 # header, respect it!
504 if ($h->authorization) {
505 return $ua->start_p($tx);
506 };
507
508 # Get auth token
509 if (my $auth_token = $c->auth->token) {
510
511 # The token is already expired!
512 my $exp = $c->session('auth_exp');
513 if (defined $exp && $exp < time) {
514
515 # Remove auth ...
516 $c->stash(auth => undef);
517
518 # And get refresh token from session
519 if (my $refresh_token = $c->session('auth_r')) {
520
521 $c->app->log->debug("Refresh is required");
522
523 # Refresh
524 return $c->auth->refresh_p($refresh_token)->then(
525 sub {
526 $c->app->log->debug("Search with refreshed tokens");
527
528 # Tokens were set - now send the request the first time!
529 $tx->req->headers->authorization($c->stash('auth'));
530 return $ua->start_p($tx);
531 }
532 );
Akroncdfd9d52019-07-23 11:35:00 +0200533 }
534
Akrond91a1ca2022-05-20 16:45:01 +0200535 # The token is expired and no refresh token is
536 # available - issue an unauthorized request!
Akroncdfd9d52019-07-23 11:35:00 +0200537 else {
538
Akrond91a1ca2022-05-20 16:45:01 +0200539 $c->stash(auth => undef);
540 $c->stash(auth_exp => undef);
541 delete $c->session->{user};
542 delete $c->session->{auth};
543 delete $c->session->{auth_r};
544 delete $c->session->{auth_exp};
545
546 # Warn on Error!
547 $c->notify(warn => $c->loc('Auth_tokenExpired'));
548 return $ua->start_p($tx);
549 };
Akroncdfd9d52019-07-23 11:35:00 +0200550 }
551
Akrond91a1ca2022-05-20 16:45:01 +0200552 # Auth token is fine
Akroncdfd9d52019-07-23 11:35:00 +0200553 else {
554
Akrond91a1ca2022-05-20 16:45:01 +0200555 # Set auth
556 $h->authorization($auth_token);
557 }
558 }
Akroncdfd9d52019-07-23 11:35:00 +0200559
Akrond91a1ca2022-05-20 16:45:01 +0200560 # No token set
561 else {
Akroncdfd9d52019-07-23 11:35:00 +0200562
Akrond91a1ca2022-05-20 16:45:01 +0200563 # Return unauthorized request
564 return $ua->start_p($tx);
565 };
Akroncdfd9d52019-07-23 11:35:00 +0200566
Akrond91a1ca2022-05-20 16:45:01 +0200567 # Issue an authorized request and automatically
568 # refresh the token on expiration!
569 return $ua->start_p($tx)->then(
570 sub {
571 my $tx = shift;
Akroncdfd9d52019-07-23 11:35:00 +0200572
Akrond91a1ca2022-05-20 16:45:01 +0200573 # Response is fine
574 if ($tx->res->is_success) {
575 return Mojo::Promise->resolve($tx);
576 }
Akroncdfd9d52019-07-23 11:35:00 +0200577
Akrond91a1ca2022-05-20 16:45:01 +0200578 # There is a client error - maybe refresh!
579 elsif ($tx->res->is_client_error) {
Akroncdfd9d52019-07-23 11:35:00 +0200580
Akrond91a1ca2022-05-20 16:45:01 +0200581 # Check the error
582 my $json = $tx->res->json('/errors/0/1');
583 if ($json && ($json =~ /expired|invalid/)) {
584 $c->stash(auth => undef);
585 $c->stash(auth_exp => undef);
586 delete $c->session->{user};
587 delete $c->session->{auth};
Akroncdfd9d52019-07-23 11:35:00 +0200588
Akrond91a1ca2022-05-20 16:45:01 +0200589 # And get refresh token from session
590 if (my $refresh_token = $c->session('auth_r')) {
Akroncdfd9d52019-07-23 11:35:00 +0200591
Akrond91a1ca2022-05-20 16:45:01 +0200592 # Refresh
593 return $c->auth->refresh_p($refresh_token)->then(
594 sub {
595 $c->app->log->debug("Search with refreshed tokens");
Akroncdfd9d52019-07-23 11:35:00 +0200596
Akrond91a1ca2022-05-20 16:45:01 +0200597 my $tx = $ua->build_tx(uc($method), $url->clone, @param);
Akroncdfd9d52019-07-23 11:35:00 +0200598
Akrond91a1ca2022-05-20 16:45:01 +0200599 # Set X-Forwarded for
600 $tx->req->headers->header(
601 'X-Forwarded-For' => $c->client_ip
602 );
603
604 # Tokens were set - now send the request the first time!
605 $tx->req->headers->authorization($c->stash('auth'));
606 return $ua->start_p($tx);
607 }
608 )
Akroncdfd9d52019-07-23 11:35:00 +0200609 };
610
Akrond91a1ca2022-05-20 16:45:01 +0200611 # Reject the invalid token
612 $c->notify(error => $c->loc('Auth_tokenInvalid'));
613 return Mojo::Promise->reject;
Akroncdfd9d52019-07-23 11:35:00 +0200614 };
615
Akrond91a1ca2022-05-20 16:45:01 +0200616 return Mojo::Promise->resolve($tx);
Akron8bbbecf2019-07-01 18:57:30 +0200617 }
Akron8bbbecf2019-07-01 18:57:30 +0200618
Akrond91a1ca2022-05-20 16:45:01 +0200619 # There is a server error - just report
620 elsif ($tx->res->is_server_error) {
621 my $err = $tx->res->error;
622 if ($err) {
623 return Mojo::Promise->reject($err->{code} . ': ' . $err->{message});
624 }
625 else {
626 $c->notify(error => $c->loc('Auth_serverError'));
627 return Mojo::Promise->reject;
628 };
629 };
630
631 $c->notify(error => $c->loc('Auth_responseError'));
632 return Mojo::Promise->reject;
633 }
634 );
635 }
636 );
637
638 # Password flow for OAuth
639 $r->post('/user/login')->to(
640 cb => sub {
641 my $c = shift;
642
643 # Validate input
644 my $v = $c->validation;
645 $v->required('handle_or_email', 'trim');
646 $v->required('pwd', 'trim');
647 $v->csrf_protect;
648 $v->optional('fwd')->closed_redirect;
649
650 my $user = check_decode($v->param('handle_or_email'));
651 unless ($user) {
652 $c->notify(error => $c->loc('Auth_invalidChar'));
653 $c->param(handle_or_email => '');
654 return $c->relative_redirect_to('index');
655 };
656
657 my $fwd = $v->param('fwd');
658
659 # Set flash for redirect
660 $c->flash(handle_or_email => $user);
661
662 if ($v->has_error || index($user, ':') >= 0) {
663 if ($v->has_error('fwd')) {
664 $c->notify(error => $c->loc('Auth_openRedirectFail'));
665 }
666 elsif ($v->has_error('csrf_token')) {
667 $c->notify(error => $c->loc('Auth_csrfFail'));
668 }
669 else {
670 $c->notify(error => $c->loc('Auth_loginFail'));
671 };
672
673 return $c->relative_redirect_to($fwd // 'index');
674 }
675
676 my $pwd = $v->param('pwd');
677
678 $c->app->log->debug("Login from user $user");
679
680 # <specific>
681
682 # Get OAuth access token
683 my $url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
684
685 # Korap request for login
686 $c->korap_request('post', $url, {}, form => {
687 grant_type => 'password',
688 username => $user,
689 password => $pwd,
690 client_id => $client_id,
691 client_secret => $client_secret
692 })->then(
693 sub {
694 # Set the tokens and return a promise
695 return $c->auth->set_tokens_p(shift->result->json)
696 }
697 )->then(
698 sub {
699 # Set user info
700 $c->session(user => $user);
701 $c->stash(user => $user);
702
703 # Notify on success
704 $c->app->log->debug(qq!Login successful: "$user"!);
705 $c->notify(success => $c->loc('Auth_loginSuccess'));
706 }
707 )->catch(
708 sub {
709
710 # Notify the user on login failure
711 unless (@_) {
712 $c->notify(error => $c->loc('Auth_loginFail'));
713 }
714
715 # There are known errors
716 foreach (@_) {
717 if (ref $_ eq 'HASH') {
718 my $err = ($_->{code} ? $_->{code} . ': ' : '') .
719 $_->{message};
720 $c->notify(error => $err);
721 # Log failure
722 $c->app->log->debug($err);
723 }
724 else {
725 $c->notify(error => $_);
726 $c->app->log->debug($_);
727 };
728 };
729
730 $c->app->log->debug(qq!Login fail: "$user"!);
731 }
732 )->finally(
733 sub {
734 # Redirect to slash
735 return $c->relative_redirect_to($fwd // 'index');
736 }
737 )
738
739 # Start IOLoop
740 ->wait;
741
742 return 1;
743 }
744 )->name('login');
745
746
747 # Log out of the session
748 $r->get('/user/logout')->to(
749 cb => sub {
750 my $c = shift;
751
752 # TODO: csrf-protection!
753
754 my $refresh_token = $c->session('auth_r');
755
756 # Revoke the token
757 state $url = Mojo::URL->new($c->korap->api)->path('oauth2/revoke');
758
759 $c->kalamar_ua->post_p($url => {} => form => {
760 client_id => $client_id,
761 client_secret => $client_secret,
762 token => $refresh_token,
763 token_type => 'refresh_token'
764 })->then(
765 sub {
766 my $tx = shift;
767 my $json = $tx->result->json;
768
769 my $promise;
770
771 # Response is fine
772 if ($tx->res->is_success) {
773 $c->app->log->info("Revocation was successful");
774 $c->notify(success => $c->loc('Auth_logoutSuccess'));
775
776 $c->stash(auth => undef);
777 $c->stash(auth_exp => undef);
778 $c->flash(handle_or_email => delete $c->session->{user});
779 delete $c->session->{auth};
780 delete $c->session->{auth_r};
781 delete $c->session->{auth_exp};
782 return Mojo::Promise->resolve;
783 };
784
785 # Token may be invalid
786 $c->notify('error', $c->loc('Auth_logoutFail'));
787
788 # There is a client error - refresh fails
789 if ($tx->res->is_client_error && $json) {
790
791 return Mojo::Promise->reject(
792 $json->{error_description}
793 );
794 };
795
796 # Resource may not be found (404)
797 return Mojo::Promise->reject
798
799 }
800 )->catch(
801 sub {
802 my $err = shift;
803
804 # Server may be irresponsible
805 $c->notify('error', $c->loc('Auth_logoutFail'));
806 return Mojo::Promise->reject($err);
807 }
808 )->finally(
809 sub {
810 return $c->redirect_to('index');
811 }
812 )->wait;
813 }
814 )->name('logout');
815
816
817 # If "experimental_registration" is set, open
818 # OAuth registration dialogues.
819 if ($param->{experimental_client_registration}) {
820
821 # Add settings
822 $app->navi->add(settings => (
823 $app->loc('Auth_oauthSettings'), 'oauth'
824 ));
825
826 # Route to oauth settings
827 $r->get('/settings/oauth')->to(
Akron33f5c672019-06-24 19:40:47 +0200828 cb => sub {
829 my $c = shift;
Akron864c2932018-11-16 17:18:55 +0100830
Akrond91a1ca2022-05-20 16:45:01 +0200831 _set_no_cache($c->res->headers);
Akron864c2932018-11-16 17:18:55 +0100832
Akrond91a1ca2022-05-20 16:45:01 +0200833 unless ($c->auth->token) {
834 return $c->render(
835 template => 'exception',
836 msg => $c->loc('Auth_authenticationFail'),
837 status => 401
838 );
Akron6a228db2021-10-14 15:57:00 +0200839 };
840
Akrond91a1ca2022-05-20 16:45:01 +0200841 # Wait for async result
842 $c->render_later;
Akron864c2932018-11-16 17:18:55 +0100843
Akrond91a1ca2022-05-20 16:45:01 +0200844 $c->auth->client_list_p->then(
845 sub {
846 $c->stash('client_list' => shift);
Akron33f5c672019-06-24 19:40:47 +0200847 }
Akrond91a1ca2022-05-20 16:45:01 +0200848 )->catch(
849 sub {
850 return;
851 }
852 )->finally(
853 sub {
854 return $c->render(template => 'auth/clients')
855 }
856 );
857 }
858 )->name('oauth-settings');
859
860 # Route to oauth client registration
861 $r->post('/settings/oauth/register')->to(
862 cb => sub {
863 my $c = shift;
864
865 _set_no_cache($c->res->headers);
866
867 my $v = $c->validation;
868
869 unless ($c->auth->token) {
870 return $c->render(
871 content => 'Unauthorized',
872 status => 401
873 );
874 };
875
876 $v->csrf_protect;
877 $v->required('name', 'trim', 'not_empty')->size(3, 255);
878 $v->required('type')->in('PUBLIC', 'CONFIDENTIAL');
879 $v->required('desc', 'trim', 'not_empty')->size(3, 255);
880 $v->optional('url', 'trim', 'not_empty')->like(qr/^(http|$)/i);
881 $v->optional('redirect_uri', 'trim', 'not_empty')->like(qr/^(http|$)/i);
882 $v->optional('src', 'not_empty');
883
884 $c->stash(template => 'auth/clients');
885
886 # Render with error
887 if ($v->has_error) {
888 if ($v->has_error('csrf_token')) {
Akron33f5c672019-06-24 19:40:47 +0200889 $c->notify(error => $c->loc('Auth_csrfFail'));
890 }
891 else {
Akrond91a1ca2022-05-20 16:45:01 +0200892 $c->notify(error => $c->loc('Auth_paramError'));
Akron864c2932018-11-16 17:18:55 +0100893 };
Akrond91a1ca2022-05-20 16:45:01 +0200894 return $c->render;
895 } elsif ($c->req->is_limit_exceeded) {
896 $c->notify(error => $c->loc('Auth_fileSizeExceeded'));
897 return $c->render;
898 };
Akron864c2932018-11-16 17:18:55 +0100899
Akrond91a1ca2022-05-20 16:45:01 +0200900 my $type = $v->param('type');
901 my $src = $v->param('src');
902 my $src_json;
Akron864c2932018-11-16 17:18:55 +0100903
Akrond91a1ca2022-05-20 16:45:01 +0200904 my $json_obj = {
905 name => $v->param('name'),
906 type => $type,
907 description => $v->param('desc'),
908 url => $v->param('url'),
909 redirect_uri => $v->param('redirect_uri')
910 };
Akron864c2932018-11-16 17:18:55 +0100911
Akrond91a1ca2022-05-20 16:45:01 +0200912 # Check plugin source
913 if ($src) {
Akron33f5c672019-06-24 19:40:47 +0200914
Akrond91a1ca2022-05-20 16:45:01 +0200915 # Source need to be a file upload
Akron99968a92022-06-03 12:32:07 +0200916 if (!ref $src || !$src->isa('Mojo::Upload')) {
Akrond91a1ca2022-05-20 16:45:01 +0200917 $c->notify(error => $c->loc('Auth_jsonRequired'));
918 return $c->render;
919 };
920
921 # Uploads can't be too large
922 if ($src->size > 1_000_000) {
Akron9f2ad342022-05-04 16:16:40 +0200923 $c->notify(error => $c->loc('Auth_fileSizeExceeded'));
924 return $c->render;
925 };
926
Akrond91a1ca2022-05-20 16:45:01 +0200927 # Check upload is not empty
928 if ($src->size > 0 && $src->filename ne '') {
Akron9f2ad342022-05-04 16:16:40 +0200929
Akron99968a92022-06-03 12:32:07 +0200930 # Plugins need to be confidential
931 if ($type ne 'CONFIDENTIAL') {
932 $c->notify(error => $c->loc('Auth_confidentialRequired'));
933 return $c->render;
934 }
935
Akrond91a1ca2022-05-20 16:45:01 +0200936 my $asset = $src->asset;
Akron9f2ad342022-05-04 16:16:40 +0200937
Akrond91a1ca2022-05-20 16:45:01 +0200938 # Check for json
939 eval {
940 $src_json = decode_json($asset->slurp);
941 };
Akron9f2ad342022-05-04 16:16:40 +0200942
Akrond91a1ca2022-05-20 16:45:01 +0200943 if ($@ || !ref $src_json || ref $src_json ne 'HASH') {
Akron9f2ad342022-05-04 16:16:40 +0200944 $c->notify(error => $c->loc('Auth_jsonRequired'));
945 return $c->render;
946 };
947
Akrond91a1ca2022-05-20 16:45:01 +0200948 $json_obj->{source} = $src_json;
Akron59992122019-10-29 11:28:45 +0100949 };
Akron83209f72021-01-29 17:54:15 +0100950 };
951
Akrond91a1ca2022-05-20 16:45:01 +0200952 # Wait for async result
953 $c->render_later;
Akron83209f72021-01-29 17:54:15 +0100954
Akrond91a1ca2022-05-20 16:45:01 +0200955 # Register on server
956 state $url = Mojo::URL->new($c->korap->api)->path('oauth2/client/register');
957 $c->korap_request('POST', $url => {} => json => $json_obj)->then(
Akron83209f72021-01-29 17:54:15 +0100958 sub {
959 my $tx = shift;
Akrond91a1ca2022-05-20 16:45:01 +0200960 my $result = $tx->result;
Akron83209f72021-01-29 17:54:15 +0100961
Akrond91a1ca2022-05-20 16:45:01 +0200962 if ($result->is_error) {
963 my $json = $result->json;
964 if ($json && $json->{error}) {
965 $c->notify(
966 error => $json->{error} .
967 ($json->{error_description} ? ': ' . $json->{error_description} : '')
968 )
Akron83209f72021-01-29 17:54:15 +0100969 };
Akrond91a1ca2022-05-20 16:45:01 +0200970
971 return Mojo::Promise->reject;
Akron83209f72021-01-29 17:54:15 +0100972 };
973
Akrond91a1ca2022-05-20 16:45:01 +0200974 my $json = $result->json;
975
976 my $client_id = $json->{client_id};
977 my $client_secret = $json->{client_secret};
978
979 $c->stash('client_name' => $v->param('name'));
980 $c->stash('client_desc' => $v->param('desc'));
981 $c->stash('client_type' => $v->param('type'));
982 $c->stash('client_url' => $v->param('url'));
983 $c->stash('client_src' => $v->param('source'));
984 $c->stash('client_redirect_uri' => $v->param('redirect_uri'));
985 $c->stash('client_id' => $client_id);
986
987 if ($client_secret) {
988 $c->stash('client_secret' => $client_secret);
Akron83209f72021-01-29 17:54:15 +0100989 };
Akron83209f72021-01-29 17:54:15 +0100990
Akrond91a1ca2022-05-20 16:45:01 +0200991 $c->notify(success => $c->loc('Auth_en_registerSuccess'));
Akron83209f72021-01-29 17:54:15 +0100992
Akrond91a1ca2022-05-20 16:45:01 +0200993 return $c->render(template => 'auth/client');
Akron83209f72021-01-29 17:54:15 +0100994 }
995 )->catch(
996 sub {
Akrond91a1ca2022-05-20 16:45:01 +0200997 $c->notify('error' => $c->loc('Auth_en_registerFail'));
Akron83209f72021-01-29 17:54:15 +0100998 }
Akrond91a1ca2022-05-20 16:45:01 +0200999 )->finally(
1000 sub {
1001 return $c->redirect_to('settings' => { scope => 'oauth' });
1002 }
1003 );
Akron83209f72021-01-29 17:54:15 +01001004 }
Akrond91a1ca2022-05-20 16:45:01 +02001005 )->name('oauth-register');
Akronc1aaf932021-06-09 12:19:15 +02001006
1007
Akrond91a1ca2022-05-20 16:45:01 +02001008 # Unregister client page
1009 $r->get('/settings/oauth/:client_id/unregister')->to(
Akronc1aaf932021-06-09 12:19:15 +02001010 cb => sub {
1011 my $c = shift;
Akrond91a1ca2022-05-20 16:45:01 +02001012 _set_no_cache($c->res->headers);
1013 $c->render(template => 'auth/unregister');
1014 }
1015 )->name('oauth-unregister');
1016
1017
1018 # Unregister client
1019 $r->post('/settings/oauth/:client_id/unregister')->to(
1020 cb => sub {
1021 my $c = shift;
1022 _set_no_cache($c->res->headers);
Akronc1aaf932021-06-09 12:19:15 +02001023
1024 my $v = $c->validation;
1025
1026 unless ($c->auth->token) {
1027 return $c->render(
1028 content => 'Unauthorized',
1029 status => 401
1030 );
1031 };
1032
1033 $v->csrf_protect;
Akrond91a1ca2022-05-20 16:45:01 +02001034 $v->required('client-name', 'trim')->size(3, 255);
Akronc1aaf932021-06-09 12:19:15 +02001035
1036 # Render with error
1037 if ($v->has_error) {
1038 if ($v->has_error('csrf_token')) {
1039 $c->notify(error => $c->loc('Auth_csrfFail'));
1040 }
1041 else {
1042 $c->notify(error => $c->loc('Auth_paramError'));
1043 };
Akrond91a1ca2022-05-20 16:45:01 +02001044 return $c->redirect_to('oauth-settings');
Akronc1aaf932021-06-09 12:19:15 +02001045 };
1046
Akrond91a1ca2022-05-20 16:45:01 +02001047 my $client_id = $c->stash('client_id');
1048 my $client_name = $v->param('client-name');
1049 my $client_secret = $v->param('client-secret');
Akronc1aaf932021-06-09 12:19:15 +02001050
Akrond91a1ca2022-05-20 16:45:01 +02001051 # Get list of registered clients
1052 my $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/client/deregister/')->path(
1053 $client_id
1054 );
Akronc1aaf932021-06-09 12:19:15 +02001055
Akrond91a1ca2022-05-20 16:45:01 +02001056 my $send = {};
1057
1058 if ($client_secret) {
1059 $send->{client_secret} = $client_secret;
1060 };
1061
1062 # Get the list of all clients
1063 return $c->korap_request(delete => $r_url, {} => form => $send)->then(
Akronc1aaf932021-06-09 12:19:15 +02001064 sub {
1065 my $tx = shift;
1066
1067 # Response is fine
1068 if ($tx->res->is_success) {
Akrond91a1ca2022-05-20 16:45:01 +02001069 # Okay
1070 $c->notify(success => 'Successfully deleted ' . $client_name);
Akronc1aaf932021-06-09 12:19:15 +02001071 }
1072 else {
Akrond91a1ca2022-05-20 16:45:01 +02001073
1074 # Failure
1075 my $json = $tx->result->json;
1076 if ($json && $json->{error_description}) {
1077 $c->notify(error => $json->{error_description});
1078 } else {
1079 $c->notify(error => $c->loc('Auth_responseError'));
1080 };
Akronc1aaf932021-06-09 12:19:15 +02001081 };
Akrond91a1ca2022-05-20 16:45:01 +02001082
1083 return $c->redirect_to('oauth-settings');
Akronc1aaf932021-06-09 12:19:15 +02001084 }
Akrond91a1ca2022-05-20 16:45:01 +02001085 );
Akronc1aaf932021-06-09 12:19:15 +02001086 }
Akrond91a1ca2022-05-20 16:45:01 +02001087 )->name('oauth-unregister-post');
Akroncdfd9d52019-07-23 11:35:00 +02001088
Akron33f5c672019-06-24 19:40:47 +02001089
Akrond91a1ca2022-05-20 16:45:01 +02001090 # OAuth Client authorization
1091 $r->get('/settings/oauth/authorize')->to(
Akron33f5c672019-06-24 19:40:47 +02001092 cb => sub {
1093 my $c = shift;
1094
Akrond91a1ca2022-05-20 16:45:01 +02001095 _set_no_cache($c->res->headers);
Akron33f5c672019-06-24 19:40:47 +02001096
Akrond91a1ca2022-05-20 16:45:01 +02001097 my $v = $c->validation;
1098 $v->required('client_id');
1099 $v->optional('scope');
1100 $v->optional('state');
1101 $v->optional('redirect_uri');
1102
1103 # Redirect with error
1104 if ($v->has_error) {
1105 $c->notify(error => $c->loc('Auth_paramError'));
1106 return $c->redirect_to;
Akron6a228db2021-10-14 15:57:00 +02001107 };
1108
Akrond91a1ca2022-05-20 16:45:01 +02001109 foreach (qw!scope client_id state redirect_uri!) {
1110 $c->stash($_, $v->param($_));
1111 };
Akron33f5c672019-06-24 19:40:47 +02001112
Akrond91a1ca2022-05-20 16:45:01 +02001113 # Wait for async result
1114 $c->render_later;
Akron33f5c672019-06-24 19:40:47 +02001115
Akrond91a1ca2022-05-20 16:45:01 +02001116 my $client_id = $v->param('client_id');
1117
1118 my $client_information = $c->auth->client_list_p->then(
1119 sub {
1120 my $clients = shift;
1121 foreach (@$clients) {
1122 if ($_->{client_id} eq $client_id) {
1123 $c->stash(client_name => $_->{'client_name'});
1124 $c->stash(client_type => $_->{'client_type'});
1125 $c->stash(client_desc => $_->{'client_description'});
1126 $c->stash(client_url => $_->{'client_url'});
1127 $c->stash(redirect_uri_server => $_->{'client_redirect_uri'});
1128 last;
1129 };
1130 };
Akron33f5c672019-06-24 19:40:47 +02001131 }
Akrond91a1ca2022-05-20 16:45:01 +02001132 )->catch(
1133 sub {
1134 $c->stash(client_type => 'PUBLIC');
1135 $c->stash(client_name => $v->param('client_id'));
1136 return;
1137 }
1138 )->finally(
1139 sub {
1140
1141 # Get auth token
1142 my $auth_token = $c->auth->token;
1143
1144 # User is not logged in - log in before!
1145 unless ($auth_token) {
1146 return $c->render(template => 'auth/login');
1147 };
1148
1149 # Grant authorization
1150 return $c->render(template => 'auth/grant_scope');
1151 }
1152 );
1153 }
1154 )->name('oauth-grant-scope');
1155
1156
1157 # OAuth Client authorization
1158 # This will return a location information including some info
1159 $r->post('/settings/oauth/authorize')->to(
1160 cb => sub {
1161 my $c = shift;
1162
1163 _set_no_cache($c->res->headers);
1164
1165 # It's necessary that it's clear this was triggered by
1166 # KorAP and not by the client!
1167 my $v = $c->validation;
1168 $v->csrf_protect;
1169 $v->required('client_id');
1170 $v->optional('scope');
1171 $v->optional('state');
1172 $v->optional('redirect_uri');
1173
1174 # WARN! SIGN THIS TO PREVENT OPEN REDIRECT ATTACKS!
1175 $v->required('redirect_uri_server');
1176
1177 # Render with error
1178 if ($v->has_error) {
1179 my $url = Mojo::URL->new($v->param('redirect_uri_server') // $c->url_for('index'));
1180
1181 if ($v->has_error('csrf_token')) {
1182 $url->query([error_description => $c->loc('Auth_csrfFail')]);
Akron33f5c672019-06-24 19:40:47 +02001183 }
1184 else {
Akrond91a1ca2022-05-20 16:45:01 +02001185 $url->query([error_description => $c->loc('Auth_paramError')]);
Akron33f5c672019-06-24 19:40:47 +02001186 };
1187
Akrond91a1ca2022-05-20 16:45:01 +02001188 return $c->redirect_to($url);
1189 };
Akron33f5c672019-06-24 19:40:47 +02001190
Akrond91a1ca2022-05-20 16:45:01 +02001191 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/authorize');
1192 $c->stash(redirect_uri_server => Mojo::URL->new($v->param('redirect_uri_server')));
Akron33f5c672019-06-24 19:40:47 +02001193
Akrond91a1ca2022-05-20 16:45:01 +02001194 return $c->korap_request(post => $r_url, {} => form => {
1195 response_type => 'code',
1196 client_id => $v->param('client_id'),
1197 redirect_uri => $v->param('redirect_uri'),
1198 state => $v->param('state'),
1199 scope => $v->param('scope'),
Akron33f5c672019-06-24 19:40:47 +02001200 })->then(
1201 sub {
1202 my $tx = shift;
1203
Akrond91a1ca2022-05-20 16:45:01 +02001204 # Check for location header with code in redirects
1205 my $loc;
1206 foreach (@{$tx->redirects}) {
1207 $loc = $_->res->headers->header('Location');
Akron33f5c672019-06-24 19:40:47 +02001208
Akrond91a1ca2022-05-20 16:45:01 +02001209 my $url = Mojo::URL->new($loc);
Akron33f5c672019-06-24 19:40:47 +02001210
Akrond91a1ca2022-05-20 16:45:01 +02001211 if ($url->query->param('code')) {
1212 last;
1213 } elsif (my $err = $url->query->param('error_description')) {
1214 return Mojo::Promise->reject($err);
Akron33f5c672019-06-24 19:40:47 +02001215 }
Akron33f5c672019-06-24 19:40:47 +02001216 };
1217
Akrond91a1ca2022-05-20 16:45:01 +02001218 return Mojo::Promise->resolve($loc) if $loc;
Akron33f5c672019-06-24 19:40:47 +02001219
Akrond91a1ca2022-05-20 16:45:01 +02001220 # Failed redirect, but location set
1221 if ($tx->res->headers->location) {
1222 my $url = Mojo::URL->new($tx->res->headers->location);
1223 if (my $err = $url->query->param('error_description')) {
1224 return Mojo::Promise->reject($err);
1225 };
1226 };
Akron33f5c672019-06-24 19:40:47 +02001227
Akrond91a1ca2022-05-20 16:45:01 +02001228 # No location code
1229 return Mojo::Promise->reject('no location response');
Akron33f5c672019-06-24 19:40:47 +02001230 }
1231 )->catch(
1232 sub {
Akrond91a1ca2022-05-20 16:45:01 +02001233 my $err_msg = shift;
1234 my $url = $c->stash('redirect_uri_server');
1235 if ($err_msg) {
1236 $url = $url->query([error_description => $err_msg]);
1237 };
1238 return Mojo::Promise->resolve($url);
Akron33f5c672019-06-24 19:40:47 +02001239 }
Akrond91a1ca2022-05-20 16:45:01 +02001240 )->then(
Akron33f5c672019-06-24 19:40:47 +02001241 sub {
Akrond91a1ca2022-05-20 16:45:01 +02001242 my $loc = shift;
1243 return $c->redirect_to($loc);
Akron33f5c672019-06-24 19:40:47 +02001244 }
Akrond91a1ca2022-05-20 16:45:01 +02001245 )->wait;
1246 return $c->rendered;
Akron33f5c672019-06-24 19:40:47 +02001247 }
Akrond91a1ca2022-05-20 16:45:01 +02001248 )->name('oauth-grant-scope-post');
Akron4cefe1f2019-09-04 10:11:28 +02001249
1250
Akrond91a1ca2022-05-20 16:45:01 +02001251 # Show information of a client
1252 $r->get('/settings/oauth/:client_id')->to(
Akron4cefe1f2019-09-04 10:11:28 +02001253 cb => sub {
1254 my $c = shift;
1255
Akrond91a1ca2022-05-20 16:45:01 +02001256 _set_no_cache($c->res->headers);
Akron4cefe1f2019-09-04 10:11:28 +02001257
Akrond91a1ca2022-05-20 16:45:01 +02001258 $c->render_later;
Akron4cefe1f2019-09-04 10:11:28 +02001259
Akrond91a1ca2022-05-20 16:45:01 +02001260 $c->auth->client_list_p->then(
1261 sub {
1262 my $json = shift;
1263
1264 my ($item) = grep {
1265 $c->stash('client_id') eq $_->{client_id}
1266 } @$json;
1267
1268 unless ($item) {
1269 return Mojo::Promise->reject;
1270 };
1271
1272 $c->stash(client_name => $item->{client_name});
1273 $c->stash(client_desc => $item->{client_description});
1274 $c->stash(client_url => $item->{client_url});
1275 $c->stash(client_type => ($item->{client_type} // 'PUBLIC'));
1276 $c->stash(client_src => encode_json($item->{source})) if $item->{source};
1277
1278 $c->auth->token_list_p($c->stash('client_id'));
1279 }
Akron4cefe1f2019-09-04 10:11:28 +02001280 )->then(
Akron4cefe1f2019-09-04 10:11:28 +02001281 sub {
Akrond91a1ca2022-05-20 16:45:01 +02001282 my $json = shift;
Akron4cefe1f2019-09-04 10:11:28 +02001283
Akrond91a1ca2022-05-20 16:45:01 +02001284 $c->stash(tokens => $json);
Akron4cefe1f2019-09-04 10:11:28 +02001285
Akrond91a1ca2022-05-20 16:45:01 +02001286 return Mojo::Promise->resolve;
Akron4cefe1f2019-09-04 10:11:28 +02001287 }
Akron4cefe1f2019-09-04 10:11:28 +02001288 )->catch(
Akron4cefe1f2019-09-04 10:11:28 +02001289 sub {
Akrond91a1ca2022-05-20 16:45:01 +02001290 return $c->reply->not_found;
Akron4cefe1f2019-09-04 10:11:28 +02001291 }
Akron4cefe1f2019-09-04 10:11:28 +02001292 )->finally(
Akron4cefe1f2019-09-04 10:11:28 +02001293 sub {
Akrond91a1ca2022-05-20 16:45:01 +02001294 return $c->render(template => 'auth/client')
Akron4cefe1f2019-09-04 10:11:28 +02001295 }
Akrond91a1ca2022-05-20 16:45:01 +02001296 );
Akron4cefe1f2019-09-04 10:11:28 +02001297
Akrond91a1ca2022-05-20 16:45:01 +02001298 return;
Akron4cefe1f2019-09-04 10:11:28 +02001299 }
Akrond91a1ca2022-05-20 16:45:01 +02001300 )->name('oauth-tokens');
Akron33f5c672019-06-24 19:40:47 +02001301 };
Akron59992122019-10-29 11:28:45 +01001302
Akrond91a1ca2022-05-20 16:45:01 +02001303
1304 # Ask if new token should be issued
1305 $r->get('/settings/oauth/:client_id/token')->to(
1306 cb => sub {
1307 my $c = shift;
1308 _set_no_cache($c->res->headers);
1309 $c->render(template => 'auth/issue-token');
1310 }
1311 )->name('oauth-issue-token');
1312
1313
1314 # Ask if a token should be revoked
1315 $r->post('/settings/oauth/:client_id/token/revoke')->to(
1316 cb => sub {
1317 shift->render(template => 'auth/revoke-token');
1318 }
1319 )->name('oauth-revoke-token');
1320
1321
1322 # Issue new token
1323 $r->post('/settings/oauth/:client_id/token')->to(
1324 cb => sub {
1325 my $c = shift;
1326 _set_no_cache($c->res->headers);
1327
1328 my $v = $c->validation;
1329
1330 unless ($c->auth->token) {
1331 return $c->render(
1332 content => 'Unauthorized',
1333 status => 401
1334 );
1335 };
1336
1337 $v->csrf_protect;
1338 $v->optional('client-secret');
1339 $v->required('name', 'trim');
1340
1341 # Render with error
1342 if ($v->has_error) {
1343 if ($v->has_error('csrf_token')) {
1344 $c->notify(error => $c->loc('Auth_csrfFail'));
1345 }
1346 else {
1347 $c->notify(error => $c->loc('Auth_paramError'));
1348 };
1349 return $c->redirect_to('oauth-settings')
1350 };
1351
1352 # Get authorization token
1353 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/authorize');
1354 my $client_id = $c->stash('client_id');
1355 my $name = $v->param('name');
1356 my $redirect_url = $c->url_for->query({name => $name});
1357
1358 return $c->korap_request(post => $r_url, {} => form => {
1359 response_type => 'code',
1360 client_id => $client_id,
1361 redirect_uri => $redirect_url,
1362 # TODO: State
1363 })->then(
1364 sub {
1365 my $tx = shift;
1366
1367 # Strip the token from the location header of the fake redirect
1368 # TODO: Alternatively redirect!
1369 my ($code, $scope, $loc, $name);
1370 foreach (@{$tx->redirects}) {
1371 $loc = $_->res->headers->header('Location');
1372 if (index($loc, 'code') > 0) {
1373 my $q = Mojo::URL->new($loc)->query;
1374 $code = $q->param('code');
1375 $scope = $q->param('scope');
1376 $name = $q->param('name');
1377 last;
1378 };
1379 };
1380
1381 # Fine!
1382 if ($code) {
1383 return Mojo::Promise->resolve(
1384 $client_id,
1385 $redirect_url,
1386 $code,
1387 $scope,
1388 $name
1389 );
1390 };
1391 return Mojo::Promise->reject;
1392 }
1393 )->then(
1394 sub {
1395 my ($client_id, $redirect_url, $code, $scope, $name) = @_;
1396
1397 # Get OAuth access token
1398 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
1399 return $c->kalamar_ua->post_p($r_url, {} => form => {
1400 client_id => $client_id,
1401 # NO CLIENT_SECRET YET SUPPORTED
1402 grant_type => 'authorization_code',
1403 code => $code,
1404 redirect_uri => $redirect_url
1405 })->then(
1406 sub {
1407 my $tx = shift;
1408 my $json = $tx->res->json;
1409
1410 if ($tx->res->is_error) {
1411 $c->notify(error => 'Unable to fetch new token');
1412 return Mojo::Promise->reject;
1413 };
1414
1415 $c->notify(success => 'New access token created');
1416
1417 $c->redirect_to('oauth-tokens' => { client_id => $client_id })
1418 }
1419 )->catch(
1420 sub {
1421 my $err_msg = shift;
1422
1423 # Only raised in case of connection errors
1424 if ($err_msg) {
1425 $c->notify(error => { src => 'Backend' } => $err_msg)
1426 };
1427
1428 $c->render(
1429 status => 400,
1430 template => 'failure'
1431 );
1432 }
1433 )
1434
1435 # Start IOLoop
1436 ->wait;
1437
1438 }
1439 )->catch(
1440 sub {
1441 my $err_msg = shift;
1442
1443 # Only raised in case of connection errors
1444 if ($err_msg) {
1445 $c->notify(error => { src => 'Backend' } => $err_msg)
1446 };
1447
1448 return $c->render(
1449 status => 400,
1450 template => 'failure'
1451 );
1452 }
1453 )
1454
1455 # Start IOLoop
1456 ->wait;
1457
1458 return 1;
1459 }
1460 )->name('oauth-issue-token-post');
1461
1462
1463 # Revoke token
1464 $r->delete('/settings/oauth/:client_id/token')->to(
1465 cb => sub {
1466 my $c = shift;
1467
1468 my $v = $c->validation;
1469
1470 unless ($c->auth->token) {
1471 return $c->render(
1472 content => 'Unauthorized',
1473 status => 401
1474 );
1475 };
1476
1477 $v->csrf_protect;
1478 $v->required('token', 'trim');
1479 $v->optional('name', 'trim');
1480 my $private_client_id = $c->stash('client_id');
1481
1482 # Render with error
1483 if ($v->has_error) {
1484 if ($v->has_error('csrf_token')) {
1485 $c->notify(error => $c->loc('Auth_csrfFail'));
1486 }
1487 else {
1488 $c->notify(error => $c->loc('Auth_paramError'));
1489 };
1490 return $c->redirect_to('oauth-tokens', client_id => $private_client_id);
1491 };
1492
1493 # Revoke token using super client privileges
1494 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/revoke/super');
1495
1496 my $token = $v->param('token');
1497
1498 return $c->korap_request(post => $r_url, {} => form => {
1499 super_client_id => $client_id,
1500 super_client_secret => $client_secret,
1501 token => $token
1502 })->then(
1503 sub {
1504 my $tx = shift;
1505
1506 # Response is fine
1507 if ($tx->res->is_success) {
1508 $c->notify(success => $c->loc('Auth_revokeSuccess'));
1509 return Mojo::Promise->resolve;
1510 };
1511
1512 return Mojo::Promise->reject;
1513 }
1514 )->catch(
1515 sub {
1516 my $err_msg = shift;
1517 if ($err_msg) {
1518 $c->notify(error => { src => 'Backend' } => $err_msg );
1519 }
1520 else {
1521 $c->notify(error => $c->loc('Auth_revokeFail'));
1522 };
1523 }
1524 )->finally(
1525 sub {
1526 return $c->redirect_to('oauth-tokens', client_id => $private_client_id);
1527 }
1528 )
1529
1530 # Start IOLoop
1531 ->wait;
1532 }
1533 )->name('oauth-revoke-token-delete');
1534
Akron59992122019-10-29 11:28:45 +01001535 $app->log->info('Successfully registered Auth plugin');
Akron864c2932018-11-16 17:18:55 +01001536};
1537
Akronb3f33592020-03-16 15:14:44 +01001538
Akronad011bb2021-06-10 12:16:36 +02001539# Set 'no caching' headers
1540sub _set_no_cache {
1541 my $h = shift;
1542 $h->cache_control('max-age=0, no-cache, no-store, must-revalidate');
1543 $h->expires('Thu, 01 Jan 1970 00:00:00 GMT');
1544 $h->header('Pragma','no-cache');
1545};
1546
1547
Akron6a228db2021-10-14 15:57:00 +02001548sub check_decode {
1549 no warnings 'uninitialized';
1550 my $str = shift;
1551 my $str2 = is_utf8($str) ? b($str)->decode : $str;
1552 if (defined($str2) && $str2 && length($str2) > 1) {
1553 return $str2
1554 };
1555 return;
1556};
1557
1558
Akron864c2932018-11-16 17:18:55 +010015591;
Akrona9c8b0e2018-11-16 20:20:28 +01001560
Akronc82b1bc2018-11-18 18:06:14 +01001561
Akrona9c8b0e2018-11-16 20:20:28 +01001562__END__
Akron59992122019-10-29 11:28:45 +01001563
1564=pod
1565
1566=encoding utf8
1567
1568=head1 NAME
1569
1570Kalamar::Plugin::Auth - OAuth-2.0-based authorization plugin
1571
1572=head1 DESCRIPTION
1573
1574L<Kalamar::Plugin::Auth> is an OAuth-2.0-based authorization
1575plugin for L<Kalamar>. It requires a C<Kustvakt> full server
1576with OAuth 2.0 capabilities.
1577It is activated by loading C<Auth> as a plugin in the C<Kalamar.plugins>
1578parameter in the Kalamar configuration.
1579
1580=head1 CONFIGURATION
1581
1582L<Kalamar::Plugin::Auth> supports the following parameter for the
1583C<Kalamar-Auth> configuration section in the Kalamar configuration:
1584
1585=over 2
1586
1587=item B<client_id>
1588
1589The client identifier of Kalamar to be send with every OAuth 2.0
1590management request.
1591
1592=item B<client_secret>
1593
1594The client secret of Kalamar to be send with every OAuth 2.0
1595management request.
1596
Akron59992122019-10-29 11:28:45 +01001597=item B<experimental_client_registration>
1598
1599Activates the oauth client registration flow.
1600
1601=back
1602
1603=head2 COPYRIGHT AND LICENSE
1604
Akrond91a1ca2022-05-20 16:45:01 +02001605Copyright (C) 2015-2022, L<IDS Mannheim|http://www.ids-mannheim.de/>
Akron59992122019-10-29 11:28:45 +01001606Author: L<Nils Diewald|http://nils-diewald.de/>
1607
1608Kalamar is developed as part of the L<KorAP|http://korap.ids-mannheim.de/>
1609Corpus Analysis Platform at the
1610L<Leibniz Institute for the German Language (IDS)|http://ids-mannheim.de/>,
1611member of the
1612L<Leibniz-Gemeinschaft|http://www.leibniz-gemeinschaft.de>
1613and supported by the L<KobRA|http://www.kobra.tu-dortmund.de> project,
1614funded by the
1615L<Federal Ministry of Education and Research (BMBF)|http://www.bmbf.de/en/>.
1616
1617Kalamar is free software published under the
1618L<BSD-2 License|https://raw.githubusercontent.com/KorAP/Kalamar/master/LICENSE>.
1619
1620=cut