Skip to content

Commit cb7ebbc

Browse files
committed
Add a course selection step in content item selection for LTI 1.3.
If the new `$LTI{v1p3}{allowCourseSelection}` option is set to 1 and an instructor attempts to sign in via LTI 1.3 authentication from an LMS course that is not in the LTI course map for the site, then a list of webwork courses will be compiled that have matching LTI 1.3 parameters, that have a user that works for the LMS user with email_address set that matches the email address sent from the LMS, and such that the user has the access_instructor_tools and modify_problem_sets permissions. So it is important that the email_address be set in the webwork courses for the user. Also note that the LTI user id selection parameters (`preferred_source_of_username`, `fallback_source_of_username`, strip_domain_from_email`, and `lowercase_username`) have to be the same for all matching courses (effectively the settings from the first course found are used). Then the user will be presented a list of courses to choose from (or if only one matching course is found that course will be used). When the user selects a course (or only on course matches), it will be automatically added to the LTI course map, and then content selection will continue as usual with that course. For this to work, the user is authenticated into the first matching course. Then if the user chooses a different course, that authentication is transfered to the selected course. Authentication should work in any of them since all of the matching courses have the same LTI 1.3 parameters and the user in each course has the same user id, email address, and permissions. If no matching courses are found or if the `$LTI{v1p3}{allowCourseSelection}` option is set to 0, then the context id is shown as before. Note that the `$LTI{v1p3}{allowCourseSelection}` is necessarily a site wide option that cannot be configured per course.
1 parent 684a620 commit cb7ebbc

3 files changed

Lines changed: 210 additions & 2 deletions

File tree

conf/authen_LTI_1_3.conf.dist

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,21 @@ $LTI{v1p3}{ignoreMissingSourcedID} = 0;
261261

262262
$LTI{v1p3}{autoSyncSetDatesToLMS} = 0;
263263

264+
# If this is set and an instructor attempts to use content selection from an LMS course that is
265+
# that is not in the LTI course map, then the instructor will be offered a list of WeBWorK
266+
# courses to choose from. The WeBWorK courses that will be listed for the instructor are
267+
# courses that
268+
#
269+
# 1. have LTI 1.3 authentication parameters that match those sent by the LMS,
270+
# 2. have a user for the instructor, that
271+
# - has the email address set that matches the email address sent from the LMS, and
272+
# - has access_instructor_tools and modify_problem_sets permissions.
273+
#
274+
# Note that if only one course is found that satisfies the above conditions, then it will
275+
# be used automatically.
276+
#
277+
# This is a site wide setting only, and cannot be configured per course
278+
# (in the course.conf file of a course).
279+
$LTI{v1p3}{allowCourseSelection} = 0;
280+
264281
1; # final line of the file to reassure perl that it was read properly.

lib/WeBWorK/ContentGenerator/LTIAdvantage.pm

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,19 @@ sub initializeRoute ($c, $routeCaptures) {
6161
{
6262
$c->stash->{isContentSelection} = 1;
6363

64+
my $siteEnvironment = WeBWorK::CourseEnvironment->new;
65+
6466
# The database object used here is not associated to any course,
6567
# and so the only has access to non-native tables.
66-
my @matchingCourses = WeBWorK::DB->new(WeBWorK::CourseEnvironment->new)->getLTICourseMapsWhere({
68+
my $nonNativeDB = WeBWorK::DB->new($siteEnvironment);
69+
my @matchingCourses = $nonNativeDB->getLTICourseMapsWhere({
6770
lms_context_id =>
6871
$c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti/claim/context'}{id}
6972
});
7073

7174
if (@matchingCourses == 1) {
7275
$c->stash->{courseID} = $matchingCourses[0]->course_id;
73-
} else {
76+
} elsif ($siteEnvironment->{LTI}{v1p3}{allowCourseSelection}) {
7477
for (@matchingCourses) {
7578
my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $_->course_id }) };
7679
if ($@) { warn "Failed to initialize course environment for $_: $@\n"; next; }
@@ -86,6 +89,98 @@ sub initializeRoute ($c, $routeCaptures) {
8689
last;
8790
}
8891
}
92+
93+
# If a matching course was not found in the LTI course map and the site is configured to allow
94+
# course selection, then construct a list of all courses for which the LTI 1.3 authentication
95+
# parameters match and that have a user that has the email address set that matches the email
96+
# address sent from the LMS, and that has access_instructor_tools and modify_problem_sets
97+
# permissions.
98+
unless (defined $c->stash->{courseID}) {
99+
my @userCourses;
100+
101+
my $claims = $c->stash->{lti_jwt_claims};
102+
my $extract_claim = sub ($key) {
103+
my $value = $claims;
104+
for (split '#', $key) {
105+
if (defined $value->{$_}) {
106+
$value = $value->{$_};
107+
} else {
108+
return;
109+
}
110+
}
111+
return $value;
112+
};
113+
114+
my %mappedCourses = map { $_->course_id => 1 } $nonNativeDB->getLTICourseMapsWhere;
115+
116+
my $firstFoundUserId;
117+
118+
for (listCourses(WeBWorK::CourseEnvironment->new)) {
119+
next if $mappedCourses{$_};
120+
121+
my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $_ }) };
122+
if ($@) { $c->log->error("Failed to initialize course environment for $_: $@"); next; }
123+
124+
if (($ce->{LTIVersion} // '') eq 'v1p3'
125+
&& $ce->{LTI}{v1p3}{PlatformID}
126+
&& $ce->{LTI}{v1p3}{PlatformID} eq $c->stash->{LTILaunchData}->data->{PlatformID}
127+
&& $ce->{LTI}{v1p3}{ClientID}
128+
&& $ce->{LTI}{v1p3}{ClientID} eq $c->stash->{LTILaunchData}->data->{ClientID}
129+
&& $ce->{LTI}{v1p3}{DeploymentID}
130+
&& $ce->{LTI}{v1p3}{DeploymentID} eq
131+
$c->stash->{LTILaunchData}->data->{DeploymentID}
132+
&& $ce->{LTI}{v1p3}{preferred_source_of_username})
133+
{
134+
my $userIdSource = '';
135+
my $userId = $extract_claim->($ce->{LTI}{v1p3}{preferred_source_of_username});
136+
$userIdSource = $ce->{LTI}{v1p3}{preferred_source_of_username} if $userId;
137+
if (!defined $userId && $ce->{LTI}{v1p3}{fallback_source_of_username}) {
138+
$userId = $extract_claim->($ce->{LTI}{v1p3}{fallback_source_of_username});
139+
$userIdSource = $ce->{LTI}{v1p3}{fallback_source_of_username} if $userId;
140+
}
141+
next unless defined $userId;
142+
$userId =~ s/@.*$//
143+
if $userIdSource eq 'email' && $ce->{LTI}{v1p3}{strip_domain_from_email};
144+
$userId = lc($userId) if $ce->{LTI}{v1p3}{lowercase_username};
145+
146+
# Assert that the user id for the user is the same in all courses offered for selection.
147+
# Otherwise things will fall apart when the content_selection method attempts to sign
148+
# the user out of the intial guess course and into a different selected course.
149+
$firstFoundUserId = $userId unless defined $firstFoundUserId;
150+
next unless $userId eq $firstFoundUserId;
151+
152+
my $db = WeBWorK::DB->new($ce);
153+
my $user = $db->getUser($userId);
154+
155+
# Only allow courses for which the user has the email address set, and the email address
156+
# matches the email address sent from the LMS. This means that if one course has the LTI
157+
# user selection parameters set differently than another, then not all courses for the
158+
# user will actually be listed. This should be considered a configuration error, and the
159+
# system administrator should not set courses up this way.
160+
next unless $user && $user->email_address && $claims->{email} eq $user->email_address;
161+
162+
# Only allow courses for which the user has the access_instructor_tools and
163+
# modify_problem_sets permissions. The WeBWorK::Authz object for this request has not
164+
# yet been constructed, so the permissions check has to be performed manually.
165+
my $permission = $db->getPermissionLevel($userId);
166+
next
167+
unless $permission
168+
&& $permission->permission >=
169+
$ce->{userRoles}{ $ce->{permissionLevels}{access_instructor_tools} }
170+
&& $permission->permission >=
171+
$ce->{userRoles}{ $ce->{permissionLevels}{modify_problem_sets} };
172+
173+
push(@userCourses, $_);
174+
}
175+
}
176+
if (@userCourses) {
177+
# Use the first matching course for initial authentication. All matching courses have the
178+
# same LTI 1.3 authentication parameters, and so presumably authentication will work an any
179+
# of them.
180+
$c->stash->{courseID} = $userCourses[0];
181+
$c->stash->{courseChoices} = \@userCourses;
182+
}
183+
}
89184
}
90185
} else {
91186
$c->stash->{courseID} = $c->stash->{LTILaunchData}->data->{courseID}
@@ -183,6 +278,13 @@ sub launch ($c) {
183278
? (
184279
courseID => $c->stash->{courseID},
185280
initial_request => 1,
281+
$c->stash->{courseChoices}
282+
? (
283+
course_choices => $c->stash->{courseChoices},
284+
lms_context_id =>
285+
$c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti/claim/context'}{id}
286+
)
287+
: (),
186288
accept_multiple =>
187289
$c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'}
188290
{accept_multiple},
@@ -210,6 +312,61 @@ sub content_selection ($c) {
210312
unless $c->authz->hasPermissions($c->authen->{user_id}, 'modify_problem_sets');
211313

212314
if ($c->param('initial_request')) {
315+
my @courseChoices = $c->param('course_choices');
316+
if (@courseChoices > 1) {
317+
return $c->render(
318+
'ContentGenerator/LTI/content_item_course_selection',
319+
courseChoices => \@courseChoices,
320+
forwardParams => {
321+
accept_multiple => $c->param('accept_multiple'),
322+
deep_link_return_url => $c->param('deep_link_return_url'),
323+
lms_context_id => $c->param('lms_context_id'),
324+
$c->param('data') ? (data => $c->param('data')) : (),
325+
}
326+
);
327+
} elsif (@courseChoices) {
328+
# If only one course matched for this user, then just use it.
329+
# Add it to the course map and skip course selection.
330+
$c->db->setLTICourseMap($courseChoices[0], $c->param('lms_context_id'));
331+
}
332+
333+
my $selectedCourse = $c->param('selected_course');
334+
if ($selectedCourse && $selectedCourse ne $c->ce->{courseName}) {
335+
# The user has selected a course that is not the initial guess (the first course found that the user fit
336+
# into), and the user was authenticated into that inital guess course. So sign the user out of that course,
337+
# and sign the user into the selected course. This does not go through the entire authentication process,
338+
# but presumably that would succeed since the authhentication parameters for the two courses match and the
339+
# permissions of the user were checked in both courses already.
340+
my $key = $c->db->getKey($c->authen->{user_id});
341+
$c->db->deleteKey($c->authen->{user_id});
342+
$c->signed_cookie(
343+
'WeBWorKCourseSession.' . $c->ce->{courseName},
344+
'',
345+
{
346+
domain => $c->app->sessions->cookie_domain,
347+
expires => time,
348+
httponly => 1,
349+
path => $c->app->sessions->cookie_path,
350+
samesite => $c->app->sessions->samesite,
351+
secure => $c->app->sessions->secure
352+
}
353+
);
354+
355+
$c->stash->{courseID} = $selectedCourse;
356+
my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $selectedCourse }) };
357+
if ($@) {
358+
$c->log->error("Failed to initialize course environment for $selectedCourse: $@");
359+
return $c->render('ContentGenerator/LTI/content_item_selection_error',
360+
errorMessage => $c->maketext('The course [_1] is not correctly configured.', $selectedCourse));
361+
}
362+
$c->ce($ce);
363+
my $db = WeBWorK::DB->new($ce);
364+
$c->db($db);
365+
$c->setSessionParams;
366+
367+
$c->db->setLTICourseMap($selectedCourse, $c->param('lms_context_id'));
368+
}
369+
213370
return $c->render(
214371
'ContentGenerator/LTI/content_item_selection',
215372
visibleSets => [ $c->db->getGlobalSetsWhere({ visible => 1 }, [qw(due_date set_id)]) ],
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!DOCTYPE html>
2+
<html <%== $c->output_course_lang_and_dir %>>
3+
%
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title><%= maketext('Available Courses') %></title>
8+
<%= stylesheet $c->url({ type => 'webwork', name => 'theme', file => 'bootstrap.css' }) =%>
9+
</head>
10+
%
11+
<body class="m-3">
12+
<div class="container-fluid">
13+
<%= form_for current_route, method => 'POST', begin =%>
14+
<%= $c->hidden_authen_fields =%>
15+
<%= hidden_field courseID => $courseID =%>
16+
<%= hidden_field initial_request => 1 =%>
17+
% for (keys %$forwardParams) {
18+
<%= hidden_field $_ => $forwardParams->{$_} =%>
19+
% }
20+
<fieldset class="mb-3">
21+
<legend><%= maketext('Available Courses') %></legend>
22+
% for (@$courseChoices) {
23+
<div class="form-check">
24+
<%= radio_button selected_course => $_, id => "${_}_id", class => 'form-check-input' =%>
25+
<%= label_for "${_}_id" => $_, class => 'form-check-label' =%>
26+
</div>
27+
% }
28+
</fieldset>
29+
<%= submit_button maketext('Submit Choice'), class => 'btn btn-primary' =%>
30+
<% end =%>
31+
</div>
32+
</body>
33+
%
34+
</html>

0 commit comments

Comments
 (0)