diff --git a/cpanfile b/cpanfile index 8f72042..6bdba37 100644 --- a/cpanfile +++ b/cpanfile @@ -10,6 +10,7 @@ requires 'List::UtilsBy'; requires 'MIME::Entity'; requires 'Mojolicious'; requires 'Mojolicious::Plugin::Authentication'; +requires 'Mojolicious::Plugin::OAuth2'; requires 'Mojo::Pg'; requires 'Text::CSV'; requires 'Text::Markdown'; diff --git a/cpanfile.snapshot b/cpanfile.snapshot index 198917d..5619f56 100644 --- a/cpanfile.snapshot +++ b/cpanfile.snapshot @@ -1915,6 +1915,15 @@ DISTRIBUTIONS ExtUtils::MakeMaker 0 Mojolicious 8.0 perl 5.016 + Mojolicious-Plugin-OAuth2-2.02 + pathname: J/JH/JHTHORSEN/Mojolicious-Plugin-OAuth2-2.02.tar.gz + provides: + Mojolicious::Plugin::OAuth2 2.02 + Mojolicious::Plugin::OAuth2::Mock undef + requirements: + ExtUtils::MakeMaker 0 + IO::Socket::SSL 1.94 + Mojolicious 8.25 Moo-2.005005 pathname: H/HA/HAARG/Moo-2.005005.tar.gz provides: diff --git a/examples/travelynx.conf b/examples/travelynx.conf index c77e40f..f8eaac0 100644 --- a/examples/travelynx.conf +++ b/examples/travelynx.conf @@ -97,7 +97,31 @@ die("Changeme!"), ], + # optionally, users can link travelynx and traewelling accounts, and + # automatically synchronize check-ins. + # To do so, you need to create a travelynx application on + # . The application + # must be marked as "Confidential" and have a redirect URL that matches + # $base_url/oauth/traewelling, where $base_url refers to the URL configured + # above. For instance, travelynx.de uses + # 'https://travelynx.de/oauth/traewelling'. An incorrect redirect URL will + # cause OAuth2 to fail with unsupported_grant_type. + # + # Note that the travelynx/traewelling OAuth2 integration does not support + # travelynx installations that are reachable on multiple URLs at the + # moment -- linking a traewelling account is only possible when accessing + # travelynx via the base URL. traewelling => { + + # Uncomment the following block and insert the application ID and + # secret obtained from https://traewelling.de/settings/applications + # -> your application -> Edit. + + #oauth => { + # id => 1234, + # secret => 'mysecret', + #} + # By default, the "work" or "worker" command does not just update # real-time data of active journeys, but also performs push and pull # synchronization with traewelling for accounts that have configured it. @@ -110,7 +134,8 @@ # periodically runs "perl index.pl traewelling" (push and pull) or # two separate cronjobs that run "perl index.pl traewelling push" and # "perl index.pl traewelling pull", respectively. - ## separate_worker => 1, + + # separate_worker => 1, }, version => qx{git describe --dirty} // 'experimental', diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm index f5f56b7..551c061 100755 --- a/lib/Travelynx.pm +++ b/lib/Travelynx.pm @@ -100,6 +100,23 @@ sub startup { }, } ); + + if ( my $oa = $self->config->{traewelling}{oauth} ) { + $self->plugin( + OAuth2 => { + providers => { + traewelling => { + key => $oa->{id}, + secret => $oa->{secret}, + authorize_url => +'https://traewelling.de/oauth/authorize?response_type=code', + token_url => 'https://traewelling.de/oauth/token', + } + } + } + ); + } + $self->sessions->default_expiration( 60 * 60 * 24 * 180 ); # Starting with v8.11, Mojolicious sends SameSite=Lax Cookies by default. @@ -2140,6 +2157,11 @@ sub startup { $r->post('/login')->to('account#do_login'); $r->post('/recover')->to('account#request_password_reset'); + if ( $self->config->{traewelling}{oauth} ) { + $r->get('/oauth/traewelling')->to('traewelling#oauth'); + $r->post('/oauth/traewelling')->to('traewelling#oauth'); + } + if ( not $self->config->{registration}{disabled} ) { $r->get('/register')->to('account#registration_form'); $r->post('/register')->to('account#register'); diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm index 10732ec..b45a02f 100644 --- a/lib/Travelynx/Command/database.pm +++ b/lib/Travelynx/Command/database.pm @@ -1815,6 +1815,25 @@ my @migrations = ( } ); }, + + # v45 -> v46 + # Switch to Traewelling OAuth2 authentication. + # E-Mail is no longer needed. + sub { + my ($db) = @_; + $db->query( + qq{ + drop view traewelling_str; + create view traewelling_str as select + user_id, push_sync, pull_sync, errored, token, data, + extract(epoch from latest_run) as latest_run_ts + from traewelling + ; + alter table traewelling drop column email; + update schema_version set version = 46; + } + ); + }, ); # TODO add 'hafas' column to in_transit (and maybe journeys? undo/redo needs something to work with...) diff --git a/lib/Travelynx/Controller/Traewelling.pm b/lib/Travelynx/Controller/Traewelling.pm index 4c6bc64..e14872d 100644 --- a/lib/Travelynx/Controller/Traewelling.pm +++ b/lib/Travelynx/Controller/Traewelling.pm @@ -6,6 +6,65 @@ package Travelynx::Controller::Traewelling; use Mojo::Base 'Mojolicious::Controller'; use Mojo::Promise; +sub oauth { + my ($self) = @_; + + if ( $self->param('action') + and $self->validation->csrf_protect->has_error('csrf_token') ) + { + $self->render( + 'bad_request', + csrf => 1, + status => 400 + ); + return; + } + + $self->render_later; + + my $oa = $self->config->{traewelling}{oauth}; + + return $self->oauth2->get_token_p( + traewelling => { scope => 'read-statuses write-statuses' } )->then( + sub { + my ($provider) = @_; + if ( not defined $provider ) { + + # OAuth2 plugin performed a redirect, no need to render + return; + } + if ( not $provider or not $provider->{access_token} ) { + $self->flash( new_traewelling => 1 ); + $self->flash( login_error => 'no token received' ); + $self->redirect_to('/account/traewelling'); + return; + } + my $uid = $self->current_user->{id}; + my $token = $provider->{access_token}; + $self->traewelling->link( + uid => $self->current_user->{id}, + token => $provider->{access_token}, + expires_in => $provider->{expires_in}, + ); + return $self->traewelling_api->get_user_p( $uid, $token )->then( + sub { + $self->flash( new_traewelling => 1 ); + $self->redirect_to('/account/traewelling'); + } + ); + } + )->catch( + sub { + my ($err) = @_; + say "error $err"; + $self->flash( new_traewelling => 1 ); + $self->flash( login_error => $err ); + $self->redirect_to('/account/traewelling'); + return; + } + ); +} + sub settings { my ($self) = @_; @@ -22,38 +81,7 @@ sub settings { return; } - if ( $self->param('action') and $self->param('action') eq 'login' ) { - my $email = $self->param('email'); - my $password = $self->param('password'); - $self->render_later; - $self->traewelling_api->login_p( - uid => $uid, - email => $email, - password => $password - )->then( - sub { - my $traewelling = $self->traewelling->get( uid => $uid ); - $self->param( sync_source => 'none' ); - $self->render( - 'traewelling', - traewelling => $traewelling, - new_traewelling => 1, - ); - } - )->catch( - sub { - my ($err) = @_; - $self->render( - 'traewelling', - traewelling => {}, - new_traewelling => 1, - login_error => $err, - ); - } - )->wait; - return; - } - elsif ( $self->param('action') and $self->param('action') eq 'logout' ) { + if ( $self->param('action') and $self->param('action') eq 'logout' ) { $self->render_later; my $traewelling = $self->traewelling->get( uid => $uid ); $self->traewelling_api->logout_p( diff --git a/lib/Travelynx/Helper/Traewelling.pm b/lib/Travelynx/Helper/Traewelling.pm index 23170eb..18edc18 100644 --- a/lib/Travelynx/Helper/Traewelling.pm +++ b/lib/Travelynx/Helper/Traewelling.pm @@ -199,84 +199,6 @@ sub get_user_p { return $promise; } -sub login_p { - my ( $self, %opt ) = @_; - - my $uid = $opt{uid}; - my $email = $opt{email}; - my $password = $opt{password}; - - my $ua = $self->{user_agent}->request_timeout(20); - - my $request = { - login => $email, - password => $password, - }; - - my $promise = Mojo::Promise->new; - my $token; - - $ua->post_p( - "https://traewelling.de/api/v1/auth/login" => $self->{header}, - json => $request - )->then( - sub { - my ($tx) = @_; - if ( my $err = $tx->error ) { - my $err_msg - = "v1/auth/login: HTTP $err->{code} $err->{message}"; - $promise->reject($err_msg); - return; - } - else { - my $res = $tx->result->json->{data}; - $token = $res->{token}; - my $expiry_dt = $self->parse_datetime( $res->{expires_at} ); - - # Fall back to one year expiry - $expiry_dt //= DateTime->now( time_zone => 'Europe/Berlin' ) - ->add( years => 1 ); - $self->{model}->link( - uid => $uid, - email => $email, - token => $token, - expires => $expiry_dt - ); - return $self->get_user_p( $uid, $token ); - } - } - )->then( - sub { - $promise->resolve; - return; - } - )->catch( - sub { - my ($err) = @_; - if ($token) { - - # We have a token, but couldn't complete the login. For now, we - # solve this by logging out and invalidating the token. - $self->logout_p( - uid => $uid, - token => $token - )->finally( - sub { - $promise->reject("v1/auth/login: $err"); - return; - } - ); - } - else { - $promise->reject("v1/auth/login: $err"); - } - return; - } - )->wait; - - return $promise; -} - sub logout_p { my ( $self, %opt ) = @_; diff --git a/lib/Travelynx/Model/Traewelling.pm b/lib/Travelynx/Model/Traewelling.pm index 1939374..72ee92d 100644 --- a/lib/Travelynx/Model/Traewelling.pm +++ b/lib/Travelynx/Model/Traewelling.pm @@ -38,16 +38,15 @@ sub now { sub link { my ( $self, %opt ) = @_; - my $log = [ [ $self->now->epoch, "Erfolgreich angemeldet" ] ]; + my $log = [ [ $self->now->epoch, "Erfolgreich mittels OAuth2 verbunden" ] ]; my $data = { log => $log, - expires => $opt{expires}->epoch, + expires => $self->now->epoch + $opt{expires_in}, }; my $user_entry = { user_id => $opt{uid}, - email => $opt{email}, push_sync => 0, pull_sync => 0, token => $opt{token}, @@ -59,7 +58,7 @@ sub link { $user_entry, { on_conflict => \ -'(user_id) do update set email = EXCLUDED.email, token = EXCLUDED.token, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null' +'(user_id) do update set token = EXCLUDED.token, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null' } ); diff --git a/templates/account.html.ep b/templates/account.html.ep index c27e0f5..b64869a 100644 --- a/templates/account.html.ep +++ b/templates/account.html.ep @@ -122,33 +122,35 @@ % } - - Träwelling - - edit - % if (not ($traewelling->{token})) { - Nicht verknüpft - % } - % elsif ($traewelling->{errored}) { - Fehlerhaft - % } - % else { - Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %> - % if ($traewelling->{expired}) { - – Login-Token abgelaufen + % if (config->{traewelling}{oauth}) { + + Träwelling + + edit + % if (not ($traewelling->{token})) { + Nicht verknüpft % } - % elsif ($traewelling->{expiring}) { - – Login-Token läuft bald ab + % elsif ($traewelling->{errored}) { + Fehlerhaft % } - % elsif ($traewelling->{pull_sync}) { - – Checkins in Träwelling werden von travelynx übernommen + % else { + Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %> + % if ($traewelling->{expired}) { + – Login-Token abgelaufen + % } + % elsif ($traewelling->{expiring}) { + – Login-Token läuft bald ab + % } + % elsif ($traewelling->{pull_sync}) { + – Checkins in Träwelling werden von travelynx übernommen + % } + % elsif ($traewelling->{push_sync}) { + – Checkins in travelynx werden zu Träwelling weitergereicht + % } % } - % elsif ($traewelling->{push_sync}) { - – Checkins in travelynx werden zu Träwelling weitergereicht - % } - % } - - + + + % } Externe Dienste diff --git a/templates/traewelling.html.ep b/templates/traewelling.html.ep index 23e2e35..4147140 100644 --- a/templates/traewelling.html.ep +++ b/templates/traewelling.html.ep @@ -4,20 +4,20 @@

Träwelling

-% if (stash('new_traewelling')) { +% if (flash('new_traewelling')) {
% if ($traewelling->{token}) {
Träwelling verknüpft - % my $user = $traewelling->{data}{user_name} // $traewelling->{email}; + % my $user = $traewelling->{data}{user_name} // '???';

Dein travelynx-Account hat nun ein Jahr lang Zugriff auf den Träwelling-Account @<%= $user %>.

% } - % elsif (my $login_err = stash('login_error')) { + % elsif (my $login_err = flash('login_error')) {
Login-Fehler @@ -30,7 +30,7 @@
Logout-Fehler

Der Logout bei Träwelling ist fehlgeschlagen: <%= $logout_err %>. - Dein Login-Token bei travelynx wurde dennoch gelöscht, so + Dein Token bei travelynx wurde dennoch gelöscht, so dass nun kein Zugriff von travelynx auf Träwelling mehr möglich ist. In den Träwelling-Einstellungen @@ -73,10 +73,10 @@

% if ($traewelling->{expired}) { - Login-Token abgelaufen + Token abgelaufen % } % else { - Login-Token läuft bald ab + Token läuft bald ab % }

Melde deinen travelynx-Account von Träwelling ab und verbinde ihn mit deinem Träwelling-Passwort erneut, @@ -105,37 +105,19 @@ verknüpfen. Dies erlaubt die automatische Übernahme zukünftiger Checkins zwischen den beiden Diensten. Träwelling-Checkins in Nahverkehrsmittel und Züge außerhalb des deutschen Schienennetzes - werden nicht unterstützt und ignoriert. Checkins, die vor dem - Verknüpfen der Accounts stattgefunden haben, werden nicht - synchronisiert. Bei synchronisierten Checkins wird der zugehörige - Träwelling-Status von deiner travelynx-Statusseite aus verlinkt. -

-

- Mit E-Mail und Passwort wird ein Login über die Träwelling-API - durchgeführt. Die E-Mail und das dabei generierte Token werden - von travelynx gespeichert. Das Passwort wird ausschließlich für - den Login verwendet und nicht gespeichert. Der Login kann jederzeit - sowohl auf dieser Seite als auch über die Träwelling-Einstellungen - widerrufen werden. Nach einem Jahr läuft er automatisch ab. + werden (noch) nicht unterstützt und ignoriert. Checkins, die + vor dem Verknüpfen der Accounts stattgefunden haben, werden + nicht synchronisiert. Bei synchronisierten Checkins wird der + zugehörige Träwelling-Status von deiner travelynx-Statusseite + aus verlinkt.

- %= form_for '/account/traewelling' => (method => 'POST') => begin + %= form_for '/oauth/traewelling' => (method => 'POST') => begin %= csrf_field -
- account_circle - %= text_field 'email', id => 'email', class => 'validate', required => undef, maxlength => 250 - -
-
- lock - %= password_field 'password', id => 'password', class => 'validate', required => undef - -
- @@ -154,7 +136,7 @@ % else { %= $traewelling->{email} % } - verknüpft. Der Login-Token läuft <%= $traewelling->{expires_on}->strftime('am %d.%m.%Y um %H:%M Uhr') %> ab. + verknüpft. Der Token läuft <%= $traewelling->{expires_on}->strftime('am %d.%m.%Y um %H:%M Uhr') %> ab.