diff options
| author | Murtaza Sultani <sultani@data-quest.de> | 2026-01-28 13:18:57 +0100 |
|---|---|---|
| committer | Murtaza Sultani <sultani@data-quest.de> | 2026-03-19 17:36:05 +0100 |
| commit | d19ffd22ca67b9f383f79bb7fc3d98c856256f0a (patch) | |
| tree | e38544d300888397840d1ce9a762624673fd97c1 /app | |
| parent | 4a4d3409de1ac30ae60c44f217a6987de984f233 (diff) | |
LTI Deeplinking
Diffstat (limited to 'app')
| -rw-r--r-- | app/controllers/enroll/lti.php | 260 | ||||
| -rw-r--r-- | app/views/enroll/lti/_errors.php | 13 | ||||
| -rw-r--r-- | app/views/enroll/lti/_messages.php | 17 | ||||
| -rw-r--r-- | app/views/enroll/lti/create_new_account.php | 4 | ||||
| -rw-r--r-- | app/views/enroll/lti/deeplink_callback.php | 3 | ||||
| -rw-r--r-- | app/views/enroll/lti/provisioning_modes.php | 16 | ||||
| -rw-r--r-- | app/views/enroll/lti/select_contents.php | 89 |
7 files changed, 321 insertions, 81 deletions
diff --git a/app/controllers/enroll/lti.php b/app/controllers/enroll/lti.php index 83a5cec..a80dee3 100644 --- a/app/controllers/enroll/lti.php +++ b/app/controllers/enroll/lti.php @@ -1,27 +1,30 @@ <?php use Lti\Publication; -use Lti\PublicationUser; +use Lti\Registration; use Ramsey\Uuid\Uuid; use Trails\Dispatcher; use Studip\Cache\Factory; +use Lti\UserIdentityMapping; use Studip\LTI13a\RoleMapper; use Studip\LTI13a\ToolManager; -use Studip\LTI13a\UserEnrollment; +use Studip\LTI13a\UserManager; use Lti\Enum\UserProvisioningMode; use Studip\OAuth2\NegotiatesWithPsr7; -use Studip\LTI13a\PublicationValidator; use Studip\LTI13a\RegistrationManager; +use Studip\LTI13a\PublicationValidator; +use Lti\Enum\UserIdentityMappingContext; use OAT\Library\Lti1p3Core\Exception\LtiException; use OAT\Library\Lti1p3Core\Security\Oidc\OidcInitiator; +use OAT\Library\Lti1p3Core\Resource\ResourceCollection; use OAT\Library\Lti1p3Core\Security\Nonce\NonceRepository; use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepository; -use OAT\Library\Lti1p3Core\Exception\LtiExceptionInterface; use OAT\Library\Lti1p3Core\Security\Jwks\Exporter\JwksExporter; use OAT\Library\Lti1p3Core\Security\Jwks\Server\JwksRequestHandler; use OAT\Library\Lti1p3Core\Message\Payload\LtiMessagePayloadInterface; use OAT\Library\Lti1p3Core\Message\Launch\Validator\Tool\ToolLaunchValidator; use OAT\Library\Lti1p3Core\Security\Oidc\Server\OidcInitiationRequestHandler; +use OAT\Library\Lti1p3DeepLinking\Message\Launch\Builder\DeepLinkingLaunchResponseBuilder; use OAT\Library\Lti1p3Core\Message\Launch\Validator\Result\LaunchValidationResultInterface; class Enroll_LtiController extends AuthenticatedController @@ -36,9 +39,26 @@ class Enroll_LtiController extends AuthenticatedController if (in_array($action, ['jwks', 'auth_init'])) { $this->with_session = false; } + + if (in_array($action, ['select_contents', 'deeplink_callback'])) { + $this->allow_nobody = false; + } + parent::__construct($dispatcher); } + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + if (!LtiToolModule::isToolSharingEnabled()) { + throw new AccessDeniedException(); + } + + PageLayout::disableSidebar(); + PageLayout::setBodyElementId('lti'); + } + public function jwks_action(): void { $keyChainRepo = new KeyChainRepository(); @@ -61,9 +81,6 @@ class Enroll_LtiController extends AuthenticatedController $this->renderPsrResponse($response); } - /** - * @throws LtiExceptionInterface - */ public function launch_action(): void { $validator = new ToolLaunchValidator( @@ -88,59 +105,121 @@ class Enroll_LtiController extends AuthenticatedController $this->resolveProvisioningMode($request, $publication); } - public function launch_deeplink_action(): void { + $validator = new ToolLaunchValidator( + new RegistrationManager(), + new NonceRepository(Factory::getCache()) + ); + + $request = $validator->validatePlatformOriginatingLaunch($this->getPsrRequest()); + + if ($request->hasError()) { + throw new LtiException($request->getError()); + } + + $localRoles = RoleMapper::toLocal($request->getPayload()->getRoles()); + if(!in_array($localRoles['course'], ['dozent', 'tutor'])) { + throw new AccessDeniedException(); + } + $this->resolveDeeplinkProvisioningMode($request); } - public function provisioning_modes_action(): void + public function select_contents_action(): void { - PageLayout::setTitle(_('Bereitstellungsmodus')); + PageLayout::setTitle(_('Inhalt auswählen')); $this->callbackId = Request::get('callback_id'); - if (empty($_SESSION['callbacks'][$this->callbackId])) { - throw new AccessDeniedException('Missing or invalid callback ID'); + $callbackData = $this->validateCallbackData($this->callbackId); + if ($callbackData['action'] !== 'deeplink_callback') { + throw new AccessDeniedException('Invalid callback action.'); } - $callbackData = $_SESSION['callbacks'][$this->callbackId]; - if ( - $callbackData['context'] !== 'lti' - || !isset($callbackData['provisioning_mode']) - || $callbackData['expires_at'] < time() - ) { - throw new AccessDeniedException('Invalid or expired callback data'); + if (!$GLOBALS['perm']->have_perm('tutor')) { + $this->errors[] = _('Sie haben nicht die Berechtigung, diese Aktion auszuführen.'); + return; } - $this->provisioningMode = (int) $callbackData['provisioning_mode']; - - PageLayout::disableSidebar(); - PageLayout::setBodyElementId('lti'); + $this->courses = Course::findBySQL( + "JOIN seminar_user USING(Seminar_id) + WHERE user_id = :user_id AND seminar_user.status IN ('dozent', 'tutor') + ORDER BY mkdate DESC, Name", + [ + 'user_id' => User::findCurrent()->id + ] + ); } - public function create_new_account_action(): void + public function deeplink_callback_action(): void { CSRFProtection::verifyUnsafeRequest(); $callbackId = Request::get('callback_id'); - if (empty($_SESSION['callbacks'][$callbackId])) { - throw new AccessDeniedException('Missing or invalid callback ID'); + $callbackData = $this->validateCallbackData($callbackId); + if ($callbackData['action'] !== 'deeplink_callback') { + throw new AccessDeniedException('Invalid callback action.'); } - $callbackData = $_SESSION['callbacks'][$callbackId]; - if ( - $callbackData['context'] !== 'lti' - || $callbackData['action'] !== 'enroll_user' - || $callbackData['expires_at'] < time() - ) { - throw new AccessDeniedException('Invalid or expired callback data'); + if (count(Request::getArray('courses_id')) === 0) { + PageLayout::postError(_('Sie haben keinen Inhalt ausgewählt.')); + $this->redirect('enroll/lti/select_contents?callback_id=' . $callbackId); + return; + } + + $registration = Registration::find($callbackData['registration_id']); + + $resourceCollection = new ResourceCollection(); + foreach (Request::getArray('courses_id') as $courseId) { + $course = Course::find($courseId); + + if ($course === null) { + continue; + } + + $resourceCollection->add($course->toLti1p3ResourceLink($registration->name)); + } + + $deepLinkingSettingsClaim = $callbackData['settings_claim']; + + $this->message = (new DeepLinkingLaunchResponseBuilder())->buildDeepLinkingLaunchResponse( + $resourceCollection, + $registration->toLti1p3Registration(), + $deepLinkingSettingsClaim->getDeepLinkingReturnUrl(), + $registration->getDefaultDeployment()->deployment_key, + $deepLinkingSettingsClaim->getData() + ); + } + + public function provisioning_modes_action(): void + { + PageLayout::setTitle(_('Bereitstellungsmodus')); + + $this->callbackId = Request::get('callback_id'); + $callbackData = $this->validateCallbackData($this->callbackId); + if (!isset($callbackData['provisioning_mode'])) { + throw new AccessDeniedException('Invalid callback data'); + } + + $this->provisioningMode = (int) $callbackData['provisioning_mode']; + } + + public function create_new_account_action(): void + { + CSRFProtection::verifyUnsafeRequest(); + + $callbackId = Request::get('callback_id'); + $callbackData = $this->validateCallbackData($callbackId); + if ($callbackData['action'] !== 'enroll_user') { + throw new AccessDeniedException('Invalid callback action'); } $publication = Publication::find($callbackData['publication_id']); - $userEnrollment = new UserEnrollment($publication, $callbackData['local_roles'], $callbackData['registration_id']); - $userEnrollment - ->enroll($callbackData['user_identity']) + $userManager = new UserManager(); + $userManager + ->setUserIdentity($callbackData['user_identity']) + ->enroll($publication, $callbackData['local_roles'], $callbackData['registration_id']) ->authenticate(); unset($_SESSION['callbacks'][$callbackId]); @@ -148,9 +227,32 @@ class Enroll_LtiController extends AuthenticatedController $this->redirect('course/overview?cid='.$publication->range->id); } - /** - * @throws LtiExceptionInterface - */ + public function reset_account_mapping_action(): void + { + CSRFProtection::verifyUnsafeRequest(); + + $callbackId = Request::get('callback_id'); + $callbackData = $this->validateCallbackData($callbackId); + if ($callbackData['action'] !== 'deeplink_callback') { + throw new AccessDeniedException('Invalid callback action.'); + } + + UserIdentityMapping::deleteBySQL( + "user_id = :user_id AND context = :context", + [ + 'user_id' => User::findCurrent()->id, + 'context' => UserIdentityMappingContext::DeepLink->value + ] + ); + + sess()->destroy(); + sess()->start(); + $_SESSION['callbacks'][$callbackId] = $callbackData; + $_SESSION['redirect_after_login'] = URLHelper::getLink('dispatch.php/enroll/lti/select_contents?callback_id=' . $callbackId); + + $this->redirect('login?callback_id=' . $callbackId); + } + private function resolveProvisioningMode(LaunchValidationResultInterface $request, Publication $publication): void { $authUser = User::findCurrent(); @@ -161,22 +263,23 @@ class Enroll_LtiController extends AuthenticatedController $payload = $request->getPayload(); $localRoles = RoleMapper::toLocal($payload->getRoles()); - $userEnrollment = new UserEnrollment($publication, $localRoles, $request->getRegistration()->getIdentifier()); + $userManager = new UserManager(); - $publicationUser = PublicationUser::findOneBySQL( - "external_email = :external_email AND external_user_id = :external_user_id AND registration_id = :registration_id", + $userIdentity = UserIdentityMapping::findOneBySQL( + "context = :context AND external_email = :external_email AND external_user_id = :external_user_id AND registration_id = :registration_id", [ + 'context' => UserIdentityMappingContext::ResourceLink->value, 'external_email' => $payload->getUserIdentity()->getEmail(), 'external_user_id' => $payload->getUserIdentity()->getIdentifier(), 'registration_id' => $request->getRegistration()->getIdentifier() ] ); - if ($publicationUser) { - $userEnrollment + if ($userIdentity) { + $userManager ->setUserIdentity($payload->getUserIdentity()) - ->syncUser($publicationUser->user) - ->syncRangeMember() + ->setUser($userIdentity->user) + ->enroll($publication, $localRoles, $request->getRegistration()->getIdentifier()) ->authenticate(); $this->redirect('course/overview?cid='.$publication->range->id); @@ -192,8 +295,9 @@ class Enroll_LtiController extends AuthenticatedController }; if ($provisioningMode === UserProvisioningMode::NewAccountsOnly->value) { - $userEnrollment - ->enroll($payload->getUserIdentity()) + $userManager + ->setUserIdentity($payload->getUserIdentity()) + ->enroll($publication, $localRoles, $request->getRegistration()->getIdentifier()) ->authenticate(); $this->redirect('course/overview?cid='.$publication->range->id); @@ -216,6 +320,49 @@ class Enroll_LtiController extends AuthenticatedController $this->redirect('enroll/lti/provisioning_modes?callback_id=' . $callbackId); } + private function resolveDeeplinkProvisioningMode(LaunchValidationResultInterface $request): void + { + $callbackId = Uuid::uuid4()->toString(); + $_SESSION['callbacks'][$callbackId] = [ + 'user_identity' => $request->getPayload()->getUserIdentity(), + 'registration_id' => $request->getRegistration()->getIdentifier(), + 'settings_claim' => $request->getPayload()->getDeepLinkingSettings(), + 'provisioning_mode' => UserProvisioningMode::ExistingAccountsOnly->value, + 'context' => 'lti', + 'action' => 'deeplink_callback', + 'expires_at' => time() + 1800 + ]; + + $authUser = User::findCurrent(); + if ($authUser) { + $this->redirect('enroll/lti/select_contents?callback_id=' . $callbackId); + return; + } + + $payload = $request->getPayload(); + + $userIdentity = UserIdentityMapping::findOneBySQL( + "context = :context AND external_email = :external_email AND external_user_id = :external_user_id AND registration_id = :registration_id", + [ + 'context' => UserIdentityMappingContext::DeepLink->value, + 'external_email' => $payload->getUserIdentity()->getEmail(), + 'external_user_id' => $payload->getUserIdentity()->getIdentifier(), + 'registration_id' => $request->getRegistration()->getIdentifier() + ] + ); + + if ($userIdentity) { + (new UserManager()) + ->setUser($userIdentity->user) + ->authenticate(); + + $this->redirect('enroll/lti/select_contents?callback_id=' . $callbackId); + return; + } + + $_SESSION['redirect_after_login'] = URLHelper::getLink('dispatch.php/enroll/lti/select_contents?callback_id=' . $callbackId); + $this->redirect('enroll/lti/provisioning_modes?callback_id=' . $callbackId); + } private function getPublication(LtiMessagePayloadInterface $payload): ?Publication { @@ -225,4 +372,21 @@ class Enroll_LtiController extends AuthenticatedController return Publication::findOneBySQL("publication_key = ?", [$payload->getCustom()['id']]); } + + private function validateCallbackData(string $callbackId): array + { + if (empty($_SESSION['callbacks'][$callbackId])) { + throw new AccessDeniedException('Missing or invalid callback ID'); + } + + $callbackData = $_SESSION['callbacks'][$callbackId]; + if ( + $callbackData['context'] !== 'lti' + || $callbackData['expires_at'] < time() + ) { + throw new AccessDeniedException('Invalid or expired callback data'); + } + + return $callbackData; + } } diff --git a/app/views/enroll/lti/_errors.php b/app/views/enroll/lti/_errors.php new file mode 100644 index 0000000..fb06eeb --- /dev/null +++ b/app/views/enroll/lti/_errors.php @@ -0,0 +1,13 @@ +<?php +/** + * @var array $errors + */ +?> + +<ul class="messages-container"> + <? foreach ($errors as $error): ?> + <li> + <?= MessageBox::error($error) ?> + </li> + <? endforeach ?> +</ul> diff --git a/app/views/enroll/lti/_messages.php b/app/views/enroll/lti/_messages.php deleted file mode 100644 index 932dec6..0000000 --- a/app/views/enroll/lti/_messages.php +++ /dev/null @@ -1,17 +0,0 @@ -<?php -/** - * @var array $messages - */ -?> - -<ul class="messages-container"> - <? foreach ($messages as $message): ?> - <li> - <? if ($message['type'] == 'error'): ?> - <?= MessageBox::error($message['text']) ?> - <? else: ?> - <?= MessageBox::info($message['text']) ?> - <? endif; ?> - </li> - <? endforeach ?> -</ul> diff --git a/app/views/enroll/lti/create_new_account.php b/app/views/enroll/lti/create_new_account.php index 40450f4..0692b5c 100644 --- a/app/views/enroll/lti/create_new_account.php +++ b/app/views/enroll/lti/create_new_account.php @@ -1,7 +1,7 @@ <?php /** - * @var array $messages + * @var array $errors */ ?> -<?= $this->render_partial('enroll/lti/_messages', ['messages' => $messages ?? []]); ?> +<?= $this->render_partial('enroll/lti/_errors', ['errors' => $errors ?? []]); ?> diff --git a/app/views/enroll/lti/deeplink_callback.php b/app/views/enroll/lti/deeplink_callback.php index f4a5e8d..d29da3c 100644 --- a/app/views/enroll/lti/deeplink_callback.php +++ b/app/views/enroll/lti/deeplink_callback.php @@ -1,6 +1,9 @@ <?php +use OAT\Library\Lti1p3Core\Message\LtiMessageInterface; + /** * @var Enroll_LtiController $controller + * @var LtiMessageInterface $message */ ?> diff --git a/app/views/enroll/lti/provisioning_modes.php b/app/views/enroll/lti/provisioning_modes.php index e3809b7..851466e 100644 --- a/app/views/enroll/lti/provisioning_modes.php +++ b/app/views/enroll/lti/provisioning_modes.php @@ -1,29 +1,18 @@ <?php +use Lti\Enum\UserProvisioningMode; + /** * @var Enroll_LtiController $controller * @var int $provisioningMode * @var string $callbackId */ - -use Lti\Enum\UserProvisioningMode; - -?> - -<?php -/** - * @var array $messages - */ ?> -<?= $this->render_partial('enroll/lti/_messages', ['messages' => $messages ?? []]); ?> - -<? if(empty($messages)): ?> <div class="provisioning-modes"> <h1><?= _('Willkommen!') ?></h1> <?= MessageBox::info(_('Es sieht so aus, als wäre dies Ihr erstes Mal hier. Bitte wählen Sie eine der folgenden Kontooptionen aus.')) ?> <br /> <ul class="studip-card-container"> - <li class="studip-card"> <header class="studip-card__header"> <p class="studip-card__title"> @@ -71,4 +60,3 @@ use Lti\Enum\UserProvisioningMode; <? endif ?> </ul> </div> -<? endif ?> diff --git a/app/views/enroll/lti/select_contents.php b/app/views/enroll/lti/select_contents.php new file mode 100644 index 0000000..9f46e1a --- /dev/null +++ b/app/views/enroll/lti/select_contents.php @@ -0,0 +1,89 @@ +<?php +use Lti\UserIdentityMapping; +use Lti\Enum\UserIdentityMappingContext; + +/** + * @var Enroll_LtiController $controller + * @var ?SimpleORMapCollection<Course> $courses + * @var string $callbackId + * @var ?array $errors + */ +?> + + +<div class="lti"> + <? if(empty($errors)): ?> + <? if (count($courses) > 0): ?> + <div class="lti-resources"> + <form action="<?= $controller->link_for('enroll/lti/deeplink_callback') ?>" method="POST" class="default"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="callback_id" value="<?= $callbackId ?>" /> + <table class="default sortable-table"> + <caption><?= _('Veröffentlichte Inhalte') ?></caption> + <colgroup> + <col style="width: 10%" /> + <col /> + </colgroup> + <thead> + + <tr class="sortable"> + <th scope="col" data-sort="false"> + <input + aria-label="<?= _('Inhalt auswählen') ?>" + type="checkbox" + name="all_courses_id" + value="1" + data-proxyfor=":checkbox[name^=courses]" + > + </th> + <th scope="col" data-sort="text"><?= _('Name') ?></th> + </tr> + + </thead> + <tbody> + <? foreach ($courses as $course) : ?> + <tr> + <td> + <input + aria-label="<?= sprintf(_('Inhalt "%s" auswählen'), htmlReady($course->getFullName())) ?>" + type="checkbox" + name="courses_id[]" + value="<?= $course->id ?>" + /> + </td> + <td data-text="<?= htmlReady($course->getFullName()) ?>"> + <?= htmlReady($course->getFullName()) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + <tfoot> + <tr> + <td colspan="2"> + <button type="submit" class="button add"> + <?= _('Ausgewählte Inhalte hinzufügen') ?> + </button> + </td> + </tr> + </tfoot> + </table> + </form> + </div> + <? else: ?> + <?= MessageBox::info(_('Es wurden keine Inhalte gefunden.')) ?> + <? endif ?> + <? else: ?> + <?= $this->render_partial('enroll/lti/_errors', ['errors' => $errors]); ?> + <? endif ?> + <? if (empty($courses)|| !empty($errors)): ?> + <form action="<?= $controller->link_for('enroll/lti/reset_account_mapping') ?>" method="POST" class="default use-utility-classes"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="callback_id" value="<?= $callbackId ?>" /> + <?= MessageBox::info(_('Sie können sich abmelden und es erneut mit einem anderen Konto versuchen.')) ?> + <button type="submit" class="button flex items-center gap-5"> + <?= Icon::create('door-leave', Icon::DEFAULT_ROLE, ['aria-hidden' => 'true']) ?> + <?= _('Abmelden und mit einem anderen Konto versuchen') ?> + </button> + </form> + <? endif ?> +</div> |
