Skip to content

Commit

Permalink
Add two factor authentication.
Browse files Browse the repository at this point in the history
  • Loading branch information
drgrice1 committed Feb 21, 2024
1 parent d5aff02 commit 96df0fd
Show file tree
Hide file tree
Showing 9 changed files with 450 additions and 8 deletions.
3 changes: 3 additions & 0 deletions bin/check_modules.pl
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ =head1 DESCRIPTION
HTML::Tagset
HTML::Template
HTTP::Async
Imager::Color
Imager::QRCode
IO::File
IO::Socket::SSL
Iterator
Expand All @@ -117,6 +119,7 @@ =head1 DESCRIPTION
Locale::Maketext::Lexicon
Locale::Maketext::Simple
LWP::Protocol::https
MIME::Base32
MIME::Base64
Math::Random::Secure
Minion
Expand Down
48 changes: 48 additions & 0 deletions conf/defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,54 @@ $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, OTP link, 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 ensure 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.
$twoFA{email_sender} = '';

################################################################################
# WeBWorK Caliper
################################################################################
Expand Down
49 changes: 49 additions & 0 deletions conf/localOverrides.conf.dist
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,55 @@ $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, OTP link, 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 ensure 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.
#$twoFA{email_sender} = 'noreply@your.school.edu';

################################################################################
# Searching for set.def files to import
################################################################################
Expand Down
13 changes: 10 additions & 3 deletions lib/WeBWorK.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand Down
59 changes: 56 additions & 3 deletions lib/WeBWorK/Authen.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -155,9 +156,18 @@ 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->two_factor_authentication_enabled
&& !$self->{external_auth}
&& ($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;
Expand Down Expand Up @@ -419,10 +429,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/
Expand Down Expand Up @@ -885,4 +928,14 @@ sub write_log_entry {
return;
}

sub two_factor_authentication_enabled {
my $self = shift;
my $ce = $self->{c}->ce;

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};
}

1;
136 changes: 136 additions & 0 deletions lib/WeBWorK/ContentGenerator/TwoFactorAuthentication.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
################################################################################
# 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};

my $mail =
Email::Stuffer->to($recipient)->from($ce->{twoFA}{email_sender})
->subject($c->maketext('Setup One-Time Password Authentication'))->html_body(
$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(
'p',
sub {
$c->maketext(
'Alternately, after installing an authenticator app on a mobile device, '
. 'open this email on that device, and click <a href="[_1]">here</a>.',
$otp_link
);
}
),
$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, content_type => 'image/png', filename => 'QRCode.png')
->header('X-Remote-Host' => $c->tx->remote_address || 'UNKNOWN')
->transport(createEmailSenderTransportSMTP($ce));

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;
5 changes: 3 additions & 2 deletions lib/WeBWorK/DB/Record/Password.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
);
}

Expand Down
Loading

0 comments on commit 96df0fd

Please sign in to comment.