diff options
| -rw-r--r-- | app/controllers/admin/saml.php | 33 | ||||
| -rw-r--r-- | composer.json | 3 | ||||
| -rw-r--r-- | composer.lock | 108 | ||||
| -rw-r--r-- | lib/classes/JsonApi/RouteMap.php | 7 | ||||
| -rw-r--r-- | lib/classes/JsonApi/Routes/SAML/ConfigurationShow.php | 30 | ||||
| -rw-r--r-- | lib/classes/JsonApi/Routes/SAML/ConfigurationUpdate.php | 35 | ||||
| -rw-r--r-- | lib/classes/JsonApi/Routes/SAML/SetupInformation.php | 40 | ||||
| -rw-r--r-- | lib/navigation/AdminNavigation.php | 2 | ||||
| -rw-r--r-- | resources/vue/apps/SSOSAML.vue | 160 |
9 files changed, 416 insertions, 2 deletions
diff --git a/app/controllers/admin/saml.php b/app/controllers/admin/saml.php new file mode 100644 index 0000000..aa65027 --- /dev/null +++ b/app/controllers/admin/saml.php @@ -0,0 +1,33 @@ +<?php + +use Studip\OAuth2\Container; +use Studip\OAuth2\Models\Client; +use Studip\OAuth2\SetupInformation; + +class Admin_SAMLController extends AuthenticatedController +{ + /** + * @param string $action + * @param string[] $args + * + * @return void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + $GLOBALS['perm']->check('root'); + + Navigation::activateItem('/admin/config/saml'); + PageLayout::setTitle(_('SAML Verwaltung')); + } + + public function index_action(): void + { + $this->render_vue_app( + Studip\VueApp::create('SSOSAML') + ->withProps([ + ]) + ); + } +} diff --git a/composer.json b/composer.json index 278b2f6..e2bb7d6 100644 --- a/composer.json +++ b/composer.json @@ -137,7 +137,8 @@ "phpseclib/phpseclib2_compat": "1.0.6", "oat-sa/lib-lti1p3-deep-linking": "4.1.0", "lcobucci/jwt": "^4.3", - "guzzlehttp/guzzle": "^7.9.2" + "guzzlehttp/guzzle": "^7.9.2", + "onelogin/php-saml": "^4.3" }, "replace": { "symfony/polyfill-php54": "*", diff --git a/composer.lock b/composer.lock index 6377a6a..5d16bba 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dc44dd49880b457f30e1faec87e74daf", + "content-hash": "19a2a8677273053cbce3c6e37db911ec", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -3039,6 +3039,70 @@ "time": "2023-09-26T11:13:49+00:00" }, { + "name": "onelogin/php-saml", + "version": "4.3.0", + "source": { + "type": "git", + "url": "https://github.com/SAML-Toolkits/php-saml.git", + "reference": "bf5efce9f2df5d489d05e78c27003a0fc8bc50f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/bf5efce9f2df5d489d05e78c27003a0fc8bc50f0", + "reference": "bf5efce9f2df5d489d05e78c27003a0fc8bc50f0", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "robrichards/xmlseclibs": "^3.1" + }, + "require-dev": { + "pdepend/pdepend": "^2.8.0", + "php-coveralls/php-coveralls": "^2.0", + "phploc/phploc": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "phpunit/phpunit": "^9.5", + "sebastian/phpcpd": "^4.0 || ^5.0 || ^6.0 ", + "squizlabs/php_codesniffer": "^3.5.8" + }, + "suggest": { + "ext-curl": "Install curl lib to be able to use the IdPMetadataParser for parsing remote XMLs", + "ext-dom": "Install xml lib", + "ext-openssl": "Install openssl lib in order to handle with x509 certs (require to support sign and encryption)", + "ext-zlib": "Install zlib" + }, + "type": "library", + "autoload": { + "psr-4": { + "OneLogin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP SAML Toolkit", + "homepage": "https://github.com/SAML-Toolkits/php-saml", + "keywords": [ + "Federation", + "SAML2", + "SSO", + "identity", + "saml" + ], + "support": { + "email": "sixto.martin.garcia@gmail.com", + "issues": "https://github.com/onelogin/SAML-Toolkits/issues", + "source": "https://github.com/onelogin/SAML-Toolkits/" + }, + "funding": [ + { + "url": "https://github.com/SAML-Toolkits", + "type": "github" + } + ], + "time": "2025-05-25T14:28:00+00:00" + }, + { "name": "opis/json-schema", "version": "2.4.1", "source": { @@ -4628,6 +4692,48 @@ "time": "2024-04-27T21:32:50+00:00" }, { + "name": "robrichards/xmlseclibs", + "version": "3.1.3", + "source": { + "type": "git", + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07", + "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">= 5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobRichards\\XMLSecLibs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP library for XML Security", + "homepage": "https://github.com/robrichards/xmlseclibs", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "support": { + "issues": "https://github.com/robrichards/xmlseclibs/issues", + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3" + }, + "time": "2024-11-20T21:13:56+00:00" + }, + { "name": "scssphp/scssphp", "version": "v2.0.1", "source": { diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 86c6d92..530ae96 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -141,6 +141,7 @@ class RouteMap $this->addAuthenticatedStudyAreasRoutes($group); $this->addAuthenticatedUserFilterRoutes($group); $this->addAuthenticatedWikiRoutes($group); + $this->addAuthenticatedSAMLRoutes($group); } /** @@ -743,6 +744,12 @@ class RouteMap } + private function addAuthenticatedSAMLRoutes(RouteCollectorProxy $group): void + { + $group->get('/saml/configuration', Routes\SAML\ConfigurationShow::class); + $group->patch('/saml/configuration', Routes\SAML\ConfigurationUpdate::class); + } + private function addRelationship(RouteCollectorProxy $group, string $url, string $handler): void { $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler); diff --git a/lib/classes/JsonApi/Routes/SAML/ConfigurationShow.php b/lib/classes/JsonApi/Routes/SAML/ConfigurationShow.php new file mode 100644 index 0000000..e3b9ce3 --- /dev/null +++ b/lib/classes/JsonApi/Routes/SAML/ConfigurationShow.php @@ -0,0 +1,30 @@ +<?php + +namespace JsonApi\Routes\SAML; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Routes\Route; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\SAML\SetupInformation; + +class ConfigurationShow extends Route +{ + public function __invoke(Request $request, Response $response, array $args): Response + { + if (!$GLOBALS['perm']->have_perm('root')) { + throw new AuthorizationFailedException(); + } + + $setupInformation = $this->container->get(SetupInformation::class); + $config = $setupInformation->getConfiguration(); + + return $this->jsonResponse($response, [ + 'data' => [ + 'type' => 'saml-configuration', + 'id' => '1', + 'attributes' => $config, + ], + ]); + } +}
\ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/SAML/ConfigurationUpdate.php b/lib/classes/JsonApi/Routes/SAML/ConfigurationUpdate.php new file mode 100644 index 0000000..7845d2d --- /dev/null +++ b/lib/classes/JsonApi/Routes/SAML/ConfigurationUpdate.php @@ -0,0 +1,35 @@ +<?php + +namespace JsonApi\Routes\SAML; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Routes\Route; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\SAML\SetupInformation; + +class ConfigurationUpdate extends Route +{ + public function __invoke(Request $request, Response $response, array $args): Response + { + if (!$GLOBALS['perm']->have_perm('root')) { + throw new AuthorizationFailedException(); + } + + $data = $this->getJsonApiData($request); + $attributes = $data['attributes'] ?? []; + + $setupInformation = $this->container->get(SetupInformation::class); + $setupInformation->updateConfiguration($attributes); + + $updatedConfig = $setupInformation->getConfiguration(); + + return $this->jsonResponse($response, [ + 'data' => [ + 'type' => 'saml-configuration', + 'id' => '1', + 'attributes' => $updatedConfig, + ], + ]); + } +}
\ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/SAML/SetupInformation.php b/lib/classes/JsonApi/Routes/SAML/SetupInformation.php new file mode 100644 index 0000000..6dc8f44 --- /dev/null +++ b/lib/classes/JsonApi/Routes/SAML/SetupInformation.php @@ -0,0 +1,40 @@ +<?php + +namespace Studip\SAML; + +use Config; + +class SetupInformation +{ + private const CONFIG_KEY = 'SAML_CONFIG'; + + public function getConfiguration(): array + { + $config = Config::get(); + $samlConfig = json_decode($config->{self::CONFIG_KEY} ?? '{}', true); + + return [ + 'entityId' => $samlConfig['entityId'] ?? '', + 'assertionConsumerService' => $samlConfig['assertionConsumerService'] ?? '', + 'singleLogoutService' => $samlConfig['singleLogoutService'] ?? '', + 'nameIdFormat' => $samlConfig['nameIdFormat'] ?? '', + 'x509cert' => $samlConfig['x509cert'] ?? '', + 'privateKey' => $samlConfig['privateKey'] ?? '', + 'security' => [ + 'authnRequestsSigned' => $samlConfig['security']['authnRequestsSigned'] ?? false, + 'wantMessagesSigned' => $samlConfig['security']['wantMessagesSigned'] ?? false, + 'wantAssertionsSigned' => $samlConfig['security']['wantAssertionsSigned'] ?? false, + ], + ]; + } + + public function updateConfiguration(array $config): void + { + $existingConfig = $this->getConfiguration(); + $updatedConfig = array_merge($existingConfig, $config); + + $configInstance = Config::get(); + $configInstance->{self::CONFIG_KEY} = json_encode($updatedConfig); + $configInstance->store(self::CONFIG_KEY, $configInstance->{self::CONFIG_KEY}); + } +}
\ No newline at end of file diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php index fe31b72..1557670 100644 --- a/lib/navigation/AdminNavigation.php +++ b/lib/navigation/AdminNavigation.php @@ -206,6 +206,8 @@ class AdminNavigation extends Navigation $navigation->addSubNavigation('admissionrules', new Navigation(_('Anmelderegeln'), 'dispatch.php/admission/ruleadministration')); $navigation->addSubNavigation('oauth2', new Navigation(_('OAuth2'), 'dispatch.php/admin/oauth2/index')); + $navigation->addSubNavigation('saml', new Navigation(_('SAML'), 'dispatch.php/admin/saml/index')); + $navigation->addSubNavigation('globalsearch', new Navigation(_('Globale Suche'), 'dispatch.php/globalsearch/settings')); $navigation->addSubNavigation('cache', new Navigation(_('Cache'), 'dispatch.php/admin/cache/settings')); diff --git a/resources/vue/apps/SSOSAML.vue b/resources/vue/apps/SSOSAML.vue new file mode 100644 index 0000000..00fb46a --- /dev/null +++ b/resources/vue/apps/SSOSAML.vue @@ -0,0 +1,160 @@ +<template> + <div class="ssosaml-config"> + <ContentBar isContentBar icon="lock-locked" :title="$gettext('SAML Service Provider Configuration')"> + <template #info-text> + {{ $gettext('Configure your SAML Service Provider settings here.') }} + </template> + </ContentBar> + + <form @submit.prevent="saveConfig" class="default"> + <fieldset> + <legend>{{ $gettext('Basic Settings') }}</legend> + + <label for="entityId"> + {{ $gettext('Entity ID') }} + <input type="text" id="entityId" v-model="config.entityId" required> + </label> + + <label for="assertionConsumerService"> + {{ $gettext('Assertion Consumer Service URL') }} + <input type="url" id="assertionConsumerService" v-model="config.assertionConsumerService" required> + </label> + + <label for="singleLogoutService"> + {{ $gettext('Single Logout Service URL') }} + <input type="url" id="singleLogoutService" v-model="config.singleLogoutService"> + </label> + + <label for="nameIdFormat"> + {{ $gettext('NameID Format') }} + <select id="nameIdFormat" v-model="config.nameIdFormat"> + <option value="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">Unspecified</option> + <option value="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">Email Address</option> + <option value="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">Persistent</option> + <option value="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">Transient</option> + </select> + </label> + </fieldset> + + <fieldset> + <legend>{{ $gettext('Certificate Settings') }}</legend> + + <label for="x509cert"> + {{ $gettext('X.509 Certificate') }} + <textarea id="x509cert" v-model="config.x509cert" rows="5"></textarea> + </label> + + <label for="privateKey"> + {{ $gettext('Private Key') }} + <textarea id="privateKey" v-model="config.privateKey" rows="5"></textarea> + </label> + </fieldset> + + <fieldset> + <legend>{{ $gettext('Security Settings') }}</legend> + + <label> + <input type="checkbox" v-model="config.security.authnRequestsSigned"> + {{ $gettext('Sign AuthnRequests') }} + </label> + + <label> + <input type="checkbox" v-model="config.security.wantMessagesSigned"> + {{ $gettext('Require Signed Messages') }} + </label> + + <label> + <input type="checkbox" v-model="config.security.wantAssertionsSigned"> + {{ $gettext('Require Signed Assertions') }} + </label> + </fieldset> + + <footer data-dialog-button> + <button type="submit" class="button"> + {{ $gettext('Save Configuration') }} + </button> + <button type="button" class="button" @click="resetForm"> + {{ $gettext('Reset') }} + </button> + </footer> + </form> + </div> +</template> + +<script> +import ContentBar from '@/vue/components/ContentBar.vue'; +import { mapActions } from 'vuex'; + +export default { + name: 'ssosaml', + components: { ContentBar }, + data() { + return { + config: { + entityId: '', + assertionConsumerService: '', + singleLogoutService: '', + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', + x509cert: '', + privateKey: '', + security: { + authnRequestsSigned: false, + wantMessagesSigned: false, + wantAssertionsSigned: false, + } + }, + originalConfig: {}, + isLoading: false, + error: null + }; + }, + methods: { + ...mapActions('jsonapi', ['get', 'patch']), + + async saveConfig() { + this.isLoading = true; + this.error = null; + try { + const response = await this.patch({ + type: 'saml-configuration', + id: '1', + attributes: this.config + }); + this.config = response.data.attributes; + this.originalConfig = JSON.parse(JSON.stringify(this.config)); + this.$studip.message('success', this.$gettext('SAML configuration saved successfully.')); + } catch (error) { + console.error('Error saving SAML configuration:', error); + this.error = this.$gettext('Failed to save SAML configuration. Please try again.'); + this.$studip.message('error', this.error); + } finally { + this.isLoading = false; + } + }, + resetForm() { + this.config = JSON.parse(JSON.stringify(this.originalConfig)); + }, + async loadConfig() { + this.isLoading = true; + this.error = null; + try { + const response = await this.get({ + type: 'saml-configuration', + id: '1' + }); + this.config = response.data.attributes; + this.originalConfig = JSON.parse(JSON.stringify(this.config)); + } catch (error) { + console.error('Error loading SAML configuration:', error); + this.error = this.$gettext('Failed to load SAML configuration. Please refresh the page.'); + this.$studip.message('error', this.error); + } finally { + this.isLoading = false; + } + } + }, + mounted() { + this.loadConfig(); + } +}; +</script>
\ No newline at end of file |
