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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions conf/defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,18 @@ $authen{proctor_module} = "WeBWorK::Authen::Proctor";
# This is used instead of $authen{user_module} when logging into the admin course.
$authen{admin_module} = ['WeBWorK::Authen::Basic_TheLastOption'];

# If set to true, then a user who is not logged into course X, but whose browser still
# holds an active session cookie for the admin course, will be logged into course X
# automatically, provided that:
# - the site uses cookie session management (not key)
# - the admin course user holds the create_and_delete_courses permission there, and
# - course X has a user with the same user_id whose password hash matches the one in
# the admin course. Note that password hashes will match when a user is added to
# course X from the admin course at the time course X is initialized. However,
# password hashes will not match simply from setting the same password in course X
# directly.
$authen{admin_cross_course_login} = 0;

################################################################################
# Authorization system (Make local overrides in localOverrides.conf )
################################################################################
Expand Down
12 changes: 12 additions & 0 deletions conf/localOverrides.conf.dist
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,18 @@ $mail{feedbackRecipients} = [
# the templates for a new course can be copied.
#$modelCoursesForCopy = [ "modelCourse", "anotherModelCourse", "aThirdOne" ];

# If set to true, then a user who is not logged into course X, but whose browser still
# holds an active session cookie for the admin course, will be logged into course X
# automatically, provided that:
# - the site uses cookie session management (not key)
# - the admin course user holds the create_and_delete_courses permission there, and
# - course X has a user with the same user_id whose password hash matches the one in
# the admin course. Note that password hashes will match when a user is added to
# course X from the admin course at the time course X is initialized. However,
# password hashes will not match simply from setting the same password in course X
# directly.
#$authen{admin_cross_course_login} = 1;

################################################################################
# OpenProblemLibrary
################################################################################
Expand Down
123 changes: 121 additions & 2 deletions lib/WeBWorK/Authen.pm
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ use Scalar::Util qw(weaken);
use Mojo::Util qw(b64_encode b64_decode);
use Math::Random::Secure qw(irand);

use WeBWorK::CourseEnvironment;
use WeBWorK::DB;
use WeBWorK::Debug;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be

Suggested change
use WeBWorK::Debug;
use WeBWorK::Debug qw(debug);

and is the reason for the conflict.

use WeBWorK::Utils qw(x runtime_use utf8Crypt cryptPassword);
use WeBWorK::Utils::Logs qw(writeCourseLog);
Expand Down Expand Up @@ -199,7 +201,9 @@ sub verify {
} else {
$self->write_log_entry("LOGIN FAILED $self->{log_error}") if defined $self->{log_error};
$self->maybe_kill_cookie;
$c->stash(authen_error => $self->{error}) if $self->{error} && $self->{error} =~ /\S/;
# A failed admin cross-course login attempt should not surface an error to the user.
$c->stash(authen_error => $self->{error})
if $self->{error} && $self->{error} =~ /\S/ && ($self->{credential_source} // '') ne 'admin_cross_course';
}

my $caliper_sensor = Caliper::Sensor->new($c->ce);
Expand Down Expand Up @@ -261,6 +265,8 @@ sub do_verify {

if (defined $self->{login_type} && $self->{login_type} eq 'guest') {
return $self->verify_practice_user;
} elsif (($self->{credential_source} // '') eq 'admin_cross_course') {
return $self->verify_admin_cross_course_user;
} else {
return $self->verify_normal_user;
}
Expand Down Expand Up @@ -378,7 +384,89 @@ sub get_credentials {
return 1;
}

return 0;
return $self->try_admin_cross_course_credentials;
}

=head2 try_admin_cross_course_credentials

This is a last resort used by C<get_credentials> when a request for this course has no cookie or
C<user> parameter. If C<< $ce->{authen}{admin_cross_course_login} >> is enabled, the admin course
uses C<session_cookie> session management, and the browser still holds an active session cookie
for the admin course, this decodes that cookie, confirms the session is still valid, and confirms
the admin course user has the C<create_and_delete_courses> permission. If all of that succeeds,
C<user_id> and C<credential_source> are set so that C<verify_admin_cross_course_user> can finish
authenticating the user into this course.

=cut

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

return 0
unless $ce->{authen}{admin_cross_course_login}
&& defined $ce->{admin_course_id}
&& $ce->{courseName} ne $ce->{admin_course_id};

my ($ce_admin, $db_admin);
eval {
$ce_admin = WeBWorK::CourseEnvironment->new({ courseName => $ce->{admin_course_id} });
$db_admin = WeBWorK::DB->new($ce_admin);
};
return 0 if $@ || !$db_admin || ($ce_admin->{session_management_via} // '') ne 'session_cookie';

# Decode the admin course's session cookie
my $sessions = $c->app->sessions;
my $adminCookieName = 'WeBWorKCourseSession.' . $ce->{admin_course_id};
my $cookieMethod = $sessions->encrypted ? 'encrypted_cookie' : 'signed_cookie';
my $rawValue = $c->$cookieMethod($adminCookieName);
Comment on lines +420 to +423

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be

Suggested change
my $sessions = $c->app->sessions;
my $adminCookieName = 'WeBWorKCourseSession.' . $ce->{admin_course_id};
my $cookieMethod = $sessions->encrypted ? 'encrypted_cookie' : 'signed_cookie';
my $rawValue = $c->$cookieMethod($adminCookieName);
my $sessions = $c->app->sessions;
my $rawValue = $c->signed_cookie('WeBWorKCourseSession.' . $ce->{admin_course_id});

Webwork uses signed_cookies (the default). So there is not need to check if the session cookie is encrypted or not. It isn't. Also the encrypted cookie feature was not added to Mojolicious until version 9.39, and we currently allow version 9.34 or newer (except a few bad versions) of Mojolicious. Even if we do move to requiring newer versions of Mojolicious and use encrypted cookies, this check would not be necessary. This would then use the encrypted_cookie method instead. We know which one we are using, so no need to check.

return 0 unless $rawValue;
$rawValue =~ y/-/=/;
my $adminSession = eval { $sessions->deserialize->(b64_decode($rawValue)) };
return 0 unless $adminSession;
Comment on lines +426 to +427

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need for the eval here. Mojolicious does not use an eval in their code for this, so I see no reason that we should. In fact, might as well use essentially their code here and make this

Suggested change
my $adminSession = eval { $sessions->deserialize->(b64_decode($rawValue)) };
return 0 unless $adminSession;
return 0 unless my $adminSession = $sessions->deserialize->(b64_decode($rawValue));


my $expiration = $adminSession->{expiration} // $sessions->default_expiration;
my $expires = delete $adminSession->{expires};
return 0 if !$expires && $expiration || defined $expires && $expires <= time;
Comment on lines +429 to +431

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to mention this. I included it in the pull request.

Most of this code seems to be copied from the load method of the Mojolicious::Sessions package. They delete the expires key from the session, but this shouldn't. This could be cleaned up and changed to

Suggested change
my $expiration = $adminSession->{expiration} // $sessions->default_expiration;
my $expires = delete $adminSession->{expires};
return 0 if !$expires && $expiration || defined $expires && $expires <= time;
return 0
if !$adminSession->{expires} && ($adminSession->{expiration} // $sessions->default_expiration)
|| defined $adminSession->{expires} && $adminSession->{expires} <= time;


my ($adminUserID, $adminKey, $adminTimestamp) = @{$adminSession}{qw(user_id key timestamp)};
return 0 unless $adminUserID && $adminKey;

# Confirm the admin session is still valid
my $AdminKey = $db_admin->getKey($adminUserID);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not use Pascal case for variable names. Use camel case. I have been working on removing that sort of thing in the webwork2 code. I recommend using the variable $adminKeyRecord here, since $adminKey is already used above, and that makes the difference clear here. The actual key in the database is the key column, and this is the database record that contains that column.

return 0
unless defined $AdminKey
&& $AdminKey->key eq $adminKey
&& time <= ($adminTimestamp // $AdminKey->timestamp) + $ce_admin->{sessionTimeout};

return 0 unless _admin_course_has_create_delete_permission($ce_admin, $db_admin, $adminUserID);

my $AdminPassword = $db_admin->getPassword($adminUserID);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also should not be Pascal case. I recommend using $adminPasswordRecord for consistency with the $adminKeyRecord variable above.

return 0 unless defined $AdminPassword && $AdminPassword->password =~ /\S/;

$self->{user_id} = $adminUserID;
$self->{admin_cross_course_password} = $AdminPassword->password;
$self->{login_type} = 'normal';
$self->{credential_source} = 'admin_cross_course';
debug('credential source: "admin_cross_course", user: "', $self->{user_id}, '"');
return 1;
}

# Mirrors the relevant part of WeBWorK::Authz::hasPermissions for the admin course, since that method requires a
# full Mojolicious controller bound to the admin course, and here the controller is bound to the course being
# entered instead.
sub _admin_course_has_create_delete_permission {
my ($ce_admin, $db_admin, $user) = @_;

my $activity_role = $ce_admin->{permissionLevels}{create_and_delete_courses};
return 0 unless defined $activity_role && exists $ce_admin->{userRoles}{$activity_role};
my $role_permlevel = $ce_admin->{userRoles}{$activity_role};

my $PermissionLevel = $db_admin->getPermissionLevel($user);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize this variable was copied from Authz.pm, but this also should not be Pascal case. The Authz.pm file needs a lot of clean up. It is a mess.

return 0 unless defined $PermissionLevel && defined $PermissionLevel->permission;

return $PermissionLevel->permission >= $role_permlevel;
}

sub check_user {
Expand Down Expand Up @@ -594,6 +682,37 @@ sub verify_normal_user {
}
}

=head2 verify_admin_cross_course_user

Finishes authenticating a user identified by C<try_admin_cross_course_credentials>.
Compares this course's password hash for the user against the admin course's hash
captured by C<try_admin_cross_course_credentials>.

=cut

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

my $user_id = $self->{user_id};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need for a local $user_id variable. Just use $self->{user_id} in the two places the $user_id variable is used in this method.


my $coursePassword = $c->db->getPassword($user_id);
unless (defined $coursePassword
&& $coursePassword->password =~ /\S/
&& $coursePassword->password eq $self->{admin_cross_course_password})
{
$self->{log_error} = 'admin cross-course login: no matching password for this user in this course';
$self->{error} = $c->maketext(GENERIC_ERROR_MESSAGE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this line. The only case in which this method is called is if the credential_source is 'admin_cross_course', and in that case $self->{error} is also ignored (see line 206). So there is no point in setting it.

return 0;
}

return 0 unless $self->validate_user;

$self->{session_key} = $self->create_session($user_id);
$self->{initial_login} = 1;
return 1;
}

# Returns 1 if authentication succeeded, returns 0 if required data was present but authentication failed,
# and returns -1 if the password is missing.
sub authenticate {
Expand Down