Commit 25d0530e authored by Birte Kristina Friesel's avatar Birte Kristina Friesel
Browse files

Add password reset functionality

Closes #5
parent 6ff397c9
Loading
Loading
Loading
Loading
+85 −0
Original line number Diff line number Diff line
@@ -732,6 +732,88 @@ sub startup {
		}
	);

	$self->helper(
		'get_uid_by_name_and_mail' => sub {
			my ( $self, $name, $email ) = @_;

			my $res = $self->pg->db->select(
				'users',
				['id'],
				{
					name   => $name,
					email  => $email,
					status => 1
				}
			);

			if ( my $user = $res->hash ) {
				return $user->{id};
			}
			return;
		}
	);

	$self->helper(
		'mark_for_password_reset' => sub {
			my ( $self, $db, $uid, $token ) = @_;

			my $res = $db->select(
				'pending_passwords',
				'count(*) as count',
				{ user_id => $uid }
			);
			if ( $res->hash->{count} ) {
				return 'in progress';
			}

			$db->insert(
				'pending_passwords',
				{
					user_id => $uid,
					token   => $token,
					requested_at =>
					  DateTime->now( time_zone => 'Europe/Berlin' )
				}
			);

			return undef;
		}
	);

	$self->helper(
		'verify_password_token' => sub {
			my ( $self, $uid, $token ) = @_;

			my $res = $self->pg->db->select(
				'pending_passwords',
				'count(*) as count',
				{
					user_id => $uid,
					token   => $token
				}
			);

			if ( $res->hash->{count} ) {
				return 1;
			}
			return;
		}
	);

	$self->helper(
		'remove_password_token' => sub {
			my ( $self, $uid, $token ) = @_;

			$self->pg->db->delete(
				'pending_passwords',
				{
					user_id => $uid,
					token   => $token
				}
			);
		}
	);

	# This helper should only be called directly when also providing a user ID.
	# If you don't have one, use current_user() instead (get_user_data will
	# delegate to it anyways).
@@ -1530,6 +1612,8 @@ sub startup {
	$r->get('/api/v0/:user_action/:token')->to('api#get_v0');
	$r->get('/api/v1/:user_action/:token')->to('api#get_v1');
	$r->get('/login')->to('account#login_form');
	$r->get('/recover')->to('account#request_password_reset');
	$r->get('/recover/:id/:token')->to('account#recover_password');
	$r->get('/register')->to('account#registration_form');
	$r->get('/reg/:id/:token')->to('account#verify');
	$r->post('/action')->to('traveling#log_action');
@@ -1537,6 +1621,7 @@ sub startup {
	$r->post('/list_departures')->to('traveling#redirect_to_station');
	$r->post('/login')->to('account#do_login');
	$r->post('/register')->to('account#register');
	$r->post('/recover')->to('account#request_password_reset');

	my $authed_r = $r->under(
		sub {
+17 −0
Original line number Diff line number Diff line
@@ -376,6 +376,23 @@ my @migrations = (
			}
		);
	},

	# v6 -> v7
	# Add password_reset table to store data about pending password resets
	sub {
		my ($db) = @_;
		$db->query(
			qq{
				create table pending_passwords (
					user_id integer not null references users (id) primary key,
					token varchar(80) not null,
					requested_at timestamptz not null
				);
				comment on table pending_passwords is 'Password reset tokens';
				update schema_version set version = 7;
			}
		);
	},
);

sub setup_db {
+7 −0
Original line number Diff line number Diff line
@@ -62,6 +62,13 @@ sub run {
		printf( "Pruned unverified user %d\n", $user->{id} );
	}

	my $res = $db->delete( 'pending_passwords',
		{ requested_at => { '<', $verification_deadline } } );

	if ( my $rows = $res->rows ) {
		printf( "Pruned %d pending password reset(s)\n", $rows );
	}

	my $to_delete = $db->select( 'users', ['id'],
		{ deletion_requested => { '<', $deletion_deadline } } );
	my @uids_to_delete = $to_delete->arrays->map( sub { shift->[0] } )->each;
+154 −0
Original line number Diff line number Diff line
@@ -278,6 +278,160 @@ sub change_password {
	$self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body );
}

sub request_password_reset {
	my ($self) = @_;

	if ( $self->param('action') and $self->param('action') eq 'initiate' ) {
		if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
			$self->render( 'recover_password', invalid => 'csrf' );
			return;
		}

		my $name  = $self->param('user');
		my $email = $self->param('email');

		my $uid = $self->get_uid_by_name_and_mail( $name, $email );

		if ( not $uid ) {
			$self->render( 'recover_password', invalid => 'credentials' );
			return;
		}

		my $token = make_token();
		my $db    = $self->pg->db;
		my $tx    = $db->begin;

		my $error = $self->mark_for_password_reset( $db, $uid, $token );

		if ($error) {
			$self->render( 'recover_password', invalid => $error );
			return;
		}

		my $ip   = $self->req->headers->header('X-Forwarded-For');
		my $ua   = $self->req->headers->user_agent;
		my $date = DateTime->now( time_zone => 'Europe/Berlin' )
		  ->strftime('%d.%m.%Y %H:%M:%S %z');

		# In case Mojolicious is not running behind a reverse proxy
		$ip
		  //= sprintf( '%s:%s', $self->tx->remote_address,
			$self->tx->remote_port );
		my $recover_url = $self->url_for('recover')->to_abs->scheme('https');
		my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');

		my $body = "Hallo ${name},\n\n";
		$body .= "Unter ${recover_url}/${uid}/${token}\n";
		$body
		  .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n";
		$body
		  .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n";
		$body
		  .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n";
		$body .= "ausging, kannst du sie ignorieren.\n\n";
		$body .= "Daten zur Anfrage:\n";
		$body .= " * Datum: ${date}\n";
		$body .= " * Client: ${ip}\n";
		$body .= " * UserAgent: ${ua}\n\n\n";
		$body .= "Impressum: ${imprint_url}\n";

		my $success
		  = $self->sendmail->custom( $email, 'travelynx: Neues Passwort',
			$body );

		if ($success) {
			$tx->commit;
			$self->render( 'recover_password', success => 1 );
		}
		else {
			$self->render( 'recover_password', invalid => 'sendmail' );
		}
	}
	elsif ( $self->param('action')
		and $self->param('action') eq 'set_password' )
	{
		my $id        = $self->param('id');
		my $token     = $self->param('token');
		my $password  = $self->param('newpw');
		my $password2 = $self->param('newpw2');

		if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
			$self->render( 'set_password', invalid => 'csrf' );
			return;
		}
		if ( not $self->verify_password_token( $id, $token ) ) {
			$self->render( 'recover_password', invalid => 'token' );
			return;
		}
		if ( $password ne $password2 ) {
			$self->render( 'set_password', invalid => 'password_notequal' );
			return;
		}

		if ( length($password) < 8 ) {
			$self->render( 'set_password', invalid => 'password_short' );
			return;
		}

		my $pw_hash = hash_password($password);
		$self->set_user_password( $id, $pw_hash );

		my $account = $self->get_user_data($id);

		if ( not $self->authenticate( $account->{name}, $password ) ) {
			$self->render( 'set_password',
				invalid => 'Authentication failure – WTF?' );
		}

		$self->redirect_to('account');

		$self->remove_password_token( $id, $token );

		my $user  = $account->{name};
		my $email = $account->{email};
		my $ip    = $self->req->headers->header('X-Forwarded-For');
		my $ua    = $self->req->headers->user_agent;
		my $date  = DateTime->now( time_zone => 'Europe/Berlin' )
		  ->strftime('%d.%m.%Y %H:%M:%S %z');

		# In case Mojolicious is not running behind a reverse proxy
		$ip
		  //= sprintf( '%s:%s', $self->tx->remote_address,
			$self->tx->remote_port );
		my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');

		my $body = "Hallo ${user},\n\n";
		$body
		  .= "Das Passwort deines travelynx-Accounts wurde soeben über die";
		$body .= " 'Passwort vergessen'-Funktion geändert.\n\n";
		$body .= "Daten zur Änderung:\n";
		$body .= " * Datum: ${date}\n";
		$body .= " * Client: ${ip}\n";
		$body .= " * UserAgent: ${ua}\n\n\n";
		$body .= "Impressum: ${imprint_url}\n";

		$self->sendmail->custom( $email, 'travelynx: Passwort geändert',
			$body );
	}
	else {
		$self->render('recover_password');
	}
}

sub recover_password {
	my ($self) = @_;

	my $id    = $self->stash('id');
	my $token = $self->stash('token');

	if ( $self->verify_password_token( $id, $token ) ) {
		$self->render('set_password');
	}
	else {
		$self->render( 'recover_password', invalid => 'token' );
	}
}

sub account {
	my ($self) = @_;

+40 −0
Original line number Diff line number Diff line
@@ -159,5 +159,45 @@ $t->post_ok(
);
$t->status_is(302)->header_is( location => '/' );

$csrf_token = $t->ua->get('/account')->res->dom->at('input[name=csrf_token]')
  ->attr('value');
$t->post_ok(
	'/logout' => form => {
		csrf_token => $csrf_token,
	}
);
$t->status_is(302)->header_is( location => '/login' );

$csrf_token = $t->ua->get('/recover')->res->dom->at('input[name=csrf_token]')
  ->attr('value');
$t->post_ok(
	'/recover' => form => {
		csrf_token => $csrf_token,
		action     => 'initiate',
		user       => 'someone',
		email      => 'foo@example.org',
	}
);
$t->status_is(200)->content_like(qr{wird durchgeführt});

$res = $t->app->pg->db->select( 'pending_passwords', ['token'],
	{ user_id => $uid } );
$token = $res->hash->{token};

$t->get_ok("/recover/${uid}/${token}")->status_is(200)
  ->content_like(qr{Neues Passwort eintragen});

$t->post_ok(
	'/recover' => form => {
		csrf_token => $csrf_token,
		action     => 'set_password',
		id         => $uid,
		token      => $token,
		newpw      => 'foofoofoo2',
		newpw2     => 'foofoofoo2',
	}
);
$t->status_is(302)->header_is( location => '/account' );

$t->app->pg->db->query('drop schema travelynx_test_02 cascade');
done_testing();
Loading