blob: 941e71640f6c416e30c098056920d9dfe75fdec1 [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';
Akron6a228db2021-10-14 15:57:00 +02006use Mojo::Util qw!deprecated b64_encode encode!;
Akron9f2ad342022-05-04 16:16:40 +02007use Mojo::JSON 'decode_json';
Akron6a228db2021-10-14 15:57:00 +02008use Encode 'is_utf8';
Akron864c2932018-11-16 17:18:55 +01009
Akroncdfd9d52019-07-23 11:35:00 +020010# This is a plugin to deal with the Kustvakt OAuth server.
11# It establishes both the JWT as well as the OAuth password
12# flow for login.
13# All tokens are stored in the session. Access tokens are short-lived,
14# which limits the effects of misuse.
15# Refresh tokens are bound to client id and client secret,
16# which again limits the effects of misuse.
17
18# TODO:
19# Establish a plugin 'OAuth' that works independent of 'Auth'.
20
Akron864c2932018-11-16 17:18:55 +010021# TODO:
Akronc82b1bc2018-11-18 18:06:14 +010022# CSRF-protect logout!
Akron864c2932018-11-16 17:18:55 +010023
Akroncdfd9d52019-07-23 11:35:00 +020024# TODO:
25# Remove the Bearer prefix from auth.
26
27# In case no expiration time is returned by the server,
28# take this time.
Akron8bbbecf2019-07-01 18:57:30 +020029our $EXPECTED_EXPIRATION_IN = 259200;
30
Akron864c2932018-11-16 17:18:55 +010031# Register the plugin
32sub register {
33 my ($plugin, $app, $param) = @_;
34
Akron864c2932018-11-16 17:18:55 +010035 # Load parameter from config file
36 if (my $config_param = $app->config('Kalamar-Auth')) {
37 $param = { %$param, %$config_param };
38 };
39
Akron864c2932018-11-16 17:18:55 +010040 # Load 'notifications' plugin
41 unless (exists $app->renderer->helpers->{notify}) {
42 $app->plugin(Notifications => {
43 HTML => 1
44 });
45 };
46
Akron33f5c672019-06-24 19:40:47 +020047 # Get the client id and the client_secret as a requirement
48 unless ($param->{client_id} && $param->{client_secret}) {
49 $app->log->error('client_id or client_secret not defined');
50 };
Akron864c2932018-11-16 17:18:55 +010051
Akron59992122019-10-29 11:28:45 +010052 # Load localize
Akron864c2932018-11-16 17:18:55 +010053 $app->plugin('Localize' => {
54 dict => {
Akrone997bb52021-06-11 16:44:06 +020055 de => {
56 abort => 'Abbrechen'
57 },
58 -en => {
59 abort => 'Abort'
60 },
Akron864c2932018-11-16 17:18:55 +010061 Auth => {
62 _ => sub { $_->locale },
63 de => {
Akrona8efaa92022-04-09 14:45:43 +020064 loginPlease => 'Bitte melden Sie sich an!',
Akron864c2932018-11-16 17:18:55 +010065 loginSuccess => 'Anmeldung erfolgreich',
66 loginFail => 'Anmeldung fehlgeschlagen',
67 logoutSuccess => 'Abmeldung erfolgreich',
68 logoutFail => 'Abmeldung fehlgeschlagen',
Akronff088112021-06-15 15:26:04 +020069 authenticationFail => 'Nicht authentifiziert',
Akron864c2932018-11-16 17:18:55 +010070 csrfFail => 'Fehlerhafter CSRF Token',
Akron6a228db2021-10-14 15:57:00 +020071 invalidChar => 'Ungültiges Zeichen in Anfrage',
Akron8bbbecf2019-07-01 18:57:30 +020072 openRedirectFail => 'Weiterleitungsfehler',
Akroncdfd9d52019-07-23 11:35:00 +020073 tokenExpired => 'Zugriffstoken abgelaufen',
74 tokenInvalid => 'Zugriffstoken ungültig',
75 refreshFail => 'Fehlerhafter Refresh-Token',
Akron59992122019-10-29 11:28:45 +010076 responseError => 'Unbekannter Autorisierungsfehler',
Akroncce055c2021-07-02 12:18:03 +020077 serverError => 'Unbekannter Serverfehler',
Akronc1aaf932021-06-09 12:19:15 +020078 revokeFail => 'Der Token kann nicht widerrufen werden',
79 revokeSuccess => 'Der Token wurde erfolgreich widerrufen',
Akron59992122019-10-29 11:28:45 +010080 paramError => 'Einige Eingaben sind fehlerhaft',
81 redirectUri => 'Weiterleitungs-Adresse',
Akron9f2ad342022-05-04 16:16:40 +020082 pluginSrc => 'Beschreibung des Plugins (*.json-Datei)',
Akron59992122019-10-29 11:28:45 +010083 homepage => 'Webseite',
84 desc => 'Kurzbeschreibung',
Akronc1aaf932021-06-09 12:19:15 +020085 revoke => 'Widerrufen',
Akron59992122019-10-29 11:28:45 +010086 clientCredentials => 'Client Daten',
87 clientType => 'Art der Client-Applikation',
88 clientName => 'Name der Client-Applikation',
89 clientID => 'ID der Client-Applikation',
90 clientSecret => 'Client-Secret',
91 clientRegister => 'Neue Client-Applikation registrieren',
92 registerSuccess => 'Registrierung erfolgreich',
93 registerFail => 'Registrierung fehlgeschlagen',
94 oauthSettings => 'OAuth',
Akrone997bb52021-06-11 16:44:06 +020095 oauthUnregister => {
96 -long => 'Möchten sie <span class="client-name"><%= $client_name %></span> wirklich löschen?',
97 short => 'Löschen'
98 },
Akron9f2ad342022-05-04 16:16:40 +020099 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 +0100100 loginHint => 'Möglicherweise müssen sie sich zunächst einloggen.',
Akrone997bb52021-06-11 16:44:06 +0200101 oauthIssueToken => {
102 -long => 'Stelle einen neuen Token für <span class="client-name"><%= $client_name %></span> aus',
103 short => 'Neuen Token ausstellen'
104 },
Akron9ffb4a32021-06-08 16:11:21 +0200105 accessToken => 'Access Token',
Akrone997bb52021-06-11 16:44:06 +0200106 oauthRevokeToken => {
107 -long => 'Widerrufe einen Token für <span class="client-name"><%= $client_name %></span>',
108 short => 'Widerrufe'
109 },
Akrona8efaa92022-04-09 14:45:43 +0200110 oauthGrantScope => {
111 -long => '<span class="client-name"><%= $client_name %></span> möchte Zugriffsrechte',
112 short => 'Zugriffsrechte erteilen'
113 },
Akron408bc7c2022-04-28 15:46:43 +0200114 oauthGrantPublicWarn => 'Achtung - dies ist ein öffentlicher Client!',
Akrone997bb52021-06-11 16:44:06 +0200115 createdAt => 'Erstellt am <time datetime="<%= stash("date") %>"><%= stash("date") %></date>.',
Akron9f2ad342022-05-04 16:16:40 +0200116 expiresIn => 'Läuft in <%= stash("seconds") %> Sekunden ab.',
117 fileSizeExceeded => 'Dateigröße überschritten'
Akron864c2932018-11-16 17:18:55 +0100118 },
119 -en => {
Akrona8efaa92022-04-09 14:45:43 +0200120 loginPlease => 'Please log in!',
Akron864c2932018-11-16 17:18:55 +0100121 loginSuccess => 'Login successful',
122 loginFail => 'Access denied',
123 logoutSuccess => 'Logout successful',
124 logoutFail => 'Logout failed',
Akronff088112021-06-15 15:26:04 +0200125 authenticationFail => 'Not authenticated',
Akron864c2932018-11-16 17:18:55 +0100126 csrfFail => 'Bad CSRF token',
Akron6a228db2021-10-14 15:57:00 +0200127 invalidChar => 'Invalid character in request',
Akron8bbbecf2019-07-01 18:57:30 +0200128 openRedirectFail => 'Redirect failure',
Akroncdfd9d52019-07-23 11:35:00 +0200129 tokenExpired => 'Access token expired',
130 tokenInvalid => 'Access token invalid',
131 refreshFail => 'Bad refresh token',
Akron59992122019-10-29 11:28:45 +0100132 responseError => 'Unknown authorization error',
Akroncce055c2021-07-02 12:18:03 +0200133 serverError => 'Unknown server error',
Akronc1aaf932021-06-09 12:19:15 +0200134 revokeFail => 'Token can\'t be revoked',
135 revokeSuccess => 'Token was revoked successfully',
Akron59992122019-10-29 11:28:45 +0100136 paramError => 'Some fields are invalid',
137 redirectUri => 'Redirect URI',
Akron9f2ad342022-05-04 16:16:40 +0200138 pluginSrc => 'Declaration of the plugin (*.json file)',
Akron59992122019-10-29 11:28:45 +0100139 homepage => 'Homepage',
140 desc => 'Short description',
Akronc1aaf932021-06-09 12:19:15 +0200141 revoke => 'Revoke',
Akron59992122019-10-29 11:28:45 +0100142 clientCredentials => 'Client Credentials',
143 clientType => 'Type of the client application',
144 clientName => 'Name of the client application',
145 clientID => 'ID of the client application',
146 clientSecret => 'Client secret',
147 clientRegister => 'Register new client application',
148 registerSuccess => 'Registration successful',
149 registerFail => 'Registration denied',
150 oauthSettings => 'OAuth',
Akrone997bb52021-06-11 16:44:06 +0200151 oauthUnregister => {
152 -long => 'Do you really want to unregister <span class="client-name"><%= $client_name %></span>?',
153 short => 'Unregister'
154 },
Akron9f2ad342022-05-04 16:16:40 +0200155 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 +0100156 loginHint => 'Maybe you need to log in first?',
Akrone997bb52021-06-11 16:44:06 +0200157 oauthIssueToken => {
158 -long => 'Issue a new token for <span class="client-name"><%= $client_name %></span>',
159 short => 'Issue new token'
160 },
Akron9ffb4a32021-06-08 16:11:21 +0200161 accessToken => 'Access Token',
Akrone997bb52021-06-11 16:44:06 +0200162 oauthRevokeToken => {
163 -long => 'Revoke a token for <span class="client-name"><%= $client_name %></span>',
164 short => 'Revoke'
165 },
Akrona8efaa92022-04-09 14:45:43 +0200166 oauthGrantScope => {
167 -long => '<span class="client-name"><%= $client_name %></span> wants to have access',
168 short => 'Grant access'
169 },
Akron408bc7c2022-04-28 15:46:43 +0200170 oauthGrantPublicWarn => 'Warning - this is a public client!',
Akrone997bb52021-06-11 16:44:06 +0200171 createdAt => 'Created at <time datetime="<%= stash("date") %>"><%= stash("date") %></date>.',
Akron9f2ad342022-05-04 16:16:40 +0200172 expiresIn => 'Expires in <%= stash("seconds") %> seconds.',
173 fileSizeExceeded => 'File size exceeded',
174 confidentialRequired => 'Plugins need to be confidential',
175 jsonRequired => 'Plugin declarations need to be json files',
Akron864c2932018-11-16 17:18:55 +0100176 }
177 }
178 }
179 });
180
181
Akrona9c8b0e2018-11-16 20:20:28 +0100182 # Add login frame to sidebar
183 $app->content_block(
184 sidebar => {
185 template => 'partial/auth/login'
186 }
187 );
188
189
Akronc82b1bc2018-11-18 18:06:14 +0100190 # Add logout button to header button list
191 $app->content_block(
192 headerButtonGroup => {
193 template => 'partial/auth/logout'
194 }
195 );
196
Akron27031aa2020-04-28 14:57:10 +0200197
198 # Add hook after search
199 $app->hook(
200 after_search => sub {
201 my $c = shift;
202
203 # User is not logged in
204 if ($c->stash('results')->size == 0 && !$c->auth->token) {
205 $c->content_for(
206 'after_search_results' =>
207 $c->render_to_string(
208 inline => '<p class="hint"><%= loc "Auth_loginHint" %></p>'
209 )
210 );
211 };
212 }
213 );
214
Akron59992122019-10-29 11:28:45 +0100215 # The plugin path
216 my $path = catdir(dirname(__FILE__), 'Auth');
217
218 # Append "templates"
219 push @{$app->renderer->paths}, catdir($path, 'templates');
Akron864c2932018-11-16 17:18:55 +0100220
Akron4796e002019-07-05 10:13:15 +0200221 # Get or set the user token necessary for authorization
Akron864c2932018-11-16 17:18:55 +0100222 $app->helper(
223 'auth.token' => sub {
Akroncdfd9d52019-07-23 11:35:00 +0200224 my ($c, $token, $expires_in) = @_;
Akron864c2932018-11-16 17:18:55 +0100225
Akroncdfd9d52019-07-23 11:35:00 +0200226 if ($token) {
227 # Set auth token
Akron4796e002019-07-05 10:13:15 +0200228 $c->stash(auth => $token);
Akroncdfd9d52019-07-23 11:35:00 +0200229 $c->session(auth => $token);
230 $c->session(auth_exp => time + $expires_in);
231 return 1;
Akron4796e002019-07-05 10:13:15 +0200232 };
233
Akroncdfd9d52019-07-23 11:35:00 +0200234 # Get token from stash
235 $token = $c->stash('auth');
236
237 return $token if $token;
238
239 # Get auth from session
240 $token = $c->session('auth') or return;
241 $c->stash(auth => $token);
242
243 # Return stashed value
244 return $token;
Akron864c2932018-11-16 17:18:55 +0100245 }
246 );
247
Akron864c2932018-11-16 17:18:55 +0100248 # Log in to the system
249 my $r = $app->routes;
Akron864c2932018-11-16 17:18:55 +0100250
Akron33f5c672019-06-24 19:40:47 +0200251 if ($param->{oauth2}) {
Akron864c2932018-11-16 17:18:55 +0100252
Akron8bbbecf2019-07-01 18:57:30 +0200253 my $client_id = $param->{client_id};
254 my $client_secret = $param->{client_secret};
255
Akroncdfd9d52019-07-23 11:35:00 +0200256
257 # Sets a requested token and returns
258 # an error, if it didn't work
Akron8bbbecf2019-07-01 18:57:30 +0200259 $app->helper(
Akroncdfd9d52019-07-23 11:35:00 +0200260 'auth.set_tokens_p' => sub {
261 my ($c, $json) = @_;
262 my $promise = Mojo::Promise->new;
263
264 # No json object
265 unless ($json) {
266 return $promise->reject({
267 message => 'Response is no valid JSON object (remote)'
268 });
269 };
270
271 # There is an error here
272 # Dealing with errors here
273 if ($json->{error} && ref $json->{error} ne 'ARRAY') {
274 return $promise->reject(
275 {
276 message => $json->{error} . ($json->{error_description} ? ': ' . $json->{error_description} : '')
277 }
278 );
279 }
280
281 # There is an array of errors
282 elsif (my $error = $json->{errors} // $json->{error}) {
283 if (ref $error eq 'ARRAY') {
284 my @errors = ();
285 foreach (@{$error}) {
286 if ($_->[1]) {
287 push @errors, { code => $_->[0], message => $_->[1]}
288 }
289 }
290 return $promise->reject(@errors);
291 }
292
293 return $promise->reject({message => $error});
294 };
295
296 # Everything is fine
297 my $access_token = $json->{access_token};
Akronc58bfc42020-10-05 12:09:45 +0200298 my $token_type = $json->{token_type};
Akroncdfd9d52019-07-23 11:35:00 +0200299 my $refresh_token = $json->{refresh_token};
300 my $expires_in = $json->{"expires_in"} // $EXPECTED_EXPIRATION_IN;
301 my $auth = $token_type . ' ' . $access_token;
302 # my $scope = $json->{scope};
303
304 # Set session info
305 $c->session(auth => $auth);
306
307 # Expiration of the token minus tolerance
308 $c->session(auth_exp => time + $expires_in - 60);
309
310 # Set session info for refresh token
311 # This can be stored in the session, as it is useless
312 # unless the client secret is stolen
313 $c->session(auth_r => $refresh_token) if $refresh_token;
314
315 # Set stash info
316 $c->stash(auth => $auth);
317
318 return $promise->resolve;
319 }
320 );
321
322
323 # Refresh tokens and return a promise
324 $app->helper(
325 'auth.refresh_p' => sub {
Akron8bbbecf2019-07-01 18:57:30 +0200326 my $c = shift;
327 my $refresh_token = shift;
328
Akron8bbbecf2019-07-01 18:57:30 +0200329 # Get OAuth access token
Akroncdfd9d52019-07-23 11:35:00 +0200330 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
Akron8bbbecf2019-07-01 18:57:30 +0200331
Akron4cefe1f2019-09-04 10:11:28 +0200332 $c->app->log->debug("Refresh at $r_url");
Akroncdfd9d52019-07-23 11:35:00 +0200333
334 return $c->kalamar_ua->post_p($r_url, {} => form => {
Akron8bbbecf2019-07-01 18:57:30 +0200335 grant_type => 'refresh_token',
336 client_id => $client_id,
337 client_secret => $client_secret,
338 refresh_token => $refresh_token
339 })->then(
340 sub {
Akroncdfd9d52019-07-23 11:35:00 +0200341 my $tx = shift;
342 my $json = $tx->result->json;
343
344 # Response is fine
345 if ($tx->res->is_success) {
346
347 $c->app->log->info("Refresh was successful");
348
349 # Set the tokens and return a promise
350 return $c->auth->set_tokens_p($json);
351 };
352
353 # There is a client error - refresh fails
354 if ($tx->res->is_client_error && $json) {
355
356 $c->stash(auth => undef);
357 $c->stash(auth_exp => undef);
358 delete $c->session->{user};
359 delete $c->session->{auth};
360 delete $c->session->{auth_r};
361 delete $c->session->{auth_exp};
362
363 # Response is 400
364 return Mojo::Promise->reject(
365 $json->{error_description} // $c->loc('Auth_refreshFail')
366 );
367 };
368
Akroncce055c2021-07-02 12:18:03 +0200369 if ($tx->res->is_server_error) {
370 return Mojo::Promise->reject(
371 '600'
372 )
373 };
374
Akroncdfd9d52019-07-23 11:35:00 +0200375 $c->notify(error => $c->loc('Auth_responseError'));
376 return Mojo::Promise->reject;
377 }
378 )
379 }
380 );
381
Akron0f1b93b2020-03-17 11:37:19 +0100382 # Get a list of registered clients
383 $app->helper(
384 'auth.client_list_p' => sub {
385 my $c = shift;
386
387 # Get list of registered clients
388 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/client/list');
389
Akron1a9d5be2020-03-19 17:28:33 +0100390 # Get the list of all clients
Akron0f1b93b2020-03-17 11:37:19 +0100391 return $c->korap_request(post => $r_url, {} => form => {
Akron276afc02021-06-14 11:00:21 +0200392 super_client_id => $client_id,
393 super_client_secret => $client_secret,
Akron0f1b93b2020-03-17 11:37:19 +0100394 authorized_only => 'no'
395 })->then(
396 sub {
397 my $tx = shift;
398 my $json = $tx->result->json;
399
400 # Response is fine
401 if ($tx->res->is_success) {
402 return Mojo::Promise->resolve($json);
403 };
404
405 $c->log->error($c->dumper($tx->res->to_string));
406
407 # Failure
408 $c->notify(error => $c->loc('Auth_responseError'));
409 return Mojo::Promise->reject($json // 'No response');
410 }
411 );
412 }
413 );
Akroncdfd9d52019-07-23 11:35:00 +0200414
Akron1a9d5be2020-03-19 17:28:33 +0100415
Akronbc94a9c2021-04-15 00:07:35 +0200416 # Get a list of registered clients
417 $app->helper(
418 'auth.token_list_p' => sub {
419 my $c = shift;
420 my $user_client_id = shift;
421
422 # Revoke the token
423 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/token/list');
424
425 my $form = {
426 super_client_id => $client_id,
427 super_client_secret => $client_secret,
428 token_type => 'access_token',
429 };
430
431 if ($user_client_id) {
432 $form->{client_id} = $user_client_id;
433 };
434
435 # Get the list of all clients
436 return $c->korap_request(post => $r_url, {} => form => $form)->then(
437 sub {
438 my $tx = shift;
439 my $json = $tx->result->json;
440
441 # Response is fine
442 if ($tx->res->is_success) {
443 return Mojo::Promise->resolve($json);
444 };
445
446 $c->log->error($c->dumper($tx->res->to_string));
447
448 # Failure
449 $c->notify(error => $c->loc('Auth_responseError'));
450 return Mojo::Promise->reject($json // 'No response');
451 }
452 );
453 }
454 );
455
456
Akroncdfd9d52019-07-23 11:35:00 +0200457 # Issue a korap request with "oauth"orization
458 # This will override the core request helper
459 $app->helper(
460 korap_request => sub {
461 my $c = shift;
462 my $method = shift;
463 my $path = shift;
464 my @param = @_;
465
466 # TODO:
467 # Check if $tx is not leaked!
468
469 # Get plugin user agent
470 my $ua = $c->kalamar_ua;
471
472 my $url = Mojo::URL->new($path);
473 my $tx = $ua->build_tx(uc($method), $url->clone, @param);
474
475 # Set X-Forwarded for
476 $tx->req->headers->header(
477 'X-Forwarded-For' => $c->client_ip
478 );
479
480 # Emit Hook to alter request
481 $c->app->plugins->emit_hook(
482 before_korap_request => ($c, $tx)
483 );
484
485 my $h = $tx->req->headers;
486
487 # If the request already has an Authorization
488 # header, respect it!
489 if ($h->authorization) {
Akrona8efaa92022-04-09 14:45:43 +0200490
Akroncdfd9d52019-07-23 11:35:00 +0200491 return $ua->start_p($tx);
492 };
493
494 # Get auth token
495 if (my $auth_token = $c->auth->token) {
496
497 # The token is already expired!
498 my $exp = $c->session('auth_exp');
499 if (defined $exp && $exp < time) {
500
501 # Remove auth ...
502 $c->stash(auth => undef);
503
504 # And get refresh token from session
505 if (my $refresh_token = $c->session('auth_r')) {
506
507 $c->app->log->debug("Refresh is required");
508
509 # Refresh
510 return $c->auth->refresh_p($refresh_token)->then(
511 sub {
512 $c->app->log->debug("Search with refreshed tokens");
513
514 # Tokens were set - now send the request the first time!
515 $tx->req->headers->authorization($c->stash('auth'));
516 return $ua->start_p($tx);
517 }
518 );
519 }
520
521 # The token is expired and no refresh token is
522 # available - issue an unauthorized request!
523 else {
Akrona8efaa92022-04-09 14:45:43 +0200524
Akroncdfd9d52019-07-23 11:35:00 +0200525 $c->stash(auth => undef);
526 $c->stash(auth_exp => undef);
527 delete $c->session->{user};
528 delete $c->session->{auth};
529 delete $c->session->{auth_r};
530 delete $c->session->{auth_exp};
531
532 # Warn on Error!
533 $c->notify(warn => $c->loc('Auth_tokenExpired'));
534 return $ua->start_p($tx);
535 };
536 }
537
538 # Auth token is fine
539 else {
540
541 # Set auth
542 $h->authorization($auth_token);
543 }
544 }
545
546 # No token set
547 else {
548
549 # Return unauthorized request
550 return $ua->start_p($tx);
551 };
552
553 # Issue an authorized request and automatically
554 # refresh the token on expiration!
555 return $ua->start_p($tx)->then(
556 sub {
557 my $tx = shift;
558
559 # Response is fine
560 if ($tx->res->is_success) {
561 return Mojo::Promise->resolve($tx);
562 }
563
564 # There is a client error - maybe refresh!
565 elsif ($tx->res->is_client_error) {
566
567 # Check the error
568 my $json = $tx->res->json('/errors/0/1');
569 if ($json && ($json =~ /expired|invalid/)) {
570 $c->stash(auth => undef);
571 $c->stash(auth_exp => undef);
572 delete $c->session->{user};
573 delete $c->session->{auth};
574
575 # And get refresh token from session
576 if (my $refresh_token = $c->session('auth_r')) {
577
578 # Refresh
579 return $c->auth->refresh_p($refresh_token)->then(
580 sub {
581 $c->app->log->debug("Search with refreshed tokens");
582
583 my $tx = $ua->build_tx(uc($method), $url->clone, @param);
584
585 # Set X-Forwarded for
586 $tx->req->headers->header(
587 'X-Forwarded-For' => $c->client_ip
588 );
589
590 # Tokens were set - now send the request the first time!
591 $tx->req->headers->authorization($c->stash('auth'));
592 return $ua->start_p($tx);
593 }
594 )
595 };
596
597 # Reject the invalid token
598 $c->notify(error => $c->loc('Auth_tokenInvalid'));
599 return Mojo::Promise->reject;
600 };
601
602 return Mojo::Promise->resolve($tx);
Akroncce055c2021-07-02 12:18:03 +0200603 }
604
605 # There is a server error - just report
606 elsif ($tx->res->is_server_error) {
607 my $err = $tx->res->error;
608 if ($err) {
609 return Mojo::Promise->reject($err->{code} . ': ' . $err->{message});
610 }
611 else {
612 $c->notify(error => $c->loc('Auth_serverError'));
613 return Mojo::Promise->reject;
614 };
Akroncdfd9d52019-07-23 11:35:00 +0200615 };
616
617 $c->notify(error => $c->loc('Auth_responseError'));
618 return Mojo::Promise->reject;
Akron8bbbecf2019-07-01 18:57:30 +0200619 }
620 );
621 }
622 );
623
Akroncdfd9d52019-07-23 11:35:00 +0200624 # Password flow for OAuth
Akron33f5c672019-06-24 19:40:47 +0200625 $r->post('/user/login')->to(
626 cb => sub {
627 my $c = shift;
Akron864c2932018-11-16 17:18:55 +0100628
Akron33f5c672019-06-24 19:40:47 +0200629 # Validate input
630 my $v = $c->validation;
Akrone208d302020-11-28 11:14:50 +0100631 $v->required('handle', 'trim');
Akron33f5c672019-06-24 19:40:47 +0200632 $v->required('pwd', 'trim');
633 $v->csrf_protect;
634 $v->optional('fwd')->closed_redirect;
Akron864c2932018-11-16 17:18:55 +0100635
Akron6a228db2021-10-14 15:57:00 +0200636 my $user = check_decode($v->param('handle'));
637 unless ($user) {
638 $c->notify(error => $c->loc('Auth_invalidChar'));
639 $c->param(handle_or_email => '');
640 return $c->relative_redirect_to('index');
641 };
642
Akron33f5c672019-06-24 19:40:47 +0200643 my $fwd = $v->param('fwd');
Akron864c2932018-11-16 17:18:55 +0100644
Akron33f5c672019-06-24 19:40:47 +0200645 # Set flash for redirect
Akrone208d302020-11-28 11:14:50 +0100646 $c->flash(handle => $user);
Akron864c2932018-11-16 17:18:55 +0100647
Akron33f5c672019-06-24 19:40:47 +0200648 if ($v->has_error || index($user, ':') >= 0) {
649 if ($v->has_error('fwd')) {
650 $c->notify(error => $c->loc('Auth_openRedirectFail'));
651 }
652 elsif ($v->has_error('csrf_token')) {
653 $c->notify(error => $c->loc('Auth_csrfFail'));
654 }
655 else {
656 $c->notify(error => $c->loc('Auth_loginFail'));
Akron864c2932018-11-16 17:18:55 +0100657 };
658
Akron864c2932018-11-16 17:18:55 +0100659 return $c->relative_redirect_to($fwd // 'index');
660 }
Akron864c2932018-11-16 17:18:55 +0100661
Akron33f5c672019-06-24 19:40:47 +0200662 my $pwd = $v->param('pwd');
Akron864c2932018-11-16 17:18:55 +0100663
Akroncdfd9d52019-07-23 11:35:00 +0200664 $c->app->log->debug("Login from user $user");
Akron33f5c672019-06-24 19:40:47 +0200665
666 # <specific>
667
668 # Get OAuth access token
669 my $url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
670
671 # Korap request for login
672 $c->korap_request('post', $url, {}, form => {
673 grant_type => 'password',
674 username => $user,
675 password => $pwd,
Akron8bbbecf2019-07-01 18:57:30 +0200676 client_id => $client_id,
677 client_secret => $client_secret
Akron33f5c672019-06-24 19:40:47 +0200678 })->then(
679 sub {
Akron8bbbecf2019-07-01 18:57:30 +0200680 # Set the tokens and return a promise
Akroncdfd9d52019-07-23 11:35:00 +0200681 return $c->auth->set_tokens_p(shift->result->json)
Akron33f5c672019-06-24 19:40:47 +0200682 }
Akron3b3c7af2020-05-15 16:23:55 +0200683 )->then(
684 sub {
685 # Set user info
686 $c->session(user => $user);
687 $c->stash(user => $user);
688
689 # Notify on success
690 $c->app->log->debug(qq!Login successful: "$user"!);
691 $c->notify(success => $c->loc('Auth_loginSuccess'));
692 }
Akron33f5c672019-06-24 19:40:47 +0200693 )->catch(
694 sub {
Akron33f5c672019-06-24 19:40:47 +0200695
Akron8bbbecf2019-07-01 18:57:30 +0200696 # Notify the user on login failure
697 unless (@_) {
698 $c->notify(error => $c->loc('Auth_loginFail'));
699 }
Akron33f5c672019-06-24 19:40:47 +0200700
Akron8bbbecf2019-07-01 18:57:30 +0200701 # There are known errors
702 foreach (@_) {
703 if (ref $_ eq 'HASH') {
704 my $err = ($_->{code} ? $_->{code} . ': ' : '') .
705 $_->{message};
706 $c->notify(error => $err);
707 # Log failure
708 $c->app->log->debug($err);
709 }
710 else {
711 $c->notify(error => $_);
712 $c->app->log->debug($_);
713 };
714 };
Akron33f5c672019-06-24 19:40:47 +0200715
716 $c->app->log->debug(qq!Login fail: "$user"!);
Akron8bbbecf2019-07-01 18:57:30 +0200717 }
Akron33f5c672019-06-24 19:40:47 +0200718 )->finally(
719 sub {
Akron33f5c672019-06-24 19:40:47 +0200720 # Redirect to slash
721 return $c->relative_redirect_to($fwd // 'index');
722 }
723 )
724
725 # Start IOLoop
726 ->wait;
727
728 return 1;
729 }
730 )->name('login');
Akroncdfd9d52019-07-23 11:35:00 +0200731
732
733 # Log out of the session
Akron4cefe1f2019-09-04 10:11:28 +0200734 $r->get('/user/logout')->to(
735 cb => sub {
736 my $c = shift;
737
738 # TODO: csrf-protection!
739
740 my $refresh_token = $c->session('auth_r');
741
742 # Revoke the token
743 state $url = Mojo::URL->new($c->korap->api)->path('oauth2/revoke');
744
745 $c->kalamar_ua->post_p($url => {} => form => {
746 client_id => $client_id,
747 client_secret => $client_secret,
748 token => $refresh_token,
749 token_type => 'refresh_token'
750 })->then(
751 sub {
752 my $tx = shift;
753 my $json = $tx->result->json;
754
755 my $promise;
756
757 # Response is fine
758 if ($tx->res->is_success) {
759 $c->app->log->info("Revocation was successful");
760 $c->notify(success => $c->loc('Auth_logoutSuccess'));
761
762 $c->stash(auth => undef);
763 $c->stash(auth_exp => undef);
Akrone208d302020-11-28 11:14:50 +0100764 $c->flash(handle => delete $c->session->{user});
Akron4cefe1f2019-09-04 10:11:28 +0200765 delete $c->session->{auth};
766 delete $c->session->{auth_r};
767 delete $c->session->{auth_exp};
768 return Mojo::Promise->resolve;
769 }
770
771 # Token may be invalid
772 $c->notify('error', $c->loc('Auth_logoutFail'));
773
774 # There is a client error - refresh fails
775 if ($tx->res->is_client_error && $json) {
776
777 return Mojo::Promise->reject(
778 $json->{error_description}
779 );
780 };
781
782 # Resource may not be found (404)
783 return Mojo::Promise->reject
784
785 }
786 )->catch(
787 sub {
788 my $err = shift;
789
790 # Server may be irresponsible
791 $c->notify('error', $c->loc('Auth_logoutFail'));
792 return Mojo::Promise->reject($err);
793 }
794 )->finally(
795 sub {
796 return $c->redirect_to('index');
797 }
798 )->wait;
799 }
800 )->name('logout');
Akron59992122019-10-29 11:28:45 +0100801
Akron041ca4d2021-06-10 11:52:51 +0200802
Akron59992122019-10-29 11:28:45 +0100803 # If "experimental_registration" is set, open
804 # OAuth registration dialogues.
805 if ($param->{experimental_client_registration}) {
806
807 # Add settings
808 $app->navi->add(settings => (
809 $app->loc('Auth_oauthSettings'), 'oauth'
810 ));
811
812 # Route to oauth settings
813 $r->get('/settings/oauth')->to(
814 cb => sub {
Akron0f1b93b2020-03-17 11:37:19 +0100815 my $c = shift;
816
Akronad011bb2021-06-10 12:16:36 +0200817 _set_no_cache($c->res->headers);
818
Akron0f1b93b2020-03-17 11:37:19 +0100819 unless ($c->auth->token) {
820 return $c->render(
Akronff088112021-06-15 15:26:04 +0200821 template => 'exception',
822 msg => $c->loc('Auth_authenticationFail'),
Akron0f1b93b2020-03-17 11:37:19 +0100823 status => 401
824 );
825 };
826
827 # Wait for async result
828 $c->render_later;
829
830 $c->auth->client_list_p->then(
831 sub {
832 $c->stash('client_list' => shift);
833 }
834 )->catch(
835 sub {
836 return;
837 }
838 )->finally(
839 sub {
Akron17de86e2020-04-16 16:03:40 +0200840 return $c->render(template => 'auth/clients')
Akron0f1b93b2020-03-17 11:37:19 +0100841 }
842 );
Akron59992122019-10-29 11:28:45 +0100843 }
Akron1a9d5be2020-03-19 17:28:33 +0100844 )->name('oauth-settings');
Akron59992122019-10-29 11:28:45 +0100845
846 # Route to oauth client registration
847 $r->post('/settings/oauth/register')->to(
848 cb => sub {
849 my $c = shift;
Akronad011bb2021-06-10 12:16:36 +0200850
851 _set_no_cache($c->res->headers);
852
Akron59992122019-10-29 11:28:45 +0100853 my $v = $c->validation;
854
855 unless ($c->auth->token) {
Akron0f1b93b2020-03-17 11:37:19 +0100856 return $c->render(
857 content => 'Unauthorized',
858 status => 401
859 );
Akron59992122019-10-29 11:28:45 +0100860 };
861
862 $v->csrf_protect;
Akron9f2ad342022-05-04 16:16:40 +0200863 $v->required('name', 'trim', 'not_empty')->size(3, 255);
Akron59992122019-10-29 11:28:45 +0100864 $v->required('type')->in('PUBLIC', 'CONFIDENTIAL');
Akron9f2ad342022-05-04 16:16:40 +0200865 $v->required('desc', 'trim', 'not_empty')->size(3, 255);
866 $v->optional('url', 'trim', 'not_empty')->like(qr/^(http|$)/i);
867 $v->optional('redirect_uri', 'trim', 'not_empty')->like(qr/^(http|$)/i);
868 $v->optional('src', 'not_empty');
869
870 $c->stash(template => 'auth/clients');
Akron59992122019-10-29 11:28:45 +0100871
872 # Render with error
873 if ($v->has_error) {
874 if ($v->has_error('csrf_token')) {
875 $c->notify(error => $c->loc('Auth_csrfFail'));
876 }
877 else {
Akronc1aaf932021-06-09 12:19:15 +0200878 $c->notify(error => $c->loc('Auth_paramError'));
Akron59992122019-10-29 11:28:45 +0100879 };
Akron9f2ad342022-05-04 16:16:40 +0200880 return $c->render;
881 } elsif ($c->req->is_limit_exceeded) {
882 $c->notify(error => $c->loc('Auth_fileSizeExceeded'));
883 return $c->render;
884 };
885
886 my $type = $v->param('type');
887 my $src = $v->param('src');
888 my $src_json;
889
890 my $json_obj = {
891 name => $v->param('name'),
892 type => $type,
893 description => $v->param('desc'),
894 url => $v->param('url'),
895 redirect_uri => $v->param('redirect_uri')
896 };
897
898 # Check plugin source
899 if ($src) {
900
901 # Plugins need to be confidential
902 if ($type ne 'CONFIDENTIAL') {
903 $c->notify(error => $c->loc('Auth_confidentialRequired'));
904 return $c->render;
905 }
906
907 # Source need to be a file upload
908 elsif (!ref $src || !$src->isa('Mojo::Upload')) {
909 $c->notify(error => $c->loc('Auth_jsonRequired'));
910 return $c->render;
911 };
912
913 # Uploads can't be too large
914 if ($src->size > 1_000_000) {
915 $c->notify(error => $c->loc('Auth_fileSizeExceeded'));
916 return $c->render;
917 };
918
919 # Check upload is not empty
920 if ($src->size > 0 && $src->filename ne '') {
921
922 my $asset = $src->asset;
923
924 # Check for json
925 eval {
926 $src_json = decode_json($asset->slurp);
927 };
928
929 if ($@ || !ref $src_json || ref $src_json ne 'HASH') {
930 $c->notify(error => $c->loc('Auth_jsonRequired'));
931 return $c->render;
932 };
933
934 $json_obj->{source} = $src_json;
935 };
Akron59992122019-10-29 11:28:45 +0100936 };
937
938 # Wait for async result
939 $c->render_later;
940
941 # Register on server
942 state $url = Mojo::URL->new($c->korap->api)->path('oauth2/client/register');
Akron9f2ad342022-05-04 16:16:40 +0200943 $c->korap_request('POST', $url => {} => json => $json_obj)->then(
Akron59992122019-10-29 11:28:45 +0100944 sub {
945 my $tx = shift;
946 my $result = $tx->result;
947
948 if ($result->is_error) {
Akronb6b156e2022-03-31 14:57:49 +0200949 my $json = $result->json;
950 if ($json && $json->{error}) {
951 $c->notify(
952 error => $json->{error} .
953 ($json->{error_description} ? ': ' . $json->{error_description} : '')
954 )
955 };
956
Akron59992122019-10-29 11:28:45 +0100957 return Mojo::Promise->reject;
958 };
959
960 my $json = $result->json;
961
Akron59992122019-10-29 11:28:45 +0100962 my $client_id = $json->{client_id};
963 my $client_secret = $json->{client_secret};
964
965 $c->stash('client_name' => $v->param('name'));
966 $c->stash('client_desc' => $v->param('desc'));
967 $c->stash('client_type' => $v->param('type'));
968 $c->stash('client_url' => $v->param('url'));
Akrondc50c892021-05-05 18:12:02 +0200969 $c->stash('client_redirect_uri' => $v->param('redirect_uri'));
Akron59992122019-10-29 11:28:45 +0100970 $c->stash('client_id' => $client_id);
971
972 if ($client_secret) {
973 $c->stash('client_secret' => $client_secret);
974 };
975
976 $c->notify(success => $c->loc('Auth_en_registerSuccess'));
977
Akron17de86e2020-04-16 16:03:40 +0200978 return $c->render(template => 'auth/client');
Akron59992122019-10-29 11:28:45 +0100979 }
980 )->catch(
981 sub {
Akron59992122019-10-29 11:28:45 +0100982 $c->notify('error' => $c->loc('Auth_en_registerFail'));
Akron59992122019-10-29 11:28:45 +0100983 }
984 )->finally(
985 sub {
986 return $c->redirect_to('settings' => { scope => 'oauth' });
987 }
988 );
989 }
990 )->name('oauth-register');
Akron1a9d5be2020-03-19 17:28:33 +0100991
992
Akron041ca4d2021-06-10 11:52:51 +0200993 # Unregister client page
994 $r->get('/settings/oauth/:client_id/unregister')->to(
Akron1a9d5be2020-03-19 17:28:33 +0100995 cb => sub {
Akronad011bb2021-06-10 12:16:36 +0200996 my $c = shift;
997 _set_no_cache($c->res->headers);
998 $c->render(template => 'auth/unregister');
Akron1a9d5be2020-03-19 17:28:33 +0100999 }
1000 )->name('oauth-unregister');
1001
Akron17de86e2020-04-16 16:03:40 +02001002
Akron1a9d5be2020-03-19 17:28:33 +01001003 # Unregister client
Akron041ca4d2021-06-10 11:52:51 +02001004 $r->post('/settings/oauth/:client_id/unregister')->to(
Akron1a9d5be2020-03-19 17:28:33 +01001005 cb => sub {
1006 my $c = shift;
Akronad011bb2021-06-10 12:16:36 +02001007 _set_no_cache($c->res->headers);
Akron1a9d5be2020-03-19 17:28:33 +01001008
1009 my $v = $c->validation;
1010
1011 unless ($c->auth->token) {
1012 return $c->render(
1013 content => 'Unauthorized',
1014 status => 401
1015 );
1016 };
1017
1018 $v->csrf_protect;
1019 $v->required('client-name', 'trim')->size(3, 255);
Akron1a9d5be2020-03-19 17:28:33 +01001020
1021 # Render with error
1022 if ($v->has_error) {
1023 if ($v->has_error('csrf_token')) {
1024 $c->notify(error => $c->loc('Auth_csrfFail'));
1025 }
1026 else {
Akronc1aaf932021-06-09 12:19:15 +02001027 $c->notify(error => $c->loc('Auth_paramError'));
Akron1a9d5be2020-03-19 17:28:33 +01001028 };
Akron83209f72021-01-29 17:54:15 +01001029 return $c->redirect_to('oauth-settings');
Akron1a9d5be2020-03-19 17:28:33 +01001030 };
1031
Akron041ca4d2021-06-10 11:52:51 +02001032 my $client_id = $c->stash('client_id');
Akron1a9d5be2020-03-19 17:28:33 +01001033 my $client_name = $v->param('client-name');
1034 my $client_secret = $v->param('client-secret');
1035
1036 # Get list of registered clients
1037 my $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/client/deregister/')->path(
1038 $client_id
1039 );
1040
1041 my $send = {};
1042
1043 if ($client_secret) {
1044 $send->{client_secret} = $client_secret;
1045 };
1046
1047 # Get the list of all clients
1048 return $c->korap_request(delete => $r_url, {} => form => $send)->then(
1049 sub {
1050 my $tx = shift;
1051
1052 # Response is fine
1053 if ($tx->res->is_success) {
1054 # Okay
1055 $c->notify(success => 'Successfully deleted ' . $client_name);
1056 }
1057 else {
1058
1059 # Failure
1060 my $json = $tx->result->json;
1061 if ($json && $json->{error_description}) {
1062 $c->notify(error => $json->{error_description});
1063 } else {
1064 $c->notify(error => $c->loc('Auth_responseError'));
1065 };
1066 };
1067
1068 return $c->redirect_to('oauth-settings');
1069 }
1070 );
1071 }
1072 )->name('oauth-unregister-post');
Akron17de86e2020-04-16 16:03:40 +02001073
1074
Akrona8efaa92022-04-09 14:45:43 +02001075 # OAuth Client authorization
1076 $r->get('/settings/oauth/authorize')->to(
1077 cb => sub {
1078 my $c = shift;
1079
1080 _set_no_cache($c->res->headers);
1081
1082 my $v = $c->validation;
1083 $v->required('client_id');
1084 $v->optional('scope');
1085 $v->optional('state');
1086 $v->optional('redirect_uri');
1087
1088 # Redirect with error
1089 if ($v->has_error) {
1090 $c->notify(error => $c->loc('Auth_paramError'));
1091 return $c->redirect_to;
1092 };
1093
1094 foreach (qw!scope client_id state redirect_uri!) {
1095 $c->stash($_, $v->param($_));
1096 };
1097
Akron408bc7c2022-04-28 15:46:43 +02001098 # Wait for async result
1099 $c->render_later;
Akrona8efaa92022-04-09 14:45:43 +02001100
Akron408bc7c2022-04-28 15:46:43 +02001101 my $client_id = $v->param('client_id');
Akrona8efaa92022-04-09 14:45:43 +02001102
Akron408bc7c2022-04-28 15:46:43 +02001103 my $client_information = $c->auth->client_list_p->then(
1104 sub {
1105 my $clients = shift;
1106 foreach (@$clients) {
1107 if ($_->{client_id} eq $client_id) {
1108 $c->stash(client_name => $_->{'client_name'});
1109 $c->stash(client_type => $_->{'client_type'});
1110 $c->stash(client_desc => $_->{'client_description'});
1111 $c->stash(client_url => $_->{'client_url'});
1112 $c->stash(redirect_uri_server => $_->{'client_redirect_uri'});
1113 last;
1114 };
1115 };
1116 }
1117 )->catch(
1118 sub {
1119 $c->stash(client_type => 'PUBLIC');
1120 $c->stash(client_name => $v->param('client_id'));
1121 return;
1122 }
1123 )->finally(
1124 sub {
Akrona8efaa92022-04-09 14:45:43 +02001125
Akron408bc7c2022-04-28 15:46:43 +02001126 # Get auth token
1127 my $auth_token = $c->auth->token;
Akrona8efaa92022-04-09 14:45:43 +02001128
Akron408bc7c2022-04-28 15:46:43 +02001129 # User is not logged in - log in before!
1130 unless ($auth_token) {
1131 return $c->render(template => 'auth/login');
1132 };
1133
1134 # Grant authorization
1135 return $c->render(template => 'auth/grant_scope');
1136 }
1137 );
Akrona8efaa92022-04-09 14:45:43 +02001138 }
1139 )->name('oauth-grant-scope');
1140
1141
1142 # OAuth Client authorization
1143 # This will return a location information including some info
1144 $r->post('/settings/oauth/authorize')->to(
1145 cb => sub {
1146 my $c = shift;
1147
1148 _set_no_cache($c->res->headers);
1149
1150 # It's necessary that it's clear this was triggered by
1151 # KorAP and not by the client!
1152 my $v = $c->validation;
1153 $v->csrf_protect;
1154 $v->required('client_id');
1155 $v->optional('scope');
1156 $v->optional('state');
1157 $v->optional('redirect_uri');
1158
1159 # WARN! SIGN THIS TO PREVENT OPEN REDIRECT ATTACKS!
1160 $v->required('redirect_uri_server');
1161
1162 # Render with error
1163 if ($v->has_error) {
1164 my $url = Mojo::URL->new($v->param('redirect_uri_server') // $c->url_for('index'));
1165
1166 if ($v->has_error('csrf_token')) {
1167 $url->query([error_description => $c->loc('Auth_csrfFail')]);
1168 }
1169 else {
1170 $url->query([error_description => $c->loc('Auth_paramError')]);
1171 };
1172
1173 return $c->redirect_to($url);
1174 };
1175
1176 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/authorize');
1177 $c->stash(redirect_uri_server => Mojo::URL->new($v->param('redirect_uri_server')));
1178
1179 return $c->korap_request(post => $r_url, {} => form => {
1180 response_type => 'code',
1181 client_id => $v->param('client_id'),
1182 redirect_uri => $v->param('redirect_uri'),
1183 state => $v->param('state'),
1184 scope => $v->param('scope'),
1185 })->then(
1186 sub {
1187 my $tx = shift;
1188
1189 # Check for location header with code in redirects
1190 my $loc;
1191 foreach (@{$tx->redirects}) {
1192 $loc = $_->res->headers->header('Location');
1193
1194 my $url = Mojo::URL->new($loc);
1195
1196 if ($url->query->param('code')) {
1197 last;
1198 } elsif (my $err = $url->query->param('error_description')) {
1199 return Mojo::Promise->reject($err);
1200 }
1201 };
1202
1203 return Mojo::Promise->resolve($loc) if $loc;
1204
1205 # Failed redirect, but location set
1206 if ($tx->res->headers->location) {
1207 my $url = Mojo::URL->new($tx->res->headers->location);
1208 if (my $err = $url->query->param('error_description')) {
1209 return Mojo::Promise->reject($err);
1210 };
1211 };
1212
1213 # No location code
1214 return Mojo::Promise->reject('no location response');
1215 }
1216 )->catch(
1217 sub {
1218 my $err_msg = shift;
1219 my $url = $c->stash('redirect_uri_server');
1220 if ($err_msg) {
1221 $url = $url->query([error_description => $err_msg]);
1222 };
1223 return Mojo::Promise->resolve($url);
1224 }
1225 )->then(
1226 sub {
1227 my $loc = shift;
1228 return $c->redirect_to($loc);
1229 }
1230 )->wait;
1231 return $c->rendered;
1232 }
1233 )->name('oauth-grant-scope-post');
1234
1235
Akron17de86e2020-04-16 16:03:40 +02001236 # Show information of a client
Akron041ca4d2021-06-10 11:52:51 +02001237 $r->get('/settings/oauth/:client_id')->to(
Akron17de86e2020-04-16 16:03:40 +02001238 cb => sub {
1239 my $c = shift;
1240
Akronad011bb2021-06-10 12:16:36 +02001241 _set_no_cache($c->res->headers);
1242
Akron17de86e2020-04-16 16:03:40 +02001243 $c->render_later;
1244
1245 $c->auth->client_list_p->then(
1246 sub {
1247 my $json = shift;
1248
1249 my ($item) = grep {
Akrondc50c892021-05-05 18:12:02 +02001250 $c->stash('client_id') eq $_->{client_id}
Akron17de86e2020-04-16 16:03:40 +02001251 } @$json;
1252
1253 unless ($item) {
1254 return Mojo::Promise->reject;
1255 };
1256
Akrondc50c892021-05-05 18:12:02 +02001257 $c->stash(client_name => $item->{client_name});
Akronbc94a9c2021-04-15 00:07:35 +02001258 $c->stash(client_desc => $item->{client_description});
1259 $c->stash(client_url => $item->{client_url});
Akronb6b156e2022-03-31 14:57:49 +02001260 $c->stash(client_type => ($item->{client_type} // 'PUBLIC'));
Akron17de86e2020-04-16 16:03:40 +02001261
Akronbc94a9c2021-04-15 00:07:35 +02001262 $c->auth->token_list_p($c->stash('client_id'));
1263 }
1264 )->then(
1265 sub {
1266 my $json = shift;
1267
1268 $c->stash(tokens => $json);
1269
Akron17de86e2020-04-16 16:03:40 +02001270 return Mojo::Promise->resolve;
1271 }
1272 )->catch(
1273 sub {
1274 return $c->reply->not_found;
1275 }
1276 )->finally(
1277 sub {
Akron17de86e2020-04-16 16:03:40 +02001278 return $c->render(template => 'auth/client')
1279 }
1280 );
1281
1282 return;
1283 }
1284 )->name('oauth-tokens');
Akron59992122019-10-29 11:28:45 +01001285 };
Akron83209f72021-01-29 17:54:15 +01001286
1287
Akronc1aaf932021-06-09 12:19:15 +02001288 # Ask if new token should be issued
Akron041ca4d2021-06-10 11:52:51 +02001289 $r->get('/settings/oauth/:client_id/token')->to(
Akron83209f72021-01-29 17:54:15 +01001290 cb => sub {
Akronad011bb2021-06-10 12:16:36 +02001291 my $c = shift;
1292 _set_no_cache($c->res->headers);
1293 $c->render(template => 'auth/issue-token');
Akron83209f72021-01-29 17:54:15 +01001294 }
1295 )->name('oauth-issue-token');
1296
1297
Akronc1aaf932021-06-09 12:19:15 +02001298 # Ask if a token should be revoked
Akron041ca4d2021-06-10 11:52:51 +02001299 $r->post('/settings/oauth/:client_id/token/revoke')->to(
Akronc1aaf932021-06-09 12:19:15 +02001300 cb => sub {
1301 shift->render(template => 'auth/revoke-token');
1302 }
1303 )->name('oauth-revoke-token');
1304
1305
1306 # Issue new token
Akron041ca4d2021-06-10 11:52:51 +02001307 $r->post('/settings/oauth/:client_id/token')->to(
Akron83209f72021-01-29 17:54:15 +01001308 cb => sub {
1309 my $c = shift;
Akronad011bb2021-06-10 12:16:36 +02001310 _set_no_cache($c->res->headers);
Akron83209f72021-01-29 17:54:15 +01001311
1312 my $v = $c->validation;
1313
1314 unless ($c->auth->token) {
1315 return $c->render(
1316 content => 'Unauthorized',
1317 status => 401
1318 );
1319 };
1320
1321 $v->csrf_protect;
Akron83209f72021-01-29 17:54:15 +01001322 $v->optional('client-secret');
1323 $v->required('name', 'trim');
1324
1325 # Render with error
1326 if ($v->has_error) {
1327 if ($v->has_error('csrf_token')) {
1328 $c->notify(error => $c->loc('Auth_csrfFail'));
1329 }
1330 else {
Akronc1aaf932021-06-09 12:19:15 +02001331 $c->notify(error => $c->loc('Auth_paramError'));
Akron83209f72021-01-29 17:54:15 +01001332 };
1333 return $c->redirect_to('oauth-settings')
1334 };
1335
1336 # Get authorization token
1337 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/authorize');
1338 my $client_id = $c->stash('client_id');
1339 my $name = $v->param('name');
1340 my $redirect_url = $c->url_for->query({name => $name});
1341
1342 return $c->korap_request(post => $r_url, {} => form => {
1343 response_type => 'code',
1344 client_id => $client_id,
1345 redirect_uri => $redirect_url,
1346 # TODO: State
1347 })->then(
1348 sub {
1349 my $tx = shift;
1350
1351 # Strip the token from the location header of the fake redirect
1352 # TODO: Alternatively redirect!
1353 my ($code, $scope, $loc, $name);
1354 foreach (@{$tx->redirects}) {
1355 $loc = $_->res->headers->header('Location');
1356 if (index($loc, 'code') > 0) {
1357 my $q = Mojo::URL->new($loc)->query;
1358 $code = $q->param('code');
1359 $scope = $q->param('scope');
1360 $name = $q->param('name');
1361 last;
1362 };
1363 };
1364
1365 # Fine!
1366 if ($code) {
1367 return Mojo::Promise->resolve(
1368 $client_id,
1369 $redirect_url,
1370 $code,
1371 $scope,
1372 $name
1373 );
1374 };
1375 return Mojo::Promise->reject;
1376 }
1377 )->then(
1378 sub {
1379 my ($client_id, $redirect_url, $code, $scope, $name) = @_;
1380
1381 # Get OAuth access token
1382 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
1383 return $c->kalamar_ua->post_p($r_url, {} => form => {
1384 client_id => $client_id,
1385 # NO CLIENT_SECRET YET SUPPORTED
1386 grant_type => 'authorization_code',
1387 code => $code,
1388 redirect_uri => $redirect_url
1389 })->then(
1390 sub {
1391 my $tx = shift;
1392 my $json = $tx->res->json;
1393
1394 if ($tx->res->is_error) {
1395 $c->notify(error => 'Unable to fetch new token');
1396 return Mojo::Promise->reject;
1397 };
1398
1399 $c->notify(success => 'New access token created');
1400
Akronbc94a9c2021-04-15 00:07:35 +02001401 $c->redirect_to('oauth-tokens' => { client_id => $client_id })
Akron83209f72021-01-29 17:54:15 +01001402 }
1403 )->catch(
1404 sub {
1405 my $err_msg = shift;
1406
1407 # Only raised in case of connection errors
1408 if ($err_msg) {
1409 $c->notify(error => { src => 'Backend' } => $err_msg)
1410 };
1411
1412 $c->render(
1413 status => 400,
1414 template => 'failure'
1415 );
1416 }
1417 )
1418
1419 # Start IOLoop
1420 ->wait;
1421
1422 }
1423 )->catch(
1424 sub {
1425 my $err_msg = shift;
1426
1427 # Only raised in case of connection errors
1428 if ($err_msg) {
1429 $c->notify(error => { src => 'Backend' } => $err_msg)
1430 };
1431
1432 return $c->render(
1433 status => 400,
1434 template => 'failure'
1435 );
1436 }
1437 )
1438
1439 # Start IOLoop
1440 ->wait;
1441
1442 return 1;
1443 }
1444 )->name('oauth-issue-token-post');
Akronc1aaf932021-06-09 12:19:15 +02001445
1446
1447 # Revoke token
Akron041ca4d2021-06-10 11:52:51 +02001448 $r->delete('/settings/oauth/:client_id/token')->to(
Akronc1aaf932021-06-09 12:19:15 +02001449 cb => sub {
1450 my $c = shift;
1451
1452 my $v = $c->validation;
1453
1454 unless ($c->auth->token) {
1455 return $c->render(
1456 content => 'Unauthorized',
1457 status => 401
1458 );
1459 };
1460
1461 $v->csrf_protect;
1462 $v->required('token', 'trim');
1463 $v->optional('name', 'trim');
1464 my $private_client_id = $c->stash('client_id');
1465
1466 # Render with error
1467 if ($v->has_error) {
1468 if ($v->has_error('csrf_token')) {
1469 $c->notify(error => $c->loc('Auth_csrfFail'));
1470 }
1471 else {
1472 $c->notify(error => $c->loc('Auth_paramError'));
1473 };
1474 return $c->redirect_to('oauth-tokens', client_id => $private_client_id);
1475 };
1476
1477 # Revoke token using super client privileges
1478 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/revoke/super');
1479
1480 my $token = $v->param('token');
1481
1482 return $c->korap_request(post => $r_url, {} => form => {
1483 super_client_id => $client_id,
1484 super_client_secret => $client_secret,
1485 token => $token
1486 })->then(
1487 sub {
1488 my $tx = shift;
1489
1490 # Response is fine
1491 if ($tx->res->is_success) {
1492 $c->notify(success => $c->loc('Auth_revokeSuccess'));
1493 return Mojo::Promise->resolve;
1494 };
1495
1496 return Mojo::Promise->reject;
1497 }
1498 )->catch(
1499 sub {
1500 my $err_msg = shift;
1501 if ($err_msg) {
1502 $c->notify(error => { src => 'Backend' } => $err_msg );
1503 }
1504 else {
1505 $c->notify(error => $c->loc('Auth_revokeFail'));
1506 };
1507 }
1508 )->finally(
1509 sub {
1510 return $c->redirect_to('oauth-tokens', client_id => $private_client_id);
1511 }
1512 )
1513
1514 # Start IOLoop
1515 ->wait;
1516 }
1517 )->name('oauth-revoke-token-delete');
Akron33f5c672019-06-24 19:40:47 +02001518 }
Akroncdfd9d52019-07-23 11:35:00 +02001519
Akron33f5c672019-06-24 19:40:47 +02001520 # Use JWT login
1521 else {
1522
Akron7fb78d62021-06-10 12:51:13 +02001523 deprecated 'JWT flow is deprecated in favor of OAuth2 flow';
1524
Akroncdfd9d52019-07-23 11:35:00 +02001525 # Inject authorization to all korap requests
1526 $app->hook(
1527 before_korap_request => sub {
1528 my ($c, $tx) = @_;
1529 my $h = $tx->req->headers;
1530
1531 # If the request already has an Authorization
1532 # header, respect it
1533 unless ($h->authorization) {
1534
1535 # Get valid auth token and set as header
1536 if (my $auth_token = $c->auth->token) {
1537 $h->authorization($auth_token);
1538 };
1539 };
1540 }
1541 );
1542
1543 # Password flow with JWT
Akron33f5c672019-06-24 19:40:47 +02001544 $r->post('/user/login')->to(
1545 cb => sub {
1546 my $c = shift;
1547
1548 # Validate input
1549 my $v = $c->validation;
Akrone208d302020-11-28 11:14:50 +01001550 $v->required('handle', 'trim');
Akron33f5c672019-06-24 19:40:47 +02001551 $v->required('pwd', 'trim');
1552 $v->csrf_protect;
1553 $v->optional('fwd')->closed_redirect;
1554
Akron6a228db2021-10-14 15:57:00 +02001555 my $user = check_decode($v->param('handle'));
1556 unless ($user) {
1557 $c->notify(error => $c->loc('Auth_invalidChar'));
1558 $c->param(handle_or_email => '');
1559 return $c->relative_redirect_to('index');
1560 };
1561
Akron33f5c672019-06-24 19:40:47 +02001562 my $fwd = $v->param('fwd');
1563
1564 # Set flash for redirect
Akrone208d302020-11-28 11:14:50 +01001565 $c->flash(handle => $user);
Akron33f5c672019-06-24 19:40:47 +02001566
1567 if ($v->has_error || index($user, ':') >= 0) {
1568 if ($v->has_error('fwd')) {
1569 $c->notify(error => $c->loc('Auth_openRedirectFail'));
1570 }
1571 elsif ($v->has_error('csrf_token')) {
1572 $c->notify(error => $c->loc('Auth_csrfFail'));
1573 }
1574 else {
1575 $c->notify(error => $c->loc('Auth_loginFail'));
1576 };
1577
1578 return $c->relative_redirect_to($fwd // 'index');
1579 }
1580
1581 my $pwd = $v->param('pwd');
1582
Akroncdfd9d52019-07-23 11:35:00 +02001583 $c->app->log->debug("Login from user $user");
Akron33f5c672019-06-24 19:40:47 +02001584
1585 my $url = Mojo::URL->new($c->korap->api)->path('auth/apiToken');
1586
1587 # Korap request for login
1588 $c->korap_request('get', $url, {
1589
1590 # Set authorization header
1591 Authorization => 'Basic ' . b("$user:$pwd")->b64_encode->trim,
1592
1593 })->then(
1594 sub {
1595 my $tx = shift;
1596
1597 # Get the java token
1598 my $jwt = $tx->result->json;
1599
1600 # No java web token
1601 unless ($jwt) {
1602 $c->notify(error => 'Response is no valid JWT (remote)');
1603 return;
1604 };
1605
1606 # There is an error here
1607 # Dealing with errors here
1608 if (my $error = $jwt->{error} // $jwt->{errors}) {
1609 if (ref $error eq 'ARRAY') {
1610 foreach (@$error) {
1611 unless ($_->[1]) {
1612 $c->notify(error => $c->loc('Auth_loginFail'));
1613 }
1614 else {
1615 $c->notify(error => $_->[0] . ($_->[1] ? ': ' . $_->[1] : ''));
1616 };
1617 };
1618 }
1619 else {
1620 $c->notify(error => 'There is an unknown JWT error');
1621 };
1622 return;
1623 };
1624
1625 # TODO: Deal with user return values.
1626 my $auth = $jwt->{token_type} . ' ' . $jwt->{token};
1627
Akroncdfd9d52019-07-23 11:35:00 +02001628 $c->app->log->debug(qq!Login successful: "$user"!);
Akron33f5c672019-06-24 19:40:47 +02001629
1630 $user = $jwt->{username} ? $jwt->{username} : $user;
1631
1632 # Set session info
1633 $c->session(user => $user);
1634 $c->session(auth => $auth);
1635
1636 # Set stash info
1637 $c->stash(user => $user);
1638 $c->stash(auth => $auth);
Akron33f5c672019-06-24 19:40:47 +02001639 $c->notify(success => $c->loc('Auth_loginSuccess'));
1640 }
1641 )->catch(
1642 sub {
1643 my $e = shift;
1644
1645 # Notify the user
1646 $c->notify(
1647 error =>
1648 ($e->{code} ? $e->{code} . ': ' : '') .
1649 $e->{message} . ' for Login (remote)'
1650 );
1651
1652 # Log failure
1653 $c->app->log->debug(
1654 ($e->{code} ? $e->{code} . ' - ' : '') .
1655 $e->{message}
1656 );
1657
1658 $c->app->log->debug(qq!Login fail: "$user"!);
1659 $c->notify(error => $c->loc('Auth_loginFail'));
1660 }
1661 )->finally(
1662 sub {
1663
1664 # Redirect to slash
1665 return $c->relative_redirect_to($fwd // 'index');
1666 }
1667 )
1668
1669 # Start IOLoop
1670 ->wait;
1671
1672 return 1;
1673 }
1674 )->name('login');
Akron4cefe1f2019-09-04 10:11:28 +02001675
1676
1677 # Log out of the session
1678 $r->get('/user/logout')->to(
1679 cb => sub {
1680 my $c = shift;
1681
1682 # TODO: csrf-protection!
1683
1684 # Log out of the system
1685 my $url = Mojo::URL->new($c->korap->api)->path('auth/logout');
1686
1687 $c->korap_request(
1688 'get', $url
1689 )->then(
1690 # Logged out
1691 sub {
1692 my $tx = shift;
1693 # Clear cache
1694 # ?? Necesseary
1695 # $c->chi('user')->remove($c->auth->token);
1696
1697 # TODO:
1698 # Revoke refresh token!
1699 # based on auth token!
1700 # my $refresh_token = $c->chi('user')->get('refr_' . $c->auth->token);
1701 # $c->auth->revoke_token($refresh_token)
1702
1703 # Expire session
1704 $c->session(user => undef);
1705 $c->session(auth => undef);
1706 $c->notify(success => $c->loc('Auth_logoutSuccess'));
1707 }
1708
1709 )->catch(
1710 # Something went wrong
1711 sub {
1712 # my $err_msg = shift;
1713 $c->notify('error', $c->loc('Auth_logoutFail'));
1714 }
1715
1716 )->finally(
1717 # Redirect
1718 sub {
1719 return $c->redirect_to('index');
1720 }
1721 )
1722
1723 # Start IOLoop
1724 ->wait;
1725
1726 return 1;
1727 }
1728 )->name('logout');
Akron33f5c672019-06-24 19:40:47 +02001729 };
Akron59992122019-10-29 11:28:45 +01001730
1731 $app->log->info('Successfully registered Auth plugin');
Akron864c2932018-11-16 17:18:55 +01001732};
1733
Akronb3f33592020-03-16 15:14:44 +01001734
Akronad011bb2021-06-10 12:16:36 +02001735# Set 'no caching' headers
1736sub _set_no_cache {
1737 my $h = shift;
1738 $h->cache_control('max-age=0, no-cache, no-store, must-revalidate');
1739 $h->expires('Thu, 01 Jan 1970 00:00:00 GMT');
1740 $h->header('Pragma','no-cache');
1741};
1742
1743
Akron6a228db2021-10-14 15:57:00 +02001744sub check_decode {
1745 no warnings 'uninitialized';
1746 my $str = shift;
1747 my $str2 = is_utf8($str) ? b($str)->decode : $str;
1748 if (defined($str2) && $str2 && length($str2) > 1) {
1749 return $str2
1750 };
1751 return;
1752};
1753
1754
Akron864c2932018-11-16 17:18:55 +010017551;
Akrona9c8b0e2018-11-16 20:20:28 +01001756
Akronc82b1bc2018-11-18 18:06:14 +01001757
Akrona9c8b0e2018-11-16 20:20:28 +01001758__END__
Akron59992122019-10-29 11:28:45 +01001759
1760=pod
1761
1762=encoding utf8
1763
1764=head1 NAME
1765
1766Kalamar::Plugin::Auth - OAuth-2.0-based authorization plugin
1767
1768=head1 DESCRIPTION
1769
1770L<Kalamar::Plugin::Auth> is an OAuth-2.0-based authorization
1771plugin for L<Kalamar>. It requires a C<Kustvakt> full server
1772with OAuth 2.0 capabilities.
1773It is activated by loading C<Auth> as a plugin in the C<Kalamar.plugins>
1774parameter in the Kalamar configuration.
1775
1776=head1 CONFIGURATION
1777
1778L<Kalamar::Plugin::Auth> supports the following parameter for the
1779C<Kalamar-Auth> configuration section in the Kalamar configuration:
1780
1781=over 2
1782
1783=item B<client_id>
1784
1785The client identifier of Kalamar to be send with every OAuth 2.0
1786management request.
1787
1788=item B<client_secret>
1789
1790The client secret of Kalamar to be send with every OAuth 2.0
1791management request.
1792
1793=item B<oauth2>
1794
1795Initially L<Kalamar-Plugin-Auth> was based on JWT. This parameter
1796is historically used to switch between oauth2 and jwt. It is expected
1797to be deprecated in the future, but for the moment it is required
1798to be set to a true value.
1799
1800=item B<experimental_client_registration>
1801
1802Activates the oauth client registration flow.
1803
1804=back
1805
1806=head2 COPYRIGHT AND LICENSE
1807
1808Copyright (C) 2015-2020, L<IDS Mannheim|http://www.ids-mannheim.de/>
1809Author: L<Nils Diewald|http://nils-diewald.de/>
1810
1811Kalamar is developed as part of the L<KorAP|http://korap.ids-mannheim.de/>
1812Corpus Analysis Platform at the
1813L<Leibniz Institute for the German Language (IDS)|http://ids-mannheim.de/>,
1814member of the
1815L<Leibniz-Gemeinschaft|http://www.leibniz-gemeinschaft.de>
1816and supported by the L<KobRA|http://www.kobra.tu-dortmund.de> project,
1817funded by the
1818L<Federal Ministry of Education and Research (BMBF)|http://www.bmbf.de/en/>.
1819
1820Kalamar is free software published under the
1821L<BSD-2 License|https://raw.githubusercontent.com/KorAP/Kalamar/master/LICENSE>.
1822
1823=cut