Loading lib/Travelynx.pm +85 −0 Original line number Diff line number Diff line Loading @@ -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). Loading Loading @@ -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'); Loading @@ -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 { Loading lib/Travelynx/Command/database.pm +17 −0 Original line number Diff line number Diff line Loading @@ -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 { Loading lib/Travelynx/Command/maintenance.pm +7 −0 Original line number Diff line number Diff line Loading @@ -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; Loading lib/Travelynx/Controller/Account.pm +154 −0 Original line number Diff line number Diff line Loading @@ -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) = @_; Loading t/02-registration.t +40 −0 Original line number Diff line number Diff line Loading @@ -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
lib/Travelynx.pm +85 −0 Original line number Diff line number Diff line Loading @@ -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). Loading Loading @@ -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'); Loading @@ -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 { Loading
lib/Travelynx/Command/database.pm +17 −0 Original line number Diff line number Diff line Loading @@ -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 { Loading
lib/Travelynx/Command/maintenance.pm +7 −0 Original line number Diff line number Diff line Loading @@ -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; Loading
lib/Travelynx/Controller/Account.pm +154 −0 Original line number Diff line number Diff line Loading @@ -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) = @_; Loading
t/02-registration.t +40 −0 Original line number Diff line number Diff line Loading @@ -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();