Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions conf/defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions conf/localOverrides.conf.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion lib/WeBWorK.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions lib/WeBWorK/ContentGenerator/ForgotPassword.pm
Original file line number Diff line number Diff line change
@@ -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;
110 changes: 110 additions & 0 deletions lib/WeBWorK/ContentGenerator/ResetPassword.pm
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 5 additions & 3 deletions lib/WeBWorK/DB/Record/Password.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
);
}

Expand Down
32 changes: 28 additions & 4 deletions lib/WeBWorK/Utils/Routes.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-]*/ ] }
},
Expand All @@ -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',
Expand Down
34 changes: 34 additions & 0 deletions templates/ContentGenerator/ForgotPassword.html.ep
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<div class="row">
% if (!$ce->{password_reset_sender_email}) {
<div class="col-xl-5 col-lg-6 col-md-7 col-sm-8">
<div class="alert alert-danger"><%= maketext('Password reset is not enabled for this course.') %></div>
</div>
% } elsif (param('request_password_reset')) {
<div class="col-xl-5 col-lg-6 col-md-7 col-sm-8 my-3">
<div class="alert alert-info">
<%= 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.') %>
</div>
<%= link_to maketext('Login') => 'set_list', class => 'btn btn-primary' =%>
</div>
% } else {
<p>
<%== maketext('Please enter your username for [_1] below to reset your password:', tag('b', $courseID)) %>
</p>
<%= form_for current_route, method => 'POST', begin =%>
<div class="col-xl-5 col-lg-6 col-md-7 col-sm-8 my-3">
<div class="form-floating mb-2">
<%= text_field user => '', required => undef, class => 'form-control', placeholder => '',
autocapitalize => 'none', spellcheck => 'false' =%>
<%= label_for uname => maketext('Username') =%>
</div>
<div class="submit-buttons-container">
<%= submit_button maketext('Request Password Reset'), name => 'request_password_reset',
class => 'btn btn-primary' =%>
<%= link_to maketext('Cancel') => 'set_list', class => 'btn btn-primary' =%>
</div>
</div>
<% end =%>
% }
</div>
7 changes: 6 additions & 1 deletion templates/ContentGenerator/Login.html.ep
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@
<%= label_for rememberme => maketext('Remember Me') =%>
</div>
% }
<%= submit_button(maketext('Continue'), class => 'btn btn-primary') =%>
<div class="submit-buttons-container justify-content-between align-items-center">
<%= submit_button(maketext('Continue'), class => 'btn btn-primary') =%>
% if ($ce->{password_reset_sender_email}) {
<%= link_to 'Forgot Password' => 'forgot_password' =%>
% }
</div>
</div>
%
% # Guest login
Expand Down
Loading