From 0197b62000071439ef6f72d7a4972fefbaf1acac Mon Sep 17 00:00:00 2001 From: Moritz Strohm Date: Fri, 10 Jan 2025 13:34:43 +0000 Subject: StEP 3348, closes #3348 Closes #3348 Merge request studip/studip!2275 --- .gitignore | 1 + app/controllers/admin/lti.php | 73 +- app/controllers/course/lti.php | 661 +++++++++--- app/controllers/lti.php | 127 --- app/controllers/lti/ags.php | 106 ++ app/controllers/lti/auth.php | 250 +++++ app/controllers/lti/tool.php | 236 +++++ app/views/admin/lti/edit.php | 71 +- app/views/admin/lti/index.php | 114 +-- app/views/course/lti/config.php | 23 +- app/views/course/lti/consent.php | 72 ++ app/views/course/lti/edit.php | 117 --- app/views/course/lti/grades.php | 70 +- app/views/course/lti/grades_user.php | 56 +- app/views/course/lti/iframe.php | 52 +- app/views/course/lti/index.php | 116 ++- app/views/course/lti/select_link.php | 15 + app/views/course/lti/select_tool.php | 31 + app/views/lti/_deployment_user_info.php | 43 + app/views/lti/_platform_data.php | 45 + app/views/lti/_tool_form_fields.php | 148 +++ app/views/lti/_tool_info.php | 79 ++ app/views/lti/tool/add.php | 16 + app/views/lti/tool/edit.php | 18 + app/views/lti/tool/index.php | 7 + composer.json | 7 +- composer.lock | 1064 ++++++++++++++++++-- db/migrations/6.0.39_add_lti13a.php | 235 +++++ db/studip_default_data.sql | 1 - lib/classes/LTI13a/Identity.php | 103 ++ lib/classes/LTI13a/KeyChainFactory.php | 56 ++ lib/classes/LTI13a/KeyManager.php | 33 + lib/classes/LTI13a/LineItemRepository.php | 192 ++++ lib/classes/LTI13a/NonceGenerator.php | 34 + lib/classes/LTI13a/PlatformManager.php | 106 ++ lib/classes/LTI13a/Registration.php | 122 +++ lib/classes/LTI13a/RegistrationManager.php | 67 ++ lib/classes/LTI13a/ResultRepository.php | 64 ++ lib/classes/LTI13a/ScoreRepository.php | 29 + lib/classes/LTI13a/UserAuthenticator.php | 38 + lib/classes/auth_plugins/StudipAuthLTI.php | 42 +- lib/exceptions/KeyringException.php | 27 + lib/exceptions/LTIException.php | 17 + lib/models/Grading/Definition.php | 31 + lib/models/Grading/Instance.php | 14 + lib/models/Keyring.php | 169 ++++ lib/models/LtiData.php | 156 --- lib/models/LtiDeployment.php | 187 ++++ lib/models/LtiGrade.php | 4 +- lib/models/LtiTool.php | 154 ++- lib/models/LtiToolPrivacySettings.php | 46 + lib/modules/CoreParticipants.php | 8 +- lib/modules/LtiToolModule.php | 35 +- .../bootstrap/studip_helper_attributes.js | 32 +- 54 files changed, 4642 insertions(+), 978 deletions(-) delete mode 100644 app/controllers/lti.php create mode 100644 app/controllers/lti/ags.php create mode 100644 app/controllers/lti/auth.php create mode 100644 app/controllers/lti/tool.php create mode 100644 app/views/course/lti/consent.php delete mode 100644 app/views/course/lti/edit.php create mode 100644 app/views/course/lti/select_link.php create mode 100644 app/views/course/lti/select_tool.php create mode 100644 app/views/lti/_deployment_user_info.php create mode 100644 app/views/lti/_platform_data.php create mode 100644 app/views/lti/_tool_form_fields.php create mode 100644 app/views/lti/_tool_info.php create mode 100644 app/views/lti/tool/add.php create mode 100644 app/views/lti/tool/edit.php create mode 100644 app/views/lti/tool/index.php create mode 100644 db/migrations/6.0.39_add_lti13a.php create mode 100644 lib/classes/LTI13a/Identity.php create mode 100644 lib/classes/LTI13a/KeyChainFactory.php create mode 100644 lib/classes/LTI13a/KeyManager.php create mode 100644 lib/classes/LTI13a/LineItemRepository.php create mode 100644 lib/classes/LTI13a/NonceGenerator.php create mode 100644 lib/classes/LTI13a/PlatformManager.php create mode 100644 lib/classes/LTI13a/Registration.php create mode 100644 lib/classes/LTI13a/RegistrationManager.php create mode 100644 lib/classes/LTI13a/ResultRepository.php create mode 100644 lib/classes/LTI13a/ScoreRepository.php create mode 100644 lib/classes/LTI13a/UserAuthenticator.php create mode 100644 lib/exceptions/KeyringException.php create mode 100644 lib/exceptions/LTIException.php create mode 100644 lib/models/Keyring.php delete mode 100644 lib/models/LtiData.php create mode 100644 lib/models/LtiDeployment.php create mode 100644 lib/models/LtiToolPrivacySettings.php diff --git a/.gitignore b/.gitignore index 81dec99..db28479 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ data/oer_logos/* data/upload_doc/* public/.htaccess +public/.rnd public/assets/javascripts/*.js public/assets/javascripts/*.js.map public/assets/stylesheets/*.css diff --git a/app/controllers/admin/lti.php b/app/controllers/admin/lti.php index eb3082c..25ea3fe 100644 --- a/app/controllers/admin/lti.php +++ b/app/controllers/admin/lti.php @@ -23,16 +23,21 @@ class Admin_LtiController extends AuthenticatedController $GLOBALS['perm']->check('root'); Navigation::activateItem('/admin/config/lti'); - PageLayout::setTitle(_('Konfiguration der LTI-Tools')); + PageLayout::setTitle(_('LTI-Tools')); $widget = Sidebar::get()->addWidget(new ActionsWidget()); $widget->addLink( _('Neues LTI-Tool registrieren'), - $this->url_for('admin/lti/edit'), + $this->url_for('lti/tool/add/global'), Icon::create('add') )->asDialog(); + $widget->addLink( + _('Daten zur LTI-Plattform anzeigen'), + $this->url_for('lti/auth/platform_data'), + Icon::create('info') + )->asDialog(); - Helpbar::get()->addPlainText('', _('Hier können Sie Verknüpfungen mit externen Tools konfigurieren, sofern diese den LTI-Standard (Version 1.x) unterstützen.')); + Helpbar::get()->addPlainText('', _('Hier können Sie LTI-Tools konfigurieren. Diese müssen den LTI-Standard in Version 1.0/1.1 oder 1.3A unterstützen.')); } /** @@ -42,66 +47,4 @@ class Admin_LtiController extends AuthenticatedController { $this->tools = LtiTool::findAll(); } - - /** - * Display dialog for editing an LTI tool. - * - * @param int $id tool id - */ - public function edit_action($id = null) - { - $this->tool = new LtiTool($id); - } - - /** - * Save changes for an LTI tool. - * - * @param int $id tool id - */ - public function save_action($id) - { - CSRFProtection::verifyUnsafeRequest(); - - $tool = new LtiTool($id ?: null); - $tool->name = trim(Request::get('name')); - $tool->launch_url = trim(Request::get('launch_url')); - $tool->consumer_key = trim(Request::get('consumer_key')); - $tool->consumer_secret = trim(Request::get('consumer_secret')); - $tool->custom_parameters = trim(Request::get('custom_parameters')); - $tool->allow_custom_url = Request::int('allow_custom_url', 0); - $tool->deep_linking = Request::int('deep_linking', 0); - $tool->send_lis_person = Request::int('send_lis_person', 0); - $tool->oauth_signature_method = Request::get('oauth_signature_method', 'sha1'); - - if ($tool->store()) { - PageLayout::postSuccess(sprintf( - _('Einstellungen für "%s" wurden gespeichert.'), - htmlReady($tool->name) - )); - } - - $this->redirect('admin/lti'); - } - - /** - * Delete an LTI tool. - * - * @param int $id tool id - */ - public function delete_action($id) - { - CSRFProtection::verifyUnsafeRequest(); - - $tool = LtiTool::find($id); - $tool_name = $tool->name; - - if ($tool && $tool->delete()) { - PageLayout::postSuccess(sprintf( - _('Das LTI-Tool "%s" wurde gelöscht.'), - htmlReady($tool_name) - )); - } - - $this->redirect('admin/lti'); - } } diff --git a/app/controllers/course/lti.php b/app/controllers/course/lti.php index ab403af..5fe73d1 100644 --- a/app/controllers/course/lti.php +++ b/app/controllers/course/lti.php @@ -1,6 +1,17 @@ course_id = Context::getId(); - $this->edit_perm = $GLOBALS['perm']->have_studip_perm('tutor', $this->course_id); + $this->course = Course::find($this->course_id); - if (!in_array($action, ['index', 'iframe', 'grades']) && !$this->edit_perm) { - throw new AccessDeniedException(_('Sie besitzen keine Berechtigung, um LTI-Tools zu konfigurieren.')); + if (in_array($action, ['select_tool', 'add_link']) && !$this->course) { + throw new AccessDeniedException(); } - if ($action !== 'grades') { - Navigation::activateItem('/course/lti/index'); + $this->edit_perm = $GLOBALS['perm']->have_studip_perm('tutor', $this->course_id); + if (!in_array($action, ['index', 'iframe', 'grades', 'consent']) && !$this->edit_perm) { + throw new AccessDeniedException(); } - $title = CourseConfig::get($this->course_id)->LTI_TOOL_TITLE; - PageLayout::setTitle(Context::getHeaderLine() . ' - ' . $title); + if ( + !in_array($action, ['admin', 'grades']) + && Navigation::hasItem('/course/lti/index') + ) { + Navigation::activateItem('/course/lti/index'); + } } /** @@ -58,7 +75,17 @@ class Course_LtiController extends StudipController */ public function index_action() { - $this->lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position'); + $this->lti_data_array = []; + if ($this->edit_perm) { + $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + } else { + //Only load those deployments that are fully configured: + $this->lti_data_array = LtiDeployment::findBySQL( + "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `position`", + ['course_id' => $this->course_id] + ); + } if ($this->edit_perm) { $widget = Sidebar::get()->addWidget(new ActionsWidget()); @@ -67,17 +94,21 @@ class Course_LtiController extends StudipController $this->url_for('course/lti/config'), Icon::create('admin') )->asDialog('size=auto'); - $widget->addLink( - _('Abschnitt hinzufügen'), - $this->url_for('course/lti/edit'), - Icon::create('add') - )->asDialog(); + $global_tools_available = LtiTool::countBySQL("`range_id` = 'global'") > 0; + if (Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE || $global_tools_available) { + $widget->addLink( + _('LTI-Tool hinzufügen'), + $this->url_for('course/lti/select_tool'), + Icon::create('add') + )->asDialog('size=auto'); + } - if (LtiTool::findByDeep_linking(1)) { + $global_deep_linking_tools_exist = LtiTool::countBySQL("`deep_linking` = 1 AND `range_id` = 'global'") > 0; + if ($global_deep_linking_tools_exist) { $widget->addLink( - _('Link aus LTI-Tool einfügen'), + _('Tool mittels LTI Deep Linking hinzufügen'), $this->url_for('course/lti/add_link'), - Icon::create('add') + Icon::create('network2') )->asDialog('size=auto'); } } @@ -85,126 +116,257 @@ class Course_LtiController extends StudipController Helpbar::get()->addPlainText('', _('Auf dieser Seite können Sie externe Anwendungen einbinden, sofern diese den LTI-Standard (Version 1.x) unterstützen.')); } - /** - * Display the launch form for a tool as an iframe. - */ - public function iframe_action(string $position) + public function select_tool_action() { - $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); - $lti_link = $this->getLtiLink($lti_data); + //The permission check is done in the before filter. - $this->launch_url = $lti_data->getLaunchURL(); - $this->launch_data = $lti_link->getBasicLaunchData(); - $this->signature = $lti_link->getLaunchSignature($this->launch_data); + $this->global_tools = LtiTool::findBySQL("`lti_version` = '1.3a' AND `range_id` = 'global' ORDER BY `name` ASC"); - $this->set_layout(null); + if (!$this->global_tools) { + if (!Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE) { + PageLayout::postError(_('Es sind keine globalen LTI-Tools konfiguriert, die in dieser Veranstaltung eingebunden werden können.')); + return; + } + //Redirect to the page to configure an LTI tool for the course: + $this->redirect('lti/tool/add/' . $this->course->id); + } + + $this->selected_tool_id = ''; + if (count($this->global_tools) >= 1) { + //Preselect the first tool: + $this->selected_tool_id = $this->global_tools[0]->id; + } } - /** - * Edit the course settings. - */ - public function config_action() + public function select_tool_redirect_action() { - $this->title = CourseConfig::get($this->course_id)->LTI_TOOL_TITLE; + if (Request::isPost()) { + CSRFProtection::verifyUnsafeRequest(); + $selected_tool_id = Request::get('selected_tool_id'); + if ($selected_tool_id === 'new') { + //Redirect to the page to configure an LTI tool for the course: + $this->redirect('lti/tool/add/' . $this->course->id); + } else { + //Load the selected tool and check if it can be used in the course. + $selected_tool = LtiTool::find($selected_tool_id); + if (!$selected_tool || $selected_tool->range_id !== 'global') { + PageLayout::postError(_('Das ausgewählte LTI-Tool kann nicht genutzt werden.')); + $this->redirect('course/lti/select_tool'); + return; + } + $this->redirect('lti/tool/add/' . $this->course->id . '/' . $selected_tool->id); + } + } else { + $this->redirect('course/lti/select_tool'); + } } - /** - * Save the course settings. - */ - public function save_config_action() + public function consent_action(string $deployment_id) { - CSRFProtection::verifyUnsafeRequest(); + $this->deployment = LtiDeployment::find($deployment_id); + if (!$this->deployment) { + PageLayout::postError(_('Die Einbindung eines LTI-Tools ist ungültig.')); + return; + } - $title = trim(Request::get('title')); - CourseConfig::get($this->course_id)->store('LTI_TOOL_TITLE', $title); + $this->privacy_settings = LtiToolPrivacySettings::findOneBySQL( + 'tool_id = :tool_id AND user_id = :user_id', + ['tool_id' => $this->deployment->tool_id, 'user_id' => $GLOBALS['user']->id] + ); + if (!$this->privacy_settings) { + $this->privacy_settings = new LtiToolPrivacySettings(); + $this->privacy_settings->tool_id = $this->deployment->tool_id; + $this->privacy_settings->user_id = $GLOBALS['user']->id; + } - PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.')); - $this->redirect('course/lti'); + if (Request::isPost()) { + CSRFProtection::verifyUnsafeRequest(); + if (Request::submitted('save')) { + if (!Request::get('confirmed')) { + PageLayout::postError(_('Ohne die aktive Zustimmung zur Weitergabe Ihrer personenbezogenen Daten können Sie das LTI-Tool nicht nutzen!')); + return; + } + //Save the privacy settings and redirect to the tool: + $this->privacy_settings->accepted = '1'; + + //Check which optional fields are allowed to be transmitted to the tool: + $optional_field_list = Request::getArray('submit_optional_field', []); + $optional_fields = []; + if (array_key_exists('lang', $optional_field_list)) { + $optional_fields[] = 'lang'; + } + if (array_key_exists('avatar_url', $optional_field_list)) { + $optional_fields[] = 'avatar_url'; + } + $this->privacy_settings->allowed_optional_fields = implode(',', $optional_fields); + //Store the privacy settings: + $this->privacy_settings->store(); + } + if (Request::isDialog()) { + //Close the dialog: + $this->response->add_header('X-Dialog-Close', '1'); + } elseif (Request::submitted('redirect_to_tool') && Request::submitted('save')) { + //Redirect to the tool launch action, but only after the privacy settings have been saved: + $this->redirect('course/lti/iframe/' . $this->deployment->id); + } else { + //Redirect to the LTI tool page of the course: + $this->redirect('course/lti/index'); + } + } } /** - * Move an LTI content block (either up or down). - * - * @param int $position block position - * @param string $direction 'up' or 'down' + * Display the launch form for a tool as an iframe. */ - public function move_action($position, $direction) + public function iframe_action(string $deployment_id) { - CSRFProtection::verifyUnsafeRequest(); - - if ($direction === 'up') { - $position2 = $position - 1; - } else { - $position2 = $position + 1; + $this->deployment = LtiDeployment::find($deployment_id); + $this->show_data_protection_info = !LtiToolPrivacySettings::countBySQL( + "`tool_id` = :tool_id AND `user_id` = :user_id AND `accepted` = 1", + ['tool_id' => $this->deployment->tool_id, 'user_id' => $GLOBALS['user']->id] + ); + if ($this->show_data_protection_info) { + $this->redirect('course/lti/consent/' . $deployment_id, ['redirect_to_tool' => '1']); + return; } - $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); - $lti_data2 = LtiData::findByCourseAndPosition($this->course_id, $position2); - - if ($lti_data && $lti_data2) { - $lti_data->position = $position2; - $lti_data->store(); - - $lti_data2->position = $position; - $lti_data2->store(); + if (!$this->show_data_protection_info) { + //Redirect to the tool. + $this->lti13a_mode = false; + $lti_version = $this->deployment->getToolLtiVersion(); + if ($lti_version === '1.3a') { + //LTI 1.3a + $this->lti13a_mode = true; + + $lti_resource_link = new LtiResourceLink( + $this->deployment->tool_id . '_' . $this->deployment->id . '_' . $this->course_id, + [ + 'url' => $this->deployment->getLaunchURL(), + 'title' => $this->deployment->title + ] + ); + + $registration = new Registration($this->deployment->tool); + $builder = new LtiResourceLinkLaunchRequestBuilder(); + + //The AGS URLs need several parameters: + $ags_url_parameters = [ + 'cid' => $this->course_id, + 'tool_id' => $this->deployment->tool_id, + 'deployment_id' => $this->deployment->id, + 'cancel_login' => '1' + ]; + + //Build the message: + $this->message = $builder->buildLtiResourceLinkLaunchRequest( + $lti_resource_link, + $registration, + $GLOBALS['user']->id, + $this->deployment->id, + [ + PlatformManager::getLtiRoleClaimForStudipRole($GLOBALS['perm']->get_studip_perm($this->course_id)) + ], + array_merge( + [ + new ContextClaim( + $this->course_id, + ['http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering'], + $this->course->veranstaltungsnummer ?? '', + $this->course?->getFullName() ?? '' + ), + new AgsClaim( + [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score' + ], + $this->url_for('lti/ags/line_items', $ags_url_parameters), + $this->url_for('lti/ags/line_item', $ags_url_parameters) + ) + ], + $this->deployment->getCustomLtiParameterArray(), + ) + ); + } else { + //LTI 1.0/1.1 + $lti_link = $this->getLtiLink($this->deployment); + $this->launch_url = $this->deployment->getLaunchURL(); + $this->launch_data = $lti_link->getBasicLaunchData(); + $this->signature = $lti_link->getLaunchSignature($this->launch_data); + } + $this->set_layout(null); } + } - $this->redirect('course/lti'); + /** + * Edit the course settings. + */ + public function config_action() + { + $course_config = CourseConfig::get($this->course_id); + $this->personal_data_warning = $course_config->LTI_DATA_PROTECTION_COURSE_WARNING; + if (empty($this->personal_data_warning)) { + $this->personal_data_warning = Config::get()->LTI_DATA_PROTECTION_DEFAULT_WARNING; + } } /** - * Edit an LTI content block (using a dialog window). - * - * @param int $position block position (blank: create a new block) + * Save the course settings. */ - public function edit_action($position = '') + public function save_config_action() { - $this->lti_data = new LtiData(); + CSRFProtection::verifyUnsafeRequest(); + + $course_config = CourseConfig::get($this->course_id); - if ($position !== '') { - $this->lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); + if (Request::bool('reset_warning')) { + $course_config->delete('LTI_DATA_PROTECTION_COURSE_WARNING'); + } else { + $course_config->store( + 'LTI_DATA_PROTECTION_COURSE_WARNING', + trim(Request::get('personal_data_warning')) + ); } - $this->tools = LtiTool::findAll(); + PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.')); + $this->redirect('course/lti'); } /** - * Save an LTI content block. + * Moves an LTI deployment in a course either up or down. + * + * @param string $deployment_id The ID of the deployment to be moved. * - * @param int $position block position (blank: create a new block) + * @param string $direction 'up' for moving the deployment upwards or 'down' for downwards. */ - public function save_action($position) + public function move_action(string $deployment_id, string $direction) { CSRFProtection::verifyUnsafeRequest(); - if ($position !== '') { - $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); - } else { - $lti_data = new LtiData(); - $lti_data->course_id = $this->course_id; - $lti_data->position = LtiData::countBySQL('course_id = ?', [$this->course_id]); + $deployment = LtiDeployment::find($deployment_id); + if (!$deployment) { + //Redirect and do nothing: + $this->redirect('course/lti'); + return; } - $lti_data->title = trim(Request::get('title')); - $lti_data->description = Studip\Markup::purifyHtml(Request::get('description')); - $lti_data->tool_id = Request::int('tool_id'); + $new_position = 0; - if ($lti_data->tool_id == 0) { - $lti_data->launch_url = trim(Request::get('launch_url')); - $options['consumer_key'] = trim(Request::get('consumer_key')); - $options['consumer_secret'] = trim(Request::get('consumer_secret')); - $options['send_lis_person'] = Request::int('send_lis_person', 0); - $options['oauth_signature_method'] = Request::get('oauth_signature_method', 'sha1'); + if ($direction === 'up') { + $new_position = $deployment->position - 1; } else { - $lti_data->launch_url = trim(Request::get('custom_url')); + $new_position = $deployment->position + 1; } - $options['custom_parameters'] = trim(Request::get('custom_parameters')); - $options['document_target'] = Request::option('document_target', 'window'); - $lti_data->options = $options; - $lti_data->store(); + //Find the deployment with the new position: + $other_deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $new_position); + if ($other_deployment) { + $other_deployment->position = $deployment->position; + $other_deployment->store(); + } + $deployment->position = $new_position; + $deployment->store(); - PageLayout::postSuccess(_('Der Abschnitt wurde gespeichert.')); $this->redirect('course/lti'); } @@ -217,8 +379,8 @@ class Course_LtiController extends StudipController { CSRFProtection::verifyUnsafeRequest(); - $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); - $lti_data->delete(); + $deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $position); + $deployment->delete(); PageLayout::postSuccess(_('Der Abschnitt wurde gelöscht.')); $this->redirect('course/lti'); @@ -229,46 +391,149 @@ class Course_LtiController extends StudipController */ public function add_link_action() { - $this->tools = LtiTool::findByDeep_linking(1); + //The permission check is done in the before filter. + + $this->tools = LtiTool::findBySQL("`deep_linking` = '1' AND `range_id` = 'global' ORDER BY `name` ASC"); + if (!$this->tools) { + PageLayout::postError(_('Es sind keine globalen LTI-Tools konfiguriert.')); + return; + } } /** * Dispatch a ContentItemSelectionRequest to a specified LTI tool. */ - public function select_link_action() + public function select_link_action($deployment_id = '') { - $tool_id = Request::int('tool_id'); - $tool = LtiTool::find($tool_id); + $this->deployment = null; + if ($deployment_id) { + $this->deployment = LtiDeployment::find($deployment_id); + if (!$this->deployment) { + PageLayout::postError(_('Die Einbindung des LTI-Tools wurde nicht gefunden!')); + return; + } + if ($this->deployment->course_id !== $this->course_id) { + PageLayout::postError(_('Die Einbindung des LTI-Tools ist nicht für diese Veranstaltung bestimmt.')); + return; + } + if (empty($this->deployment->options['unfinished_deep_linking'])) { + PageLayout::postError(_('Die Einbindung des LTI-Tools ist bereits abgeschlossen.')); + return; + } + } - $custom_parameters = explode("\n", $tool->custom_parameters); - $content_item_return_url = $this->url_for('course/lti/save_link/' . $tool_id); + $this->tool = LtiTool::find(Request::int('tool_id')); + if (!$this->tool) { + PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.')); + $this->redirect('course/lti/add_link'); + return; + } + if (!$this->tool->deep_linking) { + PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.')); + $this->redirect('course/lti/add_link'); + return; + } + } - // set up ContentItemSelectionRequest - $lti_link = new LtiLink($tool->launch_url, $tool->consumer_key, $tool->consumer_secret, $tool->oauth_signature_method); - $lti_link->setUser($GLOBALS['user']->id, 'Instructor', $tool->send_lis_person); - $lti_link->setCourse($this->course_id); - $lti_link->addLaunchParameters([ - 'lti_message_type' => 'ContentItemSelectionRequest', - 'accept_media_types' => 'application/vnd.ims.lti.v1.ltilink', - 'accept_presentation_document_targets' => 'iframe,window', - 'content_item_return_url' => $content_item_return_url, - 'launch_presentation_locale' => str_replace('_', '-', $_SESSION['_language']), - 'launch_presentation_document_target' => 'window' - ]); + public function process_select_link_action($deployment_id = '') + { + CSRFProtection::verifyUnsafeRequest(); - foreach ($custom_parameters as $param) { - if (strpos($param, '=') !== false) { - list($key, $value) = explode('=', $param, 2); - $lti_link->addCustomParameter(trim($key), trim($value)); + $this->deployment = null; + if ($deployment_id) { + $this->deployment = LtiDeployment::find($deployment_id); + if (!$this->deployment) { + PageLayout::postError(_('Die Einbindung des LTI-Tools wurde nicht gefunden!')); + return; + } + if ($this->deployment->course_id !== $this->course_id) { + PageLayout::postError(_('Die Einbindung des LTI-Tools ist nicht für diese Veranstaltung bestimmt.')); + return; } + if (empty($this->deployment->options['unfinished_deep_linking'])) { + PageLayout::postError(_('Die Einbindung des LTI-Tools ist bereits abgeschlossen.')); + return; + } + } + + $this->tool = null; + if ($this->deployment) { + $this->tool = $this->deployment->tool; + } else { + $this->tool = LtiTool::find(Request::int('tool_id')); + } + if (!$this->tool) { + PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.')); + $this->redirect('course/lti/add_link'); + return; } + if (!$this->tool->deep_linking) { + PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.')); + $this->redirect('course/lti/add_link'); + return; + } + + if ($this->tool->lti_version === '1.3a') { + //LTI 1.3a + if ($this->deployment) { + $builder = new DeepLinkingLaunchRequestBuilder(); + $message = $builder->buildDeepLinkingLaunchRequest( + PlatformManager::getDeepLinkingConfiguration($this->tool->id), + new Registration($this->deployment->tool), + $GLOBALS['user']->id, + null, + $this->deployment->id, + [PlatformManager::getLtiRoleClaimForStudipRole($GLOBALS['perm']->get_studip_perm($this->course_id))] + ); + $this->render_text($message->toHtmlRedirectForm()); + } else { + //Build an LTI deployment object and mark it as not configured + //so that it can be displayed differently in the UI. + $this->deployment = new LtiDeployment(); + $this->deployment->tool_id = $this->tool->id; + $this->deployment->course_id = $this->course_id; + $this->deployment->title = $this->tool->name; + $this->deployment->options = ['unfinished_deep_linking' => true]; + if ($this->deployment->store() !== false) { + //Display the tool deployment data so that the user can enter + //them in the LTI tool. + PageLayout::postInfo( + _('Bitte tragen Sie die Daten zur Einbindung im LTI-Tool ein bevor sie fortfahren.') + ); + } + } + } else { + //LTI 1.0/1.1 + $custom_parameters = explode("\n", $this->tool->custom_parameters); + $content_item_return_url = $this->url_for('course/lti/save_link/' . $this->tool->id); + + // set up ContentItemSelectionRequest + $lti_link = new LtiLink($this->tool->launch_url, $this->tool->consumer_key, $this->tool->consumer_secret, $this->tool->oauth_signature_method); + $lti_link->setUser($GLOBALS['user']->id, 'Instructor', $this->tool->send_lis_person); + $lti_link->setCourse($this->course_id); + $lti_link->addLaunchParameters([ + 'lti_message_type' => 'ContentItemSelectionRequest', + 'accept_media_types' => 'application/vnd.ims.lti.v1.ltilink', + 'accept_presentation_document_targets' => 'iframe,window', + 'content_item_return_url' => $content_item_return_url, + 'launch_presentation_locale' => str_replace('_', '-', $_SESSION['_language']), + 'launch_presentation_document_target' => 'window' + ]); + + foreach ($custom_parameters as $param) { + if (strpos($param, '=') !== false) { + list($key, $value) = explode('=', $param, 2); + $lti_link->addCustomParameter(trim($key), trim($value)); + } + } - $this->launch_url = $lti_link->getLaunchURL(); - $this->launch_data = $lti_link->getBasicLaunchData(); - $this->signature = $lti_link->getLaunchSignature($this->launch_data); + $this->launch_url = $lti_link->getLaunchURL(); + $this->launch_data = $lti_link->getBasicLaunchData(); + $this->signature = $lti_link->getLaunchSignature($this->launch_data); - $this->set_layout(null); - $this->render_action('iframe'); + $this->set_layout(null); + $this->render_action('iframe'); + } } /** @@ -279,43 +544,103 @@ class Course_LtiController extends StudipController public function save_link_action($tool_id) { $tool = LtiTool::find($tool_id); - $lti_msg = Request::get('lti_msg'); - $lti_errormsg = Request::get('lti_errormsg'); - $content_items = Request::get('content_items'); - $content_items = json_decode($content_items, true); - if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) { - throw new Exception('Could not verify request.'); + if (!$tool) { + PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.')); + $this->redirect('course/lti/add_link'); + return; + } + if (!$tool->deep_linking) { + PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.')); + $this->redirect('course/lti/add_link'); + return; } - if (is_array($content_items) && count($content_items['@graph'])) { - // we only support selecting a single content item - $item = $content_items['@graph'][0]; - - $lti_data = new LtiData(); - $lti_data->course_id = $this->course_id; - $lti_data->position = LtiData::countBySQL('course_id = ?', [$this->course_id]); - $lti_data->title = (string) $item['title']; - $lti_data->description = Studip\Markup::purifyHtml(Studip\Markup::markAsHtml($item['text'])); - $lti_data->tool_id = $tool_id; - $lti_data->launch_url = (string) ($item['url'] ?? ''); - $options = []; - if (is_array($item['custom'])) { - $custom_parameters = ''; - foreach ($item['custom'] as $key => $value) { - $custom_parameters .= $key . '=' . $value . "\n"; - } - $options['custom_parameters'] = $custom_parameters; + if ($tool->lti_version === '1.3a') { + //LTI 1.3a + + $validator = new PlatformLaunchValidator( + new RegistrationManager(), + new NonceRepository(Studip\Cache\Factory::getCache()) + ); + $result = $validator->validateToolOriginatingLaunch($this->getPsrRequest()); + if ($result->hasError()) { + PageLayout::postError($result->getError()); + $this->redirect('course/lti/add_link'); + return; } + $all_lti_resources = (new ResourceCollectionFactory())->createFromClaim( + $result->getPayload()->getDeepLinkingContentItems() + ); - if (isset($item['placementAdvice']['presentationDocumentTarget'])) { - $options['document_target'] = $item['placementAdvice']['presentationDocumentTarget']; + $lti_resource_links = $all_lti_resources->getByType(LtiResourceLinkInterface::TYPE); + if (count($lti_resource_links) > 0) { + $use_first_link = true; + foreach ($lti_resource_links as $lti_resource_link) { + $deployment = null; + if ($use_first_link) { + //Recycle the deployment that has been created before + //for the course. + $deployment = LtiDeployment::findOneBySQL( + "`tool_id` = :tool_id AND `course_id` = :course_id + AND `options` LIKE '%unfinished_deep_linking%=%true'" + ); + $use_first_link = false; + } + if (!$deployment) { + //If this is the first link, the deployment has been removed. + //In that case and if it is not the first link, a new deployment + //has to be created. + $deployment = new LtiDeployment(); + $deployment->tool_id = $tool->id; + $deployment->title = $tool->name; + $deployment->course_id = $this->course_id; + } + $deployment->launch_url = $lti_resource_link->getUrl(); + if (!empty($deployment->options['unfinished_deep_linking'])) { + unset($deployment->options['unfinished_deep_linking']); + } + $deployment->store(); + } } + } else { + $lti_msg = Request::get('lti_msg'); + $lti_errormsg = Request::get('lti_errormsg'); + $content_items = Request::get('content_items'); + $content_items = json_decode($content_items, true); + + if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) { + throw new Exception('Could not verify request.'); + } - $lti_data->options = $options; - $lti_data->store(); + if (is_array($content_items) && count($content_items['@graph'])) { + // we only support selecting a single content item + $item = $content_items['@graph'][0]; + + $lti_data = new LtiDeployment(); + $lti_data->course_id = $this->course_id; + $lti_data->position = LtiDeployment::countBySQL('course_id = ?', [$this->course_id]); + $lti_data->title = (string) $item['title']; + $lti_data->description = Studip\Markup::purifyHtml(Studip\Markup::markAsHtml($item['text'])); + $lti_data->tool_id = $tool_id; + $lti_data->launch_url = (string) ($item['url'] ?? ''); + $options = []; + if (is_array($item['custom'])) { + $custom_parameters = ''; + foreach ($item['custom'] as $key => $value) { + $custom_parameters .= $key . '=' . $value . "\n"; + } + $options['custom_parameters'] = $custom_parameters; + } + + if (isset($item['placementAdvice']['presentationDocumentTarget'])) { + $options['document_target'] = $item['placementAdvice']['presentationDocumentTarget']; + } - PageLayout::postSuccess($lti_msg ?: _('Der Link wurde als neuer Abschnitt hinzugefügt.')); + $lti_data->options = $options; + $lti_data->store(); + PageLayout::postSuccess($lti_msg ?: _('Der Link wurde als neuer Abschnitt hinzugefügt.')); + } } if ($lti_errormsg) { @@ -328,7 +653,7 @@ class Course_LtiController extends StudipController /** * Return an LtiLink object for the configured LTI content block. * - * @param LtiData $lti_data data of LTI content block + * @param LtiDeployment $lti_data data of LTI content block * * @return LtiLink LTI link representation */ @@ -455,7 +780,7 @@ class Course_LtiController extends StudipController */ public function outcome_action($id) { - $lti_data = LtiData::find($id); + $lti_data = LtiDeployment::find($id); if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $lti_data->getConsumerSecret(), '')) { throw new Exception('Could not verify request.'); @@ -509,13 +834,22 @@ class Course_LtiController extends StudipController } /** - * Display the (simple) LTI gradebook. + * Display the (simple) LTI grade book. */ public function grades_action() { Navigation::activateItem('/course/lti/grades'); - $this->lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position'); + if ($this->edit_perm) { + $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + } else { + //Only load those deployments that are fully configured: + $this->lti_data_array = LtiDeployment::findBySQL( + "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `position`", + ['course_id' => $this->course_id] + ); + } if ($this->edit_perm) { $this->desc = Request::int('desc'); @@ -543,7 +877,16 @@ class Course_LtiController extends StudipController */ public function export_grades_action() { - $lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position'); + if ($this->edit_perm) { + $lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + } else { + //Only load those deployments that are fully configured: + $lti_data_array = LtiDeployment::findBySQL( + "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `position`", + ['course_id' => $this->course_id] + ); + } $columns = [_('Nachname'), _('Vorname')]; diff --git a/app/controllers/lti.php b/app/controllers/lti.php deleted file mode 100644 index 82d9840..0000000 --- a/app/controllers/lti.php +++ /dev/null @@ -1,127 +0,0 @@ - Request::get('oauth_consumer_key'), - 'content_item_return_url' => Request::get('content_item_return_url'), - 'document_targets' => Request::get('accept_presentation_document_targets'), - 'data' => Request::get('data') - ]; - $this->redirect('lti/content_item'); - } else if ($course_id) { - $this->redirect('course/enrolment/apply/' . $course_id); - } else { - $this->redirect('start'); - } - } - - /** - * Select course for ContentItemSelectionRequest message. - */ - public function content_item_action() - { - PageLayout::setTitle(_('Veranstaltung verknüpfen')); - Navigation::activateItem('/browse/my_courses/content_item'); - - $this->document_targets = $_SESSION['ContentItemSelection']['document_targets']; - $this->target_labels = [ - 'embed' => _('in Seite einbetten'), - 'frame' => _('gleiches Fenster oder Tab'), - 'iframe' => _('IFrame in der Seite'), - 'window' => _('neues Fenster oder Tab'), - 'popup' => _('Popup-Fenster'), - 'overlay' => _('Dialog'), - 'none' => _('nicht anzeigen') - ]; - - $sql = "JOIN seminar_user USING(Seminar_id) - LEFT JOIN semester_courses sc ON seminare.seminar_id = sc.course_id - LEFT JOIN semester_data s USING (semester_id) - WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor') - ORDER BY s.beginn DESC, Name"; - $this->courses = Course::findBySQL($sql, [$GLOBALS['user']->id]); - } - - /** - * Return the selected content item to the LTI consumer. - */ - public function link_content_item_action() - { - CSRFProtection::verifyUnsafeRequest(); - $course_id = Request::option('course_id'); - $target = Request::option('target'); - $course = Course::find($course_id); - - $consumer_key = $_SESSION['ContentItemSelection']['oauth_consumer_key']; - $return_url = $_SESSION['ContentItemSelection']['content_item_return_url']; - $data = $_SESSION['ContentItemSelection']['data']; - unset($_SESSION['ContentItemSelection']); - - $consumer_config = $GLOBALS['STUDIP_AUTH_CONFIG_LTI']['consumer_keys'][$consumer_key]; - $consumer_secret = $consumer_config['consumer_secret']; - $signature_method = $consumer_config['signature_method'] ?? 'sha1'; - - $content_items = [ - '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem', - '@graph' => [] - ]; - - if (Request::submitted('link')) { - $content_items['@graph'][] = [ - '@type' => 'LtiLinkItem', - 'mediaType' => 'application/vnd.ims.lti.v1.ltilink', - 'title' => $course->name, - 'text' => $course->beschreibung, - 'placementAdvice' => ['presentationDocumentTarget' => $target], - 'custom' => ['course' => $course_id] - ]; - } - - // set up ContentItemSelection - $lti_link = new LtiLink($return_url, $consumer_key, $consumer_secret, $signature_method); - $lti_link->addLaunchParameters([ - 'lti_message_type' => 'ContentItemSelection', - 'content_items' => json_encode($content_items), - 'data' => $data - ]); - - $this->launch_url = $lti_link->getLaunchURL(); - $this->launch_data = $lti_link->getBasicLaunchData(); - $this->signature = $lti_link->getLaunchSignature($this->launch_data); - $this->render_template('course/lti/iframe'); - } -} diff --git a/app/controllers/lti/ags.php b/app/controllers/lti/ags.php new file mode 100644 index 0000000..be4aed7 --- /dev/null +++ b/app/controllers/lti/ags.php @@ -0,0 +1,106 @@ +with_session = true; + parent::__construct($dispatcher); + } + + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + //All the work is done by the OAT-SA library and + //the implementation of its interfaces in Stud.IP. + //Only the handler changes for the endpoints. + $reg_manager = new RegistrationManager(); + $line_item_repo = new LineItemRepository(); + $validator = new RequestAccessTokenValidator($reg_manager); + $handler = null; + if ($action === 'line_item') { + if (empty($args)) { + if (Request::isPut()) { + //Update a line item: + $handler = new UpdateLineItemServiceServerRequestHandler($line_item_repo); + } elseif (Request::isDelete()) { + //Delete a line item: + $handler = new DeleteLineItemServiceServerRequestHandler($line_item_repo); + } else { + //Get a line item: + $handler = new GetLineItemServiceServerRequestHandler($line_item_repo); + } + } elseif ($args[0] === 'results') { + $handler = new ResultServiceServerRequestHandler($line_item_repo, new Studip\LTI13a\ResultRepository()); + } elseif ($args[0] === 'scores') { + $handler = new ScoreServiceServerRequestHandler($line_item_repo,new \Studip\LTI13a\ScoreRepository()); + } + } elseif ($action === 'line_items') { + if (Request::isPost()) { + //Create a line item: + $handler = new CreateLineItemServiceServerRequestHandler($line_item_repo); + } else { + //List line items: + $handler = new ListLineItemsServiceServerRequestHandler($line_item_repo); + } + } else { + //Invalid endpoint. + throw new AccessDeniedException(studip_interpolate('Invalid endpoint: %{endpoint}', ['endpoint' => $action])); + } + if (!$handler) { + throw new \Studip\LTIException('No handler available for this request.'); + } + $server = new LtiServiceServer($validator, $handler); + $this->renderPsrResponse($server->handle($this->getPsrRequest())); + } + + /** + * This is the endpoint for the LTI AGS lineitem service. + * + * @return void + */ + public function line_item_action(): void + { + //Nothing here. All is done in the before_filter. + } + + /** + * This is the endpoint for the LTI AGS lineitems service. + * + * @return void + */ + public function line_items_action(): void + { + //Nothing here. All is done in the before_filter. + } +} diff --git a/app/controllers/lti/auth.php b/app/controllers/lti/auth.php new file mode 100644 index 0000000..07b6096 --- /dev/null +++ b/app/controllers/lti/auth.php @@ -0,0 +1,250 @@ +allow_nobody = false; + $action = basename(get_route()); + if (in_array($action, ['jwks', 'oauth2_token'])) { + $this->allow_nobody = true; + $this->with_session = $action !== 'jwks'; + } + parent::__construct($dispatcher); + } + + /** + * Callback function being called before an action is executed. + */ + public function before_filter(&$action, &$args) + { + if (in_array($action, ['index', 'content_item', 'link_content_item'])) { + // enforce LTI SSO login + Request::set('sso', 'lti'); + } + parent::before_filter($action, $args); + } + + /** + * Redirect to enrolment action for the given course, if needed. + */ + public function index_action($course_id = null) + { + $course_id = Request::option('custom_cid', $course_id); + $course_id = Request::option('custom_course', $course_id); + $message_type = Request::option('lti_message_type'); + + if ($message_type === 'ContentItemSelectionRequest') { + $_SESSION['ContentItemSelection'] = [ + 'oauth_consumer_key' => Request::get('oauth_consumer_key'), + 'content_item_return_url' => Request::get('content_item_return_url'), + 'document_targets' => Request::get('accept_presentation_document_targets'), + 'data' => Request::get('data') + ]; + $this->redirect('lti/content_item'); + } else if ($course_id) { + $this->redirect('course/enrolment/apply/' . $course_id); + } else { + $this->redirect('start'); + } + } + + /** + * Select course for ContentItemSelectionRequest message. + */ + public function content_item_action() + { + PageLayout::setTitle(_('Veranstaltung verknüpfen')); + Navigation::activateItem('/browse/my_courses/content_item'); + + $this->document_targets = $_SESSION['ContentItemSelection']['document_targets']; + $this->target_labels = [ + 'embed' => _('in Seite einbetten'), + 'frame' => _('gleiches Fenster oder Tab'), + 'iframe' => _('IFrame in der Seite'), + 'window' => _('neues Fenster oder Tab'), + 'popup' => _('Popup-Fenster'), + 'overlay' => _('Dialog'), + 'none' => _('nicht anzeigen') + ]; + + $sql = "JOIN seminar_user USING(Seminar_id) + LEFT JOIN semester_courses sc ON seminare.seminar_id = sc.course_id + LEFT JOIN semester_data s USING (semester_id) + WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor') + ORDER BY s.beginn DESC, Name"; + $this->courses = Course::findBySQL($sql, [$GLOBALS['user']->id]); + } + + /** + * Return the selected content item to the LTI consumer. + */ + public function link_content_item_action() + { + CSRFProtection::verifyUnsafeRequest(); + $course_id = Request::option('course_id'); + $target = Request::option('target'); + $course = Course::find($course_id); + + $consumer_key = $_SESSION['ContentItemSelection']['oauth_consumer_key']; + $return_url = $_SESSION['ContentItemSelection']['content_item_return_url']; + $data = $_SESSION['ContentItemSelection']['data']; + unset($_SESSION['ContentItemSelection']); + + $consumer_config = $GLOBALS['STUDIP_AUTH_CONFIG_LTI']['consumer_keys'][$consumer_key]; + $consumer_secret = $consumer_config['consumer_secret']; + $signature_method = $consumer_config['signature_method'] ?? 'sha1'; + + $content_items = [ + '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem', + '@graph' => [] + ]; + + if (Request::submitted('link')) { + $content_items['@graph'][] = [ + '@type' => 'LtiLinkItem', + 'mediaType' => 'application/vnd.ims.lti.v1.ltilink', + 'title' => $course->name, + 'text' => $course->beschreibung, + 'placementAdvice' => ['presentationDocumentTarget' => $target], + 'custom' => ['course' => $course_id] + ]; + } + + // set up ContentItemSelection + $lti_link = new LtiLink($return_url, $consumer_key, $consumer_secret, $signature_method); + $lti_link->addLaunchParameters([ + 'lti_message_type' => 'ContentItemSelection', + 'content_items' => json_encode($content_items), + 'data' => $data + ]); + + $this->launch_url = $lti_link->getLaunchURL(); + $this->launch_data = $lti_link->getBasicLaunchData(); + $this->signature = $lti_link->getLaunchSignature($this->launch_data); + $this->render_template('course/lti/iframe'); + } + + /** + * This action handles OIDC (OpenID connect) requests. + * + * @return void + */ + public function oidc_init_action(): void + { + $reg_manager = new RegistrationManager(); + $user_authenticator = new UserAuthenticator(); + $request = $this->getPsrRequest(); + + $oidc_handler = new OidcAuthenticationRequestHandler( + new OidcAuthenticator( + $reg_manager, + $user_authenticator, + //The following is necessary due to a library bug. + //See: https://github.com/oat-sa/lib-lti1p3-core/issues/154 + new MessagePayloadBuilder(new NonceGenerator(true)) + ) + ); + $response = $oidc_handler->handle($request); + $this->renderPsrResponse($response); + } + + /** + * This action handles JSON web key set (JWKS) requests for the platform key. + * + * @return void + */ + public function jwks_action(): void + { + $repo = new KeyChainRepository(); + $keyring = Keyring::findOneBySQL("`range_type` = 'global' AND `range_id` = 'lti13a_platform'"); + if ($keyring) { + $repo->addKeyChain($keyring->toKeyChain()); + } + $handler = new JwksRequestHandler(new JwksExporter($repo)); + $response = $handler->handle('lti13a_platform'); + $this->renderPsrResponse($response); + } + + /** + * Generates OAuth2 tokens for LTI tools. + */ + public function oauth2_token_action(): void + { + $keyring = Keyring::findOneByRange_id('lti13a_platform'); + if (!$keyring) { + throw new \Studip\Exception( + 'Stud.IP LTI 1.3a platform keyring cannot be found!' + ); + } + $key_chain = $keyring->toKeyChain(); + $response_generator = new AccessTokenResponseGenerator( + new KeyManager(), + new AuthorizationServerFactory( + new ClientRepository(new RegistrationManager()), + new AccessTokenRepository(Factory::getCache()), + new ScopeRepository( + [ + new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'), + new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'), + new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/score') + ] + ), + $key_chain->getPrivateKey()->getContent() + ) + ); + + $response = $response_generator->generate( + $this->getPsrRequest(), + $this->getPsrResponse(), + 'lti13a_platform' + ); + + $this->renderPsrResponse($response); + } + + /** + * Displays LTI platform data of the Stud.IP installation. The data are needed for configuring the + * platform on the tool side. + */ + public function platform_data_action() + { + $this->platform = PlatformManager::getPlatformConfiguration(); + $this->render_template('lti/_platform_data'); + } +} diff --git a/app/controllers/lti/tool.php b/app/controllers/lti/tool.php new file mode 100644 index 0000000..0e7077e --- /dev/null +++ b/app/controllers/lti/tool.php @@ -0,0 +1,236 @@ +tool = null; + $this->deployment = null; + $this->tool_id = ''; + $this->range_id = ''; + + if (in_array($action, ['index', 'add', 'edit', 'delete'])) { + $this->range_id = $args[0]; + $this->tool_id = $args[1] ?? ''; + + if ($action === 'add' && !$this->tool_id) { + $this->tool = new LtiTool(); + $this->tool->range_id = $this->range_id; + } else { + if (!$this->tool_id) { + PageLayout::postError(_('Es wurde kein LTI-Tool angegeben.')); + return; + } + $this->tool = LtiTool::find($this->tool_id); + if (!$this->tool) { + throw new \Studip\Exception(_('Das angegebene LTI-Tool wurde nicht gefunden.')); + } + } + } + } + + public function index_action($range_id, $tool_id): void + { + //$this->tool is created in the before-filter. + if ($this->range_id !== 'global') { + $this->deployment = LtiDeployment::findOneBySQL( + '`tool_id` = :tool_id AND `course_id` = :range_id', + ['tool_id' => $this->tool->id, 'range_id' => $this->range_id] + ); + } + } + + public function add_action($range_id, $tool_id = ''): void + { + //NOTE: The parameters are checked and processed in the before_filter. + $this->addEditHandler(); + } + + public function edit_action($range_id, $tool_id): void + { + //NOTE: The parameters are checked and processed in the before_filter. + $this->addEditHandler(); + } + + protected function addEditHandler(): void + { + if (!$this->tool) { + return; + } + $this->deployment = null; + if ($this->tool->isNew()) { + if (!Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE && $this->range_id !== 'global') { + throw new AccessDeniedException( + _('Die Einrichtung von LTI-Tools in Veranstaltungen ist ausgeschaltet.') + ); + } + if ($this->tool->range_id === 'global' && !$this->tool->isEditableByUser()) { + throw new AccessDeniedException(); + } + PageLayout::postWarning(_('Bitte beachten Sie das geltende europäische Datenschutzrecht (DSGVO)!')); + if ($this->tool->range_id !== 'global') { + $this->deployment = new LtiDeployment(); + $this->deployment->course_id = $this->tool->range_id; + } + } elseif ($this->range_id !== 'global') { + $this->deployment = LtiDeployment::findOneBySQL( + '`tool_id` = :tool_id AND `course_id` = :range_id', + ['tool_id' => $this->tool->id, 'range_id' => $this->range_id] + ); + if (!$this->deployment) { + //Create a new deployment: + $this->deployment = new LtiDeployment(); + $this->deployment->tool_id = $this->tool->id; + $this->deployment->course_id = $this->range_id; + } + } + + if (Request::isPost()) { + $this->saveTool(); + } + } + + /** + * Handles the saving of a tool. + */ + protected function saveTool(): void + { + CSRFProtection::verifyUnsafeRequest(); + if ($this->range_id === 'global') { + //The admin page for editing global tools. + $this->tool->name = trim(Request::get('name')); + $this->tool->launch_url = trim(Request::get('launch_url')); + } else { + //The page for editing tools configured in courses. + $this->deployment->title = trim(Request::get('name')); + $this->deployment->description = trim(Request::get('description')); + $this->deployment->launch_url = trim(Request::get('launch_url')); + $document_target = trim(Request::get('document_target')); + if ($document_target === 'iframe') { + if (!is_array($this->deployment->options)) { + $this->deployment->options = []; + } + $this->deployment->options['document_target'] = $document_target; + } elseif (isset($this->deployment->options['document_target'])) { + unset($this->deployment->options['document_target']); + } + } + + //If a deployment is present, the tool is not used in the global context. + //If a tool is not used in the global context and the range_id is not set to "global", + //it is a tool that is only used for one course. + if ( + !$this->deployment + || $this->tool->range_id !== 'global' + || $GLOBALS['perm']->have_perm('root') + ) { + $this->tool->name = trim(Request::get('name')); + $this->tool->launch_url = trim(Request::get('launch_url')); + $this->tool->terms_of_use_url = trim(Request::get('terms_of_use_url')); + $this->tool->privacy_policy_url = trim(Request::get('privacy_policy_url')); + $this->tool->data_protection_notes = trim(Request::get('data_protection_notes')); + $this->tool->lti_version = Request::get('lti_version', '1.3a'); + if ($this->tool->lti_version === '1.3a') { + $this->tool->oauth_signature_method = 'sha256'; + $this->tool->oidc_init_url = trim(Request::get('oidc_init_url')); + $this->tool->jwks_url = trim(Request::get('jwks_url')); + $this->tool->jwks_key_id = trim(Request::get('jwks_key_id')); + $this->tool->deep_linking_url = trim(Request::get('deep_linking_url')); + $this->tool->deep_linking = (bool) $this->tool->deep_linking_url; + } else { + //LTI 1.0/1.1: + $this->tool->oauth_signature_method = 'sha1'; + $this->tool->consumer_key = trim(Request::get('consumer_key')); + $this->tool->consumer_secret = trim(Request::get('consumer_secret')); + } + $this->tool->send_lis_person = Request::int('send_lis_person', 0); + $this->tool->custom_parameters = trim(Request::get('custom_parameters')); + $tool_public_key = trim(Request::get('tool_public_key')); + $errors = $this->tool->validate(); + if ($errors) { + PageLayout::postError( + _('Die folgenden Daten zum LTI-Tool sind fehlerhaft:'), + array_map('htmlReady', $errors) + ); + return; + } + if ($this->tool->lti_version === '1.3a' && !$tool_public_key && !$this->tool->jwks_url) { + PageLayout::postError( + _('Es wurde weder ein öffentlicher Schlüssel noch eine JWKS-URL zum Tool angegeben.') + ); + return; + } + if ($this->tool->store() !== false) { + if ($this->deployment) { + $this->deployment->tool_id = $this->tool->id; + } + } else { + PageLayout::postError(_('Das LTI-Tool konnte nicht gespeichert werden.')); + return; + } + } + if ($this->deployment) { + $this->deployment->store(); + } + if ($this->tool->lti_version === '1.3a' && $tool_public_key) { + if (!$this->tool->updatePublicKey($tool_public_key)) { + PageLayout::postError( + _('Der öffentliche Schlüssel des LTI-Tools konnte nicht gespeichert werden.') + ); + } + } + + PageLayout::postSuccess(_('Das LTI-Tool wurde gespeichert.')); + if (Request::isDialog()) { + $this->response->add_header('X-Dialog-Close', '1'); + $this->render_nothing(); + } elseif ($this->range_id === 'global') { + $this->redirect('admin/lti'); + } else { + $this->redirect('course/lti'); + } + } + + public function delete_action($range_id, $tool_id): void + { + //NOTE: The parameters are checked and processed in the before_filter. + CSRFProtection::verifyUnsafeRequest(); + $deleted = false; + $tool_name = $this->tool->name; + if ($this->tool->range_id === 'global') { + if ($range_id === 'global') { + $deleted = $this->tool->delete(); + } else { + //A tool shall be deleted from a course: Delete the deployment instead. + $deployment = LtiDeployment::findOneBySQL( + "`tool_id` = :tool_id AND `course_id` = :course_id", + ['tool_id' => $this->tool->id, 'course_id' => $range_id] + ); + if ($deployment) { + $tool_name = $deployment->title; + $deleted = $deployment->delete(); + } else { + PageLayout::postError(sprintf(_('Das LTI-Tool „%s“ ist in dieser Veranstaltung nicht vorhanden.'), htmlReady($this->tool->name))); + return; + } + } + } else { + //Delete the tool directly: + $deleted = $this->tool->delete(); + } + if ($deleted !== false) { + PageLayout::postSuccess(sprintf(_('Das LTI-Tool „%s“ wurde gelöscht.'), htmlReady($tool_name))); + } else { + PageLayout::postError(_('Das LTI-Tool „%s“ konnte nicht gelöscht werden.'), htmlReady($tool_name)); + } + if ($range_id === 'global') { + //Redirect to the admin overview page. + $this->redirect('admin/lti'); + } elseif (Course::exists($range_id)) { + //Redirect to the LTI module of the course: + $this->redirect('course/lti', ['cid' => $range_id]); + } + } +} diff --git a/app/views/admin/lti/edit.php b/app/views/admin/lti/edit.php index 0473e86..adc8c59 100644 --- a/app/views/admin/lti/edit.php +++ b/app/views/admin/lti/edit.php @@ -2,73 +2,30 @@ /** * @var Admin_LtiController $controller * @var LtiTool $tool + * @var \OAT\Library\Lti1p3Core\Platform\Platform $platform */ ?> -
+
- - - - - - - - - - - - - - - -
- + +
+ + render_partial('lti/_platform_data', ['platform' => $platform]) ?>
+