diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2d014d0..96df587bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- [PR-375](https://github.com/itk-dev/os2loop/pull/375) + Added Cura login module + ## [1.2.5] - [374](https://github.com/itk-dev/os2loop/pull/374) diff --git a/README.md b/README.md index 0f09a434c..2d6e86d28 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,8 @@ for further details. ## Coding standards ```sh -docker compose exec phpfpm composer coding-standards-apply -docker compose exec phpfpm composer coding-standards-check +docker compose exec phpfpm vendor/bin/phpcs +docker compose exec phpfpm vendor/bin/phpcbf ``` ```sh diff --git a/composer.json b/composer.json index 839a89d54..9f2861861 100644 --- a/composer.json +++ b/composer.json @@ -60,7 +60,8 @@ "drupal/viewsreference": "^2.0@beta", "drupal/xls_serialization": "^2.0", "drush/drush": "^13.0", - "jjj/chosen": "^2.2" + "jjj/chosen": "^2.2", + "os2loop/os2loop_cura_login": "^1.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", @@ -102,11 +103,21 @@ "type": "vcs", "url": "https://git.drupalcode.org/project/theme_switcher" }, + "os2loop/os2loop_cura_login": { + "type": "path", + "url": "web/profiles/custom/os2loop/modules/os2loop_cura_login", + "options": { + "symlink": true, + "versions": { + "os2loop/os2loop_cura_login": "1.0" + } + } + }, "os2loop/os2loop_fixtures": { "type": "path", "url": "web/profiles/custom/os2loop/modules/os2loop_fixtures", "options": { - "symlink": false, + "symlink": true, "versions": { "os2loop/os2loop_fixtures": "1.0" } diff --git a/composer.lock b/composer.lock index f04cf4528..260941d53 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": "6ed4fdb66736874a4e9be667abfee23c", + "content-hash": "159422ad2d499c1c75bd47b85cdb7e83", "packages": [ { "name": "asm89/stack-cors", @@ -5486,6 +5486,69 @@ }, "time": "2024-11-01T03:51:45+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, { "name": "grasmash/expander", "version": "3.0.1", @@ -6665,6 +6728,33 @@ }, "time": "2025-05-31T08:24:38+00:00" }, + { + "name": "os2loop/os2loop_cura_login", + "version": "1.0", + "dist": { + "type": "path", + "url": "web/profiles/custom/os2loop/modules/os2loop_cura_login", + "reference": "8b83fb8dde0ee70b54e7f2454784ba2c311bb67b" + }, + "require": { + "firebase/php-jwt": "^6.11" + }, + "type": "os2loop-custom-module", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "Mikkel Ricky", + "email": "rimi@aarhus.dk" + } + ], + "description": "drupal/os2loop_cura_login", + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "pear/archive_tar", "version": "1.5.0", @@ -13872,7 +13962,7 @@ "dist": { "type": "path", "url": "web/profiles/custom/os2loop/modules/os2loop_fixtures", - "reference": "4f02f1c5fabeca7800285dd74124a55528892d54" + "reference": "7b31ca9484dad6913a7e991e0ac128bc284a2b34" }, "require": { "drupal/content_fixtures": "^3.2", @@ -13890,7 +13980,7 @@ ], "description": "OS2Loop Fixtures.", "transport-options": { - "symlink": false, + "symlink": true, "relative": true } }, diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/.gitignore b/web/profiles/custom/os2loop/modules/os2loop_cura_login/.gitignore new file mode 100644 index 000000000..d8a7996ab --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor/ diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/README.md b/web/profiles/custom/os2loop/modules/os2loop_cura_login/README.md new file mode 100644 index 000000000..da679cac8 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/README.md @@ -0,0 +1,59 @@ +# Cura login + +Use `https://os2loop.example.com/os2loop-cura-login/start` as `linkURL` (or is it `formPostUrl`?) in the Cura link +configuration. + +## How does it work? + +Cura will make a `POST` `multipart/form-data` request to `/os2loop-cura-login/start` with a `payload` parameter containing +a JWT like + +``` json +{ + "header" : { + "alg" : "HS256" + }, + "payload" : { + "brugerId" : "az…", + "organisationsNavn" : "Digitalisering", + "brugerensNavn" : "…", + "sorid" : "aarhus", + "exp" : 1756978913 + }, + "signature" : "…" +} +``` + +However, it can also make a `GET` request to `/os2loop-cura-login/start/{payload}` or +`/os2loop-cura-login/start/payload={payload}`. + +## Example + +``` shell +curl "http://$(docker compose port nginx 8080)/os2loop-cura-login/start" +``` + +``` shell +drush os2loop-cura-login:get-login-url --help +``` + +``` shell name=drush-get-login-url +drush os2loop-cura-login:get-login-url az000000 --get=jwt --destination=/user \ + --algorithm="$(drush config:get --format string os2loop_cura_login.settings cura.signing_algorithm --include-overridden)" \ + --secret="$(drush config:get --format string os2loop_cura_login.settings cura.signing_secret --include-overridden)" +``` + +## Development and debugging + +``` php +# settings.local.php +$config['os2loop_cura_login.settings']['general']['log_level'] = \Drupal\Core\Logger\RfcLogLevel::DEBUG; +``` + +Run + +``` shell +drush config:get os2loop_cura_login.settings --include-overridden +``` + +to show the current module config. diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/composer.json b/web/profiles/custom/os2loop/modules/os2loop_cura_login/composer.json new file mode 100644 index 000000000..8edba29bf --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/composer.json @@ -0,0 +1,15 @@ +{ + "name": "os2loop/os2loop_cura_login", + "description": "drupal/os2loop_cura_login", + "type": "os2loop-custom-module", + "license": "GPL-2.0+", + "authors": [ + { + "name": "Mikkel Ricky", + "email": "rimi@aarhus.dk" + } + ], + "require": { + "firebase/php-jwt": "^6.11" + } +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/config/schema/os2loop_cura_login.schema.yml b/web/profiles/custom/os2loop/modules/os2loop_cura_login/config/schema/os2loop_cura_login.schema.yml new file mode 100644 index 000000000..710fcce5f --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/config/schema/os2loop_cura_login.schema.yml @@ -0,0 +1,8 @@ +# Schema for the configuration files of the OS2Loop Cura login module. +os2loop_cura_login.settings: + type: config_object + label: 'OS2Loop Cura login settings' + mapping: + example: + type: string + label: 'Example' diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.info.yml b/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.info.yml new file mode 100644 index 000000000..fc2a48a2b --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.info.yml @@ -0,0 +1,10 @@ +name: "OS2Loop Cura login" +type: module +description: "OS2Loop Cura login" +package: "OS2Loop" +core_version_requirement: ^10 || ^11 +dependencies: + - drupal:user + - openid_connect:openid_connect + +configure: os2loop_cura_login.settings diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.links.menu.yml b/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.links.menu.yml new file mode 100644 index 000000000..2bd579c0f --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.links.menu.yml @@ -0,0 +1,6 @@ +os2loop_cura_login.settings: + title: "OS2Loop Cura login settings" + route_name: os2loop_cura_login.settings + description: "Configure OS2Loop Cura login settings" + parent: os2loop.group.admin + weight: 100 diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.routing.yml b/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.routing.yml new file mode 100644 index 000000000..b2d4e5473 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.routing.yml @@ -0,0 +1,40 @@ +os2loop_cura_login.start: + path: "/os2loop-cura-login/start" + defaults: + _title: "Start Cura login with POST or GET" + _controller: '\Drupal\os2loop_cura_login\Controller\Os2loopCuraLoginController::start' + methods: [GET, POST] + requirements: + _user_is_logged_in: "FALSE" + options: + no_cache: TRUE + +os2loop_cura_login.start_get_jwt: + path: "/os2loop-cura-login/start/{jwt}" + defaults: + _title: "Start Cura login with JWT in request path" + _controller: '\Drupal\os2loop_cura_login\Controller\Os2loopCuraLoginController::start' + methods: [GET] + requirements: + _user_is_logged_in: "FALSE" + options: + no_cache: TRUE + +os2loop_cura_login.authenticate: + path: "/os2loop-cura-login/authenticate/{jwt}" + defaults: + _title: "Authenticate with Cura login" + _controller: '\Drupal\os2loop_cura_login\Controller\Os2loopCuraLoginController::authenticate' + methods: [GET] + requirements: + _user_is_logged_in: "FALSE" + options: + no_cache: TRUE + +os2loop_cura_login.settings: + path: "/admin/config/os2loop/os2loop_cura_login/settings" + defaults: + _form: '\Drupal\os2loop_cura_login\Form\SettingsForm' + _title: "OS2Loop Cura login settings" + requirements: + _permission: "administer site settings" diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.services.yml b/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.services.yml new file mode 100644 index 000000000..6d2ee44dd --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/os2loop_cura_login.services.yml @@ -0,0 +1,15 @@ +services: + _defaults: + autowire: true + + logger.channel.os2loop_cura_login: + parent: logger.channel_base + arguments: ["os2loop_cura_login"] + + Drupal\os2loop_cura_login\Settings: + + Drupal\os2loop_cura_login\CuraHelper: + + Drupal\os2loop_cura_login\IdPHelper: + + Drupal\os2loop_cura_login\UserHelper: diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Controller/Os2loopCuraLoginController.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Controller/Os2loopCuraLoginController.php new file mode 100644 index 000000000..92676e11f --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Controller/Os2loopCuraLoginController.php @@ -0,0 +1,238 @@ +curaHelper->setController($this); + $this->idPHelper->setController($this); + $this->userHelper->setController($this); + } + + /** + * Start user authentication. + */ + public function start(Request $request, ?string $jwt): Response { + try { + $content = NULL; + try { + $content = (string) $request->getContent(); + } + catch (\Exception) { + } + $this->debug('@debug', [ + '@debug' => json_encode([ + 'method' => $request->getMethod(), + 'headers' => $request->headers->all(), + 'query' => $request->query->all(), + 'content' => $content, + ]), + ]); + + if (empty($jwt)) { + $name = $this->settings->getCuraSettings()->getPayloadName(); + $jwt = Request::METHOD_POST === $request->getMethod() + ? $request->request->getString($name) + : $request->query->getString($name); + } + + $this->debug('@debug', [ + '@debug' => json_encode([ + 'jwt' => $jwt, + ]), + ]); + + if (empty($jwt)) { + throw new BadRequestHttpException('Missing or empty JWT'); + } + + [$payload, $username] = $this->curaHelper->decodeJwt($jwt); + + if (empty($username)) { + throw new BadRequestHttpException('Missing username'); + } + + // Check that we can get userinfo. + $userinfo = $this->idPHelper->fetchUserInfo($username); + + $this->debug('@debug', [ + '@debug' => json_encode([ + 'userinfo' => $userinfo, + ]), + ]); + + if (empty($userinfo)) { + // Don't disclose whether or not the user exists. + throw new BadRequestHttpException(); + } + + $user = $this->userHelper->ensureUser($username, $userinfo); + + $this->debug('@debug', [ + '@debug' => json_encode([ + 'user' => $user->toArray(), + ]), + ]); + + if (empty($user)) { + // Don't disclose whether or not the user exists. + throw new BadRequestHttpException(); + } + + return Request::METHOD_POST === $request->getMethod() + ? $this->createAuthenticateResponse($user, $request) + : $this->authenticateUser($user, $request); + } + catch (\Exception $exception) { + $this->error('start: @message', ['@message' => $exception->getMessage(), $exception]); + throw new BadRequestException($exception->getMessage()); + } + } + + /** + * Authenticate action. + */ + public function authenticate(Request $request, string $jwt): Response { + try { + if (empty($jwt)) { + throw new BadRequestHttpException(); + } + + [$payload, $_] = $this->curaHelper->decodeJwt($jwt); + $username = $payload['username'] ?? NULL; + if (empty($username)) { + throw new BadRequestHttpException(); + } + + $user = $this->userHelper->loadUser($username); + if (empty($user)) { + // Don't disclose whether or not the user exists. + throw new BadRequestHttpException(); + } + + return $this->authenticateUser($user, $request); + } + catch (\Exception $exception) { + $this->error('authenticate: @message', ['@message' => $exception->getMessage(), $exception]); + throw new BadRequestException($exception->getMessage()); + } + } + + /** + * Create authenticate response. + */ + private function createAuthenticateResponse(UserInterface $user, Request $request): Response { + // https://github.com/firebase/php-jwt?tab=readme-ov-file#example + $payload = [ + // Issued at. + 'iat' => $this->time->getRequestTime(), + // Expire after 60 seconds. + 'exp' => $this->time->getRequestTime() + 60, + 'username' => $user->getAccountName(), + ]; + $jwt = $this->encodeJwt($payload); + + $routeParameters = [ + 'jwt' => $jwt, + ]; + if ($destination = $request->query->get('destination')) { + $routeParameters['destination'] = $destination; + } + + $url = Url::fromRoute('os2loop_cura_login.authenticate', $routeParameters) + ->setAbsolute()->toString(TRUE)->getGeneratedUrl(); + + return new Response($url); + } + + /** + * Authenticate user. + */ + private function authenticateUser($user, Request $request): Response { + $this->userHelper->authenticateUser($user); + + $this->messenger()->addStatus($this->t('Welcome Cura user @user.', ['@user' => $user->getDisplayName()])); + $url = Url::fromRoute(''); + if ($destination = $request->query->get('destination')) { + try { + $url = Url::fromUserInput($destination); + } + catch (\Exception) { + // Ignore any exceptions. + } + } + + return new RedirectResponse($url->setAbsolute()->toString()); + } + + /** + * Encode JWT. + */ + private function encodeJwt(array $payload): string { + $settings = $this->settings->getCuraSettings(); + + return JWT::encode($payload, $settings->getSigningSecret(), $settings->getSigningAlgorithm()); + } + + /** + * {@inheritdoc} + */ + public function log($level, \Stringable|string $message, array $context = []): void { + // Lifted from LoggerChannel. + $levels = [ + LogLevel::EMERGENCY => RfcLogLevel::EMERGENCY, + LogLevel::ALERT => RfcLogLevel::ALERT, + LogLevel::CRITICAL => RfcLogLevel::CRITICAL, + LogLevel::ERROR => RfcLogLevel::ERROR, + LogLevel::WARNING => RfcLogLevel::WARNING, + LogLevel::NOTICE => RfcLogLevel::NOTICE, + LogLevel::INFO => RfcLogLevel::INFO, + LogLevel::DEBUG => RfcLogLevel::DEBUG, + ]; + $rfcLogLevel = $levels[$level] ?? RfcLogLevel::ERROR; + if ($this->settings->getLogLevel() >= $rfcLogLevel) { + $this->logger->log($level, $message, $context); + } + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/CuraHelper.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/CuraHelper.php new file mode 100644 index 000000000..43b24df64 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/CuraHelper.php @@ -0,0 +1,44 @@ +settings = $settings->getCuraSettings(); + } + + /** + * Decode JWT. + */ + public function decodeJwt(string $jwt): array { + $secret = $this->settings->getSigningSecret(); + + $originalLeeway = JWT::$leeway; + $leeway = $this->settings->getJwtLeeway(); + if ($leeway > 0) { + JWT::$leeway = $leeway; + } + $payload = (array) JWT::decode($jwt, new Key($secret, $this->settings->getSigningAlgorithm())); + JWT::$leeway = $originalLeeway; + + return [$payload, $payload['username'] ?? $payload['brugerId'] ?? NULL]; + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Drush/Commands/Os2loopCuraLoginCommands.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Drush/Commands/Os2loopCuraLoginCommands.php new file mode 100644 index 000000000..269e3de48 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Drush/Commands/Os2loopCuraLoginCommands.php @@ -0,0 +1,95 @@ + NULL, + 'get' => NULL, + 'secret' => NULL, + 'algorithm' => 'HS256', + 'destination' => NULL, + ], + ) { + // https://github.com/firebase/php-jwt?tab=readme-ov-file#example + $payload = [ + // Issued at. + 'iat' => $this->time->getRequestTime(), + // Expire af 60 seconds. + 'exp' => $this->time->getRequestTime() + 60, + 'username' => $username, + ]; + $jwt = JWT::encode($payload, $options['secret'], $options['algorithm']); + + $routeName = 'os2loop_cura_login.start'; + $routeParameters = []; + if ($destination = trim($options['destination'])) { + $routeParameters['destination'] = $destination; + } + $requestOptions = []; + if ($name = $options['get']) { + $method = Request::METHOD_GET; + $routeParameters[$name] = $jwt; + if ('jwt' === $name) { + $routeName = 'os2loop_cura_login.start_get_jwt'; + } + } + else { + $method = Request::METHOD_POST; + $requestOptions['form_params'] = ['payload' => $jwt]; + } + $url = Url::fromRoute($routeName, $routeParameters)->setAbsolute()->toString(TRUE)->getGeneratedUrl(); + + if ($method === Request::METHOD_GET) { + $this->io()->writeln($url); + } + else { + $this->io()->writeln(sprintf('POST\'ing to %s', $url)); + $response = $this->httpClient->request($method, $url, $requestOptions); + + $contents = $response->getBody()->getContents(); + $this->io()->writeln(Yaml::encode([ + 'status' => $response->getStatusCode(), + 'headers' => Yaml::encode($response->getHeaders()), + 'contents' => $contents, + ])); + + $this->io()->writeln($contents); + } + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Form/SettingsForm.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Form/SettingsForm.php new file mode 100644 index 000000000..4cece00a2 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Form/SettingsForm.php @@ -0,0 +1,197 @@ + TRUE, + '#type' => 'fieldset', + '#title' => t('Cura'), + ] + $this->buildCuraForm($form_state); + + $form[IdP::NAME] = [ + '#tree' => TRUE, + '#type' => 'fieldset', + '#title' => t('IdP'), + ] + $this->buildIdPForm($form_state); + + $form[Settings::NAME] = [ + '#tree' => TRUE, + '#type' => 'fieldset', + '#title' => t('General settings'), + + Settings::SETTING_LOG_LEVEL => [ + '#type' => 'select', + '#options' => RfcLogLevel::getLevels(), + '#title' => $this->t('Log level'), + '#default_value' => $this->settings->getLogLevel(), + ], + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * Build Cura form. + */ + public function buildCuraForm(FormStateInterface $form_state): array { + $settings = $this->settings->getCuraSettings(); + $form[Cura::SETTING_SIGNING_ALGORITHM] = [ + '#required' => TRUE, + '#type' => 'select', + '#options' => Cura::SIGNING_ALGORITHMS, + '#title' => $this->t('Signing algorithm'), + '#default_value' => $settings->getSigningAlgorithm(), + ]; + + $hasSigningSecret = !empty($settings->getSigningSecret()); + + $form[Cura::SETTING_SIGNING_SECRET] = [ + '#type' => 'textfield', + '#size' => 128, + '#required' => $hasSigningSecret, + '#title' => $this->t('Signing secret'), + '#default_value' => $settings->getSigningSecret(), + '#description' => !$hasSigningSecret + ? $this->t('Save the configuration to generate a random secret.') + : '', + ]; + + $form[self::GENERATE_NEW_SECRET] = [ + '#title' => $this->t('Generate new secret'), + '#type' => 'checkbox', + '#default_value' => !$hasSigningSecret, + '#description' => $this->t('Check this to generate a new random secret'), + ]; + + $form[Cura::SETTING_PAYLOAD_NAME] = [ + '#type' => 'textfield', + '#title' => $this->t('Payload name'), + '#default_value' => $settings->getPayloadName(), + '#description' => $this->t('Name of parameter used for payload'), + ]; + + $form[Cura::SETTING_JWT_LEEWAY] = [ + '#type' => 'textfield', + '#title' => $this->t('JWT leeway'), + '#default_value' => $settings->getJwtLeeway(), + ]; + + $authenticationStartUrlPost = Url::fromRoute('os2loop_cura_login.start')->setAbsolute()->toString(TRUE)->getGeneratedUrl(); + $authenticationStartUrlGet = rtrim($authenticationStartUrlPost, '/') . '/'; + + $form['info'] = [ + '#type' => 'details', + '#title' => $this->t('Cura link settings'), + '#description' => $this->t('Use these settings to set up a link in Cura (cf. the documentation we cannot refer to)'), + 'info' => [ + '#markup' => $this->t(' +
+
signingAlgorithm
+
:signing_algorithm
+ +
signingSecret (Base64 encoded)
+
:signing_secret
+ +
linkURL (postToGetLinkURL = false)
+
:link_url_get
+ +
linkURL (postToGetLinkURL = true)
+
:link_url_post
+
+', + [ + ':signing_algorithm' => $settings->getSigningAlgorithm(), + ':signing_secret' => base64_encode($settings->getSigningSecret()), + ':link_url_get' => $authenticationStartUrlGet, + ':link_url_post' => $authenticationStartUrlPost, + ] + ), + ], + ]; + + return $form; + } + + /** + * Build IdP form. + */ + public function buildIdpForm(FormStateInterface $form_state): array { + $settings = $this->settings->getIdpSettings(); + + $form['todo'] = [ + '#theme' => 'status_messages', + '#message_list' => [ + MessengerInterface::TYPE_WARNING => [$this->t('This section is incomplete.')], + ], + ]; + + $form[IdP::USERNAME_CLAIM] = [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t('Username claim'), + '#default_value' => $settings->getUsernameClaim(), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + if ($form_state->getValue([Cura::NAME, self::GENERATE_NEW_SECRET])) { + $form_state->setValue([Cura::NAME, Cura::SETTING_SIGNING_SECRET], (new Random())->name(64)); + } + $this->settings->saveSettings($form_state); + + parent::submitForm($form, $form_state); + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/IdPHelper.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/IdPHelper.php new file mode 100644 index 000000000..2c3a2e6a2 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/IdPHelper.php @@ -0,0 +1,69 @@ +settings = $settings->getIdpSettings(); + } + + /** + * Get user info from userinfo endpoint. + */ + public function fetchUserinfo(string $username): array { + // @todo Call some API here … + // $query = [ + // $this->settings->getUsernameClaim() => $username, + // ]; + // $result = fetch($query) + // Mock up some user data matching claims from OIDC login. + $result = [ + // Drupal user fields. + 'upn' => $username, + 'name' => $username, + 'email' => filter_var($username, FILTER_VALIDATE_EMAIL) ? $username : $username . '@cura.example.com', + 'samaccountname' => $username, + 'given_name' => 'Cura', + 'family_name' => 'User', + 'groups' => [ + 'GG-Rolle-B2C-Loop-AuthenticatedUser-Prod', + // 'GG-Rolle-B2C-Loop-Administrator-Prod', + // 'GG-Rolle-B2C-Loop-Administrator-Test', + // 'GG-Rolle-B2C-Loop-DocumentAuthor-Prod', + // 'GG-Rolle-B2C-Loop-DocumentAuthor-Test', + // 'GG-Rolle-B2C-Loop-DocumentCollectionEditor-Prod', + // 'GG-Rolle-B2C-Loop-DocumentCollectionEditor-Test', + // 'GG-Rolle-B2C-Loop-DocumentationCoordinator-Prod', + // 'GG-Rolle-B2C-Loop-DocumentationCoordinator-Test', + // 'GG-Rolle-B2C-Loop-ExternalSourcesEditor-Prod', + // 'GG-Rolle-B2C-Loop-ExternalSourcesEditor-Test', + // 'GG-Rolle-B2C-Loop-Manager-Prod', + // 'GG-Rolle-B2C-Loop-Manager-Test', + // 'GG-Rolle-B2C-Loop-PostAuthor-Prod', + // 'GG-Rolle-B2C-Loop-PostAuthor-Test', + // 'GG-Rolle-B2C-Loop-ReadOnly-Prod', + // 'GG-Rolle-B2C-Loop-ReadOnly-Test', + // 'GG-Rolle-B2C-Loop-UserAdministrator-Prod', + // 'GG-Rolle-B2C-Loop-UserAdministrator-Test', + ], + ]; + + return $result; + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings.php new file mode 100644 index 000000000..0f732ede1 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings.php @@ -0,0 +1,94 @@ +config = $configFactory->get(self::CONFIG_NAME); + } + + /** + * Get Cura settings. + */ + public function getCuraSettings() { + if (!isset($this->curaSettings)) { + $this->curaSettings = new Cura($this->config->get(Cura::NAME) ?? []); + } + + return $this->curaSettings; + } + + /** + * Get IdP settings. + */ + public function getIdpSettings() { + if (!isset($this->idpSettings)) { + $this->idpSettings = new IdP($this->config->get(IdP::NAME) ?? []); + } + + return $this->idpSettings; + } + + /** + * Get log level. + */ + public function getLogLevel() { + return (int) ($this->config->get(self::NAME)[self::SETTING_LOG_LEVEL] ?? RfcLogLevel::ERROR); + } + + /** + * Save settings. + */ + public function saveSettings(FormStateInterface $formState): array { + $values = array_filter( + $formState->getValues(), + static fn(string $key) => in_array($key, [self::NAME, Cura::NAME, IdP::NAME], TRUE), + ARRAY_FILTER_USE_KEY + ); + + // @todo validate values + $config = $this->configFactory->getEditable(self::CONFIG_NAME); + foreach ($values as $key => $value) { + $config->set($key, $value); + } + $config->save(); + + return $config->get(); + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings/Cura.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings/Cura.php new file mode 100644 index 000000000..20b7d92cf --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings/Cura.php @@ -0,0 +1,55 @@ + 'HS256', + 'HS384' => 'HS384', + 'HS512' => 'HS512', + ]; + + public function __construct( + private readonly array $config, + ) { + } + + /** + * Get payload name. + */ + public function getPayloadName(): string { + return $this->config[self::SETTING_PAYLOAD_NAME] ?? 'payload'; + } + + /** + * Get signing algorithm. + */ + public function getSigningAlgorithm(): string { + return $this->config['signing_algorithm'] ?? self::SIGNING_ALGORITHMS[array_key_first(self::SIGNING_ALGORITHMS)]; + } + + /** + * Get signing secret. + */ + public function getSigningSecret(): string { + return $this->config['signing_secret'] ?? ''; + } + + /** + * Get JWT leeway. + */ + public function getJwtLeeway(): int { + return (int) ($this->config['jwt_leeway'] ?? 0); + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings/IdP.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings/IdP.php new file mode 100644 index 000000000..da8843bd3 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Settings/IdP.php @@ -0,0 +1,28 @@ +config[self::USERNAME_CLAIM] ?? 'upn'; + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Trait/ControllerAwareTrait.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Trait/ControllerAwareTrait.php new file mode 100644 index 000000000..bfbf520ce --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/Trait/ControllerAwareTrait.php @@ -0,0 +1,35 @@ +controller = $controller; + } + + /** + * {@inheritdoc} + */ + public function log($level, \Stringable|string $message, array $context = []): void { + if ($this->controller) { + $this->controller->log($level, $message, $context); + } + } + +} diff --git a/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/UserHelper.php b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/UserHelper.php new file mode 100644 index 000000000..af98ec1a7 --- /dev/null +++ b/web/profiles/custom/os2loop/modules/os2loop_cura_login/src/UserHelper.php @@ -0,0 +1,84 @@ +userStorage = $entityTypeManager->getStorage('user'); + } + + /** + * Ensure user exists. + * + * @param string $username + * The username. + * @param array $userinfo + * The user info to set on the user. + * + * @return \Drupal\user\Entity\UserInterface + * The newly created or updated user. + */ + public function ensureUser(string $username, array $userinfo): UserInterface { + $user = $this->loadUser($username); + + if (NULL === $user) { + $user = $this->userStorage->create(); + } + + // Make sure that the user is active. + $user->activate(); + // saveUserinfo below needs a user id (uid). + if ($user->isNew()) { + $user->setUsername($username); + $user->save(); + } + + // We piggyback on the OpenId Connect module to set user fields and roles. + if ($this->openidConnect->saveUserinfo($user, ['userinfo' => $userinfo])) { + $this->info('Userinfo saved on user @user (@username)', ['@user' => $user->label(), '@username' => $username]); + } + else { + $this->error('Error saving info on user @user (@username)', ['@user' => $user->label(), '@username' => $username]); + } + + return $user; + } + + /** + * Load user by username. + */ + public function loadUser(string $username) : ?UserInterface { + $users = $this->userStorage->loadByProperties(['name' => $username]); + + return reset($users) ?: NULL; + } + + /** + * Authenticate user. + */ + public function authenticateUser($user) { + user_login_finalize($user); + } + +} diff --git a/web/sites/default/settings.php b/web/sites/default/settings.php index e0e2ce8cc..cdd0c0fb9 100644 --- a/web/sites/default/settings.php +++ b/web/sites/default/settings.php @@ -780,6 +780,9 @@ $settings['skip_permissions_hardening'] = TRUE; +// https://www.drupal.org/node/3079028 +$settings['config_exclude_modules'] = ['os2loop_cura_login']; + /** * Load local development override configuration, if available. *