diff options
| author | Murtaza Sultani <sultani@data-quest.de> | 2026-01-27 13:38:51 +0100 |
|---|---|---|
| committer | Murtaza Sultani <sultani@data-quest.de> | 2026-03-19 17:36:05 +0100 |
| commit | 8eae6da6ebe7a2f201b956235854bfcaec361037 (patch) | |
| tree | d66a80530db40a158768f261de532d1e4994ca60 /app | |
| parent | 097a4f4244f6e491118dafdc2f24af4ad4410fda (diff) | |
Account provisioning modes
Diffstat (limited to 'app')
| -rw-r--r-- | app/controllers/admin/lti/publications.php | 2 | ||||
| -rw-r--r-- | app/controllers/admin/lti/resources.php | 7 | ||||
| -rw-r--r-- | app/controllers/course/lti.php | 10 | ||||
| -rw-r--r-- | app/controllers/enrol/lti.php | 138 | ||||
| -rw-r--r-- | app/controllers/enroll/lti.php | 228 | ||||
| -rw-r--r-- | app/controllers/login.php | 1 | ||||
| -rw-r--r-- | app/views/enrol/lti/launch.php | 13 | ||||
| -rw-r--r-- | app/views/enroll/lti/_messages.php | 17 | ||||
| -rw-r--r-- | app/views/enroll/lti/create_new_account.php | 7 | ||||
| -rw-r--r-- | app/views/enroll/lti/deeplink_callback.php | 7 | ||||
| -rw-r--r-- | app/views/enroll/lti/launch.php | 7 | ||||
| -rw-r--r-- | app/views/enroll/lti/provisioning_modes.php | 74 | ||||
| -rw-r--r-- | app/views/lti/launch/index.php | 47 |
13 files changed, 402 insertions, 156 deletions
diff --git a/app/controllers/admin/lti/publications.php b/app/controllers/admin/lti/publications.php index 384f253..b3e993a 100644 --- a/app/controllers/admin/lti/publications.php +++ b/app/controllers/admin/lti/publications.php @@ -37,7 +37,7 @@ class Admin_Lti_PublicationsController extends AdminBaseController ] ]; - if ($GLOBALS['perm']->have_perm('root')) { + if ($GLOBALS['perm']->have_perm('root') && !$this->range_id) { $sqlQuery = [ "TRUE ORDER BY `mkdate`, `name`" ]; diff --git a/app/controllers/admin/lti/resources.php b/app/controllers/admin/lti/resources.php index 5860fb1..8f6d9b0 100644 --- a/app/controllers/admin/lti/resources.php +++ b/app/controllers/admin/lti/resources.php @@ -89,6 +89,13 @@ class Admin_Lti_ResourcesController extends AdminBaseController 'icon' => Request::get('icon') ]); + if (Request::get('registration_id')) { + $deploymentId = Registration::find(Request::get('registration_id'))?->getDefaultDeployment()->id; + $resourceLink->setData([ + 'deployment_id' => $deploymentId ?? $resourceLink->deployment_id + ]); + } + $resourceLink->store(); PageLayout::postSuccess( diff --git a/app/controllers/course/lti.php b/app/controllers/course/lti.php index 6ec281f..fe8c098 100644 --- a/app/controllers/course/lti.php +++ b/app/controllers/course/lti.php @@ -240,7 +240,9 @@ class Course_LtiController extends StudipController $deployment->deployment_key, RoleMapper::fromLocal($GLOBALS['perm']->get_studip_perm($this->range_id)), [ - ...$resourceLinkRepo->getCustomLtiParameters(), + [ + 'https://purl.imsglobal.org/spec/lti/claim/custom' => $resourceLinkRepo->getCustom() + ], new ContextClaim( $this->range_id, ['http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering'], @@ -371,15 +373,15 @@ class Course_LtiController extends StudipController $links = $ltiResources->getByType(LtiResourceLinkInterface::TYPE); if (count($links) > 0) { foreach ($links as $link) { - $custom_parameters = ''; + $customParameters = ''; foreach ($link->getCustom() as $key => $value) { - $custom_parameters .= $key . '=' . $value . "\n"; + $customParameters .= $key . '=' . $value . "\n"; } ResourceLink::create([ 'title' => $link->getTitle(), 'launch_url' => $link->getUrl(), - 'custom_parameters' => $custom_parameters, + 'custom_parameters' => $customParameters, 'launch_container' => $link->getProperties()->get('presentation')['documentTarget'] ?? $resourceLink->launch_container, 'deployment_id' => $resourceLink->deployment_id, 'course_id' => $resourceLink->course_id, diff --git a/app/controllers/enrol/lti.php b/app/controllers/enrol/lti.php deleted file mode 100644 index 9dd1eee..0000000 --- a/app/controllers/enrol/lti.php +++ /dev/null @@ -1,138 +0,0 @@ -<?php - -use Lti\Publication; -use OAT\Library\Lti1p3Core\Exception\LtiException; -use OAT\Library\Lti1p3Core\Exception\LtiExceptionInterface; -use OAT\Library\Lti1p3Core\Message\Launch\Validator\Result\LaunchValidationResultInterface; -use Studip\Authentication\Manager as AuthenticationManager; -use Studip\Cache\Factory; -use Studip\LTI13a\PublicationValidator; -use Studip\LTI13a\ToolManager; -use Studip\LTI13a\RegistrationManager; -use OAT\Library\Lti1p3Core\Security\Oidc\OidcInitiator; -use OAT\Library\Lti1p3Core\Security\Nonce\NonceRepository; -use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepository; -use OAT\Library\Lti1p3Core\Security\Jwks\Exporter\JwksExporter; -use OAT\Library\Lti1p3Core\Security\Jwks\Server\JwksRequestHandler; -use OAT\Library\Lti1p3Core\Message\Launch\Validator\Tool\ToolLaunchValidator; -use OAT\Library\Lti1p3Core\Security\Oidc\Server\OidcInitiationRequestHandler; -use Studip\LTI13a\UserEnrollment; -use Studip\OAuth2\NegotiatesWithPsr7; -use Trails\Dispatcher; - -class Enrol_LtiController extends AuthenticatedController -{ - use NegotiatesWithPsr7; - protected $allow_nobody = true; - protected $with_session = false; - - public function __construct(Dispatcher $dispatcher) - { - $action = basename(get_route()); - if (in_array($action, ['launch'])) { - $this->with_session = true; - } - parent::__construct($dispatcher); - } - - public function jwks_action(): void - { - $keyChainRepo = new KeyChainRepository(); - $toolKeyring = ToolManager::getKeyring(); - - $keyChainRepo->addKeyChain($toolKeyring->toKeyChain()); - $handler = new JwksRequestHandler(new JwksExporter($keyChainRepo)); - $this->renderPsrResponse($handler->handle($toolKeyring->range_id)); - } - - public function auth_init_action(): void - { - $oidcInitHandler = new OidcInitiationRequestHandler( - new OidcInitiator( - new RegistrationManager() - ) - ); - - $response = $oidcInitHandler->handle($this->getPsrRequest()); - $this->renderPsrResponse($response); - } - - public function launch_action(): void - { - $validator = new ToolLaunchValidator( - new RegistrationManager(), - new NonceRepository(Factory::getCache()) - ); - - $request = $validator->validatePlatformOriginatingLaunch($this->getPsrRequest()); - - if ($request->hasError()) { - dd($request->getError()); - } - - dd(Request::getInstance()); - - try { - $validator = new ToolLaunchValidator( - new RegistrationManager(), - new NonceRepository(Factory::getCache()) - ); - - $request = $validator->validatePlatformOriginatingLaunch($this->getPsrRequest()); - - if ($request->hasError()) { - throw new LtiException($request->getError()); - } - - $publication = $this->getPublication($request); - - (new PublicationValidator($publication))->validateLaunch(); - - $user = (new UserEnrollment($request, $publication))->enroll(); - - auth()->setAuthenticatedUser($user); - Metrics::increment('core.login.succeeded'); - sess()->regenerateId(AuthenticationManager::DEFAULT_KEPT_SESSION_VARIABLES); - - $this->redirect('course/overview?cid='.$publication->range->id); - } catch (Throwable $exception) { - $this->messages = [ - [ - 'type' => 'error', - 'text' => $exception->getMessage() - ] - ]; - - $this->set_layout($GLOBALS['template_factory']->open('lti/layout')); - } - } - - public function launch_deeplink_action(): void - { - dd('launch_deeplink_action: ', Request::getInstance()); - } - - /** - * @throws LtiExceptionInterface - */ - private function getPublication(LaunchValidationResultInterface $request): Publication - { - $customId = $request->getPayload()->getCustom()['id'] ?? null; - if (!$customId) { - throw new LtiException('Missing custom ID'); - } - - $publication = Publication::findOneBySQL("publication_key = ?", [$customId]); - if (!$publication) { - throw new LtiException('Invalid custom ID'); - } - - return $publication; - } - - private function showMessages(array $messages): void - { - $this->messages = $messages; - $this->set_layout($GLOBALS['template_factory']->open('lti/layout')); - } -} diff --git a/app/controllers/enroll/lti.php b/app/controllers/enroll/lti.php new file mode 100644 index 0000000..83a5cec --- /dev/null +++ b/app/controllers/enroll/lti.php @@ -0,0 +1,228 @@ +<?php + +use Lti\Publication; +use Lti\PublicationUser; +use Ramsey\Uuid\Uuid; +use Trails\Dispatcher; +use Studip\Cache\Factory; +use Studip\LTI13a\RoleMapper; +use Studip\LTI13a\ToolManager; +use Studip\LTI13a\UserEnrollment; +use Lti\Enum\UserProvisioningMode; +use Studip\OAuth2\NegotiatesWithPsr7; +use Studip\LTI13a\PublicationValidator; +use Studip\LTI13a\RegistrationManager; +use OAT\Library\Lti1p3Core\Exception\LtiException; +use OAT\Library\Lti1p3Core\Security\Oidc\OidcInitiator; +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\Lti1p3Core\Message\Launch\Validator\Result\LaunchValidationResultInterface; + +class Enroll_LtiController extends AuthenticatedController +{ + use NegotiatesWithPsr7; + protected $allow_nobody = true; + protected $with_session = true; + + public function __construct(Dispatcher $dispatcher) + { + $action = basename(get_route()); + if (in_array($action, ['jwks', 'auth_init'])) { + $this->with_session = false; + } + parent::__construct($dispatcher); + } + + public function jwks_action(): void + { + $keyChainRepo = new KeyChainRepository(); + $toolKeyring = ToolManager::getKeyring(); + + $keyChainRepo->addKeyChain($toolKeyring->toKeyChain()); + $handler = new JwksRequestHandler(new JwksExporter($keyChainRepo)); + $this->renderPsrResponse($handler->handle($toolKeyring->range_id)); + } + + public function auth_init_action(): void + { + $oidcInitHandler = new OidcInitiationRequestHandler( + new OidcInitiator( + new RegistrationManager() + ) + ); + + $response = $oidcInitHandler->handle($this->getPsrRequest()); + $this->renderPsrResponse($response); + } + + /** + * @throws LtiExceptionInterface + */ + public function launch_action(): void + { + $validator = new ToolLaunchValidator( + new RegistrationManager(), + new NonceRepository(Factory::getCache()) + ); + + $request = $validator->validatePlatformOriginatingLaunch($this->getPsrRequest()); + + if ($request->hasError()) { + throw new LtiException($request->getError()); + } + + $publication = $this->getPublication($request->getPayload()); + + if($publication === null) { + throw new LtiException('Missing or invalid custom ID'); + } + + (new PublicationValidator($publication))->validateLaunch(); + + $this->resolveProvisioningMode($request, $publication); + } + + + public function launch_deeplink_action(): void + { + + } + + public function provisioning_modes_action(): void + { + PageLayout::setTitle(_('Bereitstellungsmodus')); + + $this->callbackId = Request::get('callback_id'); + + if (empty($_SESSION['callbacks'][$this->callbackId])) { + throw new AccessDeniedException('Missing or invalid callback ID'); + } + + $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'); + } + + $this->provisioningMode = (int) $callbackData['provisioning_mode']; + + PageLayout::disableSidebar(); + PageLayout::setBodyElementId('lti'); + } + + public function create_new_account_action(): void + { + CSRFProtection::verifyUnsafeRequest(); + + $callbackId = Request::get('callback_id'); + if (empty($_SESSION['callbacks'][$callbackId])) { + throw new AccessDeniedException('Missing or invalid callback ID'); + } + + $callbackData = $_SESSION['callbacks'][$callbackId]; + if ( + $callbackData['context'] !== 'lti' + || $callbackData['action'] !== 'enroll_user' + || $callbackData['expires_at'] < time() + ) { + throw new AccessDeniedException('Invalid or expired callback data'); + } + + $publication = Publication::find($callbackData['publication_id']); + $userEnrollment = new UserEnrollment($publication, $callbackData['local_roles'], $callbackData['registration_id']); + $userEnrollment + ->enroll($callbackData['user_identity']) + ->authenticate(); + + unset($_SESSION['callbacks'][$callbackId]); + + $this->redirect('course/overview?cid='.$publication->range->id); + } + + /** + * @throws LtiExceptionInterface + */ + private function resolveProvisioningMode(LaunchValidationResultInterface $request, Publication $publication): void + { + $authUser = User::findCurrent(); + if ($authUser) { + $this->redirect('course/overview?cid='.$publication->range->id); + return; + } + + $payload = $request->getPayload(); + $localRoles = RoleMapper::toLocal($payload->getRoles()); + $userEnrollment = new UserEnrollment($publication, $localRoles, $request->getRegistration()->getIdentifier()); + + $publicationUser = PublicationUser::findOneBySQL( + "external_email = :external_email AND external_user_id = :external_user_id AND registration_id = :registration_id", + [ + 'external_email' => $payload->getUserIdentity()->getEmail(), + 'external_user_id' => $payload->getUserIdentity()->getIdentifier(), + 'registration_id' => $request->getRegistration()->getIdentifier() + ] + ); + + if ($publicationUser) { + $userEnrollment + ->setUserIdentity($payload->getUserIdentity()) + ->syncUser($publicationUser->user) + ->syncRangeMember() + ->authenticate(); + + $this->redirect('course/overview?cid='.$publication->range->id); + return; + } + + // First launch: + $publicationConfigs = $publication->getConfigValues(); + $provisioningMode = match ($localRoles['course'] ?? null) { + 'dozent' => (int) $publicationConfigs['provisioning_mode_instructor'], + 'autor' => (int) $publicationConfigs['provisioning_mode_student'], + default => throw new LtiException('Unsupported LTI role.') + }; + + if ($provisioningMode === UserProvisioningMode::NewAccountsOnly->value) { + $userEnrollment + ->enroll($payload->getUserIdentity()) + ->authenticate(); + + $this->redirect('course/overview?cid='.$publication->range->id); + return; + } + + $callbackId = Uuid::uuid4()->toString(); + $_SESSION['callbacks'][$callbackId] = [ + 'user_identity' => $payload->getUserIdentity(), + 'registration_id' => $request->getRegistration()->getIdentifier(), + 'publication_id' => $publication->id, + 'local_roles' => $localRoles, + 'provisioning_mode' => $provisioningMode, + 'context' => 'lti', + 'action' => 'enroll_user', + 'expires_at' => time() + 1800 + ]; + $_SESSION['redirect_after_login'] = URLHelper::getLink('dispatch.php/course/overview?cid='.$publication->range->id); + + $this->redirect('enroll/lti/provisioning_modes?callback_id=' . $callbackId); + } + + + private function getPublication(LtiMessagePayloadInterface $payload): ?Publication + { + if (empty($payload->getCustom()['id'])) { + return null; + } + + return Publication::findOneBySQL("publication_key = ?", [$payload->getCustom()['id']]); + } +} diff --git a/app/controllers/login.php b/app/controllers/login.php index af8f3bd..0d42ef4 100644 --- a/app/controllers/login.php +++ b/app/controllers/login.php @@ -87,6 +87,7 @@ class LoginController extends AuthenticatedController ); Metrics::increment('core.login.succeeded'); sess()->regenerateId(\Studip\Authentication\Manager::DEFAULT_KEPT_SESSION_VARIABLES); + NotificationCenter::postNotification('Authenticated', $check_auth['user'], ['callback_id' => Request::get('callback_id')]); $this->redirect('start/index'); return; } diff --git a/app/views/enrol/lti/launch.php b/app/views/enrol/lti/launch.php deleted file mode 100644 index 2a36a50..0000000 --- a/app/views/enrol/lti/launch.php +++ /dev/null @@ -1,13 +0,0 @@ -<?php -/** - * @var array $messages - */ -?> - -<ul> - <? foreach ($messages as $message): ?> - <li> - <?= $message['text'] ?> - </li> - <? endforeach ?> -</ul> diff --git a/app/views/enroll/lti/_messages.php b/app/views/enroll/lti/_messages.php new file mode 100644 index 0000000..932dec6 --- /dev/null +++ b/app/views/enroll/lti/_messages.php @@ -0,0 +1,17 @@ +<?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 new file mode 100644 index 0000000..40450f4 --- /dev/null +++ b/app/views/enroll/lti/create_new_account.php @@ -0,0 +1,7 @@ +<?php +/** + * @var array $messages + */ +?> + +<?= $this->render_partial('enroll/lti/_messages', ['messages' => $messages ?? []]); ?> diff --git a/app/views/enroll/lti/deeplink_callback.php b/app/views/enroll/lti/deeplink_callback.php new file mode 100644 index 0000000..f4a5e8d --- /dev/null +++ b/app/views/enroll/lti/deeplink_callback.php @@ -0,0 +1,7 @@ +<?php +/** + * @var Enroll_LtiController $controller + */ +?> + +<?= $message->toHtmlRedirectForm() ?> diff --git a/app/views/enroll/lti/launch.php b/app/views/enroll/lti/launch.php new file mode 100644 index 0000000..40450f4 --- /dev/null +++ b/app/views/enroll/lti/launch.php @@ -0,0 +1,7 @@ +<?php +/** + * @var array $messages + */ +?> + +<?= $this->render_partial('enroll/lti/_messages', ['messages' => $messages ?? []]); ?> diff --git a/app/views/enroll/lti/provisioning_modes.php b/app/views/enroll/lti/provisioning_modes.php new file mode 100644 index 0000000..e3809b7 --- /dev/null +++ b/app/views/enroll/lti/provisioning_modes.php @@ -0,0 +1,74 @@ +<?php +/** + * @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"> + <?= _('Ich habe bereits ein Konto') ?> + </p> + </header> + <div class="studip-card__body"> + <?= Icon::create('role') ?> + <p class="studip-card__subtitle"><?= _('Bestehendes Konto verwenden') ?></p> + <p class="studip-card__description"> + <?= _('Melde dich an, um dein bestehendes Konto zu verknüpfen.') ?> + </p> + </div> + <footer class="studip-card__footer"> + <a href="<?= URLHelper::getLink('dispatch.php/login?callback_id=' . $callbackId) ?>" class="button"> + <?= _('Anmelden') ?> + </a> + </footer> + </li> + + <? if($provisioningMode !== UserProvisioningMode::ExistingAccountsOnly->value): ?> + <li class="studip-card"> + <header class="studip-card__header"> + <p class="studip-card__title"> + <?= _('Ich möchte ein neues Konto erstellen') ?> + </p> + </header> + <div class="studip-card__body"> + <?= Icon::create('add') ?> + <p class="studip-card__subtitle"><?= _('Konto erstellen') ?></p> + <p class="studip-card__description"> + <?= _('Mit einem neuen Konto starten') ?> + </p> + </div> + <footer class="studip-card__footer"> + <form action="<?= $controller->url_for('enroll/lti/create_new_account') ?>" method="post"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="callback_id" value="<?= $callbackId ?>" /> + <button type="submit" class="button"> + <?= _('Ein neues Konto erstellen') ?> + </button> + </form> + </footer> + </li> + <? endif ?> + </ul> +</div> +<? endif ?> diff --git a/app/views/lti/launch/index.php b/app/views/lti/launch/index.php new file mode 100644 index 0000000..c35b55d --- /dev/null +++ b/app/views/lti/launch/index.php @@ -0,0 +1,47 @@ +<?php +/** + * @var StudipController $controller + * @var ?Lti\ResourceLink $resourceLink + * @var string $launchUrl + * @var array $launchData + * @var string $signature + * @var string $version + * @var \OAT\Library\Lti1p3Core\Message\LtiMessage $message + */ +?> +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title data-original="<?= htmlReady(PageLayout::getTitle()) ?>"> + <?= htmlReady(PageLayout::getTitle() . ' - ' . Config::get()->UNI_NAME_CLEAN) ?> + </title> + <? if ($version === '1.1') : ?> + <script type="text/javascript"> + document.addEventListener("DOMContentLoaded", function () { + document.ltiLaunchForm.submit(); + }); + </script> + <? endif ?> + </head> + <body> + <? if ($version === '1.3a') : ?> + <? if ($message) : ?> + <?= $message->toHtmlRedirectForm(Request::submitted('do_not_send') ? false : true) ?> + <? else: ?> + <?= _('Das LTI-Tool kann nicht aufgerufen werden.') ?> + <? endif ?> + <? endif ?> + <? if ($version === '1.1'): ?> + <form name="ltiLaunchForm" method="post" action="<?= htmlReady($launchUrl) ?>"> + <? foreach ($launchData as $key => $value): ?> + <input type="hidden" name="<?= htmlReady($key) ?>" value="<?= htmlReady($value, false) ?>" /> + <? endforeach ?> + <input type="hidden" name="oauth_signature" value="<?= $signature ?>" /> + <noscript> + <button><?= _('Anwendung starten') ?></button> + </noscript> + </form> + <? endif ?> + </body> +</html> |
