diff options
| -rw-r--r-- | cpanfile | 1 | ||||
| -rw-r--r-- | cpanfile.snapshot | 9 | ||||
| -rw-r--r-- | examples/travelynx.conf | 27 | ||||
| -rwxr-xr-x | lib/Travelynx.pm | 22 | ||||
| -rw-r--r-- | lib/Travelynx/Command/database.pm | 19 | ||||
| -rw-r--r-- | lib/Travelynx/Controller/Traewelling.pm | 90 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/Traewelling.pm | 78 | ||||
| -rw-r--r-- | lib/Travelynx/Model/Traewelling.pm | 7 | ||||
| -rw-r--r-- | templates/account.html.ep | 50 | ||||
| -rw-r--r-- | templates/traewelling.html.ep | 46 | 
10 files changed, 179 insertions, 170 deletions
| @@ -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 +	# <https://traewelling.de/settings/applications>. 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,11 +6,9 @@ package Travelynx::Controller::Traewelling;  use Mojo::Base 'Mojolicious::Controller';  use Mojo::Promise; -sub settings { +sub oauth {  	my ($self) = @_; -	my $uid = $self->current_user->{id}; -  	if (    $self->param('action')  		and $self->validation->csrf_protect->has_error('csrf_token') )  	{ @@ -22,38 +20,68 @@ 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, -				); +	$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;  			} -		)->catch( -			sub { -				my ($err) = @_; -				$self->render( -					'traewelling', -					traewelling     => {}, -					new_traewelling => 1, -					login_error     => $err, -				); +			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;  			} -		)->wait; +			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) = @_; + +	my $uid = $self->current_user->{id}; + +	if (    $self->param('action') +		and $self->validation->csrf_protect->has_error('csrf_token') ) +	{ +		$self->render( +			'bad_request', +			csrf   => 1, +			status => 400 +		);  		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 @@  					% }  				</td>  			</tr> -			<tr> -				<th scope="row">Träwelling</th> -				<td> -					<a href="/account/traewelling"><i class="material-icons">edit</i></a> -					% if (not ($traewelling->{token})) { -						<span style="color: #999999;">Nicht verknüpft</span> -					% } -					% elsif ($traewelling->{errored}) { -						Fehlerhaft <i class="material-icons" aria-hidden="true">error</i> -					% } -					% else { -						Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %> -						% if ($traewelling->{expired}) { -							– Login-Token abgelaufen <i class="material-icons" aria-hidden="true">error</i> -						% } -						% elsif ($traewelling->{expiring}) { -							– Login-Token läuft bald ab <i class="material-icons" aria-hidden="true">warning</i> +			% if (config->{traewelling}{oauth}) { +				<tr> +					<th scope="row">Träwelling</th> +					<td> +						<a href="/account/traewelling"><i class="material-icons">edit</i></a> +						% if (not ($traewelling->{token})) { +							<span style="color: #999999;">Nicht verknüpft</span>  						% } -						% elsif ($traewelling->{pull_sync}) { -							– Checkins in Träwelling werden von travelynx übernommen +						% elsif ($traewelling->{errored}) { +							Fehlerhaft <i class="material-icons" aria-hidden="true">error</i>  						% } -						% elsif ($traewelling->{push_sync}) { -							– Checkins in travelynx werden zu Träwelling weitergereicht +						% else { +							Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %> +							% if ($traewelling->{expired}) { +								– Login-Token abgelaufen <i class="material-icons" aria-hidden="true">error</i> +							% } +							% elsif ($traewelling->{expiring}) { +								– Login-Token läuft bald ab <i class="material-icons" aria-hidden="true">warning</i> +							% } +							% elsif ($traewelling->{pull_sync}) { +								– Checkins in Träwelling werden von travelynx übernommen +							% } +							% elsif ($traewelling->{push_sync}) { +								– Checkins in travelynx werden zu Träwelling weitergereicht +							% }  						% } -					% } -				</td> -			</tr> +					</td> +				</tr> +			% }  			<tr>  				<th scope="row">Externe Dienste</th>  				<td> 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 @@  <h1>Träwelling</h1> -% if (stash('new_traewelling')) { +% if (flash('new_traewelling')) {  	<div class="row">  		<div class="col s12">  			% if ($traewelling->{token}) {  				<div class="card success-color">  					<div class="card-content white-text">  						<span class="card-title">Träwelling verknüpft</span> -						% my $user = $traewelling->{data}{user_name} // $traewelling->{email}; +						% my $user = $traewelling->{data}{user_name} // '???';  						<p>Dein travelynx-Account hat nun ein Jahr lang Zugriff auf   							den Träwelling-Account <b>@<%= $user %></b>.</p>  					</div>  				</div>  			% } -			% elsif (my $login_err = stash('login_error')) { +			% elsif (my $login_err = flash('login_error')) {  				<div class="card caution-color">  					<div class="card-content white-text">  						<span class="card-title">Login-Fehler</span> @@ -30,7 +30,7 @@  					<div class="card-content white-text">  						<span class="card-title">Logout-Fehler</span>  						<p>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 <a  							href="https://traewelling.de/settings">Träwelling-Einstellungen</a> @@ -73,10 +73,10 @@  			<div class="card caution-color">  				<div class="card-content white-text">  					% if ($traewelling->{expired}) { -						<span class="card-title">Login-Token abgelaufen</span> +						<span class="card-title">Token abgelaufen</span>  					% }  					% else { -						<span class="card-title">Login-Token läuft bald ab</span> +						<span class="card-title">Token läuft bald ab</span>  					% }  					<p>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. -			</p> -			<p> -				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 <a -				href="https://traewelling.de/settings">Träwelling-Einstellungen</a> -				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.  			</p>  		</div>  	</div>  	<div class="row"> -		%= form_for '/account/traewelling' => (method => 'POST') => begin +		%= form_for '/oauth/traewelling' => (method => 'POST') => begin  			%= csrf_field -			<div class="input-field col s12"> -				<i class="material-icons prefix">account_circle</i> -				%= text_field 'email', id => 'email', class => 'validate', required => undef, maxlength => 250 -				<label for="email">Login (Name oder E-Mail)</label> -			</div> -			<div class="input-field col s12"> -				<i class="material-icons prefix">lock</i> -				%= password_field 'password', id => 'password', class => 'validate', required => undef -				<label for="password">Passwort</label> -			</div>  			<div class="col s12 center-align"> -				<button class="btn waves-effect waves-light" type="submit" name="action" value="login"> +				<button class="btn waves-effect waves-light" type="submit" name="action" value="connect">  					Verknüpfen  					<i class="material-icons right">send</i>  				</button> @@ -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.  			</p>  		</div>  	</div> | 
