diff --git a/Dockerfile b/Dockerfile index cbbbcb90bc..25d6d8df67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,6 +99,7 @@ RUN apt-get update \ libfile-find-rule-perl-perl \ libfile-sharedir-install-perl \ libfuture-asyncawait-perl \ + libgd-barcode-perl \ libgd-perl \ libhtml-scrubber-perl \ libhtml-template-perl \ @@ -113,6 +114,7 @@ RUN apt-get update \ libmail-sender-perl \ libmariadb-dev \ libmath-random-secure-perl \ + libmime-base32-perl \ libmime-tools-perl \ libminion-backend-sqlite-perl \ libminion-perl \ diff --git a/DockerfileStage1 b/DockerfileStage1 index 19f17bdc96..67cc4e8b71 100644 --- a/DockerfileStage1 +++ b/DockerfileStage1 @@ -61,6 +61,7 @@ RUN apt-get update \ libfile-find-rule-perl-perl \ libfile-sharedir-install-perl \ libfuture-asyncawait-perl \ + libgd-barcode-perl \ libgd-perl \ libhtml-scrubber-perl \ libhtml-template-perl \ @@ -75,6 +76,7 @@ RUN apt-get update \ libmail-sender-perl \ libmariadb-dev \ libmath-random-secure-perl \ + libmime-base32-perl \ libmime-tools-perl \ libminion-backend-sqlite-perl \ libminion-perl \ diff --git a/bin/check_modules.pl b/bin/check_modules.pl index 4ef30ae89e..c7dc65f069 100755 --- a/bin/check_modules.pl +++ b/bin/check_modules.pl @@ -102,6 +102,7 @@ =head1 DESCRIPTION File::Temp Future::AsyncAwait GD + GD::Barcode::QRcode Getopt::Long Getopt::Std HTML::Entities @@ -117,6 +118,7 @@ =head1 DESCRIPTION Locale::Maketext::Lexicon Locale::Maketext::Simple LWP::Protocol::https + MIME::Base32 MIME::Base64 Math::Random::Secure Minion diff --git a/bin/reset2fa b/bin/reset2fa new file mode 100644 index 0000000000..a440ace56d --- /dev/null +++ b/bin/reset2fa @@ -0,0 +1,19 @@ +warn "Pass users as additional arguments on the command line.\n" + . "Usage: wwsh $ce->{courseName} /opt/webwork/webwork2/bin/reset2fa [users]\n" + unless @ARGV; + +for (@ARGV) { + my $password = eval { $db->getPassword($_) }; + if ($@) { + warn "Unable to retrieve password record for $_ from the database: $@\n"; + next; + } + + $password->otp_secret(''); + eval { $db->putPassword($password) }; + if ($@) { + warn "Unable to reset two factor authentication secret for $_: $@\n"; + } else { + print "Successfully reset two factor authentication for $_.\n"; + } +} diff --git a/conf/defaults.config b/conf/defaults.config index b65958fbda..6936fc6a04 100644 --- a/conf/defaults.config +++ b/conf/defaults.config @@ -772,6 +772,7 @@ $authen{admin_module} = ['WeBWorK::Authen::Basic_TheLastOption']; %permissionLevels = ( login => "guest", navigation_allowed => "guest", + use_two_factor_auth => "student", report_bugs => "ta", submit_feedback => "student", change_password => "student", @@ -944,6 +945,66 @@ $CookieSecure = 0; # when the browser session ends. The default value is 7 days. $CookieLifeTime = 604800; +################################################################################ +# Two Factor Authentication +################################################################################ + +# The following variables enable two factor authentication and control how it +# works. Two factor authentication only applies to courses that use password +# authentication, i.e., the Basic_TheLastOption user authentication module +# without an external authentication approach (like LTI, CAS, Shibboleth, etc.). +# It is recommended that two factor authentication be enabled for all courses +# that use password authentication. It is extremely highly recommended that this +# be enabled for the admin course. Two factor authentication works with an +# authenticator app on a mobile device (such as Google Authenticator, +# Microsoft authenticator, Twilio Authy, etc.). + +# $twoFA{enabled} determines if two factor authentication is enabled for a +# course. If this is set to 0, then two factor authentication is disabled for +# all courses. If this is 1 (the default), then two factor authentication is +# enabled for all courses that use password authentication. If this is a string +# course name like 'admin', then two factor authentication is enabled only for +# that course. If this is an array of string course names, then two factor +# authentication is enabled only for those courses listed. This can also be set +# in a course's course.conf file. Note that only the values of 0 and 1 make +# sense there. +$twoFA{enabled} = 1; + +# There are two methods that can be used to setup two factor authentication when +# a user signs in for the first time. The setup information can be emailed to +# the user, or can be directly displayed in the browser on the next page that is +# shown after password verification succeeds. +# +# If $twoFA{email_sender} is set, then the email approach will be used. In this +# case, after a user signs in and the password is verified, the user will be +# sent an email containing a QR code and instructions on how to set up a OTP +# generator app. This is probably a more secure way to set up two factor +# authentication, as it ensures the user setting it up is the correct user. Note +# that if a user does not have an email address, then the browser method below +# will be used as a fallback. +# +# If $twoFA{email_sender} is not set, then after a user signs in and the +# password is verified, the QR code, OTP link, and instructions will be +# displayed directly on the page in the browser. This is potentially less secure +# because a hacker could guess a username and password before a user has setup +# two factor authentication (particularly if the username and password are +# initially the same), and then the hacker would gain access to that user's +# account, and the actual user would be locked out. Note that you will need to +# use this option if your server can not send emails. Also note that no-reply +# addresses may be blocked by the email server or marked as spam. So it may be +# better to find a valid email address to use for this. +$twoFA{email_sender} = ''; + +# When a user signs in and enters the two factor authentication code, the user +# has the option to skip two factor verification on a given device for +# subsequent logins. That will only last for the amount of time set as the +# skip_verification_code_interval. By default this is set to one year. However, +# good security practices most likely recommend a shorter time interval for +# this. So change this value if you want to require a shorter and thus more +# secure time interval before users will need to enter the two factor +# authentication code again. +$twoFA{skip_verification_code_interval} = 3600 * 24 * 365; + ################################################################################ # WeBWorK Caliper ################################################################################ diff --git a/conf/localOverrides.conf.dist b/conf/localOverrides.conf.dist index 0604d1f5b2..d3e2e5c529 100644 --- a/conf/localOverrides.conf.dist +++ b/conf/localOverrides.conf.dist @@ -220,7 +220,7 @@ $mail{feedbackRecipients} = [ # $permissionLevels{login} = "guest"; # The above code would give the permission to login to any user with permission -# level guest or higher. +# level guest or higher (which is the default). # By default answers for all users are logged to the past_answers table in the database # and the myCourse/logs/answer_log file. If you only want answers logged for users below @@ -574,6 +574,75 @@ $mail{feedbackRecipients} = [ #$CookieLifeTime = 604800; #$CookieLifeTime = "session"; +################################################################################ +# Two Factor Authentication +################################################################################ + +# The following variables enable two factor authentication and control how it +# works. Two factor authentication only applies to courses that use password +# authentication, i.e., the Basic_TheLastOption user authentication module +# without an external authentication approach (like LTI, CAS, Shibboleth, etc.). +# It is recommended that two factor authentication be enabled for all courses +# that use password authentication. It is extremely highly recommended that this +# be enabled for the admin course. Two factor authentication works with an +# authenticator app on a mobile device (such as Google Authenticator, +# Microsoft authenticator, Twilio Authy, etc.). + +# $twoFA{enabled} determines if two factor authentication is enabled for a +# course. If this is set to 0, then two factor authentication is disabled for +# all courses. If this is 1 (the default), then two factor authentication is +# enabled for all courses that use password authentication. If this is a string +# course name like 'admin', then two factor authentication is enabled only for +# that course. If this is an array of string course names, then two factor +# authentication is enabled only for those courses listed. This can also be set +# in a course's course.conf file. Note that only the values of 0 and 1 make +# sense there. +#$twoFA{enabled} = $admin_course_id; # Use this at the very least. +#$twoFA{enabled} = [$admin_course_id, 'another_courseID', 'another_courseID_3']; + +# There are two methods that can be used to setup two factor authentication when +# a user signs in for the first time. The setup information can be emailed to +# the user, or can be directly displayed in the browser on the next page that is +# shown after password verification succeeds. +# +# If $twoFA{email_sender} is set, then the email approach will be used. In this +# case, after a user signs in and the password is verified, the user will be +# sent an email containing a QR code and instructions on how to set up a OTP +# generator app. This is probably a more secure way to set up two factor +# authentication, as it ensures the user setting it up is the correct user. Note +# that if a user does not have an email address, then the browser method below +# will be used as a fallback. +# +# If $twoFA{email_sender} is not set, then after a user signs in and the +# password is verified, the QR code, OTP link, and instructions will be +# displayed directly on the page in the browser. This is potentially less secure +# because a hacker could guess a username and password before a user has setup +# two factor authentication (particularly if the username and password are +# initially the same), and then the hacker would gain access to that user's +# account, and the actual user would be locked out. Note that you will need to +# use this option if your server can not send emails. Also note that no-reply +# addresses may be blocked by the email server or marked as spam. So it may be +# better to find a valid email address to use for this. +#$twoFA{email_sender} = 'noreply@your.school.edu'; + +# When a user signs in and enters the two factor authentication code, the user +# has the option to skip two factor verification on a given device for +# subsequent logins. That will only last for the amount of time set as the +# skip_verification_code_interval. By default this is set to one year. However, +# good security practices most likely recommend a shorter time interval for +# this. So change this value if you want to require a shorter and thus more +# secure time interval before users will need to enter the two factor +# authentication code again. +#$twoFA{skip_verification_code_interval} = 3600 * 24 * 7; + +# By default all users with the role of "student" or higher are required to use +# two factor authentication when signing in with a username and password. If +# you want to disable two factor authentication for students, but require it for +# instructors then set the permission level below to "login_proctor" (or +# higher). + +#$permissionLevels{use_two_factor_auth} = "login_proctor"; + ################################################################################ # Searching for set.def files to import ################################################################################ diff --git a/conf/webwork2.mojolicious.dist.yml b/conf/webwork2.mojolicious.dist.yml index cb4397297e..106f36b57b 100644 --- a/conf/webwork2.mojolicious.dist.yml +++ b/conf/webwork2.mojolicious.dist.yml @@ -239,3 +239,10 @@ debug: hardcopy: # If 1, don't delete temporary files created when a hardcopy is generated. preserve_temp_files: 0 + +# Set this to 1 to allow the html2xml and render_rpc endpoints to disable +# cookies and thus skip two factor authentication. This should never be enabled +# for a typical webwork server. This should only be enabled if you want to +# allow serving content via these endpoints to links in external websites with +# usernames and passwords embedded in them such as for PreTeXt textbooks. +allow_unsecured_rpc: 0 diff --git a/htdocs/js/UserList/userlist.js b/htdocs/js/UserList/userlist.js index 7065a5006d..0b96d0dc36 100644 --- a/htdocs/js/UserList/userlist.js +++ b/htdocs/js/UserList/userlist.js @@ -118,15 +118,15 @@ e.stopPropagation(); show_errors(['export_file_err_msg'], [export_filename, export_select_target]); } - } else if (action === 'delete') { - const delete_confirm = document.getElementById('delete_select'); + } else if (action === 'delete' || action === 'reset_2fa') { + const action_confirm = document.getElementById(`${action}_select`); if (!is_user_selected()) { e.preventDefault(); e.stopPropagation(); - } else if (delete_confirm.value != 'yes') { + } else if (action_confirm.value != 'yes') { e.preventDefault(); e.stopPropagation(); - show_errors(['delete_confirm_err_msg'], [delete_confirm]); + show_errors([`${action}_confirm_err_msg`], [action_confirm]); } } }); diff --git a/lib/WeBWorK.pm b/lib/WeBWorK.pm index 5fcce651e7..c9c8480880 100644 --- a/lib/WeBWorK.pm +++ b/lib/WeBWorK.pm @@ -48,6 +48,7 @@ use WeBWorK::Debug; use WeBWorK::Upload; use WeBWorK::Utils qw(runtime_use); use WeBWorK::ContentGenerator::Login; +use WeBWorK::ContentGenerator::TwoFactorAuthentication; use WeBWorK::ContentGenerator::LoginProctor; our %SeedCE; @@ -90,12 +91,13 @@ async sub dispatch ($c) { if ($c->current_route =~ /^(render_rpc|instructor_rpc|html2xml)$/) { $c->{rpc} = 1; - $c->stash(disable_cookies => 1) if $c->current_route eq 'render_rpc' && $c->param('disableCookies'); + $c->stash(disable_cookies => 1) + if $c->current_route eq 'render_rpc' && $c->param('disableCookies') && $c->config('allow_unsecured_rpc'); # This provides compatibility for legacy html2xml parameters. # This should be deleted when the html2xml endpoint is removed. if ($c->current_route eq 'html2xml') { - $c->stash(disable_cookies => 1); + $c->stash(disable_cookies => 1) if $c->config('allow_unsecured_rpc'); for ([ 'userID', 'user' ], [ 'course_password', 'passwd' ], [ 'session_key', 'key' ]) { $c->param($_->[1], $c->param($_->[0])) if defined $c->param($_->[0]) && !defined $c->param($_->[1]); } @@ -268,9 +270,15 @@ async sub dispatch ($c) { # If the user is logging out and authentication failed, still logout. return 1 if $displayModule eq 'WeBWorK::ContentGenerator::Logout'; - debug("Bad news: authentication failed!\n"); - debug("Rendering WeBWorK::ContentGenerator::Login\n"); - await WeBWorK::ContentGenerator::Login->new($c)->go(); + if ($c->authen->session->{two_factor_verification_needed}) { + debug("Login succeeded but two factor authentication is needed.\n"); + debug("Rendering WeBWorK::ContentGenerator::TwoFactorAuthentication\n"); + await WeBWorK::ContentGenerator::TwoFactorAuthentication->new($c)->go(); + } else { + debug("Bad news: authentication failed!\n"); + debug("Rendering WeBWorK::ContentGenerator::Login\n"); + await WeBWorK::ContentGenerator::Login->new($c)->go(); + } return 0; } } diff --git a/lib/WeBWorK/Authen.pm b/lib/WeBWorK/Authen.pm index ff9cd2de24..e56e4d0bef 100644 --- a/lib/WeBWorK/Authen.pm +++ b/lib/WeBWorK/Authen.pm @@ -52,9 +52,11 @@ use warnings; use Date::Format; use Scalar::Util qw(weaken); +use Mojo::Util qw(b64_encode b64_decode); use WeBWorK::Debug; use WeBWorK::Utils qw(x writeCourseLog runtime_use); +use WeBWorK::Utils::TOTP; use WeBWorK::Localize; use Caliper::Sensor; use Caliper::Entity; @@ -167,9 +169,47 @@ sub verify { $self->{was_verified} = $self->do_verify; - $self->site_fixup if $self->can('site_fixup'); + my $remember_2fa = $c->signed_cookie('WeBWorK.2FA.' . $c->ce->{courseName}); - if ($self->{was_verified}) { + if ($self->{was_verified} + && $self->{login_type} eq 'normal' + && !$self->{external_auth} + && (!$c->{rpc} || ($c->{rpc} && !$c->stash->{disable_cookies})) + && $remember_2fa + && b64_decode($remember_2fa) eq $self->{user_id} + && !$c->db->getPassword($self->{user_id})->otp_secret) + { + # If there is not a otp secret saved in the database, and there is a cookie saved to skip two factor + # authentication, then delete it. The user needs to set up two factor authentication again. + $c->signed_cookie( + 'WeBWorK.2FA.' . $c->ce->{courseName} => 0, + { + max_age => 0, + expires => 1, + path => $c->ce->{webworkURLRoot}, + samesite => $c->ce->{CookieSameSite}, + secure => $c->ce->{CookieSecure}, + httponly => 1 + } + ); + $remember_2fa = 0; + } + + if ($self->{was_verified} + && $self->{login_type} eq 'normal' + && !$self->{external_auth} + && (!$c->{rpc} || ($c->{rpc} && !$c->stash->{disable_cookies})) + && $c->ce->two_factor_authentication_enabled + && $c->authz->hasPermissions($self->{user_id}, 'use_two_factor_auth') + && ($self->{initial_login} || $self->session->{two_factor_verification_needed}) + && (!$remember_2fa || b64_decode($remember_2fa) ne $self->{user_id})) + { + $self->{was_verified} = 0; + $self->session(two_factor_verification_needed => 1); + $self->maybe_send_cookie; + $self->set_params; + } elsif ($self->{was_verified}) { + $self->site_fixup if $self->can('site_fixup'); $self->write_log_entry("LOGIN OK") if $self->{initial_login}; $self->maybe_send_cookie; $self->set_params; @@ -431,10 +471,61 @@ sub verify_normal_user { debug("sessionExists='", $sessionExists, "' keyMatches='", $keyMatches, "' timestampValid='", $timestampValid, "'"); if ($sessionExists && $keyMatches && $timestampValid) { + if ($self->session->{two_factor_verification_needed}) { + if ($c->param('cancel_otp_verification') || !$c->param('verify_otp')) { + delete $self->session->{two_factor_verification_needed}; + delete $c->stash->{'webwork2.database_session'}; + return 0; + } + # All of the below falls through to below and returns 1. That only lets the user into the course once + # two_factor_verification_needed is deleted from the session. + my $otp_code = trim($c->param('otp_code')); + if (defined $otp_code && $otp_code ne '') { + my $password = $c->db->getPassword($user_id); + if ( + WeBWorK::Utils::TOTP->new( + secret => $self->session->{otp_secret} // $password->otp_secret, + tolerance => 1 + )->validate_otp($otp_code) + ) + { + delete $self->session->{two_factor_verification_needed}; + + # Store a cookie that signifies this devices skips two factor + # authentication if the skip_2fa checkbox was checked. + $c->signed_cookie( + 'WeBWorK.2FA.' . $c->ce->{courseName} => b64_encode($user_id) =~ s/\n//gr, + { + max_age => $c->ce->{twoFA}{skip_verification_code_interval}, + expires => time + $c->ce->{twoFA}{skip_verification_code_interval}, + path => $c->ce->{webworkURLRoot}, + samesite => $c->ce->{CookieSameSite}, + secure => $c->ce->{CookieSecure}, + httponly => 1 + } + ) if $c->param('skip_2fa'); + + # This is the case of initial setup. Save the secret from the session to the database. + if ($self->session->{otp_secret}) { + $password->otp_secret($self->session->{otp_secret}); + $c->db->putPassword($password); + delete $self->session->{otp_secret}; + } + } else { + $c->stash(authen_error => $c->maketext('Invalid security code.')); + } + } else { + $c->stash(authen_error => $c->maketext('The security code is required.')); + } + } return 1; } else { my $auth_result = $self->authenticate; + # Don't try to obtain two factor verification in this case! Two factor authentication can only be done with an + # existing session. This can still be set if a session times out, for example. + delete $self->session->{two_factor_verification_needed}; + if ($auth_result > 0) { # Deny certain roles (dropped students, proctor roles). unless ($self->{login_type} =~ /^proctor/ diff --git a/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm b/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm index 576db42b9e..efea0f392d 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm @@ -70,7 +70,7 @@ use WeBWorK::Utils::Instructor qw(getCSVList); use constant HIDE_USERS_THRESHHOLD => 200; use constant EDIT_FORMS => [qw(save_edit cancel_edit)]; use constant PASSWORD_FORMS => [qw(save_password cancel_password)]; -use constant VIEW_FORMS => [qw(filter sort edit password import export add delete)]; +use constant VIEW_FORMS => [qw(filter sort edit password import export add delete reset_2fa)]; # Prepare the tab titles for translation by maketext use constant FORM_TITLES => { @@ -84,6 +84,7 @@ use constant FORM_TITLES => { export => x('Export'), add => x('Add'), delete => x('Delete'), + reset_2fa => x('Reset Two Factor Authentication'), save_password => x('Save Password'), cancel_password => x('Cancel Password') }; @@ -94,6 +95,7 @@ use constant FORM_PERMS => { edit => 'modify_student_data', save_password => 'change_password', password => 'change_password', + reset2_2fa => 'change_password', import => 'modify_student_data', export => 'modify_classlist_files', add => 'modify_student_data', @@ -479,6 +481,39 @@ sub export_handler ($c) { return $c->maketext('[_1] users exported to file [_2]', scalar @userIDsToExport, "$dir/$fileName"); } +sub reset_2fa_handler ($c) { + my $db = $c->db; + my $user = $c->param('user'); + + my $confirm = $c->param('action.reset_2fa.confirm'); + my $num = 0; + + return $c->maketext('Reset two factor authentication for [_1] users.', $num) unless $confirm eq 'yes'; + + # grep on userIsEditable would still enforce permissions, but no UI feedback + my @userIDsForReset = keys %{ $c->{selectedUserIDs} }; + + my @resultText; + for my $userID (@userIDsForReset) { + if ($userID eq $user) { + push @resultText, $c->maketext('You cannot reset two factor authentication for yourself!'); + next; + } + + unless ($c->{userIsEditable}{$userID}) { + push @resultText, $c->maketext('You are not allowed to reset two factor authenticatio for [_1].', $userID); + next; + } + my $password = $db->getPassword($userID); + $password->otp_secret(''); + $db->putPassword($password); + $num++; + } + + unshift @resultText, $c->maketext('Reset two factor authentication for [quant,_1,user].', $num); + return join(' ', @resultText); +} + sub cancel_edit_handler ($c) { if (defined $c->param('prev_visible_users')) { $c->{visibleUserIDs} = { map { $_ => 1 } @{ $c->every_param('prev_visible_users') } }; diff --git a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm new file mode 100644 index 0000000000..3e52db92b1 --- /dev/null +++ b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm @@ -0,0 +1,142 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +package WeBWorK::ContentGenerator::TwoFactorAuthentication; +use Mojo::Base 'WeBWorK::ContentGenerator::Login', -signatures; + +=head1 NAME + +WeBWorK::ContentGenerator::TwoFactorAuthentication - display the two factor authentication form. + +=cut + +use GD::Image; # Needed since GD::Barcode::QRcode calls GD::Image->new without loading GD::Image. +use GD::Barcode::QRcode; +use Email::Stuffer; +use Mojo::Util qw(b64_encode); + +use WeBWorK::Utils::TOTP; +use WeBWorK::Utils qw(createEmailSenderTransportSMTP); + +sub pre_header_initialize ($c) { + my $ce = $c->ce; + + # Preserve the form data posted to the requested URI + my @fields_to_print = + grep { !m/^(user|passwd|key|force_passwd_authen|otp_code|verify_otp|cancel_otp_verification)$/ } $c->param; + push(@fields_to_print, 'user', 'key') if $ce->{session_management_via} ne 'session_cookie'; + $c->stash->{hidden_fields} = @fields_to_print ? $c->hidden_fields(@fields_to_print) : ''; + + # Make sure these are defined for the template. + $c->stash->{otp_link} = ''; + $c->stash->{otp_qrcode} = ''; + $c->stash->{authen_error} //= ''; + + # Note that this user has already authenticated with username and password, + # so this and the $user below should exist. + my $password = $c->db->getPassword($c->authen->{user_id}); + + if (!$password->otp_secret) { + my $totp = + WeBWorK::Utils::TOTP->new( + $c->authen->session->{otp_secret} ? (secret => $c->authen->session->{otp_secret}) : ()); + $c->authen->session(otp_secret => $totp->secret); + + my $otp_link = $totp->generate_otp($c->authen->{user_id}, $c->url_for('set_list')->to_abs =~ s|https?://||r); + + my $img_data = do { + local $SIG{__WARN__} = sub { }; + GD::Barcode::QRcode->new($otp_link, { Ecc => 'L', ModuleSize => 4, Version => 0 })->plot->png; + }; + + my $user = $c->db->getUser($c->authen->{user_id}); + + if ($ce->{twoFA}{email_sender} && (my $recipient = $user->email_address)) { + return if $c->authen->session->{otp_setup_email_sent}; + + # Ideally this could include the OTP link used to generate the QR code. Then on a mobile device that link + # could be clicked on to add the account to an authenticator app (as is done on the template if this is + # shown in the browser), since you can't scan the QR code if viewing this email on that device. However, + # gmail (and probably other email providers as well) strips any links with hrefs that don't start with + # http[s]://. The otpauth:// protocol of course does not. + my $mail = + Email::Stuffer->to($recipient)->from($ce->{twoFA}{email_sender}) + ->subject($c->maketext('Setup One-Time Password Authentication'))->html_body( + 'output_course_lang_and_dir + . '>' + . $c->c( + $c->tag( + 'p', + $c->maketext( + 'To set up one-time password generation, scan the attached QR code with an ' + . 'authenticator app (such as Google Authenticator, Microsoft Authenticator, ' + . 'Twilio Authy, etc.) installed on a mobile device.' + ) + ), + $c->tag( + 'div', + style => 'text-align:center', + $c->image('cid:logo_qrcode', alt => $c->maketext('One-time password setup QR code')) + ), + $c->tag( + 'p', + $c->maketext( + 'Once the authenticator app is set up, return to the login page in WeBWorK and ' + . 'enter the code it shows. Remember that the attached QR code is only valid as ' + . 'long as the page that you were visiting when this email was sent is still open.' + ) + ), + $c->tag( + 'p', + $c->maketext( + 'This email should be deleted once you have completely signed in the first time.') + ) + )->join('')->to_string + . '' + )->attach($img_data, filename => 'QRCode.png') + ->header('X-Remote-Host' => $c->tx->remote_address || 'UNKNOWN') + ->transport(createEmailSenderTransportSMTP($ce)); + + # In order to show the image directly in the email, the content type needs to be multipart/related. The + # attached image also needs a content id. Gmail seems to refuse to accept the email if that content id does + # not start with "logo". + $mail->header('Content-Type' => 'multipart/related'); + ($mail->parts)[1]->header_str_set('Content-Id' => ''); + + eval { $mail->send_or_die({ + $ce->{mail}{set_return_path} ? (from => $ce->{mail}{set_return_path}) : () }); }; + + if ($@) { + $c->log->error('The following error occured while attempting to send the one-time password ' + . 'generation setup email for "' + . $c->authen->{user_id} . '":' + . ref($@) ? $@->message : $@); + $c->log->error('The user will be shown the information directly in the web page.'); + $c->stash->{otp_link} = $otp_link; + $c->stash->{otp_qrcode} = $img_data; + } else { + $c->authen->session->{otp_setup_email_sent} = 1; + } + } else { + $c->stash->{otp_link} = $otp_link; + $c->stash->{otp_qrcode} = $img_data; + } + } + + return; +} + +1; diff --git a/lib/WeBWorK/CourseEnvironment.pm b/lib/WeBWorK/CourseEnvironment.pm index 98d977ddb9..3cd8c1723b 100644 --- a/lib/WeBWorK/CourseEnvironment.pm +++ b/lib/WeBWorK/CourseEnvironment.pm @@ -254,28 +254,16 @@ sub new { =head1 ACCESS -There are no formal accessor methods. However, since the course environemnt is -a hash of hashes and arrays, is exists as the self hash of an instance -variable: +The course environment is a hash and variables in the course environment can be +accessed via its hash keys. For example: - $ce->{someKey}{someOtherKey}; + $ce->{someKey}{someOtherKey}; -=head1 EXPERIMENTAL ACCESS METHODS +=head1 METHODS -This is an experiment in extending CourseEnvironment to know a little more about -its contents, and perform useful operations for me. +=head2 status_abbrev_to_name -There is a set of operations that require certain data from the course -environment. Most of these are un Utils.pm. I've been forced to pass $ce into -them, so that they can get their data out. But some things are so intrinsically -linked to the course environment that they might as well be methods in this -class. - -=head2 STATUS METHODS - -=over - -=item status_abbrev_to_name($status_abbrev) +Usage: C<< $ce->status_abbrev_to_name($status_abbrev) >> Given the abbreviation for a status, return the name. Returns undef if the abbreviation is not found. @@ -292,7 +280,9 @@ sub status_abbrev_to_name { return $ce->{_status_abbrev_to_name}{$status_abbrev}; } -=item status_name_to_abbrevs($status_name) +=head2 status_name_to_abbrevs + +Usage: C<< $ce->status_name_to_abbrevs($status_name) >> Returns the list of abbreviations for a given status. Returns an empty list if the status is not found. @@ -310,7 +300,9 @@ sub status_name_to_abbrevs { return @{ $ce->{statuses}{$status_name}{abbrevs} }; } -=item status_has_behavior($status_name, $behavior) +=head2 status_has_behavior + +Usage: C<< $ce->status_has_behavior($status_name, $behavior) >> Return true if $status_name lists $behavior. @@ -340,7 +332,9 @@ sub status_has_behavior { } } -=item status_abbrev_has_behavior($status_abbrev, $behavior) +=head2 status_abbrev_has_behavior + +Usage: C<< status_abbrev_has_behavior($status_abbrev, $behavior) >> Return true if the status abbreviated by $status_abbrev lists $behavior. @@ -365,10 +359,21 @@ sub status_abbrev_has_behavior { } } -=back +=head2 two_factor_authentication_enabled + +Usage: C<< $ce->two_factor_authentication_enabled >> + +Returns true if two factor authentication is enabled for this course. =cut -1; +sub two_factor_authentication_enabled { + my $ce = shift; + return 0 if $ce->{external_auth}; + return grep { $_ eq $ce->{courseName} } @{ $ce->{twoFA}{enabled} } if (ref($ce->{twoFA}{enabled}) eq 'ARRAY'); + return 1 if $ce->{twoFA}{enabled} ^ $ce->{twoFA}{enabled} && $ce->{courseName} eq $ce->{twoFA}{enabled}; + return 0 if $ce->{twoFA}{enabled} ^ $ce->{twoFA}{enabled}; + return $ce->{twoFA}{enabled}; +} -# perl doesn't look like line noise. line noise has way more alphanumerics. +1; diff --git a/lib/WeBWorK/DB/Record/Password.pm b/lib/WeBWorK/DB/Record/Password.pm index cd61e8d180..cbcc450319 100644 --- a/lib/WeBWorK/DB/Record/Password.pm +++ b/lib/WeBWorK/DB/Record/Password.pm @@ -27,8 +27,9 @@ use warnings; BEGIN { __PACKAGE__->_fields( - user_id => { type => "VARCHAR(100) NOT NULL", key => 1 }, - password => { type => "TEXT" }, + user_id => { type => "VARCHAR(100) NOT NULL", key => 1 }, + password => { type => "TEXT" }, + otp_secret => { type => "TEXT" } ); } diff --git a/lib/WeBWorK/Utils/TOTP.pm b/lib/WeBWorK/Utils/TOTP.pm new file mode 100644 index 0000000000..cb5cae6de7 --- /dev/null +++ b/lib/WeBWorK/Utils/TOTP.pm @@ -0,0 +1,92 @@ +package WeBWorK::Utils::TOTP; + +use strict; +use warnings; +use utf8; + +use Digest::SHA qw(hmac_sha512_hex hmac_sha256_hex hmac_sha1_hex); +use MIME::Base32 qw(encode_base32); +use Math::Random::Secure qw(irand); + +sub new { + my ($invocant, @options) = @_; + my $self = bless {}, ref($invocant) || $invocant; + + if (@options) { + my $options = ref($options[0]) eq 'HASH' ? $options[0] : {@options}; + @$self{ keys %$options } = values %$options; + } + + $self->{digits} = 6 unless $self->{digits} && $self->{digits} =~ m/^[678]$/; + $self->{period} = 30 unless $self->{period} && $self->{period} =~ m/^[36]0$/; + $self->{algorithm} = 'SHA1' unless $self->{algorithm} && $self->{algorithm} =~ m/^SHA(1|256|512)$/; + $self->{tolerance} = 0 unless defined $self->{tolerance} && $self->{tolerance} =~ m/^\d+$/; + + $self->{secret} = $self->gen_secret($self->{algorithm} eq 'SHA512' ? 64 : $self->{algorithm} eq 'SHA256' ? 32 : 20) + unless $self->{secret}; + + return $self; +} + +sub secret { + my $self = shift; + return $self->{secret}; +} + +sub gen_secret { + my ($self, $length) = @_; + $length ||= 20; + my @chars = + ('/', 1 .. 9, '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '+', '=', 'A' .. 'Z', 'a' .. 'z'); + return join('', map { @chars[ irand(@chars) ] } 0 .. $length - 1); +} + +sub hmac { + my ($self, $Td) = @_; + + if ($self->{algorithm} eq 'SHA512') { + return hmac_sha512_hex($Td, $self->{secret}); + } elsif ($self->{algorithm} eq 'SHA256') { + return hmac_sha256_hex($Td, $self->{secret}); + } else { + return hmac_sha1_hex($Td, $self->{secret}); + } +} + +sub generate_otp { + my ($self, $user, $issuer) = @_; + + return + qq[otpauth://totp/$user?secret=] + . encode_base32($self->{secret}) + . qq[&algorithm=$self->{algorithm}] + . qq[&digits=$self->{digits}] + . qq[&period=$self->{period}] + . ($issuer ? qq[&issuer=$issuer] : ''); +} + +sub validate_otp { + my ($self, $otp) = @_; + + return 0 unless $otp && $otp =~ m/^\d{$self->{digits}}$/; + + my $currentTime = time; + my @tests = ($currentTime); + for my $i (1 .. $self->{tolerance}) { + push(@tests, $currentTime - $self->{period} * $i, $currentTime + $self->{period} * $i); + } + + for my $when (@tests) { + my $hmac = $self->hmac(pack('H*', sprintf('%016x', int($when / $self->{period})))); + + # Use the 4 least significant bits (1 hex char) from the encrypted string as an offset. + # Take the 4 bytes (8 hex chars) at the offset (* 2 for hex), and drop the high bit. + my $encrypted = hex(substr($hmac, hex(substr($hmac, -1)) * 2, 8)) & 0x7fffffff; + + return 1 if sprintf("\%0$self->{digits}d", $encrypted % (10**$self->{digits})) eq $otp; + } + + return 0; +} + +1; diff --git a/templates/ContentGenerator/Instructor/UserList.html.ep b/templates/ContentGenerator/Instructor/UserList.html.ep index ae532de353..106b9cd1db 100644 --- a/templates/ContentGenerator/Instructor/UserList.html.ep +++ b/templates/ContentGenerator/Instructor/UserList.html.ep @@ -44,6 +44,7 @@ % my $default_choice; % % for my $actionID (@$formsToShow) { + % next if $actionID eq 'reset_2fa' && !$ce->two_factor_authentication_enabled; % next if $formPerms->{$actionID} && !$authz->hasPermissions(param('user'), $formPerms->{$actionID}); % % my $disabled = $actionID eq 'import' && !@$CSVList ? ' disabled' : ''; diff --git a/templates/ContentGenerator/Instructor/UserList/reset_2fa_form.html.ep b/templates/ContentGenerator/Instructor/UserList/reset_2fa_form.html.ep new file mode 100644 index 0000000000..5426b1b116 --- /dev/null +++ b/templates/ContentGenerator/Instructor/UserList/reset_2fa_form.html.ep @@ -0,0 +1,23 @@ +
+
+ + <%= maketext('Warning: This will make users need to setup two factor authentication again! Only do this ' + . 'for users that can no longer access the course due the account being lost in the authenticator app.') + =%> + +
+
+ <%= label_for reset_2fa_select => maketext('Reset two factor authentication for selected users?'), + class => 'col-form-label col-form-label-sm col-auto' =%> +
+ <%= select_field 'action.reset_2fa.confirm' => [ + [ maketext('No') => 'no', selected => undef ], + [ maketext('Yes') => 'yes' ] + ], + id => 'reset_2fa_select', class => 'form-select form-select-sm' =%> +
+
+
+ <%= maketext('Please confirm it is okay to reset two factor authentication for selected users.') %> +
+
diff --git a/templates/ContentGenerator/TwoFactorAuthentication.html.ep b/templates/ContentGenerator/TwoFactorAuthentication.html.ep new file mode 100644 index 0000000000..dff26753df --- /dev/null +++ b/templates/ContentGenerator/TwoFactorAuthentication.html.ep @@ -0,0 +1,60 @@ +% use Mojo::Util qw(b64_encode); +% +% if ($otp_link) { +

+ <%= maketext('To set up one-time password generation, scan the QR code below with an authenticator app ' + . '(such as Google Authenticator, Microsoft Authenticator, Twilio Authy, etc.) installed on a mobile ' + . 'device. Alternatively, after installing an authenticator app on a mobile device, open this page on ' + . 'that device, and click on the QR code below.') =%> +

+
+ <%= link_to $otp_link, class => 'mx-auto', begin =%> + <%= image 'data:image/png;base64,' . b64_encode($otp_qrcode), + alt => maketext('One-time password generator setup QR Code') =%> + <% end =%> +
+

+ <%= maketext('Once the authenticator app is set up, enter the code it generates below.') =%> +

+% } elsif ($authen->session->{otp_setup_email_sent}) { +

+ <%= maketext('You have been sent an email with instructions on how to set up an authenticator ' + . 'app to generate one-time passwords. Follow the instructions in that email, and then enter the ' + . 'security code shown below.') =%> +

+

+ <%= maketext('Note that the QR code and link in that email are only valid as long as this ' + . 'page is open. If you click "Cancel" below or close this page, then you will need to return to this ' + . 'page, to have another email sent with an updated QR code and link.') =%> +

+% } else { +

<%== maketext('Please enter the one-time security code generated by the authenticator app.') %>

+% } +% +<%= form_for current_route, method => 'POST', begin =%> + <%= $hidden_fields =%> + % +
+
+ <%= text_field otp_code => '', id => 'otp_code', class => 'form-control', placeholder => '', + autocomplete => 'off', autocapitalize => 'none', spellcheck => 'false' =%> + <%= label_for otp_code => maketext('One-Time Code') =%> +
+
+ <%= check_box(skip_2fa => 1, id => 'skip_2fa', class => 'form-check-input') =%> + <%= label_for skip_2fa => maketext('Skip two factor verification on this device.') =%> +
+
+ <%= maketext('If you check the box above, then two factor verification will be skipped for a limited ' + . 'time when signing in with this browser. This feature is not safe for public workstations, ' + . 'untrusted machines, and machines over which you do not have direct control.') =%> +
+ % if ($authen_error) { +
<%= $authen_error =%>
+ % } +
+ <%= submit_button(maketext('Continue'), name => 'verify_otp', class => 'btn btn-primary') =%> + <%= submit_button(maketext('Cancel'), name => 'cancel_otp_verification', class => 'btn btn-primary') =%> +
+
+<% end =%> diff --git a/templates/HelpFiles/InstructorUserList.html.ep b/templates/HelpFiles/InstructorUserList.html.ep index 03b07d668b..f766d882e1 100644 --- a/templates/HelpFiles/InstructorUserList.html.ep +++ b/templates/HelpFiles/InstructorUserList.html.ep @@ -109,6 +109,14 @@ . 'assignment data has been permanently deleted.') =%> +
<%= maketext('Reset two factor authentication for a student in the course') %>
+
+ <%= maketext('This resets two factor authentication for a student, thus making the student need to set up two ' + . 'factor authentication again. This should only be done if a student has accidentally deleted their ' + . 'account or for some other reason lost their key in the authenticator app, and so can no longer access ' + . 'the course. Note that this will only appear if two factor authentication is enabled for the course.') =%> +
+
<%= maketext('Assign sets to one student') %>
<%= maketext('To assign one or more sets to an individual student click in the column "Assigned Sets" in the '