From ea9ab05b7ae4aafe592d31232d4d33ee8919a2a1 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Mon, 6 Jul 2026 22:21:51 -0500 Subject: [PATCH] Implement a reset password flow. In order to enable the reset password flow, the course environment variable `$password_reset_sender_email` must be set. This can be set per course in the `course.conf` files. If that is set, then a "Forgot Password" link will appear on the course login page to the right of the "Continue" button. When that link is used a "Forgot Password" page is shown. The user will then need to enter their username and click "Request Password Reset". Then an email is sent to the user (assuming the user has a valid email address in the database) containing a "Reset Password" link. A single use token is embedded in the link, and that token expires in 15 minutes. When that link is used a "Reset Password" page is shown. On that page the user must enter a new password, and confirm it by entering it again, and then click "Reset Password". If the token in the link is valid, then the user's password is changed. If two factor authentication is enabled, then when the reset link that is emailed to the user is used, the user must enter a one-time security code as well as a new password in order to reset their password. Also, if the user has not yet set up two factor authentication for their account, then password reset is not allowed for the user. All requests to reset a user's password and the success or failure when using the reset password link are logged to the login log. This is to address issue #2976. --- conf/defaults.config | 5 + conf/localOverrides.conf.dist | 5 + lib/WeBWorK.pm | 6 +- .../ContentGenerator/ForgotPassword.pm | 97 +++++++++++++++ lib/WeBWorK/ContentGenerator/ResetPassword.pm | 110 ++++++++++++++++++ lib/WeBWorK/DB/Record/Password.pm | 8 +- lib/WeBWorK/Utils/Routes.pm | 32 ++++- .../ContentGenerator/ForgotPassword.html.ep | 34 ++++++ templates/ContentGenerator/Login.html.ep | 7 +- .../ContentGenerator/ResetPassword.html.ep | 49 ++++++++ 10 files changed, 344 insertions(+), 9 deletions(-) create mode 100644 lib/WeBWorK/ContentGenerator/ForgotPassword.pm create mode 100644 lib/WeBWorK/ContentGenerator/ResetPassword.pm create mode 100644 templates/ContentGenerator/ForgotPassword.html.ep create mode 100644 templates/ContentGenerator/ResetPassword.html.ep 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 =%> + % } +
+