aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTill Glöggler <till@gundk.it>2025-06-25 23:40:10 +0200
committerTill Glöggler <till@gundk.it>2025-06-25 23:40:10 +0200
commit3e7179651cfee753606ad906c07c1e5214c66fd9 (patch)
tree0af39b5af7305a7a764a3b133a29134dc0c5533f
parent4f60c4922ed96d60c0fa3b77a590e355b21841ca (diff)
working on SSO SAMLissue-5663
-rw-r--r--app/controllers/admin/saml.php33
-rw-r--r--composer.json3
-rw-r--r--composer.lock108
-rw-r--r--lib/classes/JsonApi/RouteMap.php7
-rw-r--r--lib/classes/JsonApi/Routes/SAML/ConfigurationShow.php30
-rw-r--r--lib/classes/JsonApi/Routes/SAML/ConfigurationUpdate.php35
-rw-r--r--lib/classes/JsonApi/Routes/SAML/SetupInformation.php40
-rw-r--r--lib/navigation/AdminNavigation.php2
-rw-r--r--resources/vue/apps/SSOSAML.vue160
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