diff options
-rwxr-xr-x | lib/Travelynx.pm | 85 | ||||
-rw-r--r-- | lib/Travelynx/Command/database.pm | 17 | ||||
-rw-r--r-- | lib/Travelynx/Command/maintenance.pm | 7 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Account.pm | 154 | ||||
-rw-r--r-- | t/02-registration.t | 40 | ||||
-rw-r--r-- | templates/login.html.ep | 11 | ||||
-rw-r--r-- | templates/recover_password.html.ep | 77 | ||||
-rw-r--r-- | templates/set_password.html.ep | 60 |
8 files changed, 451 insertions, 0 deletions
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm index 1e7c965..01515e9 100755 --- a/lib/Travelynx.pm +++ b/lib/Travelynx.pm @@ -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 { diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm index 393564b..62a470c 100644 --- a/lib/Travelynx/Command/database.pm +++ b/lib/Travelynx/Command/database.pm @@ -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 { diff --git a/lib/Travelynx/Command/maintenance.pm b/lib/Travelynx/Command/maintenance.pm index 3b2462c..b3702b4 100644 --- a/lib/Travelynx/Command/maintenance.pm +++ b/lib/Travelynx/Command/maintenance.pm @@ -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; diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm index 7249ab8..5f06f2d 100644 --- a/lib/Travelynx/Controller/Account.pm +++ b/lib/Travelynx/Controller/Account.pm @@ -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) = @_; diff --git a/t/02-registration.t b/t/02-registration.t index 48d7d4c..89bf475 100644 --- a/t/02-registration.t +++ b/t/02-registration.t @@ -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(); diff --git a/templates/login.html.ep b/templates/login.html.ep index 58ff0ed..283aa60 100644 --- a/templates/login.html.ep +++ b/templates/login.html.ep @@ -102,4 +102,15 @@ <div class="col s3 m3 l3"> </div> </div> + <div class="row" style="margin-top: 2em;"> + <div class="col s3 m3 l3"> + </div> + <div class="col s6 m6 l6 center-align"> + <a href="/recover"> + Passwort vergessen + </a> + </div> + <div class="col s3 m3 l3"> + </div> + </div> %= end diff --git a/templates/recover_password.html.ep b/templates/recover_password.html.ep new file mode 100644 index 0000000..4ebc41a --- /dev/null +++ b/templates/recover_password.html.ep @@ -0,0 +1,77 @@ +<h1>Passwort zurücksetzen</h1> +% if (my $invalid = stash('invalid')) { + <div class="row"> + <div class="col s12"> + <div class="card red darken-4"> + <div class="card-content white-text"> + % if ($invalid eq 'csrf') { + <span class="card-title">Ungültiger CSRF-Token</span> + <p>Sind Cookies aktiviert? Ansonsten könnte es sich um einen + Fall von <a + href="https://de.wikipedia.org/wiki/Cross-Site-Request-Forgery">CSRF</a> + handeln.</p> + % } + % elsif ($invalid eq 'credentials') { + <span class="card-title">Ungültige Daten</span> + <p>Falscher Account oder falsches Mail-Adresse.</p> + % } + % elsif ($invalid eq 'in progress') { + <span class="card-title">Passwort-Reset wird durchgeführt</span> + <p>Es wurde bereits ein Reset-Link verschickt.</p> + % } + % elsif ($invalid eq 'token') { + <span class="card-title">Ungültiger Token</span> + <p>Der Reset-Token ist ungültig oder abgelaufen. Neuen beantragen?</p> + % } + % else { + <span class="card-title">Unbekannter Fehler</span> + <p>„<%= $invalid %>“</p> + % } + </div> + </div> + </div> + </div> +% } +% if (stash('success')) { + <div class="row"> + <div class="col s12"> + <div class="card green darken-4"> + <div class="card-content white-text"> + <span class="card-title">Passwort-Reset wird durchgeführt</span> + <p> + Ein für zwei Tage gültiger Reset-Link wurde an deine + Mail-Adresse verschickt. Sobald du damit ein neues Passwort + gesetzt hast, kannst du dich wieder anmelden. + </p> + </div> + </div> + </div> + </div> +% } +%= form_for '/recover' => (method => 'POST') => begin + %= csrf_field + <div class="row"> + <div class="input-field col s12"> + <i class="material-icons prefix">account_circle</i> + %= text_field 'user', id => 'user', class => 'validate', required => undef, maxlength => 60, autocomplete => 'username' + <label for="user">Account</label> + </div> + <div class="input-field col s12"> + <i class="material-icons prefix">email</i> + %= email_field 'email', id => 'email', class => 'validate', required => undef, maxlength => 250 + <label for="email">Mail-Adresse</label> + </div> + </div> + <div class="row"> + <div class="col s3 m3 l3"> + </div> + <div class="col s6 m6 l6 center-align"> + <button class="btn waves-effect waves-light" type="submit" name="action" value="initiate"> + Passwort-Reset + <i class="material-icons right">send</i> + </button> + </div> + <div class="col s3 m3 l3"> + </div> + </div> +%= end diff --git a/templates/set_password.html.ep b/templates/set_password.html.ep new file mode 100644 index 0000000..571ee0b --- /dev/null +++ b/templates/set_password.html.ep @@ -0,0 +1,60 @@ +% if (my $invalid = stash('invalid')) { + <div class="row"> + <div class="col s12"> + <div class="card red darken-4"> + <div class="card-content white-text"> + % if ($invalid eq 'csrf') { + <span class="card-title">Ungültiger CSRF-Token</span> + <p>Sind Cookies aktiviert? Ansonsten könnte es sich um einen + Fall von <a + href="https://de.wikipedia.org/wiki/Cross-Site-Request-Forgery">CSRF</a> + handeln.</p> + % } + % elsif ($invalid eq 'password_notequal') { + <span class="card-title">Passwort ungültig</span> + <p>Die angegebenen neuen Passwörter sind nicht identisch.</p> + % } + % elsif ($invalid eq 'password_short') { + <span class="card-title">Passwort zu kurz</span> + <p>Das neue Passwort muss mindestens acht Zeichen lang sein.</p> + % } + % else { + <span class="card-title">Unbekannter Fehler</span> + <p>„<%= $invalid %>“</p> + % } + </div> + </div> + </div> + </div> +% } + +<h1>Neues Passwort eintragen</h1> +%= form_for '/recover' => (method => 'POST') => begin + %= csrf_field + %= hidden_field 'id' => param('id') + %= hidden_field 'token' => param('token') + <div class="row"> + <div class="input-field col l6 m12 s12"> + <i class="material-icons prefix">lock</i> + %= password_field 'newpw', id => 'password', class => 'validate', required => undef, minlength => 8, autocomplete => 'new-password' + <label for="password">Neues Passwort</label> + </div> + <div class="input-field col l6 m12 s12"> + <i class="material-icons prefix">lock</i> + %= password_field 'newpw2', id => 'password2', class => 'validate', required => undef, minlength => 8, autocomplete => 'new-password' + <label for="password2">Passwort wiederholen</label> + </div> + </div> + <div class="row"> + <div class="col s3 m3 l3"> + </div> + <div class="col s6 m6 l6 center-align"> + <button class="btn waves-effect waves-light" type="submit" name="action" value="set_password"> + Eintragen + <i class="material-icons right">send</i> + </button> + </div> + <div class="col s3 m3 l3"> + </div> + </div> +%= end |