blob: a41988275dc3cd21e3006ce1e97857caccd1319c [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';
6
Akroncdfd9d52019-07-23 11:35:00 +02007# This is a plugin to deal with the Kustvakt OAuth server.
8# It establishes both the JWT as well as the OAuth password
9# flow for login.
10# All tokens are stored in the session. Access tokens are short-lived,
11# which limits the effects of misuse.
12# Refresh tokens are bound to client id and client secret,
13# which again limits the effects of misuse.
14
15# TODO:
16# Establish a plugin 'OAuth' that works independent of 'Auth'.
17
Akron864c2932018-11-16 17:18:55 +010018# TODO:
Akronc82b1bc2018-11-18 18:06:14 +010019# CSRF-protect logout!
Akron864c2932018-11-16 17:18:55 +010020
Akroncdfd9d52019-07-23 11:35:00 +020021# TODO:
22# Remove the Bearer prefix from auth.
23
24# In case no expiration time is returned by the server,
25# take this time.
Akron8bbbecf2019-07-01 18:57:30 +020026our $EXPECTED_EXPIRATION_IN = 259200;
27
Akron864c2932018-11-16 17:18:55 +010028# Register the plugin
29sub register {
30 my ($plugin, $app, $param) = @_;
31
Akron864c2932018-11-16 17:18:55 +010032 # Load parameter from config file
33 if (my $config_param = $app->config('Kalamar-Auth')) {
34 $param = { %$param, %$config_param };
35 };
36
Akron864c2932018-11-16 17:18:55 +010037 # Load 'notifications' plugin
38 unless (exists $app->renderer->helpers->{notify}) {
39 $app->plugin(Notifications => {
40 HTML => 1
41 });
42 };
43
Akron33f5c672019-06-24 19:40:47 +020044 # Get the client id and the client_secret as a requirement
45 unless ($param->{client_id} && $param->{client_secret}) {
46 $app->log->error('client_id or client_secret not defined');
47 };
Akron864c2932018-11-16 17:18:55 +010048
Akron59992122019-10-29 11:28:45 +010049 # Load localize
Akron864c2932018-11-16 17:18:55 +010050 $app->plugin('Localize' => {
51 dict => {
52 Auth => {
53 _ => sub { $_->locale },
54 de => {
55 loginSuccess => 'Anmeldung erfolgreich',
56 loginFail => 'Anmeldung fehlgeschlagen',
57 logoutSuccess => 'Abmeldung erfolgreich',
58 logoutFail => 'Abmeldung fehlgeschlagen',
59 csrfFail => 'Fehlerhafter CSRF Token',
Akron8bbbecf2019-07-01 18:57:30 +020060 openRedirectFail => 'Weiterleitungsfehler',
Akroncdfd9d52019-07-23 11:35:00 +020061 tokenExpired => 'Zugriffstoken abgelaufen',
62 tokenInvalid => 'Zugriffstoken ungültig',
63 refreshFail => 'Fehlerhafter Refresh-Token',
Akron59992122019-10-29 11:28:45 +010064 responseError => 'Unbekannter Autorisierungsfehler',
65 paramError => 'Einige Eingaben sind fehlerhaft',
66 redirectUri => 'Weiterleitungs-Adresse',
67 homepage => 'Webseite',
68 desc => 'Kurzbeschreibung',
69 clientCredentials => 'Client Daten',
70 clientType => 'Art der Client-Applikation',
71 clientName => 'Name der Client-Applikation',
72 clientID => 'ID der Client-Applikation',
73 clientSecret => 'Client-Secret',
74 clientRegister => 'Neue Client-Applikation registrieren',
75 registerSuccess => 'Registrierung erfolgreich',
76 registerFail => 'Registrierung fehlgeschlagen',
77 oauthSettings => 'OAuth',
Akron1a9d5be2020-03-19 17:28:33 +010078 oauthUnregister => 'Möchten sie <span class="client-name"><%= $clientName %></span> wirklich löschen?'
Akron864c2932018-11-16 17:18:55 +010079 },
80 -en => {
81 loginSuccess => 'Login successful',
82 loginFail => 'Access denied',
83 logoutSuccess => 'Logout successful',
84 logoutFail => 'Logout failed',
85 csrfFail => 'Bad CSRF token',
Akron8bbbecf2019-07-01 18:57:30 +020086 openRedirectFail => 'Redirect failure',
Akroncdfd9d52019-07-23 11:35:00 +020087 tokenExpired => 'Access token expired',
88 tokenInvalid => 'Access token invalid',
89 refreshFail => 'Bad refresh token',
Akron59992122019-10-29 11:28:45 +010090 responseError => 'Unknown authorization error',
91 paramError => 'Some fields are invalid',
92 redirectUri => 'Redirect URI',
93 homepage => 'Homepage',
94 desc => 'Short description',
95 clientCredentials => 'Client Credentials',
96 clientType => 'Type of the client application',
97 clientName => 'Name of the client application',
98 clientID => 'ID of the client application',
99 clientSecret => 'Client secret',
100 clientRegister => 'Register new client application',
101 registerSuccess => 'Registration successful',
102 registerFail => 'Registration denied',
103 oauthSettings => 'OAuth',
Akron1a9d5be2020-03-19 17:28:33 +0100104 oauthUnregister => 'Do you really want to unregister <span class="client-name"><%= $clientName %></span>?',
Akron864c2932018-11-16 17:18:55 +0100105 }
106 }
107 }
108 });
109
110
Akrona9c8b0e2018-11-16 20:20:28 +0100111 # Add login frame to sidebar
112 $app->content_block(
113 sidebar => {
114 template => 'partial/auth/login'
115 }
116 );
117
118
Akronc82b1bc2018-11-18 18:06:14 +0100119 # Add logout button to header button list
120 $app->content_block(
121 headerButtonGroup => {
122 template => 'partial/auth/logout'
123 }
124 );
125
Akron59992122019-10-29 11:28:45 +0100126 # The plugin path
127 my $path = catdir(dirname(__FILE__), 'Auth');
128
129 # Append "templates"
130 push @{$app->renderer->paths}, catdir($path, 'templates');
Akron864c2932018-11-16 17:18:55 +0100131
Akron4796e002019-07-05 10:13:15 +0200132 # Get or set the user token necessary for authorization
Akron864c2932018-11-16 17:18:55 +0100133 $app->helper(
134 'auth.token' => sub {
Akroncdfd9d52019-07-23 11:35:00 +0200135 my ($c, $token, $expires_in) = @_;
Akron864c2932018-11-16 17:18:55 +0100136
Akroncdfd9d52019-07-23 11:35:00 +0200137 if ($token) {
138 # Set auth token
Akron4796e002019-07-05 10:13:15 +0200139 $c->stash(auth => $token);
Akroncdfd9d52019-07-23 11:35:00 +0200140 $c->session(auth => $token);
141 $c->session(auth_exp => time + $expires_in);
142 return 1;
Akron4796e002019-07-05 10:13:15 +0200143 };
144
Akroncdfd9d52019-07-23 11:35:00 +0200145 # Get token from stash
146 $token = $c->stash('auth');
147
148 return $token if $token;
149
150 # Get auth from session
151 $token = $c->session('auth') or return;
152 $c->stash(auth => $token);
153
154 # Return stashed value
155 return $token;
Akron864c2932018-11-16 17:18:55 +0100156 }
157 );
158
159
160 # Log in to the system
161 my $r = $app->routes;
Akron864c2932018-11-16 17:18:55 +0100162
Akron33f5c672019-06-24 19:40:47 +0200163 if ($param->{oauth2}) {
Akron864c2932018-11-16 17:18:55 +0100164
Akron8bbbecf2019-07-01 18:57:30 +0200165 my $client_id = $param->{client_id};
166 my $client_secret = $param->{client_secret};
167
Akroncdfd9d52019-07-23 11:35:00 +0200168
169 # Sets a requested token and returns
170 # an error, if it didn't work
Akron8bbbecf2019-07-01 18:57:30 +0200171 $app->helper(
Akroncdfd9d52019-07-23 11:35:00 +0200172 'auth.set_tokens_p' => sub {
173 my ($c, $json) = @_;
174 my $promise = Mojo::Promise->new;
175
176 # No json object
177 unless ($json) {
178 return $promise->reject({
179 message => 'Response is no valid JSON object (remote)'
180 });
181 };
182
183 # There is an error here
184 # Dealing with errors here
185 if ($json->{error} && ref $json->{error} ne 'ARRAY') {
186 return $promise->reject(
187 {
188 message => $json->{error} . ($json->{error_description} ? ': ' . $json->{error_description} : '')
189 }
190 );
191 }
192
193 # There is an array of errors
194 elsif (my $error = $json->{errors} // $json->{error}) {
195 if (ref $error eq 'ARRAY') {
196 my @errors = ();
197 foreach (@{$error}) {
198 if ($_->[1]) {
199 push @errors, { code => $_->[0], message => $_->[1]}
200 }
201 }
202 return $promise->reject(@errors);
203 }
204
205 return $promise->reject({message => $error});
206 };
207
208 # Everything is fine
209 my $access_token = $json->{access_token};
210 my $token_type = $json->{token_type};
211 my $refresh_token = $json->{refresh_token};
212 my $expires_in = $json->{"expires_in"} // $EXPECTED_EXPIRATION_IN;
213 my $auth = $token_type . ' ' . $access_token;
214 # my $scope = $json->{scope};
215
216 # Set session info
217 $c->session(auth => $auth);
218
219 # Expiration of the token minus tolerance
220 $c->session(auth_exp => time + $expires_in - 60);
221
222 # Set session info for refresh token
223 # This can be stored in the session, as it is useless
224 # unless the client secret is stolen
225 $c->session(auth_r => $refresh_token) if $refresh_token;
226
227 # Set stash info
228 $c->stash(auth => $auth);
229
230 return $promise->resolve;
231 }
232 );
233
234
235 # Refresh tokens and return a promise
236 $app->helper(
237 'auth.refresh_p' => sub {
Akron8bbbecf2019-07-01 18:57:30 +0200238 my $c = shift;
239 my $refresh_token = shift;
240
Akron8bbbecf2019-07-01 18:57:30 +0200241 # Get OAuth access token
Akroncdfd9d52019-07-23 11:35:00 +0200242 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
Akron8bbbecf2019-07-01 18:57:30 +0200243
Akron4cefe1f2019-09-04 10:11:28 +0200244 $c->app->log->debug("Refresh at $r_url");
Akroncdfd9d52019-07-23 11:35:00 +0200245
246 return $c->kalamar_ua->post_p($r_url, {} => form => {
Akron8bbbecf2019-07-01 18:57:30 +0200247 grant_type => 'refresh_token',
248 client_id => $client_id,
249 client_secret => $client_secret,
250 refresh_token => $refresh_token
251 })->then(
252 sub {
Akroncdfd9d52019-07-23 11:35:00 +0200253 my $tx = shift;
254 my $json = $tx->result->json;
255
256 # Response is fine
257 if ($tx->res->is_success) {
258
259 $c->app->log->info("Refresh was successful");
260
261 # Set the tokens and return a promise
262 return $c->auth->set_tokens_p($json);
263 };
264
265 # There is a client error - refresh fails
266 if ($tx->res->is_client_error && $json) {
267
268 $c->stash(auth => undef);
269 $c->stash(auth_exp => undef);
270 delete $c->session->{user};
271 delete $c->session->{auth};
272 delete $c->session->{auth_r};
273 delete $c->session->{auth_exp};
274
275 # Response is 400
276 return Mojo::Promise->reject(
277 $json->{error_description} // $c->loc('Auth_refreshFail')
278 );
279 };
280
281 $c->notify(error => $c->loc('Auth_responseError'));
282 return Mojo::Promise->reject;
283 }
284 )
285 }
286 );
287
Akron0f1b93b2020-03-17 11:37:19 +0100288 # Get a list of registered clients
289 $app->helper(
290 'auth.client_list_p' => sub {
291 my $c = shift;
292
293 # Get list of registered clients
294 state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/client/list');
295
Akron1a9d5be2020-03-19 17:28:33 +0100296 # Get the list of all clients
Akron0f1b93b2020-03-17 11:37:19 +0100297 return $c->korap_request(post => $r_url, {} => form => {
298 client_id => $client_id,
299 client_secret => $client_secret,
300 authorized_only => 'no'
301 })->then(
302 sub {
303 my $tx = shift;
304 my $json = $tx->result->json;
305
306 # Response is fine
307 if ($tx->res->is_success) {
308 return Mojo::Promise->resolve($json);
309 };
310
311 $c->log->error($c->dumper($tx->res->to_string));
312
313 # Failure
314 $c->notify(error => $c->loc('Auth_responseError'));
315 return Mojo::Promise->reject($json // 'No response');
316 }
317 );
318 }
319 );
Akroncdfd9d52019-07-23 11:35:00 +0200320
Akron1a9d5be2020-03-19 17:28:33 +0100321
Akroncdfd9d52019-07-23 11:35:00 +0200322 # Issue a korap request with "oauth"orization
323 # This will override the core request helper
324 $app->helper(
325 korap_request => sub {
326 my $c = shift;
327 my $method = shift;
328 my $path = shift;
329 my @param = @_;
330
331 # TODO:
332 # Check if $tx is not leaked!
333
334 # Get plugin user agent
335 my $ua = $c->kalamar_ua;
336
337 my $url = Mojo::URL->new($path);
338 my $tx = $ua->build_tx(uc($method), $url->clone, @param);
339
340 # Set X-Forwarded for
341 $tx->req->headers->header(
342 'X-Forwarded-For' => $c->client_ip
343 );
344
345 # Emit Hook to alter request
346 $c->app->plugins->emit_hook(
347 before_korap_request => ($c, $tx)
348 );
349
350 my $h = $tx->req->headers;
351
352 # If the request already has an Authorization
353 # header, respect it!
354 if ($h->authorization) {
355 return $ua->start_p($tx);
356 };
357
358 # Get auth token
359 if (my $auth_token = $c->auth->token) {
360
361 # The token is already expired!
362 my $exp = $c->session('auth_exp');
363 if (defined $exp && $exp < time) {
364
365 # Remove auth ...
366 $c->stash(auth => undef);
367
368 # And get refresh token from session
369 if (my $refresh_token = $c->session('auth_r')) {
370
371 $c->app->log->debug("Refresh is required");
372
373 # Refresh
374 return $c->auth->refresh_p($refresh_token)->then(
375 sub {
376 $c->app->log->debug("Search with refreshed tokens");
377
378 # Tokens were set - now send the request the first time!
379 $tx->req->headers->authorization($c->stash('auth'));
380 return $ua->start_p($tx);
381 }
382 );
383 }
384
385 # The token is expired and no refresh token is
386 # available - issue an unauthorized request!
387 else {
388 $c->stash(auth => undef);
389 $c->stash(auth_exp => undef);
390 delete $c->session->{user};
391 delete $c->session->{auth};
392 delete $c->session->{auth_r};
393 delete $c->session->{auth_exp};
394
395 # Warn on Error!
396 $c->notify(warn => $c->loc('Auth_tokenExpired'));
397 return $ua->start_p($tx);
398 };
399 }
400
401 # Auth token is fine
402 else {
403
404 # Set auth
405 $h->authorization($auth_token);
406 }
407 }
408
409 # No token set
410 else {
411
412 # Return unauthorized request
413 return $ua->start_p($tx);
414 };
415
416 # Issue an authorized request and automatically
417 # refresh the token on expiration!
418 return $ua->start_p($tx)->then(
419 sub {
420 my $tx = shift;
421
422 # Response is fine
423 if ($tx->res->is_success) {
424 return Mojo::Promise->resolve($tx);
425 }
426
427 # There is a client error - maybe refresh!
428 elsif ($tx->res->is_client_error) {
429
430 # Check the error
431 my $json = $tx->res->json('/errors/0/1');
432 if ($json && ($json =~ /expired|invalid/)) {
433 $c->stash(auth => undef);
434 $c->stash(auth_exp => undef);
435 delete $c->session->{user};
436 delete $c->session->{auth};
437
438 # And get refresh token from session
439 if (my $refresh_token = $c->session('auth_r')) {
440
441 # Refresh
442 return $c->auth->refresh_p($refresh_token)->then(
443 sub {
444 $c->app->log->debug("Search with refreshed tokens");
445
446 my $tx = $ua->build_tx(uc($method), $url->clone, @param);
447
448 # Set X-Forwarded for
449 $tx->req->headers->header(
450 'X-Forwarded-For' => $c->client_ip
451 );
452
453 # Tokens were set - now send the request the first time!
454 $tx->req->headers->authorization($c->stash('auth'));
455 return $ua->start_p($tx);
456 }
457 )
458 };
459
460 # Reject the invalid token
461 $c->notify(error => $c->loc('Auth_tokenInvalid'));
462 return Mojo::Promise->reject;
463 };
464
465 return Mojo::Promise->resolve($tx);
466 };
467
468 $c->notify(error => $c->loc('Auth_responseError'));
469 return Mojo::Promise->reject;
Akron8bbbecf2019-07-01 18:57:30 +0200470 }
471 );
472 }
473 );
474
Akroncdfd9d52019-07-23 11:35:00 +0200475 # Password flow for OAuth
Akron33f5c672019-06-24 19:40:47 +0200476 $r->post('/user/login')->to(
477 cb => sub {
478 my $c = shift;
Akron864c2932018-11-16 17:18:55 +0100479
Akron33f5c672019-06-24 19:40:47 +0200480 # Validate input
481 my $v = $c->validation;
482 $v->required('handle_or_email', 'trim');
483 $v->required('pwd', 'trim');
484 $v->csrf_protect;
485 $v->optional('fwd')->closed_redirect;
Akron864c2932018-11-16 17:18:55 +0100486
Akron33f5c672019-06-24 19:40:47 +0200487 my $user = $v->param('handle_or_email');
488 my $fwd = $v->param('fwd');
Akron864c2932018-11-16 17:18:55 +0100489
Akron33f5c672019-06-24 19:40:47 +0200490 # Set flash for redirect
491 $c->flash(handle_or_email => $user);
Akron864c2932018-11-16 17:18:55 +0100492
Akron33f5c672019-06-24 19:40:47 +0200493 if ($v->has_error || index($user, ':') >= 0) {
494 if ($v->has_error('fwd')) {
495 $c->notify(error => $c->loc('Auth_openRedirectFail'));
496 }
497 elsif ($v->has_error('csrf_token')) {
498 $c->notify(error => $c->loc('Auth_csrfFail'));
499 }
500 else {
501 $c->notify(error => $c->loc('Auth_loginFail'));
Akron864c2932018-11-16 17:18:55 +0100502 };
503
Akron864c2932018-11-16 17:18:55 +0100504 return $c->relative_redirect_to($fwd // 'index');
505 }
Akron864c2932018-11-16 17:18:55 +0100506
Akron33f5c672019-06-24 19:40:47 +0200507 my $pwd = $v->param('pwd');
Akron864c2932018-11-16 17:18:55 +0100508
Akroncdfd9d52019-07-23 11:35:00 +0200509 $c->app->log->debug("Login from user $user");
Akron33f5c672019-06-24 19:40:47 +0200510
511 # <specific>
512
513 # Get OAuth access token
514 my $url = Mojo::URL->new($c->korap->api)->path('oauth2/token');
515
516 # Korap request for login
517 $c->korap_request('post', $url, {}, form => {
518 grant_type => 'password',
519 username => $user,
520 password => $pwd,
Akron8bbbecf2019-07-01 18:57:30 +0200521 client_id => $client_id,
522 client_secret => $client_secret
Akron33f5c672019-06-24 19:40:47 +0200523 })->then(
524 sub {
Akron8bbbecf2019-07-01 18:57:30 +0200525 # Set the tokens and return a promise
Akroncdfd9d52019-07-23 11:35:00 +0200526 return $c->auth->set_tokens_p(shift->result->json)
Akron33f5c672019-06-24 19:40:47 +0200527 }
528 )->catch(
529 sub {
Akron33f5c672019-06-24 19:40:47 +0200530
Akron8bbbecf2019-07-01 18:57:30 +0200531 # Notify the user on login failure
532 unless (@_) {
533 $c->notify(error => $c->loc('Auth_loginFail'));
534 }
Akron33f5c672019-06-24 19:40:47 +0200535
Akron8bbbecf2019-07-01 18:57:30 +0200536 # There are known errors
537 foreach (@_) {
538 if (ref $_ eq 'HASH') {
539 my $err = ($_->{code} ? $_->{code} . ': ' : '') .
540 $_->{message};
541 $c->notify(error => $err);
542 # Log failure
543 $c->app->log->debug($err);
544 }
545 else {
546 $c->notify(error => $_);
547 $c->app->log->debug($_);
548 };
549 };
Akron33f5c672019-06-24 19:40:47 +0200550
551 $c->app->log->debug(qq!Login fail: "$user"!);
Akron8bbbecf2019-07-01 18:57:30 +0200552 }
553 )->then(
554 sub {
555 # Set user info
556 $c->session(user => $user);
557 $c->stash(user => $user);
558
559 # Notify on success
560 $c->app->log->debug(qq!Login successful: "$user"!);
561 $c->notify(success => $c->loc('Auth_loginSuccess'));
Akron33f5c672019-06-24 19:40:47 +0200562 }
563 )->finally(
564 sub {
Akron33f5c672019-06-24 19:40:47 +0200565 # Redirect to slash
566 return $c->relative_redirect_to($fwd // 'index');
567 }
568 )
569
570 # Start IOLoop
571 ->wait;
572
573 return 1;
574 }
575 )->name('login');
Akroncdfd9d52019-07-23 11:35:00 +0200576
577
578 # Log out of the session
Akron4cefe1f2019-09-04 10:11:28 +0200579 $r->get('/user/logout')->to(
580 cb => sub {
581 my $c = shift;
582
583 # TODO: csrf-protection!
584
585 my $refresh_token = $c->session('auth_r');
586
587 # Revoke the token
588 state $url = Mojo::URL->new($c->korap->api)->path('oauth2/revoke');
589
590 $c->kalamar_ua->post_p($url => {} => form => {
591 client_id => $client_id,
592 client_secret => $client_secret,
593 token => $refresh_token,
594 token_type => 'refresh_token'
595 })->then(
596 sub {
597 my $tx = shift;
598 my $json = $tx->result->json;
599
600 my $promise;
601
602 # Response is fine
603 if ($tx->res->is_success) {
604 $c->app->log->info("Revocation was successful");
605 $c->notify(success => $c->loc('Auth_logoutSuccess'));
606
607 $c->stash(auth => undef);
608 $c->stash(auth_exp => undef);
609 $c->flash(handle_or_email => delete $c->session->{user});
610 delete $c->session->{auth};
611 delete $c->session->{auth_r};
612 delete $c->session->{auth_exp};
613 return Mojo::Promise->resolve;
614 }
615
616 # Token may be invalid
617 $c->notify('error', $c->loc('Auth_logoutFail'));
618
619 # There is a client error - refresh fails
620 if ($tx->res->is_client_error && $json) {
621
622 return Mojo::Promise->reject(
623 $json->{error_description}
624 );
625 };
626
627 # Resource may not be found (404)
628 return Mojo::Promise->reject
629
630 }
631 )->catch(
632 sub {
633 my $err = shift;
634
635 # Server may be irresponsible
636 $c->notify('error', $c->loc('Auth_logoutFail'));
637 return Mojo::Promise->reject($err);
638 }
639 )->finally(
640 sub {
641 return $c->redirect_to('index');
642 }
643 )->wait;
644 }
645 )->name('logout');
Akron59992122019-10-29 11:28:45 +0100646
647 # If "experimental_registration" is set, open
648 # OAuth registration dialogues.
649 if ($param->{experimental_client_registration}) {
650
651 # Add settings
652 $app->navi->add(settings => (
653 $app->loc('Auth_oauthSettings'), 'oauth'
654 ));
655
656 # Route to oauth settings
657 $r->get('/settings/oauth')->to(
658 cb => sub {
Akron0f1b93b2020-03-17 11:37:19 +0100659 my $c = shift;
660
661 unless ($c->auth->token) {
662 return $c->render(
663 content => 'Unauthorized',
664 status => 401
665 );
666 };
667
668 # Wait for async result
669 $c->render_later;
670
671 $c->auth->client_list_p->then(
672 sub {
673 $c->stash('client_list' => shift);
674 }
675 )->catch(
676 sub {
677 return;
678 }
679 )->finally(
680 sub {
681 return $c->render(template => 'auth/tokens')
682 }
683 );
Akron59992122019-10-29 11:28:45 +0100684 }
Akron1a9d5be2020-03-19 17:28:33 +0100685 )->name('oauth-settings');
Akron59992122019-10-29 11:28:45 +0100686
687 # Route to oauth client registration
688 $r->post('/settings/oauth/register')->to(
689 cb => sub {
690 my $c = shift;
691 my $v = $c->validation;
692
693 unless ($c->auth->token) {
Akron0f1b93b2020-03-17 11:37:19 +0100694 return $c->render(
695 content => 'Unauthorized',
696 status => 401
697 );
Akron59992122019-10-29 11:28:45 +0100698 };
699
700 $v->csrf_protect;
701 $v->required('name', 'trim')->size(3, 255);
702 $v->required('type')->in('PUBLIC', 'CONFIDENTIAL');
703 $v->required('desc', 'trim')->size(3, 255);
704 $v->optional('url', 'trim')->like(qr/^(http|$)/i);
705 $v->optional('redirectUri', 'trim')->like(qr/^(http|$)/i);
706
707 # Render with error
708 if ($v->has_error) {
709 if ($v->has_error('csrf_token')) {
710 $c->notify(error => $c->loc('Auth_csrfFail'));
711 }
712 else {
713 $c->notify(warn => $c->loc('Auth_paramError'));
714 };
715 return $c->render(template => 'auth/tokens')
716 };
717
718 # Wait for async result
719 $c->render_later;
720
721 # Register on server
722 state $url = Mojo::URL->new($c->korap->api)->path('oauth2/client/register');
723 $c->korap_request('POST', $url => {} => json => {
724 name => $v->param('name'),
725 type => $v->param('type'),
726 description => $v->param('desc'),
727 url => $v->param('url'),
728 redirectURI => $v->param('redirectURI')
729 })->then(
730 sub {
731 my $tx = shift;
732 my $result = $tx->result;
733
734 if ($result->is_error) {
735 return Mojo::Promise->reject;
736 };
737
738 my $json = $result->json;
739
740 # TODO:
741 # Respond in template
742 my $client_id = $json->{client_id};
743 my $client_secret = $json->{client_secret};
744
745 $c->stash('client_name' => $v->param('name'));
746 $c->stash('client_desc' => $v->param('desc'));
747 $c->stash('client_type' => $v->param('type'));
748 $c->stash('client_url' => $v->param('url'));
749 $c->stash('client_redirect_uri' => $v->param('redirectURI'));
750 $c->stash('client_id' => $client_id);
751
752 if ($client_secret) {
753 $c->stash('client_secret' => $client_secret);
754 };
755
756 $c->notify(success => $c->loc('Auth_en_registerSuccess'));
757
758 return $c->render(template => 'auth/register-success');
759 }
760 )->catch(
761 sub {
762 # Server may be irresponsible
763 my $err = shift;
764 $c->notify('error' => $c->loc('Auth_en_registerFail'));
765 return Mojo::Promise->reject($err);
766 }
767 )->finally(
768 sub {
769 return $c->redirect_to('settings' => { scope => 'oauth' });
770 }
771 );
772 }
773 )->name('oauth-register');
Akron1a9d5be2020-03-19 17:28:33 +0100774
775
776 $r->get('/settings/oauth/unregister/:client_id')->to(
777 cb => sub {
778 shift->render(template => 'auth/unregister');
779 }
780 )->name('oauth-unregister');
781
782 # Unregister client
783 $r->post('/settings/oauth/unregister')->to(
784 cb => sub {
785 my $c = shift;
786
787 my $v = $c->validation;
788
789 unless ($c->auth->token) {
790 return $c->render(
791 content => 'Unauthorized',
792 status => 401
793 );
794 };
795
796 $v->csrf_protect;
797 $v->required('client-name', 'trim')->size(3, 255);
798 $v->required('client-id', 'trim')->size(3, 255);
799 $v->optional('client-secret');
800
801 # Render with error
802 if ($v->has_error) {
803 if ($v->has_error('csrf_token')) {
804 $c->notify(error => $c->loc('Auth_csrfFail'));
805 }
806 else {
807 $c->notify(warn => $c->loc('Auth_paramError'));
808 };
809 return $c->render(template => 'auth/tokens')
810 };
811
812 my $client_id = $v->param('client-id');
813 my $client_name = $v->param('client-name');
814 my $client_secret = $v->param('client-secret');
815
816 # Get list of registered clients
817 my $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/client/deregister/')->path(
818 $client_id
819 );
820
821 my $send = {};
822
823 if ($client_secret) {
824 $send->{client_secret} = $client_secret;
825 };
826
827 # Get the list of all clients
828 return $c->korap_request(delete => $r_url, {} => form => $send)->then(
829 sub {
830 my $tx = shift;
831
832 # Response is fine
833 if ($tx->res->is_success) {
834 # Okay
835 $c->notify(success => 'Successfully deleted ' . $client_name);
836 }
837 else {
838
839 # Failure
840 my $json = $tx->result->json;
841 if ($json && $json->{error_description}) {
842 $c->notify(error => $json->{error_description});
843 } else {
844 $c->notify(error => $c->loc('Auth_responseError'));
845 };
846 };
847
848 return $c->redirect_to('oauth-settings');
849 }
850 );
851 }
852 )->name('oauth-unregister-post');
Akron59992122019-10-29 11:28:45 +0100853 };
Akron33f5c672019-06-24 19:40:47 +0200854 }
Akroncdfd9d52019-07-23 11:35:00 +0200855
Akron33f5c672019-06-24 19:40:47 +0200856 # Use JWT login
Akroncdfd9d52019-07-23 11:35:00 +0200857 # (should be deprecated)
Akron33f5c672019-06-24 19:40:47 +0200858 else {
859
Akroncdfd9d52019-07-23 11:35:00 +0200860 # Inject authorization to all korap requests
861 $app->hook(
862 before_korap_request => sub {
863 my ($c, $tx) = @_;
864 my $h = $tx->req->headers;
865
866 # If the request already has an Authorization
867 # header, respect it
868 unless ($h->authorization) {
869
870 # Get valid auth token and set as header
871 if (my $auth_token = $c->auth->token) {
872 $h->authorization($auth_token);
873 };
874 };
875 }
876 );
877
878 # Password flow with JWT
Akron33f5c672019-06-24 19:40:47 +0200879 $r->post('/user/login')->to(
880 cb => sub {
881 my $c = shift;
882
883 # Validate input
884 my $v = $c->validation;
885 $v->required('handle_or_email', 'trim');
886 $v->required('pwd', 'trim');
887 $v->csrf_protect;
888 $v->optional('fwd')->closed_redirect;
889
890 my $user = $v->param('handle_or_email');
891 my $fwd = $v->param('fwd');
892
893 # Set flash for redirect
894 $c->flash(handle_or_email => $user);
895
896 if ($v->has_error || index($user, ':') >= 0) {
897 if ($v->has_error('fwd')) {
898 $c->notify(error => $c->loc('Auth_openRedirectFail'));
899 }
900 elsif ($v->has_error('csrf_token')) {
901 $c->notify(error => $c->loc('Auth_csrfFail'));
902 }
903 else {
904 $c->notify(error => $c->loc('Auth_loginFail'));
905 };
906
907 return $c->relative_redirect_to($fwd // 'index');
908 }
909
910 my $pwd = $v->param('pwd');
911
Akroncdfd9d52019-07-23 11:35:00 +0200912 $c->app->log->debug("Login from user $user");
Akron33f5c672019-06-24 19:40:47 +0200913
914 my $url = Mojo::URL->new($c->korap->api)->path('auth/apiToken');
915
916 # Korap request for login
917 $c->korap_request('get', $url, {
918
919 # Set authorization header
920 Authorization => 'Basic ' . b("$user:$pwd")->b64_encode->trim,
921
922 })->then(
923 sub {
924 my $tx = shift;
925
926 # Get the java token
927 my $jwt = $tx->result->json;
928
929 # No java web token
930 unless ($jwt) {
931 $c->notify(error => 'Response is no valid JWT (remote)');
932 return;
933 };
934
935 # There is an error here
936 # Dealing with errors here
937 if (my $error = $jwt->{error} // $jwt->{errors}) {
938 if (ref $error eq 'ARRAY') {
939 foreach (@$error) {
940 unless ($_->[1]) {
941 $c->notify(error => $c->loc('Auth_loginFail'));
942 }
943 else {
944 $c->notify(error => $_->[0] . ($_->[1] ? ': ' . $_->[1] : ''));
945 };
946 };
947 }
948 else {
949 $c->notify(error => 'There is an unknown JWT error');
950 };
951 return;
952 };
953
954 # TODO: Deal with user return values.
955 my $auth = $jwt->{token_type} . ' ' . $jwt->{token};
956
Akroncdfd9d52019-07-23 11:35:00 +0200957 $c->app->log->debug(qq!Login successful: "$user"!);
Akron33f5c672019-06-24 19:40:47 +0200958
959 $user = $jwt->{username} ? $jwt->{username} : $user;
960
961 # Set session info
962 $c->session(user => $user);
963 $c->session(auth => $auth);
964
965 # Set stash info
966 $c->stash(user => $user);
967 $c->stash(auth => $auth);
968
Akron33f5c672019-06-24 19:40:47 +0200969 $c->notify(success => $c->loc('Auth_loginSuccess'));
970 }
971 )->catch(
972 sub {
973 my $e = shift;
974
975 # Notify the user
976 $c->notify(
977 error =>
978 ($e->{code} ? $e->{code} . ': ' : '') .
979 $e->{message} . ' for Login (remote)'
980 );
981
982 # Log failure
983 $c->app->log->debug(
984 ($e->{code} ? $e->{code} . ' - ' : '') .
985 $e->{message}
986 );
987
988 $c->app->log->debug(qq!Login fail: "$user"!);
989 $c->notify(error => $c->loc('Auth_loginFail'));
990 }
991 )->finally(
992 sub {
993
994 # Redirect to slash
995 return $c->relative_redirect_to($fwd // 'index');
996 }
997 )
998
999 # Start IOLoop
1000 ->wait;
1001
1002 return 1;
1003 }
1004 )->name('login');
Akron4cefe1f2019-09-04 10:11:28 +02001005
1006
1007 # Log out of the session
1008 $r->get('/user/logout')->to(
1009 cb => sub {
1010 my $c = shift;
1011
1012 # TODO: csrf-protection!
1013
1014 # Log out of the system
1015 my $url = Mojo::URL->new($c->korap->api)->path('auth/logout');
1016
1017 $c->korap_request(
1018 'get', $url
1019 )->then(
1020 # Logged out
1021 sub {
1022 my $tx = shift;
1023 # Clear cache
1024 # ?? Necesseary
1025 # $c->chi('user')->remove($c->auth->token);
1026
1027 # TODO:
1028 # Revoke refresh token!
1029 # based on auth token!
1030 # my $refresh_token = $c->chi('user')->get('refr_' . $c->auth->token);
1031 # $c->auth->revoke_token($refresh_token)
1032
1033 # Expire session
1034 $c->session(user => undef);
1035 $c->session(auth => undef);
1036 $c->notify(success => $c->loc('Auth_logoutSuccess'));
1037 }
1038
1039 )->catch(
1040 # Something went wrong
1041 sub {
1042 # my $err_msg = shift;
1043 $c->notify('error', $c->loc('Auth_logoutFail'));
1044 }
1045
1046 )->finally(
1047 # Redirect
1048 sub {
1049 return $c->redirect_to('index');
1050 }
1051 )
1052
1053 # Start IOLoop
1054 ->wait;
1055
1056 return 1;
1057 }
1058 )->name('logout');
Akron33f5c672019-06-24 19:40:47 +02001059 };
Akron59992122019-10-29 11:28:45 +01001060
1061 $app->log->info('Successfully registered Auth plugin');
Akron864c2932018-11-16 17:18:55 +01001062};
1063
Akronb3f33592020-03-16 15:14:44 +01001064
Akron864c2932018-11-16 17:18:55 +010010651;
Akrona9c8b0e2018-11-16 20:20:28 +01001066
Akronc82b1bc2018-11-18 18:06:14 +01001067
Akrona9c8b0e2018-11-16 20:20:28 +01001068__END__
Akron59992122019-10-29 11:28:45 +01001069
1070=pod
1071
1072=encoding utf8
1073
1074=head1 NAME
1075
1076Kalamar::Plugin::Auth - OAuth-2.0-based authorization plugin
1077
1078=head1 DESCRIPTION
1079
1080L<Kalamar::Plugin::Auth> is an OAuth-2.0-based authorization
1081plugin for L<Kalamar>. It requires a C<Kustvakt> full server
1082with OAuth 2.0 capabilities.
1083It is activated by loading C<Auth> as a plugin in the C<Kalamar.plugins>
1084parameter in the Kalamar configuration.
1085
1086=head1 CONFIGURATION
1087
1088L<Kalamar::Plugin::Auth> supports the following parameter for the
1089C<Kalamar-Auth> configuration section in the Kalamar configuration:
1090
1091=over 2
1092
1093=item B<client_id>
1094
1095The client identifier of Kalamar to be send with every OAuth 2.0
1096management request.
1097
1098=item B<client_secret>
1099
1100The client secret of Kalamar to be send with every OAuth 2.0
1101management request.
1102
1103=item B<oauth2>
1104
1105Initially L<Kalamar-Plugin-Auth> was based on JWT. This parameter
1106is historically used to switch between oauth2 and jwt. It is expected
1107to be deprecated in the future, but for the moment it is required
1108to be set to a true value.
1109
1110=item B<experimental_client_registration>
1111
1112Activates the oauth client registration flow.
1113
1114=back
1115
1116=head2 COPYRIGHT AND LICENSE
1117
1118Copyright (C) 2015-2020, L<IDS Mannheim|http://www.ids-mannheim.de/>
1119Author: L<Nils Diewald|http://nils-diewald.de/>
1120
1121Kalamar is developed as part of the L<KorAP|http://korap.ids-mannheim.de/>
1122Corpus Analysis Platform at the
1123L<Leibniz Institute for the German Language (IDS)|http://ids-mannheim.de/>,
1124member of the
1125L<Leibniz-Gemeinschaft|http://www.leibniz-gemeinschaft.de>
1126and supported by the L<KobRA|http://www.kobra.tu-dortmund.de> project,
1127funded by the
1128L<Federal Ministry of Education and Research (BMBF)|http://www.bmbf.de/en/>.
1129
1130Kalamar is free software published under the
1131L<BSD-2 License|https://raw.githubusercontent.com/KorAP/Kalamar/master/LICENSE>.
1132
1133=cut