From 427821d77cce1bdb4d5c8aa5ffe5113eab79221e Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 20 Feb 2024 08:59:29 -0600 Subject: [PATCH 01/14] Add two factor authentication. This uses the TOTP (time-based one-time password) protocol. Any authenticator app on a mobile device that supports this protocol can be used (for example, Google Authenticator, Microsoft Authenticator, Twilio Authy, etc.). Whether this is enabled or not is controlled by new course environment variable `$twoFA{enabled}`. If that is set to 0, then two factor authentication is disabled for all courses. If that is 1 (the default), then two factor authentication is enabled for all courses that use password authentication (of course two factor authentication does not apply to courses that use external authentication methods like LTI, CAS, Shibboleth, etc.). If that is a string course name like 'admin', then two factor authentication is enabled only for that course. If that 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. 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. This is controlled by the new course environment variable `$twoFA{email_sender}`. If that 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. This requires a new database column `otp_secret` that was added to the password table (for lack of a better place to put it). There is a new wwsh script (`bin/reset2fa`) that can be used to reset two factor authentication for a user if a user somehow loses their setup in an authentactor app on their mobile device. That just removes the OTP secret from the database. This means that the user will need to go through the two factor authentication setup process again. To use it execute: `wwsh courseID /opt/webwork/webwork2/bin/reset2fa userID`. Multiple user ids can be listed if you want to reset more than one user at a time. This is the only way that an admin user can reset their own two factor authentication (and there should never be another way for admin users to do this for themselves). Perhaps a page in the admin course could be added for resetting this for instructors. I plan to add a way that instructors can reset two factor authentication for students. --- bin/check_modules.pl | 3 + bin/reset2fa | 19 +++ conf/defaults.config | 50 ++++++ conf/localOverrides.conf.dist | 51 ++++++ lib/WeBWorK.pm | 13 +- lib/WeBWorK/Authen.pm | 50 +++++- .../TwoFactorAuthentication.pm | 146 ++++++++++++++++++ lib/WeBWorK/CourseEnvironment.pm | 53 ++++--- lib/WeBWorK/DB/Record/Password.pm | 5 +- lib/WeBWorK/Utils/TOTP.pm | 92 +++++++++++ .../TwoFactorAuthentication.html.ep | 53 +++++++ 11 files changed, 503 insertions(+), 32 deletions(-) create mode 100644 bin/reset2fa create mode 100644 lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm create mode 100644 lib/WeBWorK/Utils/TOTP.pm create mode 100644 templates/ContentGenerator/TwoFactorAuthentication.html.ep diff --git a/bin/check_modules.pl b/bin/check_modules.pl index 4ef30ae89e..87a34a27b7 100755 --- a/bin/check_modules.pl +++ b/bin/check_modules.pl @@ -108,6 +108,8 @@ =head1 DESCRIPTION HTML::Tagset HTML::Template HTTP::Async + Imager::Color + Imager::QRCode IO::File IO::Socket::SSL Iterator @@ -117,6 +119,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..c8c05b58cd 100644 --- a/conf/defaults.config +++ b/conf/defaults.config @@ -944,6 +944,56 @@ $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} = ''; + ################################################################################ # WeBWorK Caliper ################################################################################ diff --git a/conf/localOverrides.conf.dist b/conf/localOverrides.conf.dist index 0604d1f5b2..9a04b08782 100644 --- a/conf/localOverrides.conf.dist +++ b/conf/localOverrides.conf.dist @@ -574,6 +574,57 @@ $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'; + ################################################################################ # Searching for set.def files to import ################################################################################ diff --git a/lib/WeBWorK.pm b/lib/WeBWorK.pm index 5fcce651e7..7b6c28413a 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; @@ -268,9 +269,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..b795e64ee1 100644 --- a/lib/WeBWorK/Authen.pm +++ b/lib/WeBWorK/Authen.pm @@ -55,6 +55,7 @@ use Scalar::Util qw(weaken); 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 +168,19 @@ sub verify { $self->{was_verified} = $self->do_verify; - $self->site_fixup if $self->can('site_fixup'); - - if ($self->{was_verified}) { + 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 + && ($self->{initial_login} || $self->session->{two_factor_verification_needed})) + { + $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 +442,43 @@ 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($self->{user_id}); + if (WeBWorK::Utils::TOTP->new(secret => $self->session->{otp_secret} // $password->otp_secret) + ->validate_otp($otp_code)) + { + delete $self->session->{two_factor_verification_needed}; + + # 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/TwoFactorAuthentication.pm b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm new file mode 100644 index 0000000000..044fcc81ab --- /dev/null +++ b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm @@ -0,0 +1,146 @@ +################################################################################ +# 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 Imager::QRCode; +use Imager::Color; +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}, $ce->{courseName}); + Imager::QRCode->new( + size => 4, + margin => 3, + level => 'L', + casesensitive => 1, + lightcolor => Imager::Color->new(255, 255, 255, 0), + darkcolor => Imager::Color->new(0, 0, 0), + )->plot($otp_link)->write(data => \(my $img_data), type => '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 and link above are ' + . '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..674f133418 --- /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} = 'SHA512' 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/TwoFactorAuthentication.html.ep b/templates/ContentGenerator/TwoFactorAuthentication.html.ep new file mode 100644 index 0000000000..6a1fbf8b77 --- /dev/null +++ b/templates/ContentGenerator/TwoFactorAuthentication.html.ep @@ -0,0 +1,53 @@ +% use Mojo::Util qw(b64_encode); +% +% if ($otp_link) { +

+ <%= maketext('To set up one-time password generation, scan the QRCode below with an authenticator app ' + . '(such as Google Authenticator, Microsoft Authenticator, Twilio Authy, etc.) installed on a mobile ' + . 'device.') =%> +

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

+ <%== maketext('Alternately, after installing an authenticator app on a mobile device, ' + . 'open this page on that device, and click here.', + $otp_link) =%> +

+

+ <%= 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') =%> +
+ % 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 =%> From c414d966e01a4de4fc483c78437d404b6cce3aa3 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Thu, 22 Feb 2024 22:14:43 -0600 Subject: [PATCH 02/14] Add a form to the Accounts Manager for resetting two factor authentication for students. This form does not allow the user to reset their own two factor authentication secret, but that of other users at equal or lesser permission level to their own. Note that in the admin course if there are multiple admin users, then one admin user can reset two factor authentication for another. Also some clean up and issue fixes in the `htdocs/js/UserList/userlist.js` file with form validation. The "change" event handler was being added multiple times to the users list table. More clean up is needed though (with this and the other pages with action forms). There is a lot of redundancy with this form validation implementation. --- htdocs/js/UserList/userlist.js | 8 ++-- .../ContentGenerator/Instructor/UserList.pm | 37 ++++++++++++++++++- .../Instructor/UserList.html.ep | 1 + .../UserList/reset_2fa_form.html.ep | 23 ++++++++++++ .../HelpFiles/InstructorUserList.html.ep | 8 ++++ 5 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 templates/ContentGenerator/Instructor/UserList/reset_2fa_form.html.ep 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/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/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/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 ' From 8a59d84ac4709c31f043a32714c35dfd04dda642 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 23 Feb 2024 08:30:04 -0600 Subject: [PATCH 03/14] Add an option to skip two factor authentication on trusted devices. A checkbox is added to the two factor verification page. If that is checked, then a signed cookie (separate from the session cookie) is set. If that cookie is set, then two factor verification is skipped for sign in attempts. --- lib/WeBWorK/Authen.pm | 42 ++++++++++++++++++- .../TwoFactorAuthentication.html.ep | 11 ++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/lib/WeBWorK/Authen.pm b/lib/WeBWorK/Authen.pm index b795e64ee1..9a7640671f 100644 --- a/lib/WeBWorK/Authen.pm +++ b/lib/WeBWorK/Authen.pm @@ -168,12 +168,38 @@ sub verify { $self->{was_verified} = $self->do_verify; + my $remember_2fa = $c->signed_cookie('WeBWorK.2FA.' . $c->ce->{courseName}); + + if ($self->{was_verified} + && $self->{login_type} eq 'normal' + && !$self->{external_auth} + && (!$c->{rpc} || ($c->{rpc} && !$c->stash->{disable_cookies})) + && $remember_2fa + && !$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} => 1, + { + 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 - && ($self->{initial_login} || $self->session->{two_factor_verification_needed})) + && ($self->{initial_login} || $self->session->{two_factor_verification_needed}) + && !$remember_2fa) { $self->{was_verified} = 0; $self->session(two_factor_verification_needed => 1); @@ -458,6 +484,20 @@ sub verify_normal_user { { 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} => 1, + { + max_age => 3600 * 24 * 365, # This cookie is valid for one year. + expires => time + 3600 * 24 * 365, + 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}); diff --git a/templates/ContentGenerator/TwoFactorAuthentication.html.ep b/templates/ContentGenerator/TwoFactorAuthentication.html.ep index 6a1fbf8b77..65e582d0e4 100644 --- a/templates/ContentGenerator/TwoFactorAuthentication.html.ep +++ b/templates/ContentGenerator/TwoFactorAuthentication.html.ep @@ -36,12 +36,21 @@ <%= 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 from now on 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 =%>
% } From 4e2134fb31eff9c2ccf3c658112839afc35308fd Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 24 Feb 2024 15:08:02 -0600 Subject: [PATCH 04/14] Add a configuration option in webwork2.mojolicious.dist.yml for allowing unsecured rpc usage. The option allow_unsecured_rpc (which defaults to off) allows the html2xml and render_rpc endpoints to be used without cookies, and thus skipping 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. --- conf/webwork2.mojolicious.dist.yml | 7 +++++++ lib/WeBWorK.pm | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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/lib/WeBWorK.pm b/lib/WeBWorK.pm index 7b6c28413a..c9c8480880 100644 --- a/lib/WeBWorK.pm +++ b/lib/WeBWorK.pm @@ -91,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]); } From 3e37dd616709d75286081636bd51731f74abb8bf Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Mon, 11 Mar 2024 19:47:09 -0500 Subject: [PATCH 05/14] Switch to using GD::Barcode::QRcode instead of Imager::QRCode. The `Imager::QRCode` seems to have some issues on various linux distributions. The Ubuntu packages apparently have some changes applied that fix these issues. However, if the package is installed from cpan it fails. On Oracle it seems the same issues occur. In testing the `GD::Barcode::QRcode` package works on Ubuntu, Oracle, and Redhat. --- bin/check_modules.pl | 3 +-- .../TwoFactorAuthentication.pm | 18 +++++------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/bin/check_modules.pl b/bin/check_modules.pl index 87a34a27b7..c7dc65f069 100755 --- a/bin/check_modules.pl +++ b/bin/check_modules.pl @@ -102,14 +102,13 @@ =head1 DESCRIPTION File::Temp Future::AsyncAwait GD + GD::Barcode::QRcode Getopt::Long Getopt::Std HTML::Entities HTML::Tagset HTML::Template HTTP::Async - Imager::Color - Imager::QRCode IO::File IO::Socket::SSL Iterator diff --git a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm index 044fcc81ab..b0156f9984 100644 --- a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm +++ b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm @@ -22,8 +22,8 @@ WeBWorK::ContentGenerator::TwoFactorAuthentication - display the two factor auth =cut -use Imager::QRCode; -use Imager::Color; +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); @@ -55,14 +55,7 @@ sub pre_header_initialize ($c) { $c->authen->session(otp_secret => $totp->secret); my $otp_link = $totp->generate_otp($c->authen->{user_id}, $ce->{courseName}); - Imager::QRCode->new( - size => 4, - margin => 3, - level => 'L', - casesensitive => 1, - lightcolor => Imager::Color->new(255, 255, 255, 0), - darkcolor => Imager::Color->new(0, 0, 0), - )->plot($otp_link)->write(data => \(my $img_data), type => 'png'); + my $img_data = GD::Barcode::QRcode->new($otp_link, { ModuleSize => 4, Version => 12 })->plot->png; my $user = $c->db->getUser($c->authen->{user_id}); @@ -98,9 +91,8 @@ sub pre_header_initialize ($c) { '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 and link above are ' - . 'only valid as long as the page that you were visiting when this email was sent ' - . 'is still open.' + . '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( From 5f30e6fcdfee226000025b3b981b8a08d4cb1e91 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Wed, 13 Mar 2024 11:42:05 -0500 Subject: [PATCH 06/14] Add the new packages to the docker build. --- Dockerfile | 2 ++ DockerfileStage1 | 2 ++ 2 files changed, 4 insertions(+) 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 \ From 66d217ae69ff43fa8b5741708db4819694a25fa4 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Wed, 13 Mar 2024 20:31:34 -0500 Subject: [PATCH 07/14] Add the permission level `use_two_factor_auth`. Roles with this permission level are required to use two factor authentication to sign in. Users below this permission level can sign in directly. The default user role with this permission is "student". But if there is strong opposition to this default, then I suppose it could be switched to "login_proctor". Note that even if this is set to "guest", guest users will still be able to sign in without two factor authentication since it never really makes sense to have guests (i.e. practice users) use two factor authentication. --- conf/defaults.config | 1 + conf/localOverrides.conf.dist | 10 +++++++++- lib/WeBWorK/Authen.pm | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/conf/defaults.config b/conf/defaults.config index c8c05b58cd..7648ba58eb 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", diff --git a/conf/localOverrides.conf.dist b/conf/localOverrides.conf.dist index 9a04b08782..d0a5d8ba54 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 @@ -625,6 +625,14 @@ $mail{feedbackRecipients} = [ # better to find a valid email address to use for this. #$twoFA{email_sender} = 'noreply@your.school.edu'; +# 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/lib/WeBWorK/Authen.pm b/lib/WeBWorK/Authen.pm index 9a7640671f..e3027a0084 100644 --- a/lib/WeBWorK/Authen.pm +++ b/lib/WeBWorK/Authen.pm @@ -198,6 +198,7 @@ sub verify { && !$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) { From 383d70dbd88374325f250f7b4d553e91e084410f Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 15 Mar 2024 10:55:12 -0500 Subject: [PATCH 08/14] Switch back to the default SHA1 algorithm. Apparently Microsoft Authenticator does not support the SHA256 or SHA512 algorithms. --- lib/WeBWorK/Utils/TOTP.pm | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/WeBWorK/Utils/TOTP.pm b/lib/WeBWorK/Utils/TOTP.pm index 674f133418..cb5cae6de7 100644 --- a/lib/WeBWorK/Utils/TOTP.pm +++ b/lib/WeBWorK/Utils/TOTP.pm @@ -17,10 +17,10 @@ sub new { @$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} = 'SHA512' unless $self->{algorithm} && $self->{algorithm} =~ m/^SHA(1|256|512)$/; - $self->{tolerance} = 0 unless defined $self->{tolerance} && $self->{tolerance} =~ m/^\d+$/; + $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}; From 0c6c2fbaed34fe341a922e8db29e85b561bfe8ca Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 15 Mar 2024 17:21:11 -0500 Subject: [PATCH 09/14] Make the two factor authentication remember me cookie user specific. --- lib/WeBWorK/Authen.pm | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/WeBWorK/Authen.pm b/lib/WeBWorK/Authen.pm index e3027a0084..1d4a689a33 100644 --- a/lib/WeBWorK/Authen.pm +++ b/lib/WeBWorK/Authen.pm @@ -52,6 +52,7 @@ 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); @@ -175,12 +176,13 @@ sub verify { && !$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} => 1, + 'WeBWorK.2FA.' . $c->ce->{courseName} => 0, { max_age => 0, expires => 1, @@ -200,7 +202,7 @@ sub verify { && $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) + && (!$remember_2fa || b64_decode($remember_2fa) ne $self->{user_id})) { $self->{was_verified} = 0; $self->session(two_factor_verification_needed => 1); @@ -323,6 +325,7 @@ sub get_credentials { } my ($cookieUser, $cookieKey, $cookieTimeStamp) = $self->fetchCookie; + $c->log->info('"' . $cookieUser . '"') if $cookieUser; if (defined $cookieUser && defined $c->param('user')) { $self->maybe_kill_cookie if $cookieUser ne $c->param('user'); @@ -479,7 +482,7 @@ sub verify_normal_user { # 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($self->{user_id}); + my $password = $c->db->getPassword($user_id); if (WeBWorK::Utils::TOTP->new(secret => $self->session->{otp_secret} // $password->otp_secret) ->validate_otp($otp_code)) { @@ -488,7 +491,7 @@ sub verify_normal_user { # 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} => 1, + 'WeBWorK.2FA.' . $c->ce->{courseName} => b64_encode($user_id) =~ s/\n//gr, { max_age => 3600 * 24 * 365, # This cookie is valid for one year. expires => time + 3600 * 24 * 365, From a6ea1c0fa909752194a6498650c1ef9352cfdfb9 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 16 Mar 2024 05:46:00 -0500 Subject: [PATCH 10/14] Increase the tolerance when checking OTP codes. This makes two factor authentication work more reliably for DUO. --- lib/WeBWorK/Authen.pm | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/WeBWorK/Authen.pm b/lib/WeBWorK/Authen.pm index 1d4a689a33..e3781b6af0 100644 --- a/lib/WeBWorK/Authen.pm +++ b/lib/WeBWorK/Authen.pm @@ -483,8 +483,12 @@ sub verify_normal_user { 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) - ->validate_otp($otp_code)) + 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}; From 3005a545634752d8830f23f411f0454f8c459ffc Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 16 Mar 2024 16:09:57 -0500 Subject: [PATCH 11/14] Make the skip two factor authentication time period configurable. The setting in defaults.config which can be overidden in localOverrides.conf is $twoFA{skip_verification_code_interval}. The default value is set to one year. --- conf/defaults.config | 12 +++++++++++- conf/localOverrides.conf.dist | 14 ++++++++++++-- lib/WeBWorK/Authen.pm | 5 ++--- .../TwoFactorAuthentication.html.ep | 6 +++--- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/conf/defaults.config b/conf/defaults.config index 7648ba58eb..6936fc6a04 100644 --- a/conf/defaults.config +++ b/conf/defaults.config @@ -975,7 +975,7 @@ $twoFA{enabled} = 1; # 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 +# 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 @@ -995,6 +995,16 @@ $twoFA{enabled} = 1; # 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 d0a5d8ba54..d3e2e5c529 100644 --- a/conf/localOverrides.conf.dist +++ b/conf/localOverrides.conf.dist @@ -605,7 +605,7 @@ $mail{feedbackRecipients} = [ # 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 +# 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 @@ -625,8 +625,18 @@ $mail{feedbackRecipients} = [ # 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 +# 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). diff --git a/lib/WeBWorK/Authen.pm b/lib/WeBWorK/Authen.pm index e3781b6af0..e56e4d0bef 100644 --- a/lib/WeBWorK/Authen.pm +++ b/lib/WeBWorK/Authen.pm @@ -325,7 +325,6 @@ sub get_credentials { } my ($cookieUser, $cookieKey, $cookieTimeStamp) = $self->fetchCookie; - $c->log->info('"' . $cookieUser . '"') if $cookieUser; if (defined $cookieUser && defined $c->param('user')) { $self->maybe_kill_cookie if $cookieUser ne $c->param('user'); @@ -497,8 +496,8 @@ sub verify_normal_user { $c->signed_cookie( 'WeBWorK.2FA.' . $c->ce->{courseName} => b64_encode($user_id) =~ s/\n//gr, { - max_age => 3600 * 24 * 365, # This cookie is valid for one year. - expires => time + 3600 * 24 * 365, + 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}, diff --git a/templates/ContentGenerator/TwoFactorAuthentication.html.ep b/templates/ContentGenerator/TwoFactorAuthentication.html.ep index 65e582d0e4..50a30eac88 100644 --- a/templates/ContentGenerator/TwoFactorAuthentication.html.ep +++ b/templates/ContentGenerator/TwoFactorAuthentication.html.ep @@ -47,9 +47,9 @@ <%= 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 from now on 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.') =%> + <%= 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 =%>
From a07d02a93f10baf6ed5cc12deb4195c8e6b419e1 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sun, 17 Mar 2024 05:20:52 -0500 Subject: [PATCH 12/14] Switch to the QR code being a link and fix wording. --- .../TwoFactorAuthentication.html.ep | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/templates/ContentGenerator/TwoFactorAuthentication.html.ep b/templates/ContentGenerator/TwoFactorAuthentication.html.ep index 50a30eac88..dff26753df 100644 --- a/templates/ContentGenerator/TwoFactorAuthentication.html.ep +++ b/templates/ContentGenerator/TwoFactorAuthentication.html.ep @@ -2,19 +2,17 @@ % % if ($otp_link) {

- <%= maketext('To set up one-time password generation, scan the QRCode below with an authenticator app ' + <%= 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.') =%> + . 'device. Alternatively, after installing an authenticator app on a mobile device, open this page on ' + . 'that device, and click on the QR code below.') =%>

- <%= image 'data:image/png;base64,' . b64_encode($otp_qrcode), - alt => maketext('One-time password generator setup QR Code'), class => 'mx-auto' =%> + <%= 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('Alternately, after installing an authenticator app on a mobile device, ' - . 'open this page on that device, and click here.', - $otp_link) =%> -

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

From d81e4f7041cc3c4a4fb871c627513bbf6d4c7d2e Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sun, 17 Mar 2024 12:16:01 -0500 Subject: [PATCH 13/14] Switch to setting the version explicitly to 0. Doing this forces the `GD::Barcode::QRcode` package to auto detect the version, ans works on all platforms to generate a working QR code. However, on some platforms (well really all of them except the packages in the Ubuntu repositories) this emits warnings. To fix that, the warnings are simply disabled when the image is generated. --- lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm index b0156f9984..08e22e8a01 100644 --- a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm +++ b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm @@ -55,7 +55,11 @@ sub pre_header_initialize ($c) { $c->authen->session(otp_secret => $totp->secret); my $otp_link = $totp->generate_otp($c->authen->{user_id}, $ce->{courseName}); - my $img_data = GD::Barcode::QRcode->new($otp_link, { ModuleSize => 4, Version => 12 })->plot->png; + + 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}); From 62bda1b5287e1b85edbab5176ffad648afd0169e Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 19 Mar 2024 19:50:24 -0500 Subject: [PATCH 14/14] Use the URL for the `set_list` route without `https?://` for the TOTP issuer. --- lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm index 08e22e8a01..3e52db92b1 100644 --- a/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm +++ b/lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm @@ -54,7 +54,7 @@ sub pre_header_initialize ($c) { $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}, $ce->{courseName}); + 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 { };