aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorMurtaza Sultani <sultani@data-quest.de>2026-01-28 13:18:57 +0100
committerMurtaza Sultani <sultani@data-quest.de>2026-03-19 17:36:05 +0100
commitd19ffd22ca67b9f383f79bb7fc3d98c856256f0a (patch)
treee38544d300888397840d1ce9a762624673fd97c1 /app
parent4a4d3409de1ac30ae60c44f217a6987de984f233 (diff)
LTI Deeplinking
Diffstat (limited to 'app')
-rw-r--r--app/controllers/enroll/lti.php260
-rw-r--r--app/views/enroll/lti/_errors.php13
-rw-r--r--app/views/enroll/lti/_messages.php17
-rw-r--r--app/views/enroll/lti/create_new_account.php4
-rw-r--r--app/views/enroll/lti/deeplink_callback.php3
-rw-r--r--app/views/enroll/lti/provisioning_modes.php16
-rw-r--r--app/views/enroll/lti/select_contents.php89
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>