From 8c08b398b4e6f82f950d6906ed25bd396aeb3778 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 27 Jan 2024 07:19:24 -0600 Subject: [PATCH] Add content item selection for LTI. See https://www.imsglobal.org/specs/lticiv1p0/specification and https://www.imsglobal.org/spec/lti-dl/v2p0. This will be needed for Moodle 4.03 (and beyond?) but might be nice for other LMS's as well. If the new content selection endpoints (https://your.webwork2.server.domain/ltiadvanced/content_selection for LTI 1.1 and https://your.webwork2.server.domain/ltiadvantage/content_selection for LTI 1.3) are added to the LMS external tool configuration, then instructors can select from a list of homework assignments in the webwork2 course, and it will add the correct link(s) to the LMS. For Moodle (at least for 4.1 and up) you can add links to all assignments with a few clicks of the mouse. Canvas is not quite as nice as it only allows selection one assignment at a time (at least as far as I have been able to figure out at this point). As usual, I am not sure what D2L can do. In order for this to work each webwork2 course must set the new LTI configuration variable `$LMSCourseName` in its course.conf file. This is the name of the LMS course that is associated with the webwork2 course. This must be done because these content item requests do not have the webwork2 course name in the link like the usual webwork2 course or assignment links. In order to support multiple LMS's with a single webwork2 server there is a little more that must be done for LTI 1.1, particularly if there is the possibility that courses from different LMS's might have the same LMS course name. In this case each LMS must use a different consumer key for its external tool for the webwork2 server, and the `$LTI{v1p1}{ConsumerKey}` must be set in each courses course.conf file. For LTI 1.3 no additional configuration is needed because the authentication parameters already uniquely identify an LMS external tool. Note that none of this new configuration is needed in the case that the webwork2 server administrator does not want instructors to be able to utilize the new content item selection routes. The `$LTICourseName` does not need to be set in the `course.conf` files, the `$LTI{v1p1}{ConsumerKey}` does not need to be set, and both versions of LTI will continue to work as they did before. Now for implementation details. For LTI 1.1 nothing is changed in the way that authentication works unless the LMS sends the user to the new `ltiadvanced_content_selection` route. In that case the correct course is identified by iterating through the webwork2 courses on the server, loading the course environment for each one, and finding the one with the correct LMS course name (and ConsumerKey if there is more than one with the same LMS course name). This is probably rather inefficient, but fortunately this is only done for content item selection requests. For LTI 1.3 things needed to be reworked quite a bit more. All of the hackery that was previously implemented to pass the course id, state, and nonce from the login request to the launch request using the key table had to go. Instead there is now a non-native table (the lti_launch_data table) that this information is stored in, identified by the state generated from the parameters provided by the LMS in the login request which are unique to the LMS user and request. This table can be accessed without a webwork2 course id since it is non-native. This allows for the state to be retrieved from the database in the launch request, and the JWT to be decoded earlier in that request (previously this was done in the authentication step, and now it is done before that in the webwork2 dispatch step). It needs to be done at dispatch time because the JWT contains the information that determines if it is a deep linking request or not. For both the login and launch requests a webwork2 course is identified by iterating through courses, loading course environments, and finding an LTI 1.3 tool configuration that matches that in the request. Again this is probably rather inefficient, but again this is only done for content item selection requests. There is a new hook that can be utilized by content generator modules to modify parameters, route captures, and do other things early in the dispatch phase before a course id has been determined and a course environment and database instance is initialized. The hook can be utilized by the content generator module providing an `initializeRoute` method. This is just clean up really. A lot of route specific special cases were starting to build up in the `dispatch` method in `lib/WeBWork.pm`, and this makes it possible for that code to be moved out of there. --- conf/authen_LTI.conf.dist | 37 +- conf/authen_LTI_1_1.conf.dist | 12 + conf/database.conf.dist | 9 + lib/WeBWorK.pm | 50 +-- lib/WeBWorK/Authen/LTIAdvantage.pm | 247 ++---------- .../Authen/LTIAdvantage/SubmitGrade.pm | 7 +- .../ContentGenerator/InstructorRPCHandler.pm | 9 + lib/WeBWorK/ContentGenerator/LTIAdvanced.pm | 147 ++++++++ lib/WeBWorK/ContentGenerator/LTIAdvantage.pm | 352 +++++++++++++++++- lib/WeBWorK/ContentGenerator/RenderViaRPC.pm | 17 + lib/WeBWorK/DB.pm | 58 +++ lib/WeBWorK/DB/Record/LTILaunchData.pm | 37 ++ lib/WeBWorK/Utils/Routes.pm | 18 + .../LTI/content_item_selection.html.ep | 61 +++ .../LTI/self_posting_form.html.ep | 9 + .../LTIAdvantage/login_repost.html.ep | 17 - 16 files changed, 793 insertions(+), 294 deletions(-) create mode 100644 lib/WeBWorK/ContentGenerator/LTIAdvanced.pm create mode 100644 lib/WeBWorK/DB/Record/LTILaunchData.pm create mode 100644 templates/ContentGenerator/LTI/content_item_selection.html.ep create mode 100644 templates/ContentGenerator/LTI/self_posting_form.html.ep delete mode 100644 templates/ContentGenerator/LTIAdvantage/login_repost.html.ep diff --git a/conf/authen_LTI.conf.dist b/conf/authen_LTI.conf.dist index 091bdc4e89..22e47f3134 100644 --- a/conf/authen_LTI.conf.dist +++ b/conf/authen_LTI.conf.dist @@ -4,33 +4,33 @@ # Configuration for using LTI authentication. # To enable this file, uncomment the appropriate lines in localOverrides.conf # The settings in this file apply to both LTI 1.1 and LTI 1.3 authentication. -# The settings specific to the LTI 1.1 authenticatio are in authen_LTI_1_1.conf. -# The settings specific to the LTI 1.3 authenticatio are in authen_LTI_1_3.conf. +# The settings specific to the LTI 1.1 authentication are in authen_LTI_1_1.conf. +# The settings specific to the LTI 1.3 authentication are in authen_LTI_1_3.conf. ################################################################################################ -# Set debug_lti_parameters to 1 to have LTI calling parameters printed to HTML page for -# debugging. This is useful when setting things up for the first time because different LMS -# systems have different parameters +# Set debug_lti_parameters to 1 to enable LTI debugging. This is useful when setting things up +# for the first time because different LMS systems have different parameters. Note that for LTI +# 1.1 these debug messages will be displayed in the HTML page. However, for LTI 1.3 none of the +# debug messages will be displayed in the HTML page due to the nature of how LTI 1.3 +# authentication works with automatic form submissions and redirects. These messages can be +# found in the webwork2 app log in that case. $debug_lti_parameters = 0; -# To get more information on passing grades back to the LMS enmass set debug_lti_grade_passback +# To get more information on passing grades back to the LMS en mass set debug_lti_grade_passback # to one. And set the LTIMassUpdateInterval to 60 (seconds). $debug_lti_grade_passback = 0; # This will print into the webwork2 app log the success or failure of updating each user/set. -# Setting both debug_lti_parameters and debug_lti_grade_passback will cause the full request and -# response between the LMS and WW to be printed into webwork2 app log file for each user/set -# update of the grade. +# Setting both debug_lti_parameters and debug_lti_grade_passback will cause the full requests +# and responses between the LMS and WW to be printed into webwork2 app log file for each +# user/set update of the grade. # The switches above can be set in course.conf to enable debugging for just one course. # If you want even more information enable the debug facility in the webwork2.mojolicious.yml # file. This will print extensive debugging messages for all courses. -# Note that for LTI 1.3 not all debug message will make it back to the HTML page due to the -# nature of how LTI 1.3 authentication works with automatic form submissions and redirects. - ################################################################################################ # Authentication settings ################################################################################################ @@ -58,6 +58,13 @@ $authen{user_module} = [ # disable LTI authentication for the course. $LTIVersion = 'v1p1'; +# The $LMSCourseName is the name of the course in the LMS that uses a particular webwork2 course +# as a tool provider. This should never be set in this file, but rather should be set in each +# webwork2 course's course.conf file. This only needs to be set in the course.conf files for +# each course if you want to allow the instructors of courses on this server to utilize content +# item selection requests. +#$LMSCourseName = 'LMS Course Title'; + # WeBWorK will automatically create users when logging in via the LMS for the first time as long # as the permission level is less than or equal to the permission level of this setting. For # security reasons accounts with high permissions should not be auto created via LTI requests. @@ -104,7 +111,7 @@ $external_auth = 0; # address should be the address of that set in the Course. Students will receive a grade for # each Link/Assignment which is determined by their percentage homework grade on the Set which # the Link/Assignment points to. Students need to use the Link/Assignment in the LMS at least -# once to enable grade passback. In particular when working in this mode it is recommended that +# once to enable grade pass back. In particular when working in this mode it is recommended that # you only allow students to log in via the LMS. # Note: For both of these modes only the grades are passed back. In particular nothing else @@ -115,7 +122,7 @@ $external_auth = 0; # Site Administrator Note for LTI 1.3: This uses OAuth2 RSA private/public keys. These keys # are automatically generated the first time that they are needed. It is recommended that new # keys are generated on a regular basis. At this point, key rotation is not automatic for -# webwork2. Howevever, it is simple. Delete the files $webwork2_dir/DATA/lti_private_key.json +# webwork2. However, it is simple. Delete the files $webwork2_dir/DATA/lti_private_key.json # and $webwork2_dir/DATA/lti_public_key.json. New keys will then be automatically generated the # next time they are needed. Probably a good rule of thumb (for now) is to do this at the # beginning of every term. @@ -128,7 +135,7 @@ $LTIGradeMode = ''; # keeps students grades up to date but can be a drain on the server. $LTIGradeOnSubmit = 1; -# If CheckPrior is set to 1 then the current LMS grade will be checked first, and if the grade +# If $LTICheckPrior is set to 1 then the current LMS grade will be checked first, and if the grade # has not changed then the grade will not be updated. This is intended to reduce changes to LMS # records when no real grade change occurred. It requires a 2 round process, first querying the # current grade from the LMS and then when needed making the grade submission. diff --git a/conf/authen_LTI_1_1.conf.dist b/conf/authen_LTI_1_1.conf.dist index 2353b66a28..efe6592ef0 100644 --- a/conf/authen_LTI_1_1.conf.dist +++ b/conf/authen_LTI_1_1.conf.dist @@ -123,6 +123,18 @@ $LTI{v1p1}{preferred_source_of_student_id} = ''; # You should choose your own secret word for security and should treat it like a password. $LTI{v1p1}{BasicConsumerSecret} = ''; +# The consumer key is entered in the LMS request form, and needs to match the entry here if this +# is set. This is only used for content item selection requests from an LMS, and this is does +# not even need to be set for that unless there are multiple courses from different LMS's that +# have the same LMS course name and both use a course on this webwork2 server. In that case +# each LMS must use a different consumer key, and the correct consumer keys should be set in the +# course.conf file for each course. If this server is a tool provider for multiple LMS's, then +# it is recommended that this be set. Usually it is not useful to set this here. However, if +# most courses use one LMS and only a few use another, then the consumer key for the first LMS +# could be set here, and then this would only need to be set in the course.conf files for the +# few courses use a different LMS. +#$LTI{v1p1}{ConsumerKey} = 'webwork'; + # The purpose of the LTI nonces is to prevent man-in-the-middle attacks. The NonceLifeTime (in # seconds) must be short enough to prevent at least casual man-in-the-middle attacks but long # enough to accommodate normal server and networking delays (and perhaps non-synchronization of diff --git a/conf/database.conf.dist b/conf/database.conf.dist index 3b13b4e393..ca2c696824 100644 --- a/conf/database.conf.dist +++ b/conf/database.conf.dist @@ -115,6 +115,15 @@ $dbLayouts{sql_single} = { engine => $database_storage_engine, params => { %sqlParams, non_native => 1 }, }, + lti_launch_data => { + record => "WeBWorK::DB::Record::LTILaunchData", + schema => "WeBWorK::DB::Schema::NewSQL::Std", + driver => "WeBWorK::DB::Driver::SQL", + source => $database_dsn, + engine => $database_storage_engine, + character_set => $database_character_set, + params => { %sqlParams, non_native => 1 }, + }, password => { record => "WeBWorK::DB::Record::Password", schema => "WeBWorK::DB::Schema::NewSQL::Std", diff --git a/lib/WeBWorK.pm b/lib/WeBWorK.pm index d0756cf6fd..99fffbc651 100644 --- a/lib/WeBWorK.pm +++ b/lib/WeBWorK.pm @@ -87,42 +87,6 @@ async sub dispatch ($c) { my $displayModule = ref $c; my %routeCaptures = %{ $c->stash->{'mojo.captures'} }; - if ($c->current_route =~ /^(render_rpc|instructor_rpc|html2xml)$/) { - $c->{rpc} = 1; - - # This provides compatibility for legacy html2xml parameters. - # This should be deleted when the html2xml endpoint is removed. - if ($c->current_route eq 'html2xml') { - for ([ 'userID', 'user' ], [ 'course_password', 'passwd' ], [ 'session_key', 'key' ]) { - $c->param($_->[1], $c->param($_->[0])) if defined $c->param($_->[0]) && !defined $c->param($_->[1]); - } - } - - # Get the courseID from the parameters for a remote procedure call. - $routeCaptures{courseID} = $c->param('courseID') if $c->param('courseID'); - } - - # If this is the login phase of an LTI 1.3 login, then extract the courseID from the target_link_uri. - if ($c->current_route eq 'ltiadvantage_login') { - my $target = $c->param('target_link_uri') ? $c->url_for($c->param('target_link_uri'))->path : ''; - $c->stash->{courseID} = $1 if $target =~ m|$location/([^/]*)|; - $routeCaptures{courseID} = $c->stash->{courseID} || '___'; - } - - # If this is the launch phase of an LTI 1.3 login, then get the courseID from the state. Note that this data can - # not be trusted. It will be verified once the JWT is decrypted. Also see the comments about the hacks involved - # here in the WeBWorK::Authen::LTIAdvantage::verify method. - if ($c->current_route eq 'ltiadvantage_launch') { - $c->stash->{LTIState} = $c->param('state') || ',set_id:'; - ($c->stash->{lti_lms_user_id}, $c->stash->{courseID}) = split ',set_id:', $c->stash->{LTIState}; - if ($c->stash->{courseID} && $c->stash->{lti_lms_user_id}) { - $c->stash->{courseID} =~ s/@/-/g; - $routeCaptures{courseID} = $c->stash->{courseID}; - } else { - $routeCaptures{courseID} = '___'; - } - } - debug("The display module for this route is $displayModule\n"); debug("This route has the following captures:\n"); for my $key (keys %routeCaptures) { @@ -153,6 +117,9 @@ async sub dispatch ($c) { debug(('-' x 80) . "\n"); + # A controller can customize route captures, parameters, and stash values if it provides an initializeRoute method. + $c->initializeRoute(\%routeCaptures) if $c->can('initializeRoute'); + # Create Course Environment debug("We need to get a course environment (with or without a courseID!)\n"); my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $routeCaptures{courseID} }) }; @@ -196,9 +163,11 @@ async sub dispatch ($c) { debug("We got a courseID from the route, now we can do some stuff:\n"); return (0, 'This course does not exist.') - unless (-e $ce->{courseDirs}{root} + unless ($routeCaptures{courseID} eq '___' + || -e $ce->{courseDirs}{root} || -e "$ce->{webwork_courses_dir}/$ce->{admin_course_id}/archives/$routeCaptures{courseID}.tar.gz"); - return (0, 'This course has been archived and closed.') unless -e $ce->{courseDirs}{root}; + return (0, 'This course has been archived and closed.') + unless $routeCaptures{courseID} eq '___' || -e $ce->{courseDirs}{root}; debug("...we can create a database object...\n"); my $db = WeBWorK::DB->new($ce->{dbLayout}); @@ -266,6 +235,11 @@ async sub dispatch ($c) { await WeBWorK::ContentGenerator::Login->new($c)->go(); return 0; } + } else { + return (0, + 'No WeBWorK course was found associated to this LMS course. ' + . 'If this is an error, please contact the WeBWorK system administrator.') + if $c->current_route eq 'ltiadvantage_login'; } return 1; diff --git a/lib/WeBWorK/Authen/LTIAdvantage.pm b/lib/WeBWorK/Authen/LTIAdvantage.pm index 49ad069138..8e13988cbc 100644 --- a/lib/WeBWorK/Authen/LTIAdvantage.pm +++ b/lib/WeBWorK/Authen/LTIAdvantage.pm @@ -27,15 +27,7 @@ use strict; use warnings; use experimental 'signatures'; -use URI::Escape; -use Mojo::UserAgent; -use Mojo::JSON qw(decode_json); -use Math::Random::Secure qw(irand); -use Digest::SHA qw(sha256_hex); -use Crypt::JWT qw(decode_jwt); - use WeBWorK::Debug; -use WeBWorK::CourseEnvironment; use WeBWorK::Localize; use WeBWorK::Utils qw(formatDateTime); use WeBWorK::Utils::Instructor qw(assignSetToUser); @@ -67,7 +59,7 @@ sub request_has_data_for_this_verification_module ($self) { return 0; } - # LTI 1.3 requests are exactly those that go through these routes. + # LTI 1.3 authentication requests are exactly those that go through these routes. if ($c->current_route eq 'ltiadvantage_login' || $c->current_route eq 'ltiadvantage_launch') { debug('LTIAdvantage returning that it has sufficient data'); return 1; @@ -83,65 +75,27 @@ sub verify ($self) { # This happens before the parent class calls request_has_data_for_this_verification_module, # so make sure to check the LTIVersion to ensure the course is configured for LTI 1.3. - if ($ce->{LTIVersion} && $ce->{LTIVersion} eq 'v1p3' && $c->current_route eq 'ltiadvantage_login') { - unless ($c->param('iss') - && $ce->{LTI}{v1p3}{PlatformID} eq $c->param('iss') - && $c->param('client_id') - && $ce->{LTI}{v1p3}{ClientID} eq $c->param('client_id') - && $c->param('lti_message_hint') - && $c->param('login_hint')) - { - warn "The LTI Advantage login route was accessed with invalid or missing parameters.\n" - if $ce->{debug_lti_parameters}; - debug('The LTI Advantage login route was accessed with invalid or missing parameters.'); - return 0; - } + if ($ce->{LTIVersion} && $ce->{LTIVersion} eq 'v1p3') { + if ($c->current_route eq 'ltiadvantage_login') { + unless ($c->param('iss') + && $ce->{LTI}{v1p3}{PlatformID} eq $c->param('iss') + && $c->param('client_id') + && $ce->{LTI}{v1p3}{ClientID} eq $c->param('client_id') + && $c->param('lti_message_hint') + && $c->param('login_hint')) + { + warn "The LTI Advantage login route was accessed with invalid or missing parameters.\n" + if $ce->{debug_lti_parameters}; + debug('The LTI Advantage login route was accessed with invalid or missing parameters.'); + return 0; + } - warn "The LTI Advantage login route was accessed with the appropriate parameters.\n" - if $ce->{debug_lti_parameters}; - debug('The LTI Advantage login route was accessed with the appropriate parameters.'); - - # Create a state and nonce and save them. These are generated so that they are cryptographically secure values. - # - # The courseID is included in the state so that it can be retrieved in the launch request before a course - # environment and database are available (in fact so that those things can be accessed for this course). The - # launch request does not contain any unencrypted information other than the state, and the LMS is required to - # send the state back unmodified. This kind of breaks the rule that the state be opaque though. - # - # To make this work when session_management_via is set to 'key', the hacks are abundant and ugly! This would be - # much simpler if that setting did not need to be supported. Then the state, nonce, and courseID could just be - # stored in a cookie. It is not even possible to do that when session_management_via is set to 'session_cookie' - # because this information is needed before it is known what that setting is. So the ugly hack described below - # needs to always be used. - # - # To save this to the database a user_id value is needed that is unique for this request, satisfies the database - # constrains on user_id's, and won't collide with webwork user_id's. So the login_hint (the LMS user id) and - # courseID are joined with ',set_id:' (hacking into the existing login proctor hack -- what an ugly hack to - # begin with). That means the LMS user id will also be needed to get the information back from the database, - # and so this is included in the state as well. - # - # To make matters worse, courseID's can contain hyphens, but user_id's can not. Fortunately courseID's can not - # contain ampersats, while user_id's can. So the hyphens in the courseID are replaced with ampersats. - # - # Finally, hack into the existing "nonce" key hack. The actual state and nonce are joined with a tab - # character and saved in the set_id field. Since the key value is "nonce", the database will not check - # to see that the user_id exists in the user table. - - my $key_id = join(',set_id:', $c->param('login_hint'), $c->stash->{courseID} =~ s/-/@/gr); - $c->stash->{LTIState} = - join(',set_id:', $key_id, sha256_hex(join('', map { [ 0 .. 9, 'a' .. 'z' ]->[ irand(36) ] } 1 .. 20))); - $c->stash->{LTINonce} = sha256_hex(join('', map { [ 0 .. 9, 'a' .. 'z' ]->[ irand(36) ] } 1 .. 20)); - - $c->db->deleteKey($key_id); # Delete a key with this user_id if one happens to exist. - my $key = $c->db->newKey( - user_id => $key_id, - key => 'nonce', - timestamp => time, - set_id => join("\t", $c->stash->{LTIState}, $c->stash->{LTINonce}) - ); - $c->db->addKey($key); + warn "The LTI Advantage login route was accessed with the appropriate parameters.\n" + if $ce->{debug_lti_parameters}; + debug('The LTI Advantage login route was accessed with the appropriate parameters.'); - return 1; + return 1; + } } return $self->SUPER::verify; @@ -157,52 +111,16 @@ sub get_credentials ($self) { # Disable password login $self->{external_auth} = 1; - # Retrieve the state and nonce from the key table and delete the key. - # See the comments about the hacks involved here in the WeBWorK::Authen::LTIAdvantage::verify method above. - my $key_id = join(',set_id:', $c->stash->{lti_lms_user_id}, $c->stash->{courseID} =~ s/-/@/gr); - my $key = $c->db->getKey($key_id); - ($c->stash->{LTIState}, $c->stash->{LTINonce}) = split "\t", $key->set_id; - $c->db->deleteKey($key_id); - - $self->purge_old_state_keys; - - # Verify the state. - unless ($c->param('state') && $c->stash->{LTIState} && $c->stash->{LTIState} eq $c->param('state')) { + # If there was an error during the extraction of the JWT, then authentication fails here. + if ($c->stash->{LTIAuthenError}) { $self->{error} = $c->maketext( 'There was an error during the login process. Please speak to your instructor or system administrator.'); - warn "Invalid state in response from LMS. Possible CSFR.\n" if $ce->{debug_lti_parameters}; - debug('Invalid state in response from LMS. Possible CSFR.'); + warn $c->stash->{LTIAuthenError} . "\n" if $ce->{debug_lti_parameters}; + debug($c->stash->{LTIAuthenError}); return 0; } - return 0 unless (my $claims = $self->extract_jwt_claims); - - if ($ce->{debug_lti_parameters}) { - warn "====== JWT PARAMETERS RECEIVED ======\n"; - warn $c->dumper($claims); - warn "\n"; - } - - # Verify the nonce. - if (!defined $claims->{nonce} || $claims->{nonce} ne $c->stash->{LTINonce}) { - $self->{error} = $c->maketext( - 'There was an error during the login process. Please speak to your instructor or system administrator.'); - warn "Incorrect nonce received in response.\n" if $ce->{debug_lti_parameters}; - debug('Incorrect nonce received in response so LTIAdvantage::get_credentials is returning 0.'); - return 0; - } - - # Verify the deployment id. - if (!defined $claims->{'https://purl.imsglobal.org/spec/lti/claim/deployment_id'} - || $claims->{'https://purl.imsglobal.org/spec/lti/claim/deployment_id'} ne $ce->{LTI}{v1p3}{DeploymentID}) - { - $c->log->info($claims->{'https://purl.imsglobal.org/spec/lti/claim/deployment_id'}); - $self->{error} = $c->maketext( - 'There was an error during the login process. Please speak to your instructor or system administrator.'); - warn "Incorrect deployment id received in response.\n" if $ce->{debug_lti_parameters}; - debug('Incorrect deployment id received in response so LTIAdvantage::get_credentials is returning 0.'); - return 0; - } + my $claims = $c->stash->{lti_jwt_claims}; # Get the target_link_uri from the claims. $c->stash->{LTILauncRedirect} = $claims->{'https://purl.imsglobal.org/spec/lti/claim/target_link_uri'}; @@ -216,20 +134,6 @@ sub get_credentials ($self) { return 0; } - # Get the courseID from the target_link_uri and verify that it is the same as the one that was in the state. - my $location = $c->location; - my $target = $c->url_for($c->stash->{LTILauncRedirect})->path; - my $courseID; - $courseID = $1 if $target =~ m|$location/([^/]*)|; - - unless ($courseID && $courseID eq $c->stash->{courseID}) { - $self->{error} = $c->maketext( - 'There was an error during the login process. Please speak to your instructor or system administrator.'); - debug('The courseID in the login request does not match the courseID in the launch request JWT. ' - . 'So LTIAdvantage::get_credentials is returning 0.'); - return 0; - } - # Determine the user_id to use, if possible. if (!$ce->{LTI}{v1p3}{preferred_source_of_username}) { warn 'LTI is not properly configured (no preferred_source_of_username). ' @@ -243,7 +147,7 @@ sub get_credentials ($self) { my $user_id_source = ''; my $type_of_source = ''; - $self->{email} = defined $claims->{email} ? uri_unescape($claims->{email} // '') : ''; + $self->{email} = $claims->{email} // ''; my $extract_claim = sub ($key) { my $value = $claims; @@ -319,7 +223,9 @@ sub get_credentials ($self) { # Extract a possible setID from the target_link_uri. This may not be an actual setID. # That will be verified later in WeBWorK::Authen::LTIAdvantage::SubmitGrade::update_sourcedid. - $c->stash->{setID} = $1 if $target =~ m|$location/$courseID/([^/]*)|; + my $location = $c->location; + my $target = $c->url_for($c->stash->{LTILauncRedirect})->path; + $c->stash->{setID} = $1 if $target =~ m|$location/$ce->{courseName}/([^/]*)|; $self->{login_type} = 'normal'; $self->{credential_source} = 'LTIAdvantage'; @@ -335,101 +241,6 @@ sub get_credentials ($self) { return 0; } -# Get the public keyset from the LMS and cache it in the database or just return what is already cached in the database. -sub get_lms_public_keyset ($self, $renew = 0) { - my $c = $self->{c}; - my $ce = $c->ce; - my $db = $c->db; - - my $keyset_str; - - if (!$renew) { - $keyset_str = $db->getSettingValue('LTIAdvantageLMSPublicKey'); - return decode_json($keyset_str) if $keyset_str; - } - - # Get public keyset from the LMS. - my $response = Mojo::UserAgent->new->get($ce->{LTI}{v1p3}{PublicKeysetURL})->result; - unless ($response->is_success) { - $self->{error} = $c->maketext( - 'There was an error during the login process. Please speak to your instructor or system administrator.'); - warn 'Failed to obtain public key from LMS: ' . $response->message . "\n" if $ce->{debug_lti_parameters}; - debug('Failed to obtain public key from LMS: ' . $response->message); - return; - } - - $keyset_str = $response->body; - my $keyset = eval { decode_json($keyset_str) }; - if ($@ || ref($keyset) ne 'HASH' || !defined $keyset->{keys}) { - $self->{error} = $c->maketext( - 'There was an error during the login process. Please speak to your instructor or system administrator.'); - warn "Received an invalid response from the LMS public keyset URL.\n" if $ce->{debug_lti_parameters}; - debug('Received an invalid response from the LMS public keyset URL.'); - return; - } - $db->setSettingValue('LTIAdvantageLMSPublicKey', $keyset_str); - - return $keyset; -} - -sub extract_jwt_claims ($self) { - my $c = $self->{c}; - my $ce = $c->ce; - - my %jwt_params = ( - token => $c->param('id_token'), - verify_iss => $ce->{LTI}{v1p3}{PlatformID}, - verify_aud => $ce->{LTI}{v1p3}{ClientID}, - verify_iat => 1, - verify_exp => 1, - # This just checks that this claim is present. - verify_sub => sub ($value) { return $value =~ /\S/ } - ); - - $jwt_params{kid_keys} = $self->get_lms_public_keyset; - return unless $jwt_params{kid_keys}; - - my $claims = eval { decode_jwt(%jwt_params); }; - - # If decoding of the JWT failed, then try to get a new LMS public keyset and try again. It could be that the - # keyset that was previously saved in the database has expired. - unless ($claims) { - $jwt_params{kid_keys} = $self->get_lms_public_keyset(1); - $claims = eval { $claims = decode_jwt(%jwt_params) }; - } - if ($@) { - $self->{error} = $c->maketext( - 'There was an error during the login process. Please speak to your instructor or system administrator.'); - warn "Failed to decode token received from LMS: $@\n" if $ce->{debug_lti_parameters}; - debug("Failed to decode token received from LMS: $@"); - return; - } - - return $claims; -} - -sub purge_old_state_keys ($self) { - my $c = $self->{c}; - my $ce = $c->ce; - my $db = $c->db; - my $time = time; - - my @userIDs = $db->listKeys(); - my @keys = $db->getKeys(@userIDs); - - my $modCourseID = $ce->{courseName} =~ s/-/@/gr; - - # Delete any "nonce state" keys for this course that are older than $ce->{LTI}{v1p3}{StateKeyLifetime}. - for my $key (@keys) { - $db->deleteKey($key->user_id) - if $key->key eq "nonce" - && ($time - $key->timestamp > $ce->{LTI}{v1p3}{StateKeyLifetime}) - && $key->user_id =~ /,set_id:$modCourseID$/; - } - - return; -} - # Minor modification of method in superclass. sub check_user ($self) { my $c = $self->{c}; diff --git a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm index b5f89e5a90..b08399956f 100644 --- a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm +++ b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm @@ -117,16 +117,17 @@ async sub get_access_token ($self) { my $c = $self->{c}; my $ce = $c->{ce}; my $db = $c->{db}; + $c = $c->{app} if $self->{post_processing_mode}; my $current_token = decode_json($db->getSettingValue('LTIAdvantageAccessToken') // '{}'); - # If the token is still valid (and not about to expire) then it can still be used. + # If the token has not expired and is not about to expire, then it can still be used. if (%$current_token && $current_token->{timestamp} + $current_token->{expires_in} > time + 60) { $self->warning('Using current access token from database.'); return $current_token; } - # The token is about to expire, so get a new one. + # The token is expired or about to, so get a new one. my ($private_key, $err) = get_site_key($ce, 1); if (!$private_key) { @@ -150,7 +151,7 @@ async sub get_access_token ($self) { ); }; if ($@) { - $self->warning("Error encoding JWT: $@") if $@; + $self->warning("Error encoding JWT: $@"); return; } diff --git a/lib/WeBWorK/ContentGenerator/InstructorRPCHandler.pm b/lib/WeBWorK/ContentGenerator/InstructorRPCHandler.pm index 4b099cb31b..4877dc299f 100644 --- a/lib/WeBWorK/ContentGenerator/InstructorRPCHandler.pm +++ b/lib/WeBWorK/ContentGenerator/InstructorRPCHandler.pm @@ -39,6 +39,15 @@ use JSON; use WebworkWebservice; +sub initializeRoute ($c, $routeCaptures) { + $c->{rpc} = 1; + + # Get the courseID from the parameters. + $routeCaptures->{courseID} = $c->param('courseID') if $c->param('courseID'); + + return; +} + async sub pre_header_initialize ($c) { unless ($c->authen->was_verified) { $c->{output} = $c->maketext('Authentication failed. Log in again to continue.'); diff --git a/lib/WeBWorK/ContentGenerator/LTIAdvanced.pm b/lib/WeBWorK/ContentGenerator/LTIAdvanced.pm new file mode 100644 index 0000000000..eae94c56d4 --- /dev/null +++ b/lib/WeBWorK/ContentGenerator/LTIAdvanced.pm @@ -0,0 +1,147 @@ +############################################################################### +# 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::LTIAdvanced; +use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; + +use Net::OAuth; +use UUID::Tiny ':std'; +use Mojo::JSON qw(encode_json); + +use WeBWorK::Utils qw(format_set_name_display); +use WeBWorK::Utils::CourseManagement qw(listCourses); + +$Net::OAuth::PROTOCOL_VERSION = Net::OAuth::PROTOCOL_VERSION_1_0A; + +sub initializeRoute ($c, $routeCaptures) { + # If this is an LTI 1.1 content item request from an LMS course, then find the courseID of the course that that has + # this LMS course name set in its course environment. If this is a submission of the content selection form, then + # get it from the form parameter. + if ($c->current_route eq 'ltiadvanced_content_selection') { + my $courseID = $c->param('courseID'); + if (!$courseID && $c->param('context_title')) { + my @matchingCourses; + + for (listCourses(WeBWorK::CourseEnvironment->new)) { + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $_ }) }; + if ($@) { warn "Failed to initialize course environment for $_: $@\n"; next; } + push(@matchingCourses, $ce) + if $ce->{LTIVersion} + && $ce->{LTIVersion} eq 'v1p1' + && $ce->{LMSCourseName} + && $ce->{LMSCourseName} eq $c->param('context_title'); + } + + if (@matchingCourses == 1) { + $courseID = $matchingCourses[0]->{courseName}; + } elsif ($c->param('oauth_consumer_key')) { + for (@matchingCourses) { + if ($_->{LTI}{v1p1}{ConsumerKey} && $_->{LTI}{v1p1}{ConsumerKey} eq $c->param('oauth_consumer_key')) + { + $courseID = $_->course_id; + last; + } + } + } + } + $routeCaptures->{courseID} = $c->stash->{courseID} = $courseID if $courseID; + } + + return; +} + +sub content_selection ($c) { + return $c->render( + text => $c->maketext( + 'No WeBWorK course was found associated to this LMS course. ' + . 'If this is an error, please contact the WeBWorK system administrator.' + ) + ) unless $c->stash->{courseID}; + + return $c->render(text => $c->maketext('You are not authorized to access instructor tools.')) + unless $c->authz->hasPermissions($c->authen->{user_id}, 'access_instructor_tools'); + + return $c->render(text => $c->maketext('You are not authorized to modify sets.')) + unless $c->authz->hasPermissions($c->authen->{user_id}, 'modify_problem_sets'); + + if (($c->param('lti_message_type') // '') eq 'ContentItemSelectionRequest') { + return $c->render( + 'ContentGenerator/LTI/content_item_selection', + visibleSets => [ $c->db->getGlobalSetsWhere({ visible => 1 }, [qw(due_date set_id)]) ], + acceptMultiple => $c->param('accept_multiple') && $c->param('accept_multiple') eq 'true', + forwardParams => { + content_item_return_url => $c->param('content_item_return_url'), + lti_version => $c->param('lti_version'), + oauth_consumer_key => $c->param('oauth_consumer_key'), + $c->param('data') ? (data => $c->param('data')) : () + } + ); + } + + my @selectedSets = $c->db->getGlobalSetsWhere({ set_id => [ $c->param('selected_sets') ] }, [qw(due_date set_id)]); + + my $request = Net::OAuth->request('request token')->from_hash( + { + lti_message_type => 'ContentItemSelection', + lti_version => $c->param('lti_version'), + oauth_version => '1.0', + oauth_consumer_key => $c->param('oauth_consumer_key') // 'webwork', + oauth_callback => 'about:blank', + oauth_signature_method => 'HMAC-SHA1', + oauth_timestamp => time, + oauth_nonce => create_uuid_as_string(UUID_SHA1, UUID_NS_URL, $c->authen->{user_id}) . '_' + . create_uuid_as_string(UUID_TIME), + @selectedSets || $c->param('course_home_link') + ? ( + content_items => encode_json({ + '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem', + '@graph' => [ + $c->param('course_home_link') + ? { + '@type' => 'LtiLinkItem', + mediaType => 'application/vnd.ims.lti.v1.ltilink', + title => $c->maketext('Assignments'), + url => $c->url_for('set_list', courseID => $c->stash->{courseID})->to_abs->to_string + } + : (), + map { { + '@type' => 'LtiLinkItem', + mediaType => 'application/vnd.ims.lti.v1.ltilink', + title => format_set_name_display($_->set_id), + $_->description ? (text => $_->description) : (), + url => + $c->url_for('problem_list', courseID => $c->stash->{courseID}, setID => $_->set_id) + ->to_abs->to_string + } } @selectedSets + ] + }) + ) + : (lti_errormsg => $c->maketext('No content was selected.')), + $c->param('data') ? (data => $c->param('data')) : () + }, + request_method => 'POST', + request_url => $c->param('content_item_return_url'), + consumer_secret => $c->ce->{LTI}{v1p1}{BasicConsumerSecret}, + ); + $request->sign; + + return $c->render( + 'ContentGenerator/LTI/self_posting_form', + form_target => $c->param('content_item_return_url'), + form_params => $request->to_hash + ); +} + +1; diff --git a/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm b/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm index 18772250cb..3ae4b1d593 100644 --- a/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm +++ b/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm @@ -16,15 +16,239 @@ package WeBWorK::ContentGenerator::LTIAdvantage; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; +use Mojo::UserAgent; +use Mojo::JSON qw(decode_json); +use Crypt::JWT qw(decode_jwt encode_jwt); +use Math::Random::Secure qw(irand); +use Digest::SHA qw(sha256_hex); + +use WeBWorK::Debug qw(debug); use WeBWorK::Authen::LTIAdvantage::SubmitGrade; -use WeBWorK::Debug; +use WeBWorK::Utils qw(format_set_name_display); +use WeBWorK::Utils::CourseManagement qw(listCourses); + +sub initializeRoute ($c, $routeCaptures) { + # If this is the login phase of an LTI 1.3 login, then extract the courseID from the target_link_uri. If this is a + # deep linking request, then attempt to find a course with the correct LTI 1.3 configuration as specified in the + # request. + if ($c->current_route eq 'ltiadvantage_login') { + my $target = $c->param('target_link_uri') ? $c->url_for($c->param('target_link_uri'))->path : ''; + my $location = $c->location; + + if ($target eq "$location/ltiadvantage/content_selection") { + # Find the first course that has the matching LTI 1.3 configuration. All courses with the matching LTI 1.3 + # configuration must be using the same external tool of the same LMS. Note that this may be the incorrect + # course for the actual request, but the correct course will be determined later in the launch request after + # the JWT is decoded. + for (listCourses(WeBWorK::CourseEnvironment->new)) { + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $_ }) }; + if ($@) { $c->log->error("Failed to initialize course environment for $_: $@"); next; } + # Moodle uses lti_deployment_id for the parameter name. Canvas uses deployment_id. The LTI 1.3 + # specification says that Moodle is correct. + if ( + ($ce->{LTIVersion} // '') eq 'v1p3' + && $ce->{LTI}{v1p3}{PlatformID} eq $c->param('iss') + && $ce->{LTI}{v1p3}{ClientID} eq $c->param('client_id') + && ($ce->{LTI}{v1p3}{DeploymentID} eq + ($c->param('lti_deployment_id') // $c->param('deployment_id'))) + ) + { + $c->stash->{courseID} = $_; + last; + } + } + } else { + $c->stash->{courseID} = $1 if $target =~ m|$location/([^/]*)|; + } + + $routeCaptures->{courseID} = $c->stash->{courseID} if $c->stash->{courseID}; + } + + # If this is the launch phase of an LTI 1.3 login, then extract the claims from the JWT and stash them. + # The state will be verified now, but the other claims will be verified during authentication later. + if ($c->current_route eq 'ltiadvantage_launch') { + $c->stash->{lti_jwt_claims} = $c->extract_jwt_claims; + if ($c->stash->{lti_jwt_claims}) { + if ($c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti/claim/message_type'} eq + 'LtiDeepLinkingRequest') + { + for (listCourses(WeBWorK::CourseEnvironment->new)) { + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $_ }) }; + if ($@) { $c->log->error("Failed to initialize course environment for $_: $@"); next; } + # This will now find the correct course for this request assuming the server administrator has + # correctly set the LMSCourseName in the course.conf file. + if ( + ($ce->{LTIVersion} // '') eq 'v1p3' + && $ce->{LTI}{v1p3}{PlatformID} eq $c->stash->{LTILaunchData}->data->{PlatformID} + && $ce->{LTI}{v1p3}{ClientID} eq $c->stash->{LTILaunchData}->data->{ClientID} + && $ce->{LTI}{v1p3}{DeploymentID} eq $c->stash->{LTILaunchData}->data->{DeploymentID} + && (($ce->{LMSCourseName} // '') eq + $c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti/claim/context'}{title}) + ) + { + $c->stash->{courseID} = $_; + last; + } + } + } else { + $c->stash->{courseID} = $c->stash->{LTILaunchData}->data->{courseID} + if $c->stash->{LTILaunchData} && $c->stash->{LTILaunchData}->data->{courseID}; + } + } + $routeCaptures->{courseID} = $c->stash->{courseID} || '___'; + } + + $routeCaptures->{courseID} = $c->stash->{courseID} = $c->param('courseID') + if $c->param('courseID') && $c->current_route eq 'ltiadvantage_content_selection'; + + return; +} sub login ($c) { - return $c->render('ContentGenerator/LTIAdvantage/login_repost'); + # Create a state and nonce and save them. These are generated + # so that they are cryptographically secure values. + my $LTIState = sha256_hex(join('_', + $c->param('login_hint'), $c->param('lti_message_hint'), + join('', map { [ 0 .. 9, 'a' .. 'z' ]->[ irand(36) ] } 1 .. 20))); + my $LTINonce = sha256_hex(join('', map { [ 0 .. 9, 'a' .. 'z' ]->[ irand(36) ] } 1 .. 20)); + + # Delete an LTI launch data item with this state if one happens to exist. + $c->db->deleteLTILaunchData($LTIState); + + $c->db->addLTILaunchData($c->db->newLTILaunchData( + state => $LTIState, + nonce => $LTINonce, + timestamp => time, + data => { + # Note that for a content item selection request this may not be the correct courseID. + courseID => $c->stash->{courseID}, + PlatformID => $c->ce->{LTI}{v1p3}{PlatformID}, + ClientID => $c->ce->{LTI}{v1p3}{ClientID}, + DeploymentID => $c->ce->{LTI}{v1p3}{DeploymentID}, + PublicKeysetURL => $c->ce->{LTI}{v1p3}{PublicKeysetURL}, + AccessTokenURL => $c->ce->{LTI}{v1p3}{AccessTokenURL}, + AuthReqURL => $c->ce->{LTI}{v1p3}{AuthReqURL} + } + )); + + return $c->render( + 'ContentGenerator/LTI/self_posting_form', + form_target => $c->ce->{LTI}{v1p3}{AuthReqURL}, + form_params => { + repost => 1, + response_type => 'id_token', + response_mode => 'form_post', + scope => 'openid', + login_hint => $c->param('login_hint'), + lti_message_hint => $c->param('lti_message_hint'), + state => $LTIState, + nonce => $LTINonce, + redirect_uri => $c->url_for('ltiadvantage_launch')->to_abs, + client_id => $c->param('client_id'), + prompt => 'none' + } + ); } sub launch ($c) { - return $c->redirect_to($c->systemLink($c->url_for($c->stash->{LTILauncRedirect}))); + return $c->redirect_to($c->systemLink( + $c->url_for($c->stash->{LTILauncRedirect}), + $c->stash->{lti_jwt_claims} + && $c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti/claim/message_type'} eq + 'LtiDeepLinkingRequest' + ? ( + params => { + courseID => $c->stash->{courseID}, + initial_request => 1, + accept_multiple => + $c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'} + {accept_multiple}, + deep_link_return_url => + $c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'} + {deep_link_return_url}, + $c->stash->{lti_jwt_claims}{'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'} + {data} + ? (data => $c->stash->{lti_jwt_claims} + {'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'}{data}) + : () + } + ) + : () + )); +} + +sub content_selection ($c) { + return $c->render(text => $c->maketext('You are not authorized to access instructor tools.')) + unless $c->authz->hasPermissions($c->authen->{user_id}, 'access_instructor_tools'); + + return $c->render(text => $c->maketext('You are not authorized to modify sets.')) + unless $c->authz->hasPermissions($c->authen->{user_id}, 'modify_problem_sets'); + + if ($c->param('initial_request')) { + return $c->render( + 'ContentGenerator/LTI/content_item_selection', + visibleSets => [ $c->db->getGlobalSetsWhere({ visible => 1 }, [qw(due_date set_id)]) ], + acceptMultiple => $c->param('accept_multiple'), + forwardParams => { + accept_multiple => $c->param('accept_multiple'), + deep_link_return_url => $c->param('deep_link_return_url'), + $c->param('data') ? (data => $c->param('data')) : () + } + ); + } + + my ($private_key, $err) = WeBWorK::Authen::LTIAdvantage::SubmitGrade::get_site_key($c->ce, 1); + return $c->render(text => $c->maketext('Error loading or generating site keys: [_1]', $err)) unless $private_key; + + my @selectedSets = $c->db->getGlobalSetsWhere({ set_id => [ $c->param('selected_sets') ] }, [qw(due_date set_id)]); + + my $jwt = eval { + encode_jwt( + payload => { + aud => $c->ce->{LTI}{v1p3}{PlatformID}, + iss => $c->ce->{LTI}{v1p3}{ClientID}, + jti => $private_key->{kid}, + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => 'LtiDeepLinkingResponse', + 'https://purl.imsglobal.org/spec/lti/claim/version' => '1.3.0', + 'https://purl.imsglobal.org/spec/lti/claim/deployment_id' => $c->ce->{LTI}{v1p3}{DeploymentID}, + $c->param('data') ? ('https://purl.imsglobal.org/spec/lti-dl/claim/data' => $c->param('data')) : (), + @selectedSets || $c->param('course_home_link') + ? ( + 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items' => [ + $c->param('course_home_link') + ? { + type => 'ltiResourceLink', + title => $c->maketext('Assignments'), + url => $c->url_for('set_list', courseID => $c->stash->{courseID})->to_abs->to_string + } + : (), + map { { + type => 'ltiResourceLink', + title => format_set_name_display($_->set_id), + $_->description ? (text => $_->description) : (), + url => + $c->url_for('problem_list', courseID => $c->stash->{courseID}, setID => $_->set_id) + ->to_abs->to_string + } } @selectedSets + ] + ) + : ('https://purl.imsglobal.org/spec/lti-dl/claim/errormsg' => + $c->maketext('No content was selected.')) + }, + key => $private_key, + extra_headers => { kid => $private_key->{kid} }, + alg => 'RS256', + auto_iat => 1, + relative_exp => 3600, + ); + }; + return $c->render(text => $c->maketext('Error encoding JWT: [_1]', $@)) if $@; + + return $c->render( + 'ContentGenerator/LTI/self_posting_form', + form_target => $c->param('deep_link_return_url'), + form_params => { JWT => $jwt } + ); } sub keys ($c) { @@ -35,4 +259,126 @@ sub keys ($c) { return $c->render(data => 'Internal site configuration error', status => 500); } +# Get the public keyset from the LMS and cache it in the database or just return what is already cached in the database. +# FIXME: This really needs another non-native table, and all courses that use a given LTI 1.3 configuration should share +# the public key that is retrieved here. +sub get_lms_public_keyset ($c, $ce, $db, $renew = 0) { + my $keyset_str; + + if (!$renew) { + $keyset_str = $db->getSettingValue('LTIAdvantageLMSPublicKey'); + return decode_json($keyset_str) if $keyset_str; + } + + # Get public keyset from the LMS. + my $response = Mojo::UserAgent->new->get($ce->{LTI}{v1p3}{PublicKeysetURL})->result; + unless ($response->is_success) { + $c->stash->{LTIAuthenError} = 'Failed to obtain public key from LMS: ' . $response->message; + return; + } + + $keyset_str = $response->body; + my $keyset = eval { decode_json($keyset_str) }; + if ($@ || ref($keyset) ne 'HASH' || !defined $keyset->{keys}) { + $c->stash->{LTIAuthenError} = 'Received an invalid response from the LMS public keyset URL.'; + return; + } + $db->setSettingValue('LTIAdvantageLMSPublicKey', $keyset_str); + + return $keyset; +} + +sub extract_jwt_claims ($c) { + return unless $c->param('state'); + + # The following database object is not associated to any course, and so the only has access to non-native tables. + my $db = WeBWorK::DB->new(WeBWorK::CourseEnvironment->new->{dbLayout}); + + # Retrieve the launch data saved in the login phase, and then delete it from the database. Note that this verifies + # the state in the request. If there is no launch data saved in the database for the state in the request, then the + # state in the request is invalid. This may indicate a possible CSFR. + $c->stash->{LTILaunchData} = $db->getLTILaunchData($c->param('state')); + unless ($c->stash->{LTILaunchData}) { + $c->stash->{LTIAuthenError} = 'Invalid state in response from LMS. Possible CSFR.'; + return; + } + + $db->deleteLTILaunchData($c->stash->{LTILaunchData}->state); + + # This occurs before the proper course environment for this request is set. So get a course environment using the + # courseID in the data. Remember that this may not be the correct courseID if this is a deep linking request, but it + # will work at this point since this course has the same LTI 1.3 parameters as the correct course. + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $c->stash->{LTILaunchData}->data->{courseID} }) }; + unless ($ce) { + $c->stash->{LTIAuthenError} = + 'Failed to initialize course environment for ' . $c->stash->{LTILaunchData}->data->{courseID} . ": $@\n"; + return; + } + $db = WeBWorK::DB->new($ce->{dbLayout}); + + $c->purge_expired_lti_data($ce, $db); + + my %jwt_params = ( + token => $c->param('id_token'), + verify_iss => $ce->{LTI}{v1p3}{PlatformID}, + verify_aud => $ce->{LTI}{v1p3}{ClientID}, + verify_iat => 1, + verify_exp => 1, + # This just checks that this claim is present. + verify_sub => sub ($value) { return $value =~ /\S/ } + ); + + $jwt_params{kid_keys} = $c->get_lms_public_keyset($ce, $db); + return unless $jwt_params{kid_keys}; + + my $claims = eval { decode_jwt(%jwt_params); }; + + # If decoding of the JWT failed, then try to get a new LMS public keyset and try again. It could be that the + # keyset that was previously saved in the database has expired. + unless ($claims) { + $jwt_params{kid_keys} = get_lms_public_keyset($c, $ce, $db, 1); + $claims = eval { $claims = decode_jwt(%jwt_params) }; + } + if ($@) { + $c->stash->{LTIAuthenError} = "Failed to decode token received from LMS: $@"; + return; + } + + if ($ce->{debug_lti_parameters}) { + $c->log->info("====== JWT PARAMETERS RECEIVED ======"); + $c->log->info($c->dumper($claims)); + } + + # Verify the nonce. + if (!defined $claims->{nonce} || $claims->{nonce} ne $c->stash->{LTILaunchData}->nonce) { + $c->stash->{LTIAuthenError} = 'Incorrect nonce received in response.'; + return 0; + } + + # Verify the deployment id. + if (!defined $claims->{'https://purl.imsglobal.org/spec/lti/claim/deployment_id'} + || $claims->{'https://purl.imsglobal.org/spec/lti/claim/deployment_id'} ne $ce->{LTI}{v1p3}{DeploymentID}) + { + $c->stash->{LTIAuthenError} = "Incorrect deployment id received in response."; + return 0; + } + + return $claims; +} + +# Delete any LTI data that is older than $ce->{LTI}{v1p3}{StateKeyLifetime}. +sub purge_expired_lti_data ($c, $ce, $db) { + my $time = time; + + my @dataToDelete; + + for my $data ($db->getLTILaunchDataWhere) { + push(@dataToDelete, $data->state) if $time - $data->timestamp > $ce->{LTI}{v1p3}{StateKeyLifetime}; + } + + $db->deleteLTILaunchDataWhere({ state => [@dataToDelete] }) if @dataToDelete; + + return; +} + 1; diff --git a/lib/WeBWorK/ContentGenerator/RenderViaRPC.pm b/lib/WeBWorK/ContentGenerator/RenderViaRPC.pm index baa17c4625..d7c9cfd1f7 100644 --- a/lib/WeBWorK/ContentGenerator/RenderViaRPC.pm +++ b/lib/WeBWorK/ContentGenerator/RenderViaRPC.pm @@ -34,6 +34,23 @@ result is returned in the JSON or HTML format as determined by the request type. use WebworkWebservice; +sub initializeRoute ($c, $routeCaptures) { + $c->{rpc} = 1; + + # This provides compatibility for legacy html2xml parameters. + # This should be deleted when the html2xml endpoint is removed. + if ($c->current_route eq 'html2xml') { + for ([ 'userID', 'user' ], [ 'course_password', 'passwd' ], [ 'session_key', 'key' ]) { + $c->param($_->[1], $c->param($_->[0])) if defined $c->param($_->[0]) && !defined $c->param($_->[1]); + } + } + + # Get the courseID from the parameters. + $routeCaptures->{courseID} = $c->param('courseID') if $c->param('courseID'); + + return; +} + async sub pre_header_initialize ($c) { $c->{wantsjson} = ($c->param('outputformat') // '') eq 'json' || ($c->param('send_pg_flags') // 0); diff --git a/lib/WeBWorK/DB.pm b/lib/WeBWorK/DB.pm index 4d59f2504c..c47340369c 100644 --- a/lib/WeBWorK/DB.pm +++ b/lib/WeBWorK/DB.pm @@ -99,6 +99,7 @@ use Carp; use Data::Dumper; use Scalar::Util qw/blessed/; use HTML::Entities qw( encode_entities ); +use Mojo::JSON qw(encode_json decode_json); use WeBWorK::DB::Schema; use WeBWorK::DB::Utils qw/make_vsetID grok_vsetID grok_setID_from_vsetID_sql @@ -1087,6 +1088,63 @@ sub deleteLocationAddress { return $self->{location_addresses}->delete($locationID, $ipmask); } +################################################################################ +# lti_launch_data functions +################################################################################ +# This database table contains LTI launch data for LTI 1.3 authentication. + +BEGIN { + *LTILaunchData = gen_schema_accessor("lti_launch_data"); + *existsLTILaunchDataWhere = gen_exists_where("lti_launch_data"); + *listLTILaunchDataWhere = gen_list_where("lti_launch_data"); + *getLTILaunchDataWhere = gen_get_records_where("lti_launch_data"); + *deleteLTILaunchDataWhere = gen_delete_where("lti_launch_data"); +} + +sub newLTILaunchData { + my ($self, @data) = @_; + my $ltiLaunchData = $self->{lti_launch_data}{record}->new(@data); + $ltiLaunchData->data({}) unless ref($ltiLaunchData->data) eq 'HASH'; + return $ltiLaunchData; +} + +sub getLTILaunchData { + my ($self, $state) = shift->checkArgs(\@_, qw/state/); + my ($ltiLaunchData) = $self->{lti_launch_data}->gets([$state]); + $ltiLaunchData->data(decode_json($ltiLaunchData->data)); + return $ltiLaunchData; +} + +sub addLTILaunchData { + my ($self, $LTILaunchData) = shift->checkArgs(\@_, qw/REC:lti_launch_data/); + my $launchDataCopy = $self->newLTILaunchData($LTILaunchData); + $launchDataCopy->data(encode_json($LTILaunchData->data)) if ref($LTILaunchData->data) eq 'HASH'; + my $result = eval { $self->{lti_launch_data}->add($launchDataCopy) }; + if (my $ex = WeBWorK::DB::Ex::RecordExists->caught) { + croak "addLTILaunchData: lti launch data exists (perhaps you meant to use putLTILaunchData?)"; + } elsif ($@) { + die $@; + } + return $result; +} + +sub putLTILaunchData { + my ($self, $LTILaunchData) = shift->checkArgs(\@_, qw/REC:lti_launch_data/); + my $launchDataCopy = $self->newLTILaunchData($LTILaunchData); + $launchDataCopy->data(encode_json($LTILaunchData->data)) if ref($LTILaunchData->data) eq 'HASH'; + my $rows = $self->{lti_launch_data}->put($launchDataCopy); + if ($rows == 0) { + croak "putLTILaunchData: lti launch data not found (perhaps you meant to use addLTILaunchData?)"; + } else { + return $rows; + } +} + +sub deleteLTILaunchData { + my ($self, $state) = shift->checkArgs(\@_, qw/state/); + return $self->{lti_launch_data}->delete_where({ state => $state }); +} + ################################################################################ # past_answers functions ################################################################################ diff --git a/lib/WeBWorK/DB/Record/LTILaunchData.pm b/lib/WeBWorK/DB/Record/LTILaunchData.pm new file mode 100644 index 0000000000..4dffde7ea9 --- /dev/null +++ b/lib/WeBWorK/DB/Record/LTILaunchData.pm @@ -0,0 +1,37 @@ +################################################################################ +# 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::DB::Record::LTILaunchData; +use base WeBWorK::DB::Record; + +=head1 NAME + +WeBWorK::DB::Record::LTILaunchData - represent a record from the lti_launch_data table. + +=cut + +use strict; +use warnings; + +BEGIN { + __PACKAGE__->_fields( + state => { type => "VARCHAR(200) NOT NULL", key => 1 }, + nonce => { type => "TEXT NOT NULL" }, + timestamp => { type => "BIGINT" }, + data => { type => "TEXT NOT NULL DEFAULT '{}'" } + ); +} + +1; diff --git a/lib/WeBWorK/Utils/Routes.pm b/lib/WeBWorK/Utils/Routes.pm index 7d2b92badc..faff83961b 100644 --- a/lib/WeBWorK/Utils/Routes.pm +++ b/lib/WeBWorK/Utils/Routes.pm @@ -32,9 +32,12 @@ PLEASE FOR THE LOVE OF GOD UPDATE THIS IF YOU CHANGE THE ROUTES BELOW!!! instructor_rpc /instructor_rpc html2xml /html2xml + ltiadvanced_content_selection /ltiadvanced/content_selection + ltiadvantage_login /ltiadvantage/login ltiadvantage_launch /ltiadvantage/launch ltiadvantage_keys /ltiadvantage/keys + ltiadvantage_content_selection /ltiadvantage/content_selection pod_index /pod pod_viewer /pod/$filePath @@ -146,9 +149,11 @@ my %routeParameters = ( render_rpc html2xml instructor_rpc + ltiadvanced_content_selection ltiadvantage_login ltiadvantage_launch ltiadvantage_keys + ltiadvantage_content_selection pod_index sample_problem_index set_list @@ -182,6 +187,13 @@ my %routeParameters = ( path => '/html2xml' }, + ltiadvanced_content_selection => { + title => x('Content Selection'), + module => 'LTIAdvanced', + path => '/ltiadvanced/content_selection', + action => 'content_selection' + }, + # Both of these routes end up at the login screen on failure, and the title is not used anywhere else. # Hence the title 'Login'. ltiadvantage_login => { @@ -202,6 +214,12 @@ my %routeParameters = ( path => '/ltiadvantage/keys', action => 'keys' }, + ltiadvantage_content_selection => { + title => x('Content Selection'), + module => 'LTIAdvantage', + path => '/ltiadvantage/content_selection', + action => 'content_selection' + }, pod_index => { title => x('POD Index'), diff --git a/templates/ContentGenerator/LTI/content_item_selection.html.ep b/templates/ContentGenerator/LTI/content_item_selection.html.ep new file mode 100644 index 0000000000..6815d44e26 --- /dev/null +++ b/templates/ContentGenerator/LTI/content_item_selection.html.ep @@ -0,0 +1,61 @@ +% use WeBWorK::Utils qw(format_set_name_display getAssetURL); +% + +output_course_lang_and_dir %>> +% + + + + <%= maketext('Available Content') %> + <%= stylesheet $c->url({ type => 'webwork', name => 'theme', file => 'bootstrap.css' }) =%> + <%= javascript getAssetURL($ce, 'js/SelectAll/selectall.js'), defer => undef =%> + +% + +
+

<%== maketext('Available Content') %>

+ <%= form_for current_route, method => 'POST', name => 'lti_content_selection', begin =%> + <%= $c->hidden_authen_fields =%> + <%= hidden_field courseID => $courseID =%> + % for (keys %$forwardParams) { + <%= hidden_field $_ => $forwardParams->{$_} =%> + % } + % if ($c->ce->{LTIGradeMode} ne 'homework') { +
+ <%= check_box course_home_link => 1, id => 'course_home_link', class => 'form-check-input' =%> + <%= label_for course_home_link => maketext('Assignments (Course Home)'), + class => 'form-check-label' =%> +
+ % } +
+ <%= maketext('Visible Sets') %> + % if ($acceptMultiple) { +
+ <%= check_box 'select-all' => '', id => 'select-all', + class => 'select-all form-check-input', + 'aria-label' => maketext('Select all available sets'), + data => { select_group => 'selected_sets' } =%> + <%= label_for 'select-all' => maketext('Select all sets'), class => 'form-check-label' =%> +
+ % } + % for (@$visibleSets) { + % my $set_id = $_->set_id; +
+ % if ($acceptMultiple) { + <%= check_box selected_sets => $set_id, id => "${set_id}_id", + class => 'form-check-input' =%> + % } else { + <%= radio_button selected_sets => $set_id, id => "${set_id}_id", + class => 'form-check-input' =%> + % } + <%= label_for "${set_id}_id" => format_set_name_display($set_id), + class => 'form-check-label' =%> +
+ % } +
+ <%= submit_button maketext('Submit Choices'), class => 'btn btn-primary' =%> + <% end =%> +
+ +% + diff --git a/templates/ContentGenerator/LTI/self_posting_form.html.ep b/templates/ContentGenerator/LTI/self_posting_form.html.ep new file mode 100644 index 0000000000..1f7d226dfd --- /dev/null +++ b/templates/ContentGenerator/LTI/self_posting_form.html.ep @@ -0,0 +1,9 @@ +<%= form_for $form_target, method => 'POST', enctype => 'application/x-www-form-urlencoded', + name => 'ltiRepost', id => 'ltiRepost', begin =%> + % for (keys %$form_params) { + <%= hidden_field $_ => $form_params->{$_} =%> + % } +<% end =%> +<%= javascript begin =%> + document.ltiRepost.submit(); +<% end =%> diff --git a/templates/ContentGenerator/LTIAdvantage/login_repost.html.ep b/templates/ContentGenerator/LTIAdvantage/login_repost.html.ep deleted file mode 100644 index 60e1d40803..0000000000 --- a/templates/ContentGenerator/LTIAdvantage/login_repost.html.ep +++ /dev/null @@ -1,17 +0,0 @@ -<%= form_for $ce->{LTI}{v1p3}{AuthReqURL}, method => 'POST', - enctype => 'application/x-www-form-urlencoded', name => 'ltiRepost', id => 'ltiRepost', begin =%> - <%= hidden_field repost => 1 =%> - <%= hidden_field response_type => 'id_token' =%> - <%= hidden_field response_mode => 'form_post' =%> - <%= hidden_field scope => 'openid' =%> - <%= hidden_field login_hint => param('login_hint') =%> - <%= hidden_field lti_message_hint => param('lti_message_hint') =%> - <%= hidden_field state => $LTIState =%> - <%= hidden_field nonce => $LTINonce =%> - <%= hidden_field redirect_uri => url_for('ltiadvantage_launch')->to_abs =%> - <%= hidden_field client_id => param('client_id') =%> - <%= hidden_field prompt => 'none' =%> -<% end =%> -<%= javascript begin =%> - document.ltiRepost.submit(); -<% end =%>