From 067e6c0113681ff4d37874408e63c55b6400a147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Fri, 26 Jan 2024 04:37:07 +0100 Subject: [PATCH 1/5] Azure AD authentication - added dependency on packages `68publishers/oauth` and `thenetworg/oauth2-azure` - added yarn dependency on `clipboard` - added database migration - added new entity `ExternalAuth` in the User domain - added `AzureAuthSettings` into the Global settings - added azure settings form on the application settings page - added possibility to sign in via Azure - added copy buttons support for text form fields - added translations --- CHANGELOG.md | 4 + assets/app.js | 1 + .../images/icon/common/document-duplicate.svg | 3 + assets/js/clipboard.js | 62 ++++ composer.json | 2 + composer.lock | 307 +++++++++++++++++- config/model/global_settings/domain.neon | 3 + .../model/global_settings/infrastructure.neon | 6 + config/model/project/domain.neon | 3 - config/model/user/domain.neon | 6 + config/model/user/infrastructure.neon | 12 + config/packages/68publishers.oauth.neon | 7 + config/packages/@packages.neon | 1 + config/services.neon | 1 + package.json | 1 + .../GlobalSettings/CachedGlobalSettings.php | 9 + .../GlobalSettings/GlobalSettings.php | 8 + .../GlobalSettings/GlobalSettingsFactory.php | 1 + .../GlobalSettingsInterface.php | 3 + .../OAuth/Azure/AzureAuthenticator.php | 166 ++++++++++ .../OAuth/Azure/Config.php | 30 ++ .../Command/PutAzureAuthSettingsCommand.php | 34 ++ .../PutAzureAuthSettingsCommandHandler.php | 35 ++ .../Event/AzureAuthSettingsChanged.php | 44 +++ src/Domain/GlobalSettings/GlobalSettings.php | 18 + .../ValueObject/AzureAuthSettings.php | 37 +++ .../StoreExternalAuthenticationCommand.php | 61 ++++ ...reExternalAuthenticationCommandHandler.php | 28 ++ .../Event/UserExternallyAuthenticated.php | 81 +++++ src/Domain/User/ExternalAuth.php | 67 ++++ src/Domain/User/User.php | 70 ++++ .../User/ValueObject/AuthProviderCode.php | 11 + .../User/ValueObject/AuthResourceOwnerId.php | 11 + src/Domain/User/ValueObject/AuthToken.php | 11 + .../DbalType/AzureAuthSettingsType.php | 13 + ...main.GlobalSettings.GlobalSettings.dcm.xml | 6 + .../Doctrine/Version20240126012912.php | 40 +++ .../DbalType/AuthProviderCodeType.php | 13 + .../DbalType/AuthResourceOwnerIdType.php | 13 + .../User/Doctrine/DbalType/AuthTokenType.php | 13 + .../App.Domain.User.ExternalAuth.dcm.xml | 29 ++ .../Mapping/App.Domain.User.User.dcm.xml | 7 + .../GlobalSettings/GlobalSettingsView.php | 4 +- .../RefreshGlobalSettingsWhenChanged.php | 2 + .../AzureAuthSettingsFormControl.php | 104 ++++++ ...uthSettingsFormControlFactoryInterface.php | 10 + .../AzureAuthSettingsUpdateFailedEvent.php | 20 ++ .../Event/AzureAuthSettingsUpdatedEvent.php | 11 + .../azureAuthSettingsFormControl.latte | 3 + .../Presenter/SettingsPresenter.php | 25 ++ .../templates/Settings.default.latte | 10 +- .../FrontModule/Presenter/OAuthPresenter.php | 42 +++ .../FrontModule/Presenter/SignInPresenter.php | 20 ++ .../FrontModule/Presenter/SignInTemplate.php | 4 + .../Presenter/templates/SignIn.default.latte | 51 ++- src/Web/Router/RouterFactory.php | 5 + src/Web/Ui/templates/forms/imports.latte | 33 +- ...sForm_AzureAuthSettingsFormControl.cs.neon | 17 + ...sForm_AzureAuthSettingsFormControl.en.neon | 17 + ...Module_Presenter_SettingsPresenter.cs.neon | 3 + ...Module_Presenter_SettingsPresenter.en.neon | 1 + ...ontModule_Presenter_OAuthPresenter.cs.neon | 3 + ...ontModule_Presenter_OAuthPresenter.en.neon | 3 + ...ntModule_Presenter_SignInPresenter.cs.neon | 3 + ...ntModule_Presenter_SignInPresenter.en.neon | 3 + translations/layout.cs.neon | 4 + translations/layout.en.neon | 4 + yarn.lock | 31 ++ 68 files changed, 1695 insertions(+), 16 deletions(-) create mode 100644 assets/images/icon/common/document-duplicate.svg create mode 100644 assets/js/clipboard.js create mode 100644 config/packages/68publishers.oauth.neon create mode 100644 src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php create mode 100644 src/Bridge/SixtyEightPublishers/OAuth/Azure/Config.php create mode 100644 src/Domain/GlobalSettings/Command/PutAzureAuthSettingsCommand.php create mode 100644 src/Domain/GlobalSettings/CommandHandler/PutAzureAuthSettingsCommandHandler.php create mode 100644 src/Domain/GlobalSettings/Event/AzureAuthSettingsChanged.php create mode 100644 src/Domain/GlobalSettings/ValueObject/AzureAuthSettings.php create mode 100644 src/Domain/User/Command/StoreExternalAuthenticationCommand.php create mode 100644 src/Domain/User/CommandHandler/StoreExternalAuthenticationCommandHandler.php create mode 100644 src/Domain/User/Event/UserExternallyAuthenticated.php create mode 100644 src/Domain/User/ExternalAuth.php create mode 100644 src/Domain/User/ValueObject/AuthProviderCode.php create mode 100644 src/Domain/User/ValueObject/AuthResourceOwnerId.php create mode 100644 src/Domain/User/ValueObject/AuthToken.php create mode 100644 src/Infrastructure/GlobalSettings/Doctrine/DbalType/AzureAuthSettingsType.php create mode 100644 src/Infrastructure/Migration/Doctrine/Version20240126012912.php create mode 100644 src/Infrastructure/User/Doctrine/DbalType/AuthProviderCodeType.php create mode 100644 src/Infrastructure/User/Doctrine/DbalType/AuthResourceOwnerIdType.php create mode 100644 src/Infrastructure/User/Doctrine/DbalType/AuthTokenType.php create mode 100644 src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.ExternalAuth.dcm.xml create mode 100644 src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControl.php create mode 100644 src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControlFactoryInterface.php create mode 100644 src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/Event/AzureAuthSettingsUpdateFailedEvent.php create mode 100644 src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/Event/AzureAuthSettingsUpdatedEvent.php create mode 100644 src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/templates/azureAuthSettingsFormControl.latte create mode 100644 src/Web/FrontModule/Presenter/OAuthPresenter.php create mode 100644 translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.cs.neon create mode 100644 translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.en.neon create mode 100644 translations/App_Web_FrontModule_Presenter_OAuthPresenter.cs.neon create mode 100644 translations/App_Web_FrontModule_Presenter_OAuthPresenter.en.neon diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b469116..decb8f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added authentication via Azure AD. + ### Fixed - Fixed resolving of cookie suggestions that were not already created by crawler. diff --git a/assets/app.js b/assets/app.js index 733f8a42..5a24a740 100644 --- a/assets/app.js +++ b/assets/app.js @@ -12,5 +12,6 @@ import './js/alpine'; import './js/live-form-validation'; import './js/modal/modal'; import './js/toastr'; +import './js/clipboard'; import './css/style.css'; diff --git a/assets/images/icon/common/document-duplicate.svg b/assets/images/icon/common/document-duplicate.svg new file mode 100644 index 00000000..916f8ccf --- /dev/null +++ b/assets/images/icon/common/document-duplicate.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/js/clipboard.js b/assets/js/clipboard.js new file mode 100644 index 00000000..3f72dc3c --- /dev/null +++ b/assets/js/clipboard.js @@ -0,0 +1,62 @@ +const Clipboard = require('clipboard'); +const tippy = require('tippy.js').default; + +(function () { + (ready => { + if (document.readyState !== 'loading') { + ready(); + return; + } + + document.addEventListener('DOMContentLoaded', ready); + })(() => { + const timeouts = new WeakMap(); + + const showTooltip = (trigger, text) => { + if (!trigger._tippy) { + tippy(trigger, { + content: '', + placement: 'bottom', + trigger: 'manual', + }) + } + + tooltip = trigger._tippy; + tooltip.setContent(text); + tooltip.show(); + + if (timeouts.has(trigger)) { + clearTimeout(timeouts.get(trigger)); + } + + const timeout = setTimeout(() => { + tooltip.hide(); + clearTimeout(timeouts.get(trigger)); + }, 1500); + + timeouts.set(trigger, timeout); + }; + + const cp = new Clipboard('[data-clipboard]'); + + cp.on('success', function (e) { + e.clearSelection(); + + const tooltipText = e.trigger.dataset.clipboardSuccessTooltip || undefined; + + if (tooltipText) { + showTooltip(e.trigger, tooltipText); + } + }); + + cp.on('error', function (e) { + e.clearSelection(); + + const tooltipText = e.trigger.dataset.clipboardErrorTooltip || undefined; + + if (tooltipText) { + showTooltip(e.trigger, tooltipText); + } + }); + }); +})(); diff --git a/composer.json b/composer.json index 354b0f85..fccc3731 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "68publishers/environment": "^1.0.1", "68publishers/event-dispatcher-extra": "^1.1", "68publishers/health-check": "^1.0.1", + "68publishers/oauth": "dev-main", "68publishers/omni": "0.1.x-dev", "68publishers/smart-nette-component": "^1.0", "68publishers/tracy-git-version": "^1.2", @@ -74,6 +75,7 @@ "spatie/color": "^1.5", "symfony/lock": "^6.3", "symfony/validator": "^6.3", + "thenetworg/oauth2-azure": "^2.2", "ublaboo/datagrid": "^6.9.5", "umpirsky/locale-list": "^1.0" }, diff --git a/composer.lock b/composer.lock index 1ee26af2..9ce95c4b 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": "0aaba82294f7a45722a43513d5022025", + "content-hash": "d27ce67fe12fe6b9d18d516514787da9", "packages": [ { "name": "68publishers/asset", @@ -403,6 +403,67 @@ }, "time": "2023-01-05T00:44:47+00:00" }, + { + "name": "68publishers/oauth", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/68publishers/oauth.git", + "reference": "7dd061dc877a0b517eac4fe041e8b98d6105f14c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/68publishers/oauth/zipball/7dd061dc877a0b517eac4fe041e8b98d6105f14c", + "reference": "7dd061dc877a0b517eac4fe041e8b98d6105f14c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "league/oauth2-client": "^2.7", + "nette/application": "^3.1", + "nette/di": "^3.0", + "nette/http": "^3.2", + "nette/security": "^3.1", + "php": "^8.1" + }, + "conflict": { + "nette/component-model": "<3.0.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.48", + "kubawerlos/php-cs-fixer-custom-fixers": "^3.19", + "league/oauth2-facebook": "^2.2", + "mockery/mockery": "^1.6", + "nette/bootstrap": "^3.1", + "nette/tester": "^2.5", + "phpstan/phpstan": "^1.10", + "roave/security-advisories": "dev-latest", + "thenetworg/oauth2-azure": "^2.2" + }, + "default-branch": true, + "type": "project", + "autoload": { + "psr-4": { + "SixtyEightPublishers\\OAuth\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "OAuth integration into Nette Framework", + "keywords": [ + "68publishers", + "azure", + "nette", + "oauth" + ], + "support": { + "issues": "https://github.com/68publishers/oauth/issues", + "source": "https://github.com/68publishers/oauth/tree/main" + }, + "time": "2024-01-25T23:44:03+00:00" + }, { "name": "68publishers/omni", "version": "0.1.x-dev", @@ -3296,6 +3357,69 @@ ], "time": "2023-05-17T18:32:04+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.10.0", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "a49db6f0a5033aef5143295342f1c95521b075ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/a49db6f0a5033aef5143295342f1c95521b075ff", + "reference": "a49db6f0a5033aef5143295342f1c95521b075ff", + "shasum": "" + }, + "require": { + "php": "^7.4||^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.5||^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^1.0||^2.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.10.0" + }, + "time": "2023-12-01T16:26:39+00:00" + }, { "name": "fmasa/messenger", "version": "dev-feature/handler-options", @@ -4345,6 +4469,76 @@ ], "time": "2023-03-11T15:57:12+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "160d6274b03562ebeb55ed18399281d8118b76c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8", + "reference": "160d6274b03562ebeb55ed18399281d8118b76c8", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0" + }, + "time": "2023-04-16T18:19:15+00:00" + }, { "name": "milo/embedded-svg", "version": "v1.3.0", @@ -6632,6 +6826,56 @@ }, "time": "2022-05-10T14:09:20+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "php-http/client-common", "version": "2.7.0", @@ -10906,6 +11150,66 @@ ], "time": "2023-04-23T19:33:36+00:00" }, + { + "name": "thenetworg/oauth2-azure", + "version": "v2.2.2", + "source": { + "type": "git", + "url": "https://github.com/TheNetworg/oauth2-azure.git", + "reference": "be204a5135f016470a9c33e82ab48785bbc11af2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/TheNetworg/oauth2-azure/zipball/be204a5135f016470a9c33e82ab48785bbc11af2", + "reference": "be204a5135f016470a9c33e82ab48785bbc11af2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "firebase/php-jwt": "~3.0||~4.0||~5.0||~6.0", + "league/oauth2-client": "~2.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "TheNetworg\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Hajek", + "email": "jan.hajek@thenetw.org", + "homepage": "https://thenetw.org" + } + ], + "description": "Azure Active Directory OAuth 2.0 Client Provider for The PHP League OAuth2-Client", + "keywords": [ + "SSO", + "aad", + "authorization", + "azure", + "azure active directory", + "client", + "microsoft", + "oauth", + "oauth2", + "windows azure" + ], + "support": { + "issues": "https://github.com/TheNetworg/oauth2-azure/issues", + "source": "https://github.com/TheNetworg/oauth2-azure/tree/v2.2.2" + }, + "time": "2023-12-19T12:10:48+00:00" + }, { "name": "tracy/tracy", "version": "v2.10.3", @@ -12666,6 +12970,7 @@ ], "minimum-stability": "stable", "stability-flags": { + "68publishers/oauth": 20, "68publishers/omni": 20, "contributte/mailing": 20, "fmasa/messenger": 20, diff --git a/config/model/global_settings/domain.neon b/config/model/global_settings/domain.neon index dcb0136b..4ad7bb55 100644 --- a/config/model/global_settings/domain.neon +++ b/config/model/global_settings/domain.neon @@ -2,6 +2,9 @@ services: - autowired: no factory: App\Domain\GlobalSettings\CommandHandler\PutLocalizationSettingsCommandHandler + - + autowired: no + factory: App\Domain\GlobalSettings\CommandHandler\PutAzureAuthSettingsCommandHandler - autowired: no factory: App\Domain\GlobalSettings\CommandHandler\PutApiCacheSettingsCommandHandler diff --git a/config/model/global_settings/infrastructure.neon b/config/model/global_settings/infrastructure.neon index aec2cc2e..bef1267e 100644 --- a/config/model/global_settings/infrastructure.neon +++ b/config/model/global_settings/infrastructure.neon @@ -20,12 +20,18 @@ nettrine.orm.xml: nettrine.dbal: connection: types: + App\Domain\GlobalSettings\ValueObject\AzureAuthSettings: + class: App\Infrastructure\GlobalSettings\Doctrine\DbalType\AzureAuthSettingsType + commented: yes + App\Domain\GlobalSettings\ValueObject\GlobalSettingsId: class: App\Infrastructure\GlobalSettings\Doctrine\DbalType\GlobalSettingsIdType commented: yes + App\Domain\GlobalSettings\ValueObject\EnvironmentSettings: class: App\Infrastructure\GlobalSettings\Doctrine\DbalType\EnvironmentSettingsType commented: yes + App\Domain\GlobalSettings\ValueObject\CrawlerSettings: class: App\Infrastructure\GlobalSettings\Doctrine\DbalType\CrawlerSettingsType commented: yes diff --git a/config/model/project/domain.neon b/config/model/project/domain.neon index 4233873f..1b6b3e16 100644 --- a/config/model/project/domain.neon +++ b/config/model/project/domain.neon @@ -17,6 +17,3 @@ services: - autowired: no factory: App\Domain\Project\CommandHandler\UpdateProjectTemplatesCommandHandler - - - autowired: no - factory: App\Domain\User\CommandHandler\ChangeNotificationPreferencesCommandHandler diff --git a/config/model/user/domain.neon b/config/model/user/domain.neon index fe2366a3..415100d9 100644 --- a/config/model/user/domain.neon +++ b/config/model/user/domain.neon @@ -2,3 +2,9 @@ services: - autowired: no factory: App\Domain\User\CommandHandler\AssignProjectsToUserCommandHandler + - + autowired: no + factory: App\Domain\User\CommandHandler\ChangeNotificationPreferencesCommandHandler + - + autowired: no + factory: App\Domain\User\CommandHandler\StoreExternalAuthenticationCommandHandler diff --git a/config/model/user/infrastructure.neon b/config/model/user/infrastructure.neon index 82de41ad..0697f672 100644 --- a/config/model/user/infrastructure.neon +++ b/config/model/user/infrastructure.neon @@ -20,6 +20,18 @@ nettrine.orm.xml: nettrine.dbal: connection: types: + App\Domain\User\ValueObject\AuthProviderCode: + class: App\Infrastructure\User\Doctrine\DbalType\AuthProviderCodeType + commented: yes + + App\Domain\User\ValueObject\AuthResourceOwnerId: + class: App\Infrastructure\User\Doctrine\DbalType\AuthResourceOwnerIdType + commented: yes + + App\Domain\User\ValueObject\AuthToken: + class: App\Infrastructure\User\Doctrine\DbalType\AuthTokenType + commented: yes + App\Domain\User\ValueObject\NotificationPreferences: class: App\Infrastructure\User\Doctrine\DbalType\NotificationPreferencesType commented: yes diff --git a/config/packages/68publishers.oauth.neon b/config/packages/68publishers.oauth.neon new file mode 100644 index 00000000..c5f505c5 --- /dev/null +++ b/config/packages/68publishers.oauth.neon @@ -0,0 +1,7 @@ +extensions: + 68publishers.oauth: SixtyEightPublishers\OAuth\Bridge\Nette\DI\OAuthExtension + 68publishers.oauth.azure: SixtyEightPublishers\OAuth\Bridge\Nette\DI\AzureOAuthExtension + +68publishers.oauth.azure: + config: App\Bridge\SixtyEightPublishers\OAuth\Azure\Config + authenticator: App\Bridge\SixtyEightPublishers\OAuth\Azure\AzureAuthenticator diff --git a/config/packages/@packages.neon b/config/packages/@packages.neon index cc343060..ea95b7b6 100644 --- a/config/packages/@packages.neon +++ b/config/packages/@packages.neon @@ -11,6 +11,7 @@ includes: - 68publishers.user_bundle.neon - 68publishers.webpack_encore_bundle.neon - 68publishers.health_check.neon + - 68publishers.oauth.neon - 68publishers.projection_bundle.neon - contributte.apitte.neon - contributte.translation.neon diff --git a/config/services.neon b/config/services.neon index ea67cc76..2a6bc9e9 100644 --- a/config/services.neon +++ b/config/services.neon @@ -48,6 +48,7 @@ services: - App\Web\AdminModule\ApplicationModule\Control\ApiCacheSettingsForm\ApiCacheSettingsFormControlFactoryInterface - App\Web\AdminModule\ApplicationModule\Control\CrawlerSettingsForm\CrawlerSettingsFormControlFactoryInterface - App\Web\AdminModule\ApplicationModule\Control\EnvironmentsForm\EnvironmentsFormControlFactoryInterface + - App\Web\AdminModule\ApplicationModule\Control\AzureAuthSettingsForm\AzureAuthSettingsFormControlFactoryInterface # Web\AdminModule\CrawlerModule - App\Web\AdminModule\CrawlerModule\Control\ScenarioList\ScenarioListControlFactoryInterface diff --git a/package.json b/package.json index 17cf4e3f..2523d3c1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "alpinejs": "^3.10.2", "autoprefixer": "^10.4.5", "chart.js": "^3.8.0", + "clipboard": "^2.0.11", "codemirror": "^5.65.1", "core-js": "^3.22.2", "dayjs": "^1.11.3", diff --git a/src/Application/GlobalSettings/CachedGlobalSettings.php b/src/Application/GlobalSettings/CachedGlobalSettings.php index d2232f23..c6dc8263 100644 --- a/src/Application/GlobalSettings/CachedGlobalSettings.php +++ b/src/Application/GlobalSettings/CachedGlobalSettings.php @@ -5,6 +5,7 @@ namespace App\Application\GlobalSettings; use App\Domain\GlobalSettings\ValueObject\ApiCache; +use App\Domain\GlobalSettings\ValueObject\AzureAuthSettings; use App\Domain\GlobalSettings\ValueObject\CrawlerSettings; use App\Domain\GlobalSettings\ValueObject\EnvironmentSettings; use Nette\Caching\Cache; @@ -68,6 +69,14 @@ public function environmentSettings(): EnvironmentSettings return $this->getInner()->environmentSettings(); } + /** + * @throws Throwable + */ + public function azureAuthSettings(): AzureAuthSettings + { + return $this->getInner()->azureAuthSettings(); + } + public function refresh(): void { $this->inner?->refresh(); diff --git a/src/Application/GlobalSettings/GlobalSettings.php b/src/Application/GlobalSettings/GlobalSettings.php index 186d51cd..2cf8a6f8 100644 --- a/src/Application/GlobalSettings/GlobalSettings.php +++ b/src/Application/GlobalSettings/GlobalSettings.php @@ -5,6 +5,7 @@ namespace App\Application\GlobalSettings; use App\Domain\GlobalSettings\ValueObject\ApiCache; +use App\Domain\GlobalSettings\ValueObject\AzureAuthSettings; use App\Domain\GlobalSettings\ValueObject\CrawlerSettings; use App\Domain\GlobalSettings\ValueObject\EnvironmentSettings; @@ -19,6 +20,7 @@ public function __construct( private readonly ApiCache $apiCache, private readonly CrawlerSettings $crawlerSettings, private readonly EnvironmentSettings $environmentSettings, + private readonly AzureAuthSettings $azureAuthSettings, ) {} public static function default(): self @@ -29,6 +31,7 @@ public static function default(): self ApiCache::create(), CrawlerSettings::fromValues(false, null, null, null, null), EnvironmentSettings::createDefault(), + AzureAuthSettings::fromValues(false, null, null), ); } @@ -57,6 +60,11 @@ public function environmentSettings(): EnvironmentSettings return $this->environmentSettings; } + public function azureAuthSettings(): AzureAuthSettings + { + return $this->azureAuthSettings; + } + public function refresh(): void { } diff --git a/src/Application/GlobalSettings/GlobalSettingsFactory.php b/src/Application/GlobalSettings/GlobalSettingsFactory.php index 55252abd..48349e6c 100644 --- a/src/Application/GlobalSettings/GlobalSettingsFactory.php +++ b/src/Application/GlobalSettings/GlobalSettingsFactory.php @@ -43,6 +43,7 @@ public function create(): GlobalSettingsInterface apiCache: $globalSettingsView->apiCache, crawlerSettings: $globalSettingsView->crawlerSettings, environmentSettings: $globalSettingsView->environmentSettings, + azureAuthSettings: $globalSettingsView->azureAuthSettings, ); } } diff --git a/src/Application/GlobalSettings/GlobalSettingsInterface.php b/src/Application/GlobalSettings/GlobalSettingsInterface.php index c5317c44..aa2995a0 100644 --- a/src/Application/GlobalSettings/GlobalSettingsInterface.php +++ b/src/Application/GlobalSettings/GlobalSettingsInterface.php @@ -5,6 +5,7 @@ namespace App\Application\GlobalSettings; use App\Domain\GlobalSettings\ValueObject\ApiCache; +use App\Domain\GlobalSettings\ValueObject\AzureAuthSettings; use App\Domain\GlobalSettings\ValueObject\CrawlerSettings; use App\Domain\GlobalSettings\ValueObject\EnvironmentSettings; @@ -23,5 +24,7 @@ public function crawlerSettings(): CrawlerSettings; public function environmentSettings(): EnvironmentSettings; + public function azureAuthSettings(): AzureAuthSettings; + public function refresh(): void; } diff --git a/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php b/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php new file mode 100644 index 00000000..96a3b849 --- /dev/null +++ b/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php @@ -0,0 +1,166 @@ +resourceOwner->toArray(); + + if (empty($userData['email'] ?? '')) { + $this->logger->error(sprintf( + 'Unable to login user with oid %s via %s. Missing claim for property "email".', + $authorizationResult->resourceOwner->getId(), + $flowName, + )); + + throw new AuthenticationException('Missing claim for property "email".'); + } + + $username = $userData['email']; + + try { + $userView = $this->queryBus->dispatch(GetUserByEmailAddressQuery::create( + emailAddress: $username, + )); + } catch (Throwable $e) { + throw new AuthenticationException($e->getMessage(), 0, $e); + } + + $roles = []; + $allRoles = RolesEnum::values(); + + foreach ((array) ($userData['roles'] ?? []) as $role) { + if (in_array($role, $allRoles, true)) { + $roles[] = $role; + } + } + + if (0 >= count($roles)) { + $roles = [ + RolesEnum::MANAGER, + ]; + } + + if (!$userView instanceof UserView) { + $userId = $this->createUser( + resourceOwner: $authorizationResult->resourceOwner, + flowName: $flowName, + username: $username, + roles: $roles, + ); + } else { + $userId = $userView->id->toString(); + } + + $this->storeExternalAuth( + authorizationResult: $authorizationResult, + flowName: $flowName, + userId: $userId, + roles: $roles, + ); + + $identity = IdentityDecorator::newInstance()->wakeupIdentity( + identity: Identity::createSleeping($userId), + queryBus: $this->queryBus, + ); + + $identity = NetteIdentity::of($identity); + + try { + $identity->data(); + } catch (IdentityException $e) { + throw new AuthenticationException($e->getMessage(), 0, $e); + } + + return $identity; + } + + /** + * @param array $roles + */ + private function createUser(ResourceOwnerInterface $resourceOwner, string $flowName, string $username, array $roles): string + { + $userId = UserId::new()->toString(); + $userData = $resourceOwner->toArray(); + + try { + $this->commandBus->dispatch(CreateUserCommand::create( + username: $username, + password: null, + emailAddress: $username, + firstname: $userData['given_name'] ?? '', + surname: $userData['family_name'] ?? '', + roles: $roles, + userId: $userId, + )); + } catch (Throwable $e) { + $this->logger->error(sprintf( + 'Unable to create the user with oid %s via %s. %s', + $resourceOwner->getId(), + $flowName, + $e->getMessage(), + ), [ + 'exception' => $e, + ]); + + throw new AuthenticationException($e->getMessage(), 0, $e); + } + + return $userId; + } + + private function storeExternalAuth(AuthorizationResult $authorizationResult, string $flowName, string $userId, array $roles): void + { + try { + $this->commandBus->dispatch(StoreExternalAuthenticationCommand::create( + userId: $userId, + providerCode: $flowName, + resourceOwnerId: (string) $authorizationResult->resourceOwner->getId(), + token: $authorizationResult->accessToken->getToken(), + refreshToken: (string) $authorizationResult->accessToken->getRefreshToken(), + roles: $roles, + )); + } catch (Throwable $e) { + $this->logger->error(sprintf( + 'Unable to update the user with oid %s via %s. %s', + $authorizationResult->resourceOwner->getId(), + $flowName, + $e->getMessage(), + ), [ + 'exception' => $e, + ]); + + throw new AuthenticationException($e->getMessage(), 0, $e); + } + } +} diff --git a/src/Bridge/SixtyEightPublishers/OAuth/Azure/Config.php b/src/Bridge/SixtyEightPublishers/OAuth/Azure/Config.php new file mode 100644 index 00000000..a59f19cb --- /dev/null +++ b/src/Bridge/SixtyEightPublishers/OAuth/Azure/Config.php @@ -0,0 +1,30 @@ +azureAuthSettings(); + + return new SimpleConfig( + flowEnabled: $azureAuthSetting->enabled(), + options: [ + AzureAuthorizator::OptClientId => $azureAuthSetting->clientId(), + AzureAuthorizator::OptClientSecret => $azureAuthSetting->clientSecret(), + ], + ); + }, + ); + } +} diff --git a/src/Domain/GlobalSettings/Command/PutAzureAuthSettingsCommand.php b/src/Domain/GlobalSettings/Command/PutAzureAuthSettingsCommand.php new file mode 100644 index 00000000..761289ad --- /dev/null +++ b/src/Domain/GlobalSettings/Command/PutAzureAuthSettingsCommand.php @@ -0,0 +1,34 @@ + $enabled, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]); + } + + public function enabled(): bool + { + return $this->getParam('enabled'); + } + + public function clientId(): ?string + { + return $this->getParam('client_id'); + } + + public function clientSecret(): ?string + { + return $this->getParam('client_secret'); + } +} diff --git a/src/Domain/GlobalSettings/CommandHandler/PutAzureAuthSettingsCommandHandler.php b/src/Domain/GlobalSettings/CommandHandler/PutAzureAuthSettingsCommandHandler.php new file mode 100644 index 00000000..e1955ebb --- /dev/null +++ b/src/Domain/GlobalSettings/CommandHandler/PutAzureAuthSettingsCommandHandler.php @@ -0,0 +1,35 @@ +globalSettingsRepository->get(); + + if (!$globalSettings instanceof GlobalSettings) { + $globalSettings = GlobalSettings::createEmpty(); + } + + $globalSettings->updateAzureAuthSettings(AzureAuthSettings::fromValues( + $command->enabled(), + $command->clientId(), + $command->clientSecret(), + )); + + $this->globalSettingsRepository->save($globalSettings); + } +} diff --git a/src/Domain/GlobalSettings/Event/AzureAuthSettingsChanged.php b/src/Domain/GlobalSettings/Event/AzureAuthSettingsChanged.php new file mode 100644 index 00000000..1fafe825 --- /dev/null +++ b/src/Domain/GlobalSettings/Event/AzureAuthSettingsChanged.php @@ -0,0 +1,44 @@ +toString(), [ + 'azure_auth_settings' => $azureAuthSettings->values(), + ]); + + $event->globalSettingsId = $globalSettingsId; + $event->azureAuthSettings = $azureAuthSettings; + + return $event; + } + + public function globalSettingsId(): GlobalSettingsId + { + return $this->globalSettingsId; + } + + public function azureAuthSettings(): AzureAuthSettings + { + return $this->azureAuthSettings; + } + + protected function reconstituteState(array $parameters): void + { + $this->globalSettingsId = GlobalSettingsId::fromUuid($this->aggregateId()->id()); + $this->azureAuthSettings = AzureAuthSettings::fromArray($parameters['azure_auth_settings']); + } +} diff --git a/src/Domain/GlobalSettings/GlobalSettings.php b/src/Domain/GlobalSettings/GlobalSettings.php index 32cf91ef..340392f9 100644 --- a/src/Domain/GlobalSettings/GlobalSettings.php +++ b/src/Domain/GlobalSettings/GlobalSettings.php @@ -5,11 +5,13 @@ namespace App\Domain\GlobalSettings; use App\Domain\GlobalSettings\Event\ApiCacheSettingsChanged; +use App\Domain\GlobalSettings\Event\AzureAuthSettingsChanged; use App\Domain\GlobalSettings\Event\CrawlerSettingsChanged; use App\Domain\GlobalSettings\Event\EnvironmentSettingsChanged; use App\Domain\GlobalSettings\Event\GlobalSettingsCreated; use App\Domain\GlobalSettings\Event\LocalizationSettingsChanged; use App\Domain\GlobalSettings\ValueObject\ApiCache; +use App\Domain\GlobalSettings\ValueObject\AzureAuthSettings; use App\Domain\GlobalSettings\ValueObject\CrawlerSettings; use App\Domain\GlobalSettings\ValueObject\EnvironmentSettings; use App\Domain\GlobalSettings\ValueObject\GlobalSettingsId; @@ -39,6 +41,8 @@ final class GlobalSettings implements AggregateRootInterface private EnvironmentSettings $environmentSettings; + private AzureAuthSettings $azureAuthSettings; + public static function createEmpty(): self { $globalSettings = new self(); @@ -76,6 +80,13 @@ public function updateEnvironmentSettings(EnvironmentSettings $environmentSettin } } + public function updateAzureAuthSettings(AzureAuthSettings $azureAuthSettings): void + { + if (!$this->azureAuthSettings->equals($azureAuthSettings)) { + $this->recordThat(AzureAuthSettingsChanged::create($this->id, $azureAuthSettings)); + } + } + public function aggregateId(): AggregateId { return AggregateId::fromUuid($this->id->id()); @@ -90,6 +101,7 @@ protected function whenGlobalSettingsCreated(GlobalSettingsCreated $event): void $this->apiCache = ApiCache::create(); $this->crawlerSettings = CrawlerSettings::fromValues(false, null, null, null, null); $this->environmentSettings = EnvironmentSettings::createDefault(); + $this->azureAuthSettings = AzureAuthSettings::fromValues(false, null, null); } protected function whenLocalizationSettingsChanged(LocalizationSettingsChanged $event): void @@ -115,4 +127,10 @@ protected function whenEnvironmentSettingsChanged(EnvironmentSettingsChanged $ev $this->lastUpdateAt = $event->createdAt(); $this->environmentSettings = $event->environmentSettings(); } + + protected function whenAzureAuthSettingsChanged(AzureAuthSettingsChanged $event): void + { + $this->lastUpdateAt = $event->createdAt(); + $this->azureAuthSettings = $event->azureAuthSettings(); + } } diff --git a/src/Domain/GlobalSettings/ValueObject/AzureAuthSettings.php b/src/Domain/GlobalSettings/ValueObject/AzureAuthSettings.php new file mode 100644 index 00000000..1ef77d3d --- /dev/null +++ b/src/Domain/GlobalSettings/ValueObject/AzureAuthSettings.php @@ -0,0 +1,37 @@ + $enabled, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]); + } + + public function enabled(): bool + { + return $this->get('enabled') ?? false; + } + + public function clientId(): ?string + { + return $this->get('client_id'); + } + + public function clientSecret(): ?string + { + return $this->get('client_secret'); + } +} diff --git a/src/Domain/User/Command/StoreExternalAuthenticationCommand.php b/src/Domain/User/Command/StoreExternalAuthenticationCommand.php new file mode 100644 index 00000000..909ab1a7 --- /dev/null +++ b/src/Domain/User/Command/StoreExternalAuthenticationCommand.php @@ -0,0 +1,61 @@ + $roles + */ + public static function create( + string $userId, + string $providerCode, + string $resourceOwnerId, + string $token, + string $refreshToken, + array $roles, + ): self { + return self::fromParameters([ + 'user_id' => $userId, + 'provider_code' => $providerCode, + 'resource_owner_id' => $resourceOwnerId, + 'token' => $token, + 'refresh_token' => $refreshToken, + 'roles' => $roles, + ]); + } + + public function userId(): string + { + return $this->getParam('user_id'); + } + + public function providerCode(): string + { + return $this->getParam('provider_code'); + } + + public function resourceOwnerId(): string + { + return $this->getParam('resource_owner_id'); + } + + public function token(): string + { + return $this->getParam('token'); + } + + public function refreshToken(): string + { + return $this->getParam('refresh_token'); + } + + public function roles(): array + { + return $this->getParam('roles'); + } +} diff --git a/src/Domain/User/CommandHandler/StoreExternalAuthenticationCommandHandler.php b/src/Domain/User/CommandHandler/StoreExternalAuthenticationCommandHandler.php new file mode 100644 index 00000000..d50e3588 --- /dev/null +++ b/src/Domain/User/CommandHandler/StoreExternalAuthenticationCommandHandler.php @@ -0,0 +1,28 @@ +userRepository->get(UserId::fromString($command->userId())); + assert($user instanceof User); + + $user->storeExternalAuthentication($command); + + $this->userRepository->save($user); + } +} diff --git a/src/Domain/User/Event/UserExternallyAuthenticated.php b/src/Domain/User/Event/UserExternallyAuthenticated.php new file mode 100644 index 00000000..1bda2416 --- /dev/null +++ b/src/Domain/User/Event/UserExternallyAuthenticated.php @@ -0,0 +1,81 @@ +toString(), [ + 'provider_code' => $providerCode->value(), + 'resource_owner_id' => $resourceOwnerId->value(), + 'token' => $token->value(), + 'refresh_token' => $refreshToken->value(), + ]); + + $event->userId = $userId; + $event->providerCode = $providerCode; + $event->resourceOwnerId = $resourceOwnerId; + $event->token = $token; + $event->refreshToken = $refreshToken; + + return $event; + } + + public function userId(): UserId + { + return $this->userId; + } + + public function providerCode(): AuthProviderCode + { + return $this->providerCode; + } + + public function resourceOwnerId(): AuthResourceOwnerId + { + return $this->resourceOwnerId; + } + + public function token(): AuthToken + { + return $this->token; + } + + public function refreshToken(): AuthToken + { + return $this->refreshToken; + } + + protected function reconstituteState(array $parameters): void + { + $this->userId = UserId::fromUuid($this->aggregateId()->id()); + $this->providerCode = AuthProviderCode::fromValue($parameters['provider_code']); + $this->resourceOwnerId = AuthResourceOwnerId::fromValue($parameters['resource_owner_id']); + $this->token = AuthToken::fromValue($parameters['token']); + $this->refreshToken = AuthToken::fromValue($parameters['refresh_token']); + } +} diff --git a/src/Domain/User/ExternalAuth.php b/src/Domain/User/ExternalAuth.php new file mode 100644 index 00000000..e1c27690 --- /dev/null +++ b/src/Domain/User/ExternalAuth.php @@ -0,0 +1,67 @@ +user; + } + + public function getProviderCode(): AuthProviderCode + { + return $this->providerCode; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getResourceOwnerId(): AuthResourceOwnerId + { + return $this->resourceOwnerId; + } + + public function getToken(): AuthToken + { + return $this->token; + } + + public function getRefreshToken(): AuthToken + { + return $this->refreshToken; + } + + public function updateTokens(AuthResourceOwnerId $resourceOwnerId, AuthToken $token, AuthToken $refreshToken): void + { + if (!$this->resourceOwnerId->equals($resourceOwnerId)) { + $this->resourceOwnerId = $resourceOwnerId; + } + + if (!$this->token->equals($token)) { + $this->token = $token; + } + + if (!$this->refreshToken->equals($refreshToken)) { + $this->refreshToken = $refreshToken; + } + } +} diff --git a/src/Domain/User/User.php b/src/Domain/User/User.php index 36928b6a..c8115543 100644 --- a/src/Domain/User/User.php +++ b/src/Domain/User/User.php @@ -6,10 +6,15 @@ use App\Domain\Project\ValueObject\ProjectId; use App\Domain\Shared\ValueObject\Locale; +use App\Domain\User\Command\StoreExternalAuthenticationCommand; +use App\Domain\User\Event\UserExternallyAuthenticated; use App\Domain\User\Event\UserNotificationPreferencesChanged; use App\Domain\User\Event\UserProfileChanged; use App\Domain\User\Event\UserProjectsChanged; use App\Domain\User\Event\UserTimezoneChanged; +use App\Domain\User\ValueObject\AuthProviderCode; +use App\Domain\User\ValueObject\AuthResourceOwnerId; +use App\Domain\User\ValueObject\AuthToken; use App\Domain\User\ValueObject\NotificationPreferences; use DateTimeZone; use Doctrine\Common\Collections\ArrayCollection; @@ -21,7 +26,9 @@ use SixtyEightPublishers\UserBundle\Domain\Command\CreateUserCommand; use SixtyEightPublishers\UserBundle\Domain\Command\UpdateUserCommand; use SixtyEightPublishers\UserBundle\Domain\Event\UserCreated; +use SixtyEightPublishers\UserBundle\Domain\Event\UserRolesChanged; use SixtyEightPublishers\UserBundle\Domain\PasswordHashAlgorithmInterface; +use SixtyEightPublishers\UserBundle\Domain\ValueObject\Roles; final class User extends BaseUser { @@ -32,6 +39,9 @@ final class User extends BaseUser /** @var Collection */ private Collection $projects; + /** @var Collection */ + private Collection $externalAuths; + private NotificationPreferences $notificationPreferences; public static function create(CreateUserCommand $command, PasswordHashAlgorithmInterface $algorithm, CheckEmailAddressUniquenessInterface $checkEmailAddressUniqueness, CheckUsernameUniquenessInterface $checkUsernameUniqueness): self @@ -77,6 +87,38 @@ public function update(UpdateUserCommand $command, PasswordHashAlgorithmInterfac } } + public function storeExternalAuthentication(StoreExternalAuthenticationCommand $command): void + { + $providerCode = AuthProviderCode::fromValue($command->providerCode()); + $resourceOwnerId = AuthResourceOwnerId::fromValue($command->resourceOwnerId()); + $token = AuthToken::fromValue($command->token()); + $refreshToken = AuthToken::fromValue($command->refreshToken()); + $roles = Roles::reconstitute($command->roles()); + + $externalAuth = $this->externalAuths->get($providerCode->value()); + + if (null === $externalAuth + || !$externalAuth->getResourceOwnerId()->equals($resourceOwnerId) + || !$externalAuth->getToken()->equals($token) + || !$externalAuth->getRefreshToken()->equals($refreshToken) + ) { + $this->recordThat(UserExternallyAuthenticated::create( + userId: $this->id, + providerCode: $providerCode, + resourceOwnerId: $resourceOwnerId, + token: $token, + refreshToken: $refreshToken, + )); + } + + if (!$this->roles->equals($roles)) { + $this->recordThat(UserRolesChanged::create( + userId: $this->id, + roles: $roles, + )); + } + } + public function changeProfile(Locale $profileLocale): void { if (!$this->profileLocale->equals($profileLocale)) { @@ -138,6 +180,7 @@ protected function whenUserCreated(UserCreated $event): void $this->timezone = new DateTimeZone('UTC'); $this->projects = new ArrayCollection(); + $this->externalAuths = new ArrayCollection(); $this->notificationPreferences = NotificationPreferences::empty(); } @@ -177,6 +220,33 @@ protected function whenUserNotificationPreferencesChanged(UserNotificationPrefer $this->notificationPreferences = $event->notificationPreferences(); } + protected function whenUserExternallyAuthenticated(UserExternallyAuthenticated $event): void + { + $externalAuth = $this->externalAuths->get($event->providerCode()->value()); + + if (null !== $externalAuth) { + $externalAuth->updateTokens( + resourceOwnerId: $event->resourceOwnerId(), + token: $event->token(), + refreshToken: $event->refreshToken(), + ); + + return; + } + + $this->externalAuths->set( + key: $event->providerCode()->value(), + value: new ExternalAuth( + user: $this, + providerCode: $event->providerCode(), + createdAt: $event->createdAt(), + resourceOwnerId: $event->resourceOwnerId(), + token: $event->token(), + refreshToken: $event->refreshToken(), + ), + ); + } + /** * @param array $current * @param array $new diff --git a/src/Domain/User/ValueObject/AuthProviderCode.php b/src/Domain/User/ValueObject/AuthProviderCode.php new file mode 100644 index 00000000..756e746c --- /dev/null +++ b/src/Domain/User/ValueObject/AuthProviderCode.php @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/Infrastructure/Migration/Doctrine/Version20240126012912.php b/src/Infrastructure/Migration/Doctrine/Version20240126012912.php new file mode 100644 index 00000000..49bd55fe --- /dev/null +++ b/src/Infrastructure/Migration/Doctrine/Version20240126012912.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE user_external_auth (provider_code VARCHAR(100) NOT NULL, user_id UUID NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, resource_owner_id VARCHAR(255) NOT NULL, token TEXT NOT NULL, refresh_token TEXT NOT NULL, PRIMARY KEY(provider_code, user_id))'); + $this->addSql('CREATE INDEX IDX_834F7D25A76ED395 ON user_external_auth (user_id)'); + $this->addSql('CREATE INDEX idx_user_ea_user_id_created_at ON user_external_auth (user_id, created_at)'); + $this->addSql('COMMENT ON COLUMN user_external_auth.provider_code IS \'(DC2Type:App\\Domain\\User\\ValueObject\\AuthProviderCode)\''); + $this->addSql('COMMENT ON COLUMN user_external_auth.user_id IS \'(DC2Type:SixtyEightPublishers\\UserBundle\\Domain\\ValueObject\\UserId)\''); + $this->addSql('COMMENT ON COLUMN user_external_auth.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN user_external_auth.resource_owner_id IS \'(DC2Type:App\\Domain\\User\\ValueObject\\AuthResourceOwnerId)\''); + $this->addSql('COMMENT ON COLUMN user_external_auth.token IS \'(DC2Type:App\\Domain\\User\\ValueObject\\AuthToken)\''); + $this->addSql('COMMENT ON COLUMN user_external_auth.refresh_token IS \'(DC2Type:App\\Domain\\User\\ValueObject\\AuthToken)\''); + $this->addSql('ALTER TABLE user_external_auth ADD CONSTRAINT FK_834F7D25A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE global_settings ADD azure_auth_settings JSONB NOT NULL DEFAULT \'{}\''); + $this->addSql('ALTER TABLE global_settings ALTER COLUMN azure_auth_settings DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN global_settings.azure_auth_settings IS \'(DC2Type:App\\Domain\\GlobalSettings\\ValueObject\\AzureAuthSettings)\''); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE user_external_auth DROP CONSTRAINT FK_834F7D25A76ED395'); + $this->addSql('DROP TABLE user_external_auth'); + $this->addSql('ALTER TABLE global_settings DROP azure_auth_settings'); + } +} diff --git a/src/Infrastructure/User/Doctrine/DbalType/AuthProviderCodeType.php b/src/Infrastructure/User/Doctrine/DbalType/AuthProviderCodeType.php new file mode 100644 index 00000000..58aa5bbb --- /dev/null +++ b/src/Infrastructure/User/Doctrine/DbalType/AuthProviderCodeType.php @@ -0,0 +1,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.User.dcm.xml b/src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.User.dcm.xml index 3a5abc52..2bb92a01 100644 --- a/src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.User.dcm.xml +++ b/src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.User.dcm.xml @@ -62,6 +62,13 @@ + + + + + + + diff --git a/src/ReadModel/GlobalSettings/GlobalSettingsView.php b/src/ReadModel/GlobalSettings/GlobalSettingsView.php index 7eb0be74..db0a17b6 100644 --- a/src/ReadModel/GlobalSettings/GlobalSettingsView.php +++ b/src/ReadModel/GlobalSettings/GlobalSettingsView.php @@ -5,6 +5,7 @@ namespace App\ReadModel\GlobalSettings; use App\Domain\GlobalSettings\ValueObject\ApiCache; +use App\Domain\GlobalSettings\ValueObject\AzureAuthSettings; use App\Domain\GlobalSettings\ValueObject\CrawlerSettings; use App\Domain\GlobalSettings\ValueObject\EnvironmentSettings; use App\Domain\GlobalSettings\ValueObject\GlobalSettingsId; @@ -29,6 +30,8 @@ final class GlobalSettingsView extends AbstractView public EnvironmentSettings $environmentSettings; + public AzureAuthSettings $azureAuthSettings; + public function jsonSerialize(): array { return [ @@ -43,7 +46,6 @@ public function jsonSerialize(): array 'cacheControlDirectives' => $this->apiCache->cacheControlDirectives(), 'useEntityTag' => $this->apiCache->useEntityTag(), ], - 'crawlerSettings' => $this->crawlerSettings->values(), 'environmentSettings' => $this->environmentSettings->toNative(), ]; } diff --git a/src/Subscribers/GlobalSettings/RefreshGlobalSettingsWhenChanged.php b/src/Subscribers/GlobalSettings/RefreshGlobalSettingsWhenChanged.php index c99982d4..1fab5503 100644 --- a/src/Subscribers/GlobalSettings/RefreshGlobalSettingsWhenChanged.php +++ b/src/Subscribers/GlobalSettings/RefreshGlobalSettingsWhenChanged.php @@ -6,6 +6,7 @@ use App\Application\GlobalSettings\GlobalSettingsInterface; use App\Domain\GlobalSettings\Event\ApiCacheSettingsChanged; +use App\Domain\GlobalSettings\Event\AzureAuthSettingsChanged; use App\Domain\GlobalSettings\Event\CrawlerSettingsChanged; use App\Domain\GlobalSettings\Event\EnvironmentSettingsChanged; use App\Domain\GlobalSettings\Event\GlobalSettingsCreated; @@ -26,6 +27,7 @@ public static function getHandledMessages(): iterable yield ApiCacheSettingsChanged::class; yield CrawlerSettingsChanged::class; yield EnvironmentSettingsChanged::class; + yield AzureAuthSettingsChanged::class; } public function __invoke(): void diff --git a/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControl.php b/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControl.php new file mode 100644 index 00000000..da887684 --- /dev/null +++ b/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControl.php @@ -0,0 +1,104 @@ +formFactory->create($this->getFormFactoryOptions()); + $translator = $this->getPrefixedTranslator(); + + $form->setTranslator($translator); + + $enabledField = $form->addCheckbox('enabled', 'enabled.field'); + + $enabledField->addCondition($form::EQUAL, true) + ->toggle('#' . $this->getUniqueId() . '-client_id-container') + ->toggle('#' . $this->getUniqueId() . '-client_secret-container') + ->toggle('#' . $this->getUniqueId() . '-callback_uri-container'); + + $form->addText('client_id', 'client_id.field') + ->setOption('id', $this->getUniqueId() . '-client_id-container') + ->addConditionOn($enabledField, $form::EQUAL, true) + ->setRequired('client_id.required'); + + $form->addText('client_secret', 'client_secret.field') + ->setOption('id', $this->getUniqueId() . '-client_secret-container') + ->addConditionOn($enabledField, $form::EQUAL, true) + ->setRequired('client_secret.required'); + + $form->addText('callback_uri', 'callback_uri.field') + ->setDisabled() + ->setOmitted() + ->setValue($this->getPresenter()->link('//:Front:OAuth:authenticate', ['type' => 'azure'])) + ->setOption('id', $this->getUniqueId() . '-callback_uri-container') + ->setOption('clipboard', 'copy') + ->setOption('description', 'callback_uri.description'); + + $form->addProtection('//layout.form_protection'); + + $form->addSubmit('save', 'save.field'); + + $defaults = $this->globalSettings->azureAuthSettings(); + + $form->setDefaults([ + 'enabled' => $defaults->enabled(), + 'client_id' => (string) $defaults->clientId(), + 'client_secret' => (string) $defaults->clientSecret(), + ]); + + $form->onSuccess[] = function (Form $form): void { + $this->saveGlobalSettings($form); + }; + + return $form; + } + + private function saveGlobalSettings(Form $form): void + { + $values = $form->getValues(); + $command = PutAzureAuthSettingsCommand::create( + $values->enabled, + $values->client_id ?: null, + $values->client_secret ?: null, + ); + + try { + $this->commandBus->dispatch($command); + } catch (Throwable $e) { + $this->logger->error((string) $e); + $this->dispatchEvent(new AzureAuthSettingsUpdateFailedEvent($e)); + + return; + } + + $this->dispatchEvent(new AzureAuthSettingsUpdatedEvent()); + $this->redrawControl(); + } +} diff --git a/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControlFactoryInterface.php b/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControlFactoryInterface.php new file mode 100644 index 00000000..108df2d5 --- /dev/null +++ b/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControlFactoryInterface.php @@ -0,0 +1,10 @@ +error; + } +} diff --git a/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/Event/AzureAuthSettingsUpdatedEvent.php b/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/Event/AzureAuthSettingsUpdatedEvent.php new file mode 100644 index 00000000..8654580d --- /dev/null +++ b/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/Event/AzureAuthSettingsUpdatedEvent.php @@ -0,0 +1,11 @@ +azureAuthSettingsFormControlFactory->create(); + + $control->setFormFactoryOptions([ + FormFactoryInterface::OPTION_AJAX => true, + ]); + + $control->addEventListener(AzureAuthSettingsUpdatedEvent::class, function (): void { + $this->subscribeFlashMessage(FlashMessage::success('azure_auth_updated')); + $this->redrawSidebar(); + }); + + $control->addEventListener(AzureAuthSettingsUpdateFailedEvent::class, function (): void { + $this->subscribeFlashMessage(FlashMessage::error('azure_auth_update_failed')); + }); + + return $control; + } } diff --git a/src/Web/AdminModule/ApplicationModule/Presenter/templates/Settings.default.latte b/src/Web/AdminModule/ApplicationModule/Presenter/templates/Settings.default.latte index 3ced7e79..4aa0743c 100644 --- a/src/Web/AdminModule/ApplicationModule/Presenter/templates/Settings.default.latte +++ b/src/Web/AdminModule/ApplicationModule/Presenter/templates/Settings.default.latte @@ -24,10 +24,18 @@ {control apiCacheForm} -
+

{_heading_crawler}

{control crawlerForm}
+ +
+
+

{_heading_azure_auth}

+
+ + {control azureAuthForm} +
diff --git a/src/Web/FrontModule/Presenter/OAuthPresenter.php b/src/Web/FrontModule/Presenter/OAuthPresenter.php new file mode 100644 index 00000000..920b4cf0 --- /dev/null +++ b/src/Web/FrontModule/Presenter/OAuthPresenter.php @@ -0,0 +1,42 @@ +logger->error($error->getMessage(), [ + 'exception' => $error, + ]); + + $this->subscribeFlashMessage(FlashMessage::error('authentication_failure.' . $flowName)); + $this->redirect(':Front:SignIn:'); + } + + protected function onAuthenticationFailed(string $flowName, OAuthExceptionInterface $error): never + { + $this->subscribeFlashMessage(FlashMessage::error('authentication_failure.' . $flowName)); + $this->redirect(':Front:SignIn:'); + } + + protected function onUserAuthenticated(string $flowName): never + { + $this->redirect(':Admin:Dashboard:'); + } +} diff --git a/src/Web/FrontModule/Presenter/SignInPresenter.php b/src/Web/FrontModule/Presenter/SignInPresenter.php index a5005c73..267dadc9 100644 --- a/src/Web/FrontModule/Presenter/SignInPresenter.php +++ b/src/Web/FrontModule/Presenter/SignInPresenter.php @@ -9,6 +9,8 @@ use App\Web\FrontModule\Control\SignIn\SignInControl; use App\Web\FrontModule\Control\SignIn\SignInControlFactoryInterface; use SixtyEightPublishers\FlashMessageBundle\Domain\FlashMessage; +use SixtyEightPublishers\OAuth\OAuthFlowInterface; +use SixtyEightPublishers\OAuth\OAuthFlowProviderInterface; final class SignInPresenter extends FrontPresenter { @@ -17,10 +19,28 @@ final class SignInPresenter extends FrontPresenter public function __construct( private readonly SignInControlFactoryInterface $signInControlFactory, + private readonly OAuthFlowProviderInterface $oauthFlowProvider, ) { parent::__construct(); } + protected function beforeRender(): void + { + parent::beforeRender(); + + $template = $this->getTemplate(); + assert($template instanceof SignInTemplate); + + $template->backLink = !empty($this->backLink) ? $this->backLink : null; + $template->enabledOauthTypes = array_map( + static fn (OAuthFlowInterface $flow): string => $flow->getName(), + array_filter( + $this->oauthFlowProvider->all(), + static fn (OauthFlowInterface $flow): bool => $flow->isEnabled(), + ), + ); + } + protected function createComponentSignIn(): SignInControl { $control = $this->signInControlFactory->create(); diff --git a/src/Web/FrontModule/Presenter/SignInTemplate.php b/src/Web/FrontModule/Presenter/SignInTemplate.php index 6e5f3342..cf606df3 100644 --- a/src/Web/FrontModule/Presenter/SignInTemplate.php +++ b/src/Web/FrontModule/Presenter/SignInTemplate.php @@ -8,4 +8,8 @@ final class SignInTemplate extends DefaultPresenterTemplate { + public ?string $backLink = null; + + /** @var array */ + public array $enabledOauthTypes; } diff --git a/src/Web/FrontModule/Presenter/templates/SignIn.default.latte b/src/Web/FrontModule/Presenter/templates/SignIn.default.latte index c7e3bdbb..2964a072 100644 --- a/src/Web/FrontModule/Presenter/templates/SignIn.default.latte +++ b/src/Web/FrontModule/Presenter/templates/SignIn.default.latte @@ -1,9 +1,48 @@ +{templateType App\Web\FrontModule\Presenter\SignInTemplate} + {block #content} + {control signIn} + + -{control signIn} + +{/block} - +{define login-icon-azure} + + + + + + + + + + + + + + + + + + + + + + + +{/define} diff --git a/src/Web/Router/RouterFactory.php b/src/Web/Router/RouterFactory.php index 0a168ad2..8d20e930 100644 --- a/src/Web/Router/RouterFactory.php +++ b/src/Web/Router/RouterFactory.php @@ -44,6 +44,11 @@ public function create(): RouteList 'locale' => $this->profiles->default()->locale(), ]); + $router->addRoute('oauth//', [ + 'module' => 'Front', + 'presenter' => 'OAuth', + ]); + $router->addRoute('project//[/][/]', [ 'module' => 'Admin:Project', 'action' => 'default', diff --git a/src/Web/Ui/templates/forms/imports.latte b/src/Web/Ui/templates/forms/imports.latte index 09f8622e..5988103a 100644 --- a/src/Web/Ui/templates/forms/imports.latte +++ b/src/Web/Ui/templates/forms/imports.latte @@ -117,10 +117,35 @@ {/define} {define #input-type-text} - + {if null === $control->getOption('clipboard')} + + {else} + {var $clipboardAction = $control->isDisabled() ? 'copy' : $control->getOption('clipboard')} + +
+
+ + +
+
+ {/if} {/define} {define #input-type-color-picker} diff --git a/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.cs.neon b/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.cs.neon new file mode 100644 index 00000000..5bbc4195 --- /dev/null +++ b/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.cs.neon @@ -0,0 +1,17 @@ +enabled: + field: Zapnuto + +client_id: + field: 'ID aplikace (klienta)' + required: Vyplňte prosím ID aplikace. + +client_secret: + field: Tajný kód klienta + required: Vyplňte prosím tajný kód klienta. + +callback_uri: + field: Callback URI + description: 'V nastavení aplikace na webu Azure nastavte jako "Identifikátor URI pro přesměrování".' + +save: + field: Uložit diff --git a/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.en.neon b/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.en.neon new file mode 100644 index 00000000..1f54b838 --- /dev/null +++ b/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.en.neon @@ -0,0 +1,17 @@ +enabled: + field: Enabled + +client_id: + field: 'Application ID (client)' + required: Please enter a application ID. + +client_secret: + field: Client secret code + required: Please enter a client secret code. + +callback_uri: + field: Callback URI + description: 'In the application settings on the Azure site, set as the "Redirect URI".' + +save: + field: Save diff --git a/translations/App_Web_AdminModule_ApplicationModule_Presenter_SettingsPresenter.cs.neon b/translations/App_Web_AdminModule_ApplicationModule_Presenter_SettingsPresenter.cs.neon index 00a79bbf..823e67e0 100644 --- a/translations/App_Web_AdminModule_ApplicationModule_Presenter_SettingsPresenter.cs.neon +++ b/translations/App_Web_AdminModule_ApplicationModule_Presenter_SettingsPresenter.cs.neon @@ -3,6 +3,7 @@ heading_localization: Lokalizace heading_environments: Prostředí heading_api_cache: Výkon heading_crawler: Crawler +heading_azure_auth: Autentizace pomocí Azure AD message: localization_settings_updated: Nastavení lokalizace bylo uloženo. @@ -13,3 +14,5 @@ message: crawler_settings_update_failed: Při ukládaní nastavení došlo k chybě. environments_updated: Nastavení prostředí bylo uloženo. environments_update_failed: Při ukládaní nastavení došlo k chybě. + azure_auth_updated: Nastavení Azure AD bylo uloženo. + azure_auth_update_failed: Při ukládaní nastavení došlo k chybě. diff --git a/translations/App_Web_AdminModule_ApplicationModule_Presenter_SettingsPresenter.en.neon b/translations/App_Web_AdminModule_ApplicationModule_Presenter_SettingsPresenter.en.neon index fb47cd3c..0f3942df 100644 --- a/translations/App_Web_AdminModule_ApplicationModule_Presenter_SettingsPresenter.en.neon +++ b/translations/App_Web_AdminModule_ApplicationModule_Presenter_SettingsPresenter.en.neon @@ -3,6 +3,7 @@ heading_localization: Localization heading_environments: Environments heading_api_cache: Performance heading_crawler: Crawler +heading_azure_auth: Authentication with Azure AD message: localization_settings_updated: The localization settings have been saved. diff --git a/translations/App_Web_FrontModule_Presenter_OAuthPresenter.cs.neon b/translations/App_Web_FrontModule_Presenter_OAuthPresenter.cs.neon new file mode 100644 index 00000000..5a0e921d --- /dev/null +++ b/translations/App_Web_FrontModule_Presenter_OAuthPresenter.cs.neon @@ -0,0 +1,3 @@ +message: + authentication_failure: + azure: Přihlášení skrze Azure AD se nezdařilo. diff --git a/translations/App_Web_FrontModule_Presenter_OAuthPresenter.en.neon b/translations/App_Web_FrontModule_Presenter_OAuthPresenter.en.neon new file mode 100644 index 00000000..f17339ba --- /dev/null +++ b/translations/App_Web_FrontModule_Presenter_OAuthPresenter.en.neon @@ -0,0 +1,3 @@ +message: + authentication_failure: + azure: Login via Azure AD failed. diff --git a/translations/App_Web_FrontModule_Presenter_SignInPresenter.cs.neon b/translations/App_Web_FrontModule_Presenter_SignInPresenter.cs.neon index cdd2c7dd..21952d89 100644 --- a/translations/App_Web_FrontModule_Presenter_SignInPresenter.cs.neon +++ b/translations/App_Web_FrontModule_Presenter_SignInPresenter.cs.neon @@ -1,5 +1,8 @@ page_title: Přihlášení forgot_your_password: Zapomenuté heslo? +login_with: + azure: Přihlásit se přes Azure + message: user_authentication_failed: Nesprávné přihlašovací údaje. diff --git a/translations/App_Web_FrontModule_Presenter_SignInPresenter.en.neon b/translations/App_Web_FrontModule_Presenter_SignInPresenter.en.neon index 50206272..ba895973 100644 --- a/translations/App_Web_FrontModule_Presenter_SignInPresenter.en.neon +++ b/translations/App_Web_FrontModule_Presenter_SignInPresenter.en.neon @@ -1,5 +1,8 @@ page_title: Sign in forgot_your_password: Forgot Password? +login_with: + azure: Sign in with Azure + message: user_authentication_failed: Incorrect credentials. diff --git a/translations/layout.cs.neon b/translations/layout.cs.neon index 47125acf..9351028d 100644 --- a/translations/layout.cs.neon +++ b/translations/layout.cs.neon @@ -51,5 +51,9 @@ alert: text: Výchozí jazyk pro tento projekt není nastaven. link_text: Nastavte jej prosím v editaci projektu. +clipboard: + success_copy: Zkopírováno + error_copy: Kopírování se nezdařilo + message: locale_change_failed: Jazyk se nepovedlo změnit. diff --git a/translations/layout.en.neon b/translations/layout.en.neon index 6f193e4f..efd70611 100644 --- a/translations/layout.en.neon +++ b/translations/layout.en.neon @@ -51,5 +51,9 @@ alert: text: The default locale for this project is not set. link_text: Please set it in the app settings. +clipboard: + success_copy: Copied + error_copy: Copying failed + message: locale_change_failed: The language could not be changed. diff --git a/yarn.lock b/yarn.lock index 88e74c06..dfaebb4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2117,6 +2117,15 @@ cli-boxes@^2.2.1: resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== +clipboard@^2.0.11: + version "2.0.11" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.11.tgz#62180360b97dd668b6b3a84ec226975762a70be5" + integrity sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw== + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -2614,6 +2623,11 @@ del@^4.1.1: pify "^4.0.1" rimraf "^2.6.3" +delegate@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== + depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -3339,6 +3353,13 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" +good-listener@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + integrity sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw== + dependencies: + delegate "^3.1.2" + got@^11.8.5, got@^9.6.0: version "11.8.5" resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046" @@ -5370,6 +5391,11 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= +select@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + integrity sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA== + selfsigned@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.0.1.tgz#8b2df7fa56bf014d19b6007655fff209c0ef0a56" @@ -5804,6 +5830,11 @@ thunky@^1.0.2: resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== +tiny-emitter@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + tippy.js@^6.3.7: version "6.3.7" resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c" From d608b78f6df9b5cb09e6c3f70b75ea4dc12f01e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Fri, 26 Jan 2024 05:42:28 +0100 Subject: [PATCH 2/5] Fixed Azure authenticator + External auth list - fixed Azure authenticator - fixed Doctrine mapping - added the list of external authentication on the user edit page --- config/model/user/infrastructure.neon | 3 ++ config/services.neon | 1 + .../OAuth/Azure/AzureAuthenticator.php | 15 ++++-- .../User/ValueObject/AuthProviderCode.php | 4 ++ .../Mapping/App.Domain.User.User.dcm.xml | 2 +- ...indExternalAuthenticationsQueryHandler.php | 51 +++++++++++++++++++ src/ReadModel/User/ExternalAuthView.php | 17 +++++++ .../User/FindExternalAuthenticationsQuery.php | 22 ++++++++ .../ExternalAuthListControl.php | 29 +++++++++++ ...xternalAuthListControlFactoryInterface.php | 10 ++++ .../ExternalAuthListTemplate.php | 14 +++++ .../templates/externalAuthListControl.latte | 26 ++++++++++ .../Presenter/EditUserPresenter.php | 10 ++++ .../templates/EditUser.default.latte | 10 +++- ...alAuthList_ExternalAuthListControl.cs.neon | 3 ++ ...alAuthList_ExternalAuthListControl.en.neon | 3 ++ ...Module_Presenter_EditUserPresenter.cs.neon | 1 + ...Module_Presenter_EditUserPresenter.en.neon | 1 + 18 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/Infrastructure/User/Doctrine/ReadModel/FindExternalAuthenticationsQueryHandler.php create mode 100644 src/ReadModel/User/ExternalAuthView.php create mode 100644 src/ReadModel/User/FindExternalAuthenticationsQuery.php create mode 100644 src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListControl.php create mode 100644 src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListControlFactoryInterface.php create mode 100644 src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListTemplate.php create mode 100644 src/Web/AdminModule/UserModule/Control/ExternalAuthList/templates/externalAuthListControl.latte create mode 100644 translations/App_Web_AdminModule_UserModule_Control_ExternalAuthList_ExternalAuthListControl.cs.neon create mode 100644 translations/App_Web_AdminModule_UserModule_Control_ExternalAuthList_ExternalAuthListControl.en.neon diff --git a/config/model/user/infrastructure.neon b/config/model/user/infrastructure.neon index 0697f672..1c015ea6 100644 --- a/config/model/user/infrastructure.neon +++ b/config/model/user/infrastructure.neon @@ -10,6 +10,9 @@ services: - autowired: no factory: App\Infrastructure\User\Doctrine\ReadModel\FindNotificationReceiversByTypeAndProjectIdsQueryHandler + - + autowired: no + factory: App\Infrastructure\User\Doctrine\ReadModel\FindExternalAuthenticationsQueryHandler # infra: doctrine mapping nettrine.orm.xml: diff --git a/config/services.neon b/config/services.neon index 2a6bc9e9..c07337d5 100644 --- a/config/services.neon +++ b/config/services.neon @@ -77,6 +77,7 @@ services: - App\Web\AdminModule\UserModule\Control\UserForm\UserFormControlFactoryInterface - App\Web\AdminModule\ProfileModule\Control\PasswordChange\PasswordChangeControlFactoryInterface - App\Web\AdminModule\UserModule\Control\NotificationPreferences\NotificationPreferencesControlFactoryInterface + - App\Web\AdminModule\UserModule\Control\ExternalAuthList\ExternalAuthListControlFactoryInterface # Web\AdminModule\CookieModule - App\Web\AdminModule\CookieModule\Control\CategoryList\CategoryListControlFactoryInterface diff --git a/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php b/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php index 96a3b849..c6110286 100644 --- a/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php +++ b/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php @@ -4,6 +4,8 @@ namespace App\Bridge\SixtyEightPublishers\OAuth\Azure; +use App\Application\Localization\ApplicationDateTimeZone; +use App\Application\Localization\Profiles; use App\Domain\User\Command\StoreExternalAuthenticationCommand; use App\Domain\User\RolesEnum; use App\ReadModel\User\UserView; @@ -30,6 +32,7 @@ public function __construct( private readonly CommandBusInterface $commandBus, private readonly QueryBusInterface $queryBus, private readonly LoggerInterface $logger, + private readonly Profiles $profiles, ) {} public function authenticate(string $flowName, AuthorizationResult $authorizationResult): IIdentity @@ -90,7 +93,7 @@ public function authenticate(string $flowName, AuthorizationResult $authorizatio ); $identity = IdentityDecorator::newInstance()->wakeupIdentity( - identity: Identity::createSleeping($userId), + identity: Identity::createSleeping(UserId::fromString($userId)), queryBus: $this->queryBus, ); @@ -114,7 +117,7 @@ private function createUser(ResourceOwnerInterface $resourceOwner, string $flowN $userData = $resourceOwner->toArray(); try { - $this->commandBus->dispatch(CreateUserCommand::create( + $command = CreateUserCommand::create( username: $username, password: null, emailAddress: $username, @@ -122,7 +125,13 @@ private function createUser(ResourceOwnerInterface $resourceOwner, string $flowN surname: $userData['family_name'] ?? '', roles: $roles, userId: $userId, - )); + ); + + $command = $command + ->withParam('profile', $this->profiles->active()->locale()) + ->withParam('timezone', ApplicationDateTimeZone::get()->getName()); + + $this->commandBus->dispatch($command); } catch (Throwable $e) { $this->logger->error(sprintf( 'Unable to create the user with oid %s via %s. %s', diff --git a/src/Domain/User/ValueObject/AuthProviderCode.php b/src/Domain/User/ValueObject/AuthProviderCode.php index 756e746c..26e7dc29 100644 --- a/src/Domain/User/ValueObject/AuthProviderCode.php +++ b/src/Domain/User/ValueObject/AuthProviderCode.php @@ -8,4 +8,8 @@ final class AuthProviderCode extends AbstractStringValueObject { + public function __toString(): string + { + return $this->value(); + } } diff --git a/src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.User.dcm.xml b/src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.User.dcm.xml index 2bb92a01..12b0582c 100644 --- a/src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.User.dcm.xml +++ b/src/Infrastructure/User/Doctrine/Mapping/App.Domain.User.User.dcm.xml @@ -63,7 +63,7 @@ - + diff --git a/src/Infrastructure/User/Doctrine/ReadModel/FindExternalAuthenticationsQueryHandler.php b/src/Infrastructure/User/Doctrine/ReadModel/FindExternalAuthenticationsQueryHandler.php new file mode 100644 index 00000000..4501f22d --- /dev/null +++ b/src/Infrastructure/User/Doctrine/ReadModel/FindExternalAuthenticationsQueryHandler.php @@ -0,0 +1,51 @@ + + * @throws Exception + */ + public function __invoke(FindExternalAuthenticationsQuery $query): array + { + $rows = $this->connection->createQueryBuilder() + ->select('uea.user_id, uea.provider_code, uea.created_at, uea.resource_owner_id') + ->from('user_external_auth', 'uea') + ->join('uea', '"user"', 'u', 'u.id = uea.user_id AND u.deleted_at IS NULL') + ->where('uea.user_id = :userId') + ->orderBy('uea.created_at', 'DESC') + ->setParameters([ + 'userId' => $query->userId(), + ]) + ->fetchAllAssociative(); + + $result = []; + + foreach ($rows as $row) { + $result[] = new ExternalAuthView( + userId: $row['user_id'], + providerCode: $row['provider_code'], + createdAt: new DateTimeImmutable($row['created_at'], new DateTimeZone('UTC')), + resourceOwnerId: $row['resource_owner_id'], + ); + } + + return $result; + } +} diff --git a/src/ReadModel/User/ExternalAuthView.php b/src/ReadModel/User/ExternalAuthView.php new file mode 100644 index 00000000..1904c575 --- /dev/null +++ b/src/ReadModel/User/ExternalAuthView.php @@ -0,0 +1,17 @@ + $userId, + ]); + } + + public function userId(): string + { + return $this->getParam('user_id'); + } +} diff --git a/src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListControl.php b/src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListControl.php new file mode 100644 index 00000000..f3b665d1 --- /dev/null +++ b/src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListControl.php @@ -0,0 +1,29 @@ +getTemplate(); + assert($template instanceof ExternalAuthListTemplate); + + $template->externalAuths = $this->queryBus->dispatch(FindExternalAuthenticationsQuery::create( + userId: $this->userId, + )); + } +} diff --git a/src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListControlFactoryInterface.php b/src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListControlFactoryInterface.php new file mode 100644 index 00000000..03a3f6c8 --- /dev/null +++ b/src/Web/AdminModule/UserModule/Control/ExternalAuthList/ExternalAuthListControlFactoryInterface.php @@ -0,0 +1,10 @@ + */ + public array $externalAuths; +} diff --git a/src/Web/AdminModule/UserModule/Control/ExternalAuthList/templates/externalAuthListControl.latte b/src/Web/AdminModule/UserModule/Control/ExternalAuthList/templates/externalAuthListControl.latte new file mode 100644 index 00000000..99fc59f4 --- /dev/null +++ b/src/Web/AdminModule/UserModule/Control/ExternalAuthList/templates/externalAuthListControl.latte @@ -0,0 +1,26 @@ +{templateType App\Web\AdminModule\UserModule\Control\ExternalAuthList\ExternalAuthListTemplate} + +
+
+
+ + + + + + + + + + + + + + + + + +
{_auth_type}{_resource_owner_id}
{$externalAuth->providerCode}{$externalAuth->resourceOwnerId}
{_no_rows}
+
+
+
diff --git a/src/Web/AdminModule/UserModule/Presenter/EditUserPresenter.php b/src/Web/AdminModule/UserModule/Presenter/EditUserPresenter.php index 66635a81..5e454539 100644 --- a/src/Web/AdminModule/UserModule/Presenter/EditUserPresenter.php +++ b/src/Web/AdminModule/UserModule/Presenter/EditUserPresenter.php @@ -7,6 +7,8 @@ use App\Application\Acl\UserResource; use App\ReadModel\User\UserView; use App\Web\AdminModule\Presenter\AdminPresenter; +use App\Web\AdminModule\UserModule\Control\ExternalAuthList\ExternalAuthListControl; +use App\Web\AdminModule\UserModule\Control\ExternalAuthList\ExternalAuthListControlFactoryInterface; use App\Web\AdminModule\UserModule\Control\NotificationPreferences\Event\NotificationPreferencesProcessingFailedEvent; use App\Web\AdminModule\UserModule\Control\NotificationPreferences\Event\NotificationPreferencesUpdatedEvent; use App\Web\AdminModule\UserModule\Control\NotificationPreferences\NotificationPreferencesControl; @@ -31,6 +33,7 @@ final class EditUserPresenter extends AdminPresenter public function __construct( private readonly UserFormControlFactoryInterface $userFormControlFactory, private readonly NotificationPreferencesControlFactoryInterface $notificationPreferencesControlFactory, + private readonly ExternalAuthListControlFactoryInterface $externalAuthListControlFactory, private readonly QueryBusInterface $queryBus, ) { parent::__construct(); @@ -91,4 +94,11 @@ protected function createComponentNotificationPreferences(): NotificationPrefere return $control; } + + protected function createComponentExternalAuthList(): ExternalAuthListControl + { + return $this->externalAuthListControlFactory->create( + userId: $this->userView->id->toString(), + ); + } } diff --git a/src/Web/AdminModule/UserModule/Presenter/templates/EditUser.default.latte b/src/Web/AdminModule/UserModule/Presenter/templates/EditUser.default.latte index 4bbe03c1..e5662109 100644 --- a/src/Web/AdminModule/UserModule/Presenter/templates/EditUser.default.latte +++ b/src/Web/AdminModule/UserModule/Presenter/templates/EditUser.default.latte @@ -15,10 +15,18 @@ {control userForm}
-
+

{_notification_preferences}

{control notificationPreferences}
+ +
+
+

{_external_auth_list}

+
+ + {control externalAuthList} +
diff --git a/translations/App_Web_AdminModule_UserModule_Control_ExternalAuthList_ExternalAuthListControl.cs.neon b/translations/App_Web_AdminModule_UserModule_Control_ExternalAuthList_ExternalAuthListControl.cs.neon new file mode 100644 index 00000000..d1822815 --- /dev/null +++ b/translations/App_Web_AdminModule_UserModule_Control_ExternalAuthList_ExternalAuthListControl.cs.neon @@ -0,0 +1,3 @@ +auth_type: Typ autentizace +resource_owner_id: ID uživatele +no_rows: Uživatel nepoužívá žádnou externí autentizaci. diff --git a/translations/App_Web_AdminModule_UserModule_Control_ExternalAuthList_ExternalAuthListControl.en.neon b/translations/App_Web_AdminModule_UserModule_Control_ExternalAuthList_ExternalAuthListControl.en.neon new file mode 100644 index 00000000..8e44ab44 --- /dev/null +++ b/translations/App_Web_AdminModule_UserModule_Control_ExternalAuthList_ExternalAuthListControl.en.neon @@ -0,0 +1,3 @@ +auth_type: Authentication type +resource_owner_id: User ID +no_rows: The user does not use any external authentication. diff --git a/translations/App_Web_AdminModule_UserModule_Presenter_EditUserPresenter.cs.neon b/translations/App_Web_AdminModule_UserModule_Presenter_EditUserPresenter.cs.neon index b25fceed..900e665d 100644 --- a/translations/App_Web_AdminModule_UserModule_Presenter_EditUserPresenter.cs.neon +++ b/translations/App_Web_AdminModule_UserModule_Presenter_EditUserPresenter.cs.neon @@ -1,6 +1,7 @@ page_title: Upravit uživatele heading_basic: Základní informace notification_preferences: Nastavení notifikací +external_auth_list: Externí autentizace back_to_list: Zpět na výpis uživatelů message: diff --git a/translations/App_Web_AdminModule_UserModule_Presenter_EditUserPresenter.en.neon b/translations/App_Web_AdminModule_UserModule_Presenter_EditUserPresenter.en.neon index c23efcc8..51833579 100644 --- a/translations/App_Web_AdminModule_UserModule_Presenter_EditUserPresenter.en.neon +++ b/translations/App_Web_AdminModule_UserModule_Presenter_EditUserPresenter.en.neon @@ -1,6 +1,7 @@ page_title: Edit user heading_basic: Basic information notification_preferences: Notification settings +external_auth_list: External authentication back_to_list: Back to the user list message: From 731b3e06a4cadafd34096345dbebf99209d91c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Mon, 29 Jan 2024 03:17:46 +0100 Subject: [PATCH 3/5] Azure - single tenant support and Fallback for missing email claim - Microsoft Graph API is called during authentication for retrieve an email address if the access token does not contain the `email` field - added optional field `Tenant API` in Azure AD configuration form. This adds support for applications that are configured as single tenant - updated package `68publishers/oauth` --- composer.json | 2 +- composer.lock | 16 ++++---- .../GlobalSettings/GlobalSettings.php | 2 +- .../OAuth/Azure/AzureAuthenticator.php | 40 +++++++++++++++---- .../OAuth/Azure/Config.php | 1 + .../Command/PutAzureAuthSettingsCommand.php | 8 +++- .../PutAzureAuthSettingsCommandHandler.php | 7 ++-- src/Domain/GlobalSettings/GlobalSettings.php | 2 +- .../ValueObject/AzureAuthSettings.php | 7 ++++ .../AzureAuthSettingsFormControl.php | 13 ++++-- .../FrontModule/Presenter/OAuthPresenter.php | 3 +- ...sForm_AzureAuthSettingsFormControl.cs.neon | 4 ++ ...sForm_AzureAuthSettingsFormControl.en.neon | 6 ++- 13 files changed, 83 insertions(+), 28 deletions(-) diff --git a/composer.json b/composer.json index fccc3731..6919bea8 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "68publishers/environment": "^1.0.1", "68publishers/event-dispatcher-extra": "^1.1", "68publishers/health-check": "^1.0.1", - "68publishers/oauth": "dev-main", + "68publishers/oauth": "^1.0", "68publishers/omni": "0.1.x-dev", "68publishers/smart-nette-component": "^1.0", "68publishers/tracy-git-version": "^1.2", diff --git a/composer.lock b/composer.lock index 9ce95c4b..952a9cb1 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": "d27ce67fe12fe6b9d18d516514787da9", + "content-hash": "5d250921197ce7129c855e0b4223f481", "packages": [ { "name": "68publishers/asset", @@ -405,16 +405,16 @@ }, { "name": "68publishers/oauth", - "version": "dev-main", + "version": "v1.0.0", "source": { "type": "git", "url": "https://github.com/68publishers/oauth.git", - "reference": "7dd061dc877a0b517eac4fe041e8b98d6105f14c" + "reference": "98c077b382cb37b572d9ac9d28c80d2dd2c1d70f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/68publishers/oauth/zipball/7dd061dc877a0b517eac4fe041e8b98d6105f14c", - "reference": "7dd061dc877a0b517eac4fe041e8b98d6105f14c", + "url": "https://api.github.com/repos/68publishers/oauth/zipball/98c077b382cb37b572d9ac9d28c80d2dd2c1d70f", + "reference": "98c077b382cb37b572d9ac9d28c80d2dd2c1d70f", "shasum": "" }, "require": { @@ -440,7 +440,6 @@ "roave/security-advisories": "dev-latest", "thenetworg/oauth2-azure": "^2.2" }, - "default-branch": true, "type": "project", "autoload": { "psr-4": { @@ -460,9 +459,9 @@ ], "support": { "issues": "https://github.com/68publishers/oauth/issues", - "source": "https://github.com/68publishers/oauth/tree/main" + "source": "https://github.com/68publishers/oauth/tree/v1.0.0" }, - "time": "2024-01-25T23:44:03+00:00" + "time": "2024-01-29T01:14:07+00:00" }, { "name": "68publishers/omni", @@ -12970,7 +12969,6 @@ ], "minimum-stability": "stable", "stability-flags": { - "68publishers/oauth": 20, "68publishers/omni": 20, "contributte/mailing": 20, "fmasa/messenger": 20, diff --git a/src/Application/GlobalSettings/GlobalSettings.php b/src/Application/GlobalSettings/GlobalSettings.php index 2cf8a6f8..5391ab4b 100644 --- a/src/Application/GlobalSettings/GlobalSettings.php +++ b/src/Application/GlobalSettings/GlobalSettings.php @@ -31,7 +31,7 @@ public static function default(): self ApiCache::create(), CrawlerSettings::fromValues(false, null, null, null, null), EnvironmentSettings::createDefault(), - AzureAuthSettings::fromValues(false, null, null), + AzureAuthSettings::fromValues(false, null, null, null), ); } diff --git a/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php b/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php index c6110286..cd40979d 100644 --- a/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php +++ b/src/Bridge/SixtyEightPublishers/OAuth/Azure/AzureAuthenticator.php @@ -24,6 +24,8 @@ use SixtyEightPublishers\UserBundle\Domain\Command\CreateUserCommand; use SixtyEightPublishers\UserBundle\Domain\ValueObject\UserId; use SixtyEightPublishers\UserBundle\ReadModel\Query\GetUserByEmailAddressQuery; +use TheNetworg\OAuth2\Client\Provider\Azure; +use TheNetworg\OAuth2\Client\Token\AccessToken; use Throwable; final class AzureAuthenticator implements AuthenticatorInterface @@ -39,7 +41,32 @@ public function authenticate(string $flowName, AuthorizationResult $authorizatio { $userData = $authorizationResult->resourceOwner->toArray(); - if (empty($userData['email'] ?? '')) { + $username = $userData['email'] ?? ''; + $firstname = $userData['given_name'] ?? ''; + $surname = $userData['family_name'] ?? ''; + + if ('' === $username) { + try { + $client = $authorizationResult->client; + $token = $authorizationResult->accessToken; + assert($client instanceof Azure && $token instanceof AccessToken); + + $data = $client->get($client->getRootMicrosoftGraphUri($token) . '/v1.0/me', $token); + $username = $data['mail'] ?? ''; + $firstname = $data['givenName'] ?? $firstname; + $surname = $data['surname'] ?? $surname; + } catch (Throwable $e) { + $this->logger->error(sprintf( + 'Unable to request profile for user with oid %s via %s.', + $authorizationResult->resourceOwner->getId(), + $flowName, + )); + + throw new AuthenticationException($e->getMessage(), 0, $e); + } + } + + if ('' === $username) { $this->logger->error(sprintf( 'Unable to login user with oid %s via %s. Missing claim for property "email".', $authorizationResult->resourceOwner->getId(), @@ -49,8 +76,6 @@ public function authenticate(string $flowName, AuthorizationResult $authorizatio throw new AuthenticationException('Missing claim for property "email".'); } - $username = $userData['email']; - try { $userView = $this->queryBus->dispatch(GetUserByEmailAddressQuery::create( emailAddress: $username, @@ -80,6 +105,8 @@ public function authenticate(string $flowName, AuthorizationResult $authorizatio flowName: $flowName, username: $username, roles: $roles, + firstname: (string) $firstname, + surname: (string) $surname, ); } else { $userId = $userView->id->toString(); @@ -111,18 +138,17 @@ public function authenticate(string $flowName, AuthorizationResult $authorizatio /** * @param array $roles */ - private function createUser(ResourceOwnerInterface $resourceOwner, string $flowName, string $username, array $roles): string + private function createUser(ResourceOwnerInterface $resourceOwner, string $flowName, string $username, array $roles, string $firstname, string $surname): string { $userId = UserId::new()->toString(); - $userData = $resourceOwner->toArray(); try { $command = CreateUserCommand::create( username: $username, password: null, emailAddress: $username, - firstname: $userData['given_name'] ?? '', - surname: $userData['family_name'] ?? '', + firstname: $firstname, + surname: $surname, roles: $roles, userId: $userId, ); diff --git a/src/Bridge/SixtyEightPublishers/OAuth/Azure/Config.php b/src/Bridge/SixtyEightPublishers/OAuth/Azure/Config.php index a59f19cb..cec6c042 100644 --- a/src/Bridge/SixtyEightPublishers/OAuth/Azure/Config.php +++ b/src/Bridge/SixtyEightPublishers/OAuth/Azure/Config.php @@ -22,6 +22,7 @@ public function __construct(GlobalSettingsInterface $globalSettings) options: [ AzureAuthorizator::OptClientId => $azureAuthSetting->clientId(), AzureAuthorizator::OptClientSecret => $azureAuthSetting->clientSecret(), + AzureAuthorizator::OptTenantId => $azureAuthSetting->tenantId(), ], ); }, diff --git a/src/Domain/GlobalSettings/Command/PutAzureAuthSettingsCommand.php b/src/Domain/GlobalSettings/Command/PutAzureAuthSettingsCommand.php index 761289ad..5237a097 100644 --- a/src/Domain/GlobalSettings/Command/PutAzureAuthSettingsCommand.php +++ b/src/Domain/GlobalSettings/Command/PutAzureAuthSettingsCommand.php @@ -8,12 +8,13 @@ final class PutAzureAuthSettingsCommand extends AbstractCommand { - public static function create(bool $enabled, ?string $clientId, ?string $clientSecret): self + public static function create(bool $enabled, ?string $clientId, ?string $clientSecret, ?string $tenantId): self { return self::fromParameters([ 'enabled' => $enabled, 'client_id' => $clientId, 'client_secret' => $clientSecret, + 'tenant_id' => $tenantId, ]); } @@ -31,4 +32,9 @@ public function clientSecret(): ?string { return $this->getParam('client_secret'); } + + public function tenantId(): ?string + { + return $this->getParam('tenant_id'); + } } diff --git a/src/Domain/GlobalSettings/CommandHandler/PutAzureAuthSettingsCommandHandler.php b/src/Domain/GlobalSettings/CommandHandler/PutAzureAuthSettingsCommandHandler.php index e1955ebb..fdf6a807 100644 --- a/src/Domain/GlobalSettings/CommandHandler/PutAzureAuthSettingsCommandHandler.php +++ b/src/Domain/GlobalSettings/CommandHandler/PutAzureAuthSettingsCommandHandler.php @@ -25,9 +25,10 @@ public function __invoke(PutAzureAuthSettingsCommand $command): void } $globalSettings->updateAzureAuthSettings(AzureAuthSettings::fromValues( - $command->enabled(), - $command->clientId(), - $command->clientSecret(), + enabled: $command->enabled(), + clientId: $command->clientId(), + clientSecret: $command->clientSecret(), + tenantId: $command->tenantId(), )); $this->globalSettingsRepository->save($globalSettings); diff --git a/src/Domain/GlobalSettings/GlobalSettings.php b/src/Domain/GlobalSettings/GlobalSettings.php index 340392f9..71fa3115 100644 --- a/src/Domain/GlobalSettings/GlobalSettings.php +++ b/src/Domain/GlobalSettings/GlobalSettings.php @@ -101,7 +101,7 @@ protected function whenGlobalSettingsCreated(GlobalSettingsCreated $event): void $this->apiCache = ApiCache::create(); $this->crawlerSettings = CrawlerSettings::fromValues(false, null, null, null, null); $this->environmentSettings = EnvironmentSettings::createDefault(); - $this->azureAuthSettings = AzureAuthSettings::fromValues(false, null, null); + $this->azureAuthSettings = AzureAuthSettings::fromValues(false, null, null, null); } protected function whenLocalizationSettingsChanged(LocalizationSettingsChanged $event): void diff --git a/src/Domain/GlobalSettings/ValueObject/AzureAuthSettings.php b/src/Domain/GlobalSettings/ValueObject/AzureAuthSettings.php index 1ef77d3d..b2aecbae 100644 --- a/src/Domain/GlobalSettings/ValueObject/AzureAuthSettings.php +++ b/src/Domain/GlobalSettings/ValueObject/AzureAuthSettings.php @@ -12,11 +12,13 @@ public static function fromValues( bool $enabled, ?string $clientId, ?string $clientSecret, + ?string $tenantId, ): self { return self::fromArray([ 'enabled' => $enabled, 'client_id' => $clientId, 'client_secret' => $clientSecret, + 'tenant_id' => $tenantId, ]); } @@ -34,4 +36,9 @@ public function clientSecret(): ?string { return $this->get('client_secret'); } + + public function tenantId(): ?string + { + return $this->get('tenant_id'); + } } diff --git a/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControl.php b/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControl.php index da887684..2995ce53 100644 --- a/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControl.php +++ b/src/Web/AdminModule/ApplicationModule/Control/AzureAuthSettingsForm/AzureAuthSettingsFormControl.php @@ -41,6 +41,7 @@ protected function createComponentForm(): Form $enabledField->addCondition($form::EQUAL, true) ->toggle('#' . $this->getUniqueId() . '-client_id-container') ->toggle('#' . $this->getUniqueId() . '-client_secret-container') + ->toggle('#' . $this->getUniqueId() . '-tenant_id-container') ->toggle('#' . $this->getUniqueId() . '-callback_uri-container'); $form->addText('client_id', 'client_id.field') @@ -53,6 +54,10 @@ protected function createComponentForm(): Form ->addConditionOn($enabledField, $form::EQUAL, true) ->setRequired('client_secret.required'); + $form->addText('tenant_id', 'tenant_id.field') + ->setOption('id', $this->getUniqueId() . '-tenant_id-container') + ->setOption('description', 'tenant_id.description'); + $form->addText('callback_uri', 'callback_uri.field') ->setDisabled() ->setOmitted() @@ -71,6 +76,7 @@ protected function createComponentForm(): Form 'enabled' => $defaults->enabled(), 'client_id' => (string) $defaults->clientId(), 'client_secret' => (string) $defaults->clientSecret(), + 'tenant_id' => (string) $defaults->tenantId(), ]); $form->onSuccess[] = function (Form $form): void { @@ -84,9 +90,10 @@ private function saveGlobalSettings(Form $form): void { $values = $form->getValues(); $command = PutAzureAuthSettingsCommand::create( - $values->enabled, - $values->client_id ?: null, - $values->client_secret ?: null, + enabled: $values->enabled, + clientId: $values->client_id ?: null, + clientSecret: $values->client_secret ?: null, + tenantId: $values->tenant_id ?: null, ); try { diff --git a/src/Web/FrontModule/Presenter/OAuthPresenter.php b/src/Web/FrontModule/Presenter/OAuthPresenter.php index 920b4cf0..2dbf4719 100644 --- a/src/Web/FrontModule/Presenter/OAuthPresenter.php +++ b/src/Web/FrontModule/Presenter/OAuthPresenter.php @@ -4,12 +4,13 @@ namespace App\Web\FrontModule\Presenter; +use App\Web\Ui\Presenter; use Psr\Log\LoggerInterface; use SixtyEightPublishers\FlashMessageBundle\Domain\FlashMessage; use SixtyEightPublishers\OAuth\Bridge\Nette\Application\OAuthPresenterTrait; use SixtyEightPublishers\OAuth\Exception\OAuthExceptionInterface; -final class OAuthPresenter extends FrontPresenter +final class OAuthPresenter extends Presenter { use OAuthPresenterTrait; diff --git a/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.cs.neon b/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.cs.neon index 5bbc4195..037d9988 100644 --- a/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.cs.neon +++ b/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.cs.neon @@ -9,6 +9,10 @@ client_secret: field: Tajný kód klienta required: Vyplňte prosím tajný kód klienta. +tenant_id: + field: 'ID adresáře (tenanta)' + description: 'Vyplňte pouze pokud je v nastavení aplikace na webu Azure nastaveno "Podporované typy účtu" na možnost "Jen účty v tomto adresáři organizace" - jeden tenant. Jinak ponechte prázdné.' + callback_uri: field: Callback URI description: 'V nastavení aplikace na webu Azure nastavte jako "Identifikátor URI pro přesměrování".' diff --git a/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.en.neon b/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.en.neon index 1f54b838..9baa401c 100644 --- a/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.en.neon +++ b/translations/App_Web_AdminModule_ApplicationModule_Control_AzureAuthSettingsForm_AzureAuthSettingsFormControl.en.neon @@ -2,13 +2,17 @@ enabled: field: Enabled client_id: - field: 'Application ID (client)' + field: 'Application (client) ID' required: Please enter a application ID. client_secret: field: Client secret code required: Please enter a client secret code. +tenant_id: + field: 'Directory (tenant) ID' + description: 'Fill only if the "Supported account types" option in the application settings on the Azure site is set to "Accounts in this organizational directory only" - single tenant. Otherwise, leave blank.' + callback_uri: field: Callback URI description: 'In the application settings on the Azure site, set as the "Redirect URI".' From da34b5c58e760cbca2e450b67c2789b292ec7221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Tue, 27 Feb 2024 23:29:02 +0100 Subject: [PATCH 4/5] Oauth UI improvements --- .../templates/externalAuthListControl.latte | 46 ++++++++++--------- .../Presenter/templates/SignIn.default.latte | 26 +++++++---- ...ntModule_Presenter_SignInPresenter.cs.neon | 1 + ...ntModule_Presenter_SignInPresenter.en.neon | 1 + 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/Web/AdminModule/UserModule/Control/ExternalAuthList/templates/externalAuthListControl.latte b/src/Web/AdminModule/UserModule/Control/ExternalAuthList/templates/externalAuthListControl.latte index 99fc59f4..980ecac9 100644 --- a/src/Web/AdminModule/UserModule/Control/ExternalAuthList/templates/externalAuthListControl.latte +++ b/src/Web/AdminModule/UserModule/Control/ExternalAuthList/templates/externalAuthListControl.latte @@ -1,26 +1,28 @@ {templateType App\Web\AdminModule\UserModule\Control\ExternalAuthList\ExternalAuthListTemplate} -
-
-
- - - - - - - - - - - - - - - - - -
{_auth_type}{_resource_owner_id}
{$externalAuth->providerCode}{$externalAuth->resourceOwnerId}
{_no_rows}
+{if 0 < count($externalAuths)} +
+
+
+ + + + + + + + + + + + + +
{_auth_type}{_resource_owner_id}
{$externalAuth->providerCode}{$externalAuth->resourceOwnerId}
+
-
+{else} +
+
{_no_rows}
+
+{/if} diff --git a/src/Web/FrontModule/Presenter/templates/SignIn.default.latte b/src/Web/FrontModule/Presenter/templates/SignIn.default.latte index 2964a072..f555dfc7 100644 --- a/src/Web/FrontModule/Presenter/templates/SignIn.default.latte +++ b/src/Web/FrontModule/Presenter/templates/SignIn.default.latte @@ -3,15 +3,25 @@ {block #content} {control signIn} -
- +
+
+ +
+ {_oauth_divider} +
+
+
+ - {ifset block "login-icon-$oauthType"}{include block "login-icon-$oauthType"}{/ifset} - {_'login_with.' . $oauthType} - + {ifset block "login-icon-$oauthType"}{include block "login-icon-$oauthType"}{/ifset} + {_'login_with.' . $oauthType} + +
diff --git a/translations/App_Web_FrontModule_Presenter_SignInPresenter.cs.neon b/translations/App_Web_FrontModule_Presenter_SignInPresenter.cs.neon index 21952d89..a0ba8bf6 100644 --- a/translations/App_Web_FrontModule_Presenter_SignInPresenter.cs.neon +++ b/translations/App_Web_FrontModule_Presenter_SignInPresenter.cs.neon @@ -1,5 +1,6 @@ page_title: Přihlášení forgot_your_password: Zapomenuté heslo? +oauth_divider: Nebo login_with: azure: Přihlásit se přes Azure diff --git a/translations/App_Web_FrontModule_Presenter_SignInPresenter.en.neon b/translations/App_Web_FrontModule_Presenter_SignInPresenter.en.neon index ba895973..70539604 100644 --- a/translations/App_Web_FrontModule_Presenter_SignInPresenter.en.neon +++ b/translations/App_Web_FrontModule_Presenter_SignInPresenter.en.neon @@ -1,5 +1,6 @@ page_title: Sign in forgot_your_password: Forgot Password? +oauth_divider: Or login_with: azure: Sign in with Azure From 9e4d52bd7adb7cd9c6366bd4b132bd3b1f6a04d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Tue, 27 Feb 2024 23:31:42 +0100 Subject: [PATCH 5/5] Added new categories `ad_user_data` and `ad_personalization` in the fixtures --- CHANGELOG.md | 1 + src/Application/Fixture/resources/demo.php | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index decb8f78..cf1b8888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added authentication via Azure AD. +- Added new categories `ad_user_data` and `ad_personalization` in the fixtures. ### Fixed diff --git a/src/Application/Fixture/resources/demo.php b/src/Application/Fixture/resources/demo.php index b1535a16..229e2c15 100644 --- a/src/Application/Fixture/resources/demo.php +++ b/src/Application/Fixture/resources/demo.php @@ -62,6 +62,26 @@ 'active' => true, 'necessary' => false, ], + 'ad_user_data' => [ + 'category_id' => CategoryId::new()->toString(), + 'code' => 'ad_user_data', + 'names' => [ + 'cs' => 'Ad user data', + 'en' => 'Ad user data', + ], + 'active' => true, + 'necessary' => false, + ], + 'ad_personalization' => [ + 'category_id' => CategoryId::new()->toString(), + 'code' => 'ad_personalization', + 'names' => [ + 'cs' => 'Ad personalization', + 'en' => 'Ad personalization', + ], + 'active' => true, + 'necessary' => false, + ], 'analytics_storage' => [ 'category_id' => CategoryId::new()->toString(), 'code' => 'analytics_storage',