diff --git a/conf/defaults.config b/conf/defaults.config index d2bbfaf56c..6bd4f18998 100644 --- a/conf/defaults.config +++ b/conf/defaults.config @@ -38,6 +38,11 @@ $feedback_sender_email = ''; # For student feedback $instructor_sender_email = ''; # For instructors emailing students $jitar_sender_email = ''; # For notifications of incomplete JiTaR sets +# This email address will be used for the "From:" address in password reset +# emails. Furthermore, setting this enables the password reset functionality. +# Note that this can be set per course in the course.conf files. +$password_reset_sender_email = ''; + # By default, feedback is sent to all users who have permission to # receive_feedback in a course. If this list is non-empty, feedback is also sent # to the addresses specified here. diff --git a/conf/localOverrides.conf.dist b/conf/localOverrides.conf.dist index 560366ead1..8c72492d7c 100644 --- a/conf/localOverrides.conf.dist +++ b/conf/localOverrides.conf.dist @@ -40,6 +40,11 @@ $feedback_sender_email = ''; # For student feedback $instructor_sender_email = ''; # For instructors emailing students $jitar_sender_email = ''; # For notifications of incomplete JiTaR sets +# This email address will be used for the "From:" address in password reset +# emails. Furthermore, setting this enables the password reset functionality. +# Note that this can be set per course in the course.conf files. +$password_reset_sender_email = ''; + # By default, feedback is sent to all users who have permission to # receive_feedback in a course. If this list is non-empty, feedback is also sent # to the addresses specified here. diff --git a/lib/WeBWorK.pm b/lib/WeBWorK.pm index ce8bb73f74..ae5fc3dd86 100644 --- a/lib/WeBWorK.pm +++ b/lib/WeBWorK.pm @@ -74,7 +74,7 @@ async sub dispatch ($c) { debug("The raw params:\n"); for my $key ($c->param) { - # Make it so we dont debug plain text passwords + # Make it so we do not debug plain text passwords. my $vals; if ($key eq 'passwd' || $key eq 'confirmPassword' @@ -150,6 +150,10 @@ async sub dispatch ($c) { debug("(here's the DB handle: $db)\n"); $c->db($db); + # The "forgot_password" and "reset_password" routes must have a valid courseID and the database, + # but will not be able to authenticate. + return 1 if $c->current_route eq 'forgot_password' || $c->current_route eq 'reset_password'; + if ($authen->verify) { # If this is the first phase of LTI 1.3 authentication, then return so its special content generator # module will render and submit the login repost form. This does not contain the necessary information diff --git a/lib/WeBWorK/ContentGenerator/ForgotPassword.pm b/lib/WeBWorK/ContentGenerator/ForgotPassword.pm new file mode 100644 index 0000000000..51c30aa8cf --- /dev/null +++ b/lib/WeBWorK/ContentGenerator/ForgotPassword.pm @@ -0,0 +1,97 @@ +package WeBWorK::ContentGenerator::ForgotPassword; +use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; + +=head1 NAME + +WeBWorK::ContentGenerator::ForgotPassword - render the forgot password form + +=cut + +use Email::Stuffer; +use Email::Sender::Transport::SMTP; +use Digest::SHA qw(sha256_hex); +use Math::Random::Secure qw(irand); + +use WeBWorK::Debug qw(debug); +use WeBWorK::Utils qw(createEmailSenderTransportSMTP); +use WeBWorK::Utils::Logs qw(writeCourseLog); + +# Override the can method to disable links for the forgot password page. +sub can ($c, $arg) { + return $arg eq 'links' ? 0 : $c->SUPER::can($arg); +} + +sub initialize ($c) { + my $ce = $c->ce; + + $c->stash->{footerWidthClass} = 'col-xl-5 col-lg-6 col-md-7 col-sm-8'; + + return unless $c->param('request_password_reset') && $ce->{password_reset_sender_email}; + + my $user = $c->db->getUser($c->param('user')); + + # Send a password reset email to the user if the user exists, the user has an email address set, the user has the + # permission to change their passworrd, and the user already has a password (the user cannot create a password via + # the password reset flow). + return + unless $user + && $user->email_address =~ /\S/ + && $c->authz->hasPermissions($user->user_id, 'change_password'); + + my $password = $c->db->getPassword($user->user_id); + return unless $password && $password->password; + + my $resetToken = join('', map { [ 0 .. 9, 'a' .. 'z' ]->[ irand(36) ] } 1 .. 64); + $password->reset_token(sha256_hex($resetToken)); + $password->reset_token_expiration(time + 900); + eval { $c->db->putPassword($password) }; + if ($@) { + $c->log->error('Unable to save password reset token to database for user ' . $user->user_id . ": $@"); + return; + } + + my $resetURL = $c->url_for('reset_password')->to_abs->query(user => $user->user_id, token => $resetToken); + + my $email = + Email::Stuffer->to($user->rfc822_mailbox) + ->from($ce->{password_reset_sender_email}) + ->subject($c->maketext('Reset Password')) + ->text_body($c->maketext( + "Use the following link to reset your password: [_1]\n\nThat link will only be valid until [_2].", + $resetURL, $c->formatDateTime($password->reset_token_expiration) + )) + ->html_body($c->tag( + 'html', + $c->tag( + 'body', + $c->c( + $c->tag('div', $c->link_to($c->maketext('Reset password') => $resetURL)), + $c->tag( + 'div', + $c->maketext( + 'That link will only be valid until [_1].', + $c->formatDateTime($password->reset_token_expiration) + ) + ) + )->join('') + ) + ))->header('X-Remote-Host' => $c->tx->remote_address || 'UNKNOWN'); + + eval { + $email->send_or_die({ + transport => createEmailSenderTransportSMTP($ce), + $ce->{mail}{set_return_path} ? (from => $ce->{mail}{set_return_path}) : () + }); + debug 'Successfully sent password reset email to ' . $user->email_address; + }; + if ($@) { + my $exception_message = ref($@) ? $@->message : $@; + $c->log->error('Error sending password reset email to ' . $user->email_address . ": $exception_message"); + } + + writeCourseLog($ce, 'login_log', 'PASSWORD RESET REQUEST user_id=' . $user->user_id); + + return; +} + +1; diff --git a/lib/WeBWorK/ContentGenerator/ResetPassword.pm b/lib/WeBWorK/ContentGenerator/ResetPassword.pm new file mode 100644 index 0000000000..0d1db7ee5c --- /dev/null +++ b/lib/WeBWorK/ContentGenerator/ResetPassword.pm @@ -0,0 +1,110 @@ +package WeBWorK::ContentGenerator::ResetPassword; +use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; + +=head1 NAME + +WeBWorK::ContentGenerator::ResetPassword - reset a users password if requested to do so + +=cut + +use Digest::SHA qw(sha256_hex); + +use WeBWorK::Utils qw(cryptPassword); +use WeBWorK::Utils::Logs qw(writeCourseLog); + +# Override the can method to disable links for the password reset page. +sub can ($c, $arg) { + return $arg eq 'links' ? 0 : $c->SUPER::can($arg); +} + +sub initialize ($c) { + $c->stash->{footerWidthClass} = 'col-xl-6 col-lg-7 col-md-8 col-12'; + $c->stash->{userID} = $c->param('user') // ''; + $c->stash->{errorMessage} = ''; + $c->stash->{entryErrorMessage} = ''; + + unless ($c->ce->{password_reset_sender_email}) { + $c->stash->{errorMessage} = $c->maketext('Password reset is not enabled for this course.'); + return; + } + + my $user = $c->db->getUser($c->stash->{userID}); + my $password = $c->db->getPassword($c->stash->{userID}); + unless ($user + && $password + && $password->password + && $password->reset_token + && $password->reset_token_expiration ne '' + && $password->reset_token_expiration > time + && sha256_hex($c->param('token') // '') eq $password->reset_token) + { + $c->deleteResetToken($password) if $password; + writeCourseLog($c->ce, 'login_log', + 'PASSWORD RESET FAILURE user_id=' . $c->stash->{userID} . ': Invalid user or token.') + if $c->stash->{userID}; + $c->stash->{errorMessage} = $c->maketext('An invalid or expired password reset URL was used.'); + return; + } + + my $twoFactorAuthenticationEnabled = $c->ce->two_factor_authentication_enabled + && $c->authz->hasPermissions($user->user_id, 'use_two_factor_auth'); + + if ($twoFactorAuthenticationEnabled && !$password->otp_secret) { + $c->deleteResetToken($password); + writeCourseLog($c->ce, 'login_log', + 'PASSWORD RESET FAILURE user_id=' . $user->user_id . ': Two factor authentication not enabled.'); + $c->stash->{errorMessage} = $c->maketext('Two factor authentication has not been set up for this account. ' + . 'Password reset is not allowed until that is done.'); + return; + } + + return unless $c->param('reset_password'); + + my $newPassword = $c->param('newPassword'); + unless ($newPassword && $newPassword =~ /\S/) { + $c->stash->{entryErrorMessage} = $c->maketext('You must enter a new password.'); + return; + } + + if ($newPassword ne ($c->param('confirmPassword') // '')) { + $c->stash->{entryErrorMessage} = $c->maketext( + 'The passwords you entered in the "New Password" and "Confirm New Password" fields do not match. ' + . 'Please retype your new password and try again.',); + return; + } + + if ($twoFactorAuthenticationEnabled) { + my $otpCode = ($c->param('otp_code') // '') =~ s/(^\s+|\s+$)//gr; + if (!$otpCode) { + $c->stash->{entryErrorMessage} = + $c->maketext('You must enter the security code from your authenticator app.'); + return; + } + if (!WeBWorK::Utils::TOTP->new(secret => $password->otp_secret, tolerance => 1)->validate_otp($otpCode)) { + $c->stash->{entryErrorMessage} = $c->maketext('You have entered an invalid one-time security code.'); + return; + } + } + + $password->password(cryptPassword($newPassword)); + $password->reset_token(undef); + $password->reset_token_expiration(undef); + eval { $c->db->putPassword($password) }; + if ($@) { + $c->log->error('Error resetting the password for ' . $user->user_id . ": $@"); + $c->stash->{errorMessage} = $c->maketext('Your password was not changed due to an internal error.'); + } + + writeCourseLog($c->ce, 'login_log', 'PASSWORD RESET SUCCESS user_id=' . $user->user_id); + + return; +} + +sub deleteResetToken ($c, $password) { + $password->reset_token(undef); + $password->reset_token_expiration(undef); + eval { $c->db->putPassword($password) }; + return; +} + +1; diff --git a/lib/WeBWorK/DB/Record/Password.pm b/lib/WeBWorK/DB/Record/Password.pm index 5b30ba9a8f..f125e0b23d 100644 --- a/lib/WeBWorK/DB/Record/Password.pm +++ b/lib/WeBWorK/DB/Record/Password.pm @@ -12,9 +12,11 @@ use warnings; BEGIN { __PACKAGE__->_fields( - user_id => { type => "VARCHAR(100) NOT NULL", key => 1 }, - password => { type => "TEXT" }, - otp_secret => { type => "TEXT" } + user_id => { type => "VARCHAR(100) NOT NULL", key => 1 }, + password => { type => "TEXT" }, + otp_secret => { type => "TEXT" }, + reset_token => { type => "TEXT" }, + reset_token_expiration => { type => "BIGINT" }, ); } diff --git a/lib/WeBWorK/Utils/Routes.pm b/lib/WeBWorK/Utils/Routes.pm index d6ba2fd494..6dc6596fa6 100644 --- a/lib/WeBWorK/Utils/Routes.pm +++ b/lib/WeBWorK/Utils/Routes.pm @@ -39,6 +39,8 @@ PLEASE FOR THE LOVE OF GOD UPDATE THIS IF YOU CHANGE THE ROUTES BELOW!!! set_list /$courseID logout /$courseID/logout + forgot_password /$courseID/forgot_password + reset_password /$courseID/reset_password options /$courseID/options grades /$courseID/grades achievements /$courseID/achievements @@ -281,10 +283,22 @@ my %routeParameters = ( set_list => { title => '[_4]', - children => [ - qw(equation_display feedback gateway_quiz proctored_gateway_quiz answer_log grades hardcopy achievements - logout options instructor_tools problem_list) - ], + children => [ qw( + equation_display + feedback + gateway_quiz + proctored_gateway_quiz + answer_log + grades + hardcopy + achievements + logout + forgot_password + reset_password + options + instructor_tools + problem_list + ) ], module => 'ProblemSets', path => { '/#courseID' => [ courseID => qr/[\w-]*/ ] } }, @@ -294,6 +308,16 @@ my %routeParameters = ( module => 'Logout', path => '/logout' }, + forgot_password => { + title => x('Forgot Password'), + module => 'ForgotPassword', + path => '/forgot_password' + }, + reset_password => { + title => x('Reset Password'), + module => 'ResetPassword', + path => '/reset_password' + }, options => { title => x('Account Settings'), module => 'Options', diff --git a/templates/ContentGenerator/ForgotPassword.html.ep b/templates/ContentGenerator/ForgotPassword.html.ep new file mode 100644 index 0000000000..2378e291cd --- /dev/null +++ b/templates/ContentGenerator/ForgotPassword.html.ep @@ -0,0 +1,34 @@ +
+ % if (!$ce->{password_reset_sender_email}) { +
+
<%= maketext('Password reset is not enabled for this course.') %>
+
+ % } elsif (param('request_password_reset')) { +
+
+ <%= maketext('If the username that was entered is valid, that user has a valid email address set, ' + . 'and that user has permission to change their password, then a reset link has been emailed ' + . 'to that address. That link will only be valid for the next 15 minutes.') %> +
+ <%= link_to maketext('Login') => 'set_list', class => 'btn btn-primary' =%> +
+ % } else { +

+ <%== maketext('Please enter your username for [_1] below to reset your password:', tag('b', $courseID)) %> +

+ <%= form_for current_route, method => 'POST', begin =%> +
+
+ <%= text_field user => '', required => undef, class => 'form-control', placeholder => '', + autocapitalize => 'none', spellcheck => 'false' =%> + <%= label_for uname => maketext('Username') =%> +
+
+ <%= submit_button maketext('Request Password Reset'), name => 'request_password_reset', + class => 'btn btn-primary' =%> + <%= link_to maketext('Cancel') => 'set_list', class => 'btn btn-primary' =%> +
+
+ <% end =%> + % } +
diff --git a/templates/ContentGenerator/Login.html.ep b/templates/ContentGenerator/Login.html.ep index 9031aacee5..e32a03df7e 100644 --- a/templates/ContentGenerator/Login.html.ep +++ b/templates/ContentGenerator/Login.html.ep @@ -69,7 +69,12 @@ <%= label_for rememberme => maketext('Remember Me') =%> % } - <%= submit_button(maketext('Continue'), class => 'btn btn-primary') =%> +
+ <%= submit_button(maketext('Continue'), class => 'btn btn-primary') =%> + % if ($ce->{password_reset_sender_email}) { + <%= link_to 'Forgot Password' => 'forgot_password' =%> + % } +
% % # Guest login diff --git a/templates/ContentGenerator/ResetPassword.html.ep b/templates/ContentGenerator/ResetPassword.html.ep new file mode 100644 index 0000000000..2f73994848 --- /dev/null +++ b/templates/ContentGenerator/ResetPassword.html.ep @@ -0,0 +1,49 @@ +
+
+ % if ($errorMessage || $entryErrorMessage) { +
<%= $errorMessage || $entryErrorMessage %>
+ % } + % if ($errorMessage) { + % # The error message is shown above. Nothing else should be shown in this case. + % } elsif (!$entryErrorMessage && param('reset_password')) { +
<%= maketext('Your password has been reset.') %>
+ <%= link_to maketext('Login') => 'set_list', class => 'btn btn-primary' =%> + % } else { + <%= form_for current_route, method => 'POST', begin =%> + <%= hidden_field user => $userID =%> + <%= hidden_field token => param('token') =%> +

<%= maketext('Please enter a new password.') %>

+
+ <%= label_for newPassword => maketext("New Password"), + class => 'col-form-label col-sm-6' =%> +
+ <%= password_field 'newPassword', id => 'newPassword', class => 'form-control', + dir => 'ltr', autocomplete => 'new-password', required => undef =%> +
+
+
+ <%= label_for confirmPassword => maketext("Confirm New Password"), + class => 'col-form-label col-sm-6' =%> +
+ <%= password_field 'confirmPassword', id => 'confirmPassword', class => 'form-control', + dir => 'ltr', autocomplete => 'new-password', required => undef =%> +
+
+ % if ($ce->two_factor_authentication_enabled + % && $authz->hasPermissions($userID, 'use_two_factor_auth')) { +
+ <%= label_for confirmPassword => + maketext("One-time code from authenticator app"), + class => 'col-form-label col-sm-6' =%> +
+ <%= text_field otp_code => '', id => 'otp_code', class => 'form-control', + autocomplete => 'off', autocapitalize => 'none', spellcheck => 'false', + required => undef =%> +
+
+ % } + <%= submit_button maketext('Reset Password'), name => 'reset_password', class => 'btn btn-primary' =%> + <% end =%> + % } +
+