Skip to content

Commit 2c12724

Browse files
authored
LTI Authentication (#53)
* adds LTI authenthication to filter * checkers fixes * fix ci branch issue * fix stylelint checker * fix unit test * fix php warning: non-component has no effect
1 parent 6db0017 commit 2c12724

10 files changed

+344
-14
lines changed

.github/workflows/moodle-ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ jobs:
1111
uses: Opencast-Moodle/moodle-workflows-opencast/.github/workflows/moodle-ci.yml@main
1212
with:
1313
requires-tool-plugin: true
14-
branch-tool-plugin: master
14+
branch-tool-plugin: main

classes/local/lti_helper.php

+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
/**
18+
* LTI helper class for filter opencast.
19+
* @package filter_opencast
20+
* @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V.
21+
* @author Farbod Zamani Boroujeni <[email protected]>
22+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23+
*/
24+
25+
namespace filter_opencast\local;
26+
27+
use oauth_helper;
28+
use tool_opencast\local\settings_api;
29+
30+
/**
31+
* LTI helper class for filter opencast.
32+
* @package filter_opencast
33+
* @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V.
34+
* @author Farbod Zamani Boroujeni <[email protected]>
35+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36+
*/
37+
class lti_helper {
38+
39+
/** */
40+
const LTI_LAUNCH_PATH = '/filter/opencast/ltilaunch.php';
41+
42+
/**
43+
* Create necessary lti parameters.
44+
* @param string $consumerkey LTI consumer key.
45+
* @param string $consumersecret LTI consumer secret.
46+
* @param string $endpoint of the opencast instance.
47+
* @param string $customtool the custom tool
48+
* @param int $courseid the course id to add into the parameters
49+
*
50+
* @return array lti parameters
51+
*/
52+
public static function create_lti_parameters($consumerkey, $consumersecret, $endpoint, $customtool, $courseid) {
53+
global $CFG, $USER;
54+
55+
$course = get_course($courseid);
56+
57+
require_once($CFG->dirroot . '/mod/lti/locallib.php');
58+
require_once($CFG->dirroot . '/lib/oauthlib.php');
59+
60+
$helper = new oauth_helper(['oauth_consumer_key' => $consumerkey,
61+
'oauth_consumer_secret' => $consumersecret, ]);
62+
63+
// Set all necessary parameters.
64+
$params = [];
65+
$params['oauth_version'] = '1.0';
66+
$params['oauth_nonce'] = $helper->get_nonce();
67+
$params['oauth_timestamp'] = $helper->get_timestamp();
68+
$params['oauth_consumer_key'] = $consumerkey;
69+
70+
$params['context_id'] = $course->id;
71+
$params['context_label'] = trim($course->shortname);
72+
$params['context_title'] = trim($course->fullname);
73+
$params['resource_link_id'] = 'o' . random_int(1000, 9999) . '-' . random_int(1000, 9999);
74+
$params['resource_link_title'] = 'Opencast';
75+
$params['context_type'] = ($course->format == 'site') ? 'Group' : 'CourseSection';
76+
$params['launch_presentation_locale'] = current_language();
77+
$params['ext_lms'] = 'moodle-2';
78+
$params['tool_consumer_info_product_family_code'] = 'moodle';
79+
$params['tool_consumer_info_version'] = strval($CFG->version);
80+
$params['oauth_callback'] = 'about:blank';
81+
$params['lti_version'] = 'LTI-1p0';
82+
$params['lti_message_type'] = 'basic-lti-launch-request';
83+
$urlparts = parse_url($CFG->wwwroot);
84+
$params['tool_consumer_instance_guid'] = $urlparts['host'];
85+
$params['custom_tool'] = urlencode($customtool);
86+
87+
// User data.
88+
$params['user_id'] = $USER->id;
89+
$params['lis_person_name_given'] = $USER->firstname;
90+
$params['lis_person_name_family'] = $USER->lastname;
91+
$params['lis_person_name_full'] = $USER->firstname . ' ' . $USER->lastname;
92+
$params['ext_user_username'] = $USER->username;
93+
$params['lis_person_contact_email_primary'] = $USER->email;
94+
$params['roles'] = lti_get_ims_role($USER, null, $course->id, false);
95+
96+
if (!empty($CFG->mod_lti_institution_name)) {
97+
$params['tool_consumer_instance_name'] = trim(html_to_text($CFG->mod_lti_institution_name, 0));
98+
} else {
99+
$params['tool_consumer_instance_name'] = get_site()->shortname;
100+
}
101+
102+
$params['launch_presentation_document_target'] = 'iframe';
103+
$params['oauth_signature_method'] = 'HMAC-SHA1';
104+
$params['oauth_signature'] = $helper->sign("POST", $endpoint, $params, $consumersecret . '&');
105+
return $params;
106+
}
107+
108+
/**
109+
* Retrieves the LTI consumer key and consumer secret for a given Opencast instance ID.
110+
*
111+
* @param int $ocinstanceid The ID of the Opencast instance.
112+
*
113+
* @return array An associative array containing the 'consumerkey' and 'consumersecret' for the given Opencast instance.
114+
* If the credentials are not found, an empty array is returned.
115+
*/
116+
public static function get_lti_credentials(int $ocinstanceid) {
117+
$lticonsumerkey = settings_api::get_lticonsumerkey($ocinstanceid);
118+
$lticonsumersecret = settings_api::get_lticonsumersecret($ocinstanceid);
119+
return ['consumerkey' => $lticonsumerkey, 'consumersecret' => $lticonsumersecret];
120+
}
121+
122+
/**
123+
* Checks if LTI credentials are configured for a given Opencast instance.
124+
*
125+
* This function verifies whether both the LTI consumer key and consumer secret
126+
* are set for the specified Opencast instance.
127+
*
128+
* @param int $ocinstanceid The ID of the Opencast instance to check.
129+
*
130+
* @return bool Returns true if both LTI consumer key and secret are configured,
131+
* false otherwise.
132+
*/
133+
public static function is_lti_credentials_configured(int $ocinstanceid) {
134+
$lticredentials = self::get_lti_credentials($ocinstanceid);
135+
return !empty($lticredentials['consumerkey']) && !empty($lticredentials['consumersecret']);
136+
}
137+
138+
/**
139+
* Retrieves an object containing LTI settings for a given Opencast instance.
140+
*
141+
* This function gathers the LTI credentials and API URL for the specified Opencast instance
142+
* and returns them as a structured object.
143+
*
144+
* @param int $ocinstanceid The ID of the Opencast instance for which to retrieve LTI settings.
145+
*
146+
* @return object An object containing the following properties:
147+
* - ocinstanceid: The ID of the Opencast instance.
148+
* - consumerkey: The LTI consumer key for the instance.
149+
* - consumersecret: The LTI consumer secret for the instance.
150+
* - baseurl: The API URL for the Opencast instance.
151+
*/
152+
public static function get_lti_set_object(int $ocinstanceid) {
153+
$lticredentials = self::get_lti_credentials($ocinstanceid);
154+
$baseurl = settings_api::get_apiurl($ocinstanceid);
155+
156+
return (object) [
157+
'ocinstanceid' => $ocinstanceid,
158+
'consumerkey' => $lticredentials['consumerkey'],
159+
'consumersecret' => $lticredentials['consumersecret'],
160+
'baseurl' => $baseurl,
161+
];
162+
}
163+
164+
/**
165+
* Generates the LTI launch URL for the Opencast filter.
166+
*
167+
* This function creates a URL for launching LTI content specific to the Opencast filter,
168+
* incorporating necessary parameters such as course ID, Opencast instance ID, and episode ID.
169+
*
170+
* @param int $ocinstanceid The ID of the Opencast instance.
171+
* @param int $courseid The ID of the course.
172+
* @param string $episodeid The ID of the Opencast episode.
173+
* @param bool $output Optional. If true, returns the URL as a string. If false, returns a moodle_url object. Default is true.
174+
*
175+
* @return string|moodle_url If $output is true, returns the LTI launch URL as a string.
176+
* If $output is false, returns a moodle_url object representing the LTI launch URL.
177+
*/
178+
public static function get_filter_lti_launch_url(int $ocinstanceid, int $courseid, string $episodeid, bool $output = true) {
179+
$params = [
180+
'courseid' => $courseid,
181+
'ocinstanceid' => $ocinstanceid,
182+
'episodeid' => $episodeid,
183+
'sesskey' => sesskey(),
184+
];
185+
$ltilaunchurl = new \moodle_url(self::LTI_LAUNCH_PATH, $params);
186+
if ($output) {
187+
return $ltilaunchurl->out(false);
188+
}
189+
return $ltilaunchurl;
190+
}
191+
}

classes/text_filter.php

+24-9
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@
2626

2727
namespace filter_opencast;
2828

29+
use filter_opencast\local\lti_helper;
2930
use mod_opencast\local\paella_transform;
31+
use tool_opencast\local\settings_api;
32+
use stdClass;
33+
use moodle_url;
34+
3035

3136
/**
3237
* Automatic opencast videos filter class.
@@ -57,9 +62,9 @@ private static function get_attribute(string $tag, string $attributename, string
5762
* @return array|null [ocinstanceid, episodeid] or null if there are no matches.
5863
*/
5964
private static function test_url(string $url, array $episodeurls) {
60-
foreach ($episodeurls as [$ocinstanceid, $episoderegex, $baseurl]) {
65+
foreach ($episodeurls as [$ocinstanceid, $episoderegex, $baseurl, $shoulduselti]) {
6166
if (preg_match_all($episoderegex, $url, $matches)) {
62-
return [$ocinstanceid, $matches[1][0]];
67+
return [$ocinstanceid, $matches[1][0], $shoulduselti];
6368
}
6469
}
6570
return null;
@@ -81,7 +86,7 @@ public function filter($text, array $options = []) {
8186

8287
// First section: (Relatively) quick check if there are episode urls in the text, and only look for these later.
8388
// Improvable by combining all episode urls into one big regex if needed.
84-
$ocinstances = \tool_opencast\local\settings_api::get_ocinstances();
89+
$ocinstances = settings_api::get_ocinstances();
8590
$occurrences = [];
8691
foreach ($ocinstances as $ocinstance) {
8792
$episodeurls = get_config('filter_opencast', 'episodeurl_' . $ocinstance->id);
@@ -90,6 +95,11 @@ public function filter($text, array $options = []) {
9095
continue;
9196
}
9297

98+
$uselticonfig = get_config('filter_opencast', 'uselti_' . $ocinstance->id);
99+
// Double check.
100+
$hasconfiguredlti = lti_helper::is_lti_credentials_configured($ocinstance->id);
101+
$shoulduselti = $uselticonfig && $hasconfiguredlti;
102+
93103
foreach (explode("\n", $episodeurls) as $episodeurl) {
94104
$episodeurl = trim($episodeurl);
95105

@@ -105,7 +115,7 @@ public function filter($text, array $options = []) {
105115
if (self::str_contains($text, $baseurl)) {
106116
$episoderegex = "/" . preg_quote($episodeurl, "/") . "/";
107117
$episoderegex = preg_replace('/\\\\\[EPISODEID\\\]/', '([0-9a-zA-Z\-]+)', $episoderegex);
108-
$occurrences[] = [$ocinstance->id, $episoderegex, $baseurl];
118+
$occurrences[] = [$ocinstance->id, $episoderegex, $baseurl, $shoulduselti];
109119
}
110120
}
111121
}
@@ -139,7 +149,7 @@ public function filter($text, array $options = []) {
139149
if (self::str_starts_with($match, "</$currenttag")) {
140150
$replacement = null;
141151
if ($episode) {
142-
$replacement = $this->render_player($episode[0], $episode[1], $i++, $width, $height);
152+
$replacement = $this->render_player($episode[0], $episode[1], $episode[2], $i++, $width, $height);
143153
}
144154
if ($replacement) {
145155
$newtext .= $replacement;
@@ -192,14 +202,15 @@ public function filter($text, array $options = []) {
192202
* Render HTML for embedding video player.
193203
* @param int $ocinstanceid Id of ocinstance.
194204
* @param string $episodeid Id opencast episode.
205+
* @param bool $shoulduselti Flag to determine whether to use LTI launch for this video or not.
195206
* @param int $playerid Unique id to assign to player element.
196207
* @param int|null $width Optionally width for player.
197208
* @param int|null $height Optionally height for player.
198209
* @return string|null
199210
*/
200-
protected function render_player(int $ocinstanceid, string $episodeid, int $playerid,
201-
$width = null, $height = null) {
202-
global $OUTPUT, $PAGE;
211+
protected function render_player(int $ocinstanceid, string $episodeid, bool $shoulduselti,
212+
int $playerid, $width = null, $height = null) {
213+
global $OUTPUT, $PAGE, $COURSE;
203214

204215
$data = paella_transform::get_paella_data_json($ocinstanceid, $episodeid);
205216

@@ -218,7 +229,11 @@ protected function render_player(int $ocinstanceid, string $episodeid, int $play
218229
$mustachedata->data = json_encode($data);
219230
$mustachedata->width = $width;
220231
$mustachedata->height = $height;
221-
$mustachedata->modplayerpath = (new moodle_url('/mod/opencast/player.html'))->out(false);
232+
if ($shoulduselti) {
233+
$mustachedata->ltiplayerpath = lti_helper::get_filter_lti_launch_url($ocinstanceid, $COURSE->id, $episodeid);
234+
} else {
235+
$mustachedata->modplayerpath = (new moodle_url('/mod/opencast/player.html'))->out(false);
236+
}
222237

223238
if (isset($data['streams'])) {
224239
if (count($data['streams']) === 1) {

lang/en/filter_opencast.php

+5
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@
2424

2525
defined('MOODLE_INTERNAL') || die();
2626
$string['filtername'] = 'Opencast';
27+
$string['ltilaunch_failed'] = 'Performing LTI authentication failed, please try again!';
2728
$string['pluginname'] = 'Opencast Filter';
2829
$string['privacy:metadata'] = 'The Opencast filter plugin does not store any personal data.';
2930
$string['setting_configurl'] = 'URL to Paella config.json';
3031
$string['setting_configurl_desc'] = 'URL of the config.json used by Paella Player. Can either be a absolute URL or a URL relative to the wwwroot.';
3132
$string['setting_episodeurl'] = 'URL templates for filtering';
3233
$string['setting_episodeurl_desc'] = 'URLs matching this template are replaced with the Opencast player. You must use the placeholder [EPISODEID] to indicate where the episode ID is contained in the URL e.g. http://stable.opencast.de/play/[EPISODEID]. If you want to filter for multiple URLs, enter each URL in a new line.';
34+
$string['setting_uselti'] = 'Enable LTI authentication';
35+
$string['setting_uselti_desc'] = 'When enabled, Opencast videos are delivered through LTI authentication using the <strong>default Opencast video player</strong>. This is typically used alongside Secure Static Files in Opencast for enhanced security.';
36+
$string['setting_uselti_nolti_desc'] = 'To enable LTI Authentication for Opencast, you must configure the required credentials (Consumer Key and Consumer Secret) for this instance. Please do so via this link: {$a}';
37+
$string['setting_uselti_ocinstance_name'] = 'Opencast API {$a} Instance';

ltilaunch.php

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
/**
18+
* LTI Launch page.
19+
* Designed to be called as a link in an iframe to prepare the lti launch data and perform the launch.
20+
*
21+
* @package filter_opencast
22+
* @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V.
23+
* @author Farbod Zamani Boroujeni <[email protected]>
24+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25+
*/
26+
27+
use filter_opencast\local\lti_helper;
28+
29+
require(__DIR__ . '/../../config.php');
30+
31+
global $PAGE;
32+
33+
$courseid = required_param('courseid', PARAM_INT);
34+
$ocinstanceid = required_param('ocinstanceid', PARAM_INT);
35+
$episodeid = required_param('episodeid', PARAM_ALPHANUMEXT);
36+
37+
$baseurl = lti_helper::get_filter_lti_launch_url($ocinstanceid, $courseid, $episodeid, false);
38+
39+
$PAGE->set_pagelayout('embedded');
40+
$PAGE->set_url($baseurl);
41+
42+
require_login($courseid, false);
43+
44+
if (confirm_sesskey()) {
45+
$ltisetobject = lti_helper::get_lti_set_object($ocinstanceid);
46+
$customtool = "/play/{$episodeid}";
47+
$endpoint = rtrim($ltisetobject->baseurl, '/') . '/lti';
48+
$ltiparams = lti_helper::create_lti_parameters(
49+
$ltisetobject->consumerkey,
50+
$ltisetobject->consumersecret,
51+
$endpoint,
52+
$customtool,
53+
$courseid
54+
);
55+
$formid = "ltiLaunchForm-{$episodeid}";
56+
$formattributed = [
57+
'action' => $endpoint,
58+
'method' => 'post',
59+
'id' => $formid,
60+
'name' => $formid,
61+
'encType' => 'application/x-www-form-urlencoded',
62+
];
63+
echo html_writer::start_tag('form', $formattributed);
64+
65+
foreach ($ltiparams as $name => $value) {
66+
$attributes = ['type' => 'hidden', 'name' => htmlspecialchars($name), 'value' => htmlspecialchars($value)];
67+
echo html_writer::empty_tag('input', $attributes) . "\n";
68+
}
69+
70+
echo html_writer::end_tag('form');
71+
72+
echo html_writer::script(
73+
"window.onload = function() {
74+
document.getElementById('{$formid}').submit();
75+
};"
76+
);
77+
78+
exit();
79+
}
80+
81+
throw new \moodle_exception('ltilaunch_failed', 'filter_opencast', $baseurl);

0 commit comments

Comments
 (0)