From 593dbc0cfcf4b60228055918ccf023dfd4faa189 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Mon, 11 May 2026 18:10:04 +0200 Subject: [PATCH 01/13] require hwi/hwioauthbunle --- .env | 5 + composer.json | 1 + composer.lock | 169 ++++++++++++++++++++++++++- config/bundles.php | 1 + config/packages/hwi_oauth.yaml | 8 ++ config/routes/hwi_oauth_routing.yaml | 11 ++ symfony.lock | 13 +++ 7 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 config/packages/hwi_oauth.yaml create mode 100644 config/routes/hwi_oauth_routing.yaml diff --git a/.env b/.env index ca27cc6..1612b04 100644 --- a/.env +++ b/.env @@ -59,3 +59,8 @@ MAILER_DEFAULT_TO_EMAIL="mailer_default_to_email_is_misconfigured@tesuta.be" SITE_TITLE="Your application" ENCRYPTION_KEY="8ea13de9680e2a1441774ec26642fa65a56d8099f44a301f219864b51bbaa925" ###< sumocoders/framework-core-bundle ### + +###> hwi/oauth-bundle ### +FB_ID= +FB_SECRET= +###< hwi/oauth-bundle ### diff --git a/composer.json b/composer.json index 69363e4..7c5a203 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "beberlei/doctrineextensions": "^1.5", "doctrine/doctrine-migrations-bundle": "^4.0", "endroid/qr-code": "^6.1", + "hwi/oauth-bundle": "^2.5", "nelmio/security-bundle": "^3.7", "scheb/2fa-backup-code": "^8.3", "scheb/2fa-bundle": "^8.3", diff --git a/composer.lock b/composer.lock index 46772df..f7a7113 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": "df4449e123eaf3c8f121d4d8eeedf427", + "content-hash": "fbd662c122d18bd3eb3de20a1626bbf0", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1822,6 +1822,173 @@ ], "time": "2025-08-23T21:21:41+00:00" }, + { + "name": "hwi/oauth-bundle", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/hwi/HWIOAuthBundle.git", + "reference": "a3ebcd8c8c326ac3f268e954ccee3c165fe2b5f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hwi/HWIOAuthBundle/zipball/a3ebcd8c8c326ac3f268e954ccee3c165fe2b5f3", + "reference": "a3ebcd8c8c326ac3f268e954ccee3c165fe2b5f3", + "shasum": "" + }, + "require": { + "php": "^8.3", + "symfony/deprecation-contracts": "^3.0", + "symfony/form": "^6.4 || ^7.4 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/http-client": "^6.4 || ^7.4 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.4 || ^8.0", + "symfony/options-resolver": "^6.4 || ^7.4 || ^8.0", + "symfony/routing": "^6.4 || ^7.4 || ^8.0", + "symfony/security-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0" + }, + "conflict": { + "twig/twig": "<1.43|>=2.0,<2.13" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^2.4 || ^3.2", + "doctrine/orm": "^2.9 || ^3.6", + "firebase/php-jwt": "^7.0", + "friendsofphp/php-cs-fixer": "^3.23", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^12.3", + "symfony/browser-kit": "^6.4 || ^7.4 || ^8.0", + "symfony/css-selector": "^6.4 || ^7.4 || ^8.0", + "symfony/monolog-bundle": "^3.4 || ^4.0", + "symfony/property-access": "^6.4 || ^7.4 || ^8.0", + "symfony/stopwatch": "^6.4 || ^7.4 || ^8.0", + "symfony/translation": "^6.4 || ^7.4 || ^8.0", + "symfony/validator": "^6.4 || ^7.4 || ^8.0", + "symfony/yaml": "^6.4 || ^7.4 || ^8.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "to use Doctrine user provider", + "firebase/php-jwt": "to use JWT utility functions", + "symfony/property-access": "to use FOSUB integration with this bundle", + "symfony/twig-bundle": "to use the Twig hwi_oauth_* functions" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "HWI\\Bundle\\OAuthBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alexander", + "email": "iam.asm89@gmail.com" + }, + { + "name": "Joseph Bielawski", + "email": "stloyd@gmail.com" + }, + { + "name": "Geoffrey Bachelet", + "email": "geoffrey.bachelet@gmail.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/hwi/HWIOAuthBundle/contributors" + } + ], + "description": "Support for authenticating users using both OAuth1.0a and OAuth2 in Symfony.", + "homepage": "https://github.com/hwi/HWIOAuthBundle", + "keywords": [ + "37signals", + "Authentication", + "Deezer", + "EVE Online", + "amazon", + "apple", + "asana", + "auth0", + "azure", + "bitbucket", + "bitly", + "box", + "bufferapp", + "clever", + "dailymotion", + "deviantart", + "discogs", + "disqus", + "dropbox", + "eventbrite", + "facebook", + "firewall", + "fiware", + "flickr", + "foursquare", + "genius", + "github", + "gitlab", + "google", + "hubic", + "instagram", + "jawbone", + "jira", + "linkedin", + "mail.ru", + "oauth", + "oauth1", + "oauth2", + "odnoklassniki", + "paypal", + "qq", + "reddit", + "runkeeper", + "salesforce", + "security", + "sensio connect", + "sina weibo", + "slack", + "sound cloud", + "spotify", + "stack exchange", + "stereomood", + "strava", + "toshl", + "trakt", + "trello", + "twitch", + "twitter", + "vkontakte", + "windows live", + "wordpress", + "xing", + "yahoo", + "yandex", + "youtube" + ], + "support": { + "issues": "https://github.com/hwi/HWIOAuthBundle/issues", + "source": "https://github.com/hwi/HWIOAuthBundle/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/stloyd", + "type": "github" + } + ], + "time": "2026-02-19T21:38:10+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", diff --git a/config/bundles.php b/config/bundles.php index f658b90..203352e 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -22,4 +22,5 @@ Symfony\UX\Turbo\TurboBundle::class => ['all' => true], Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true], Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], + HWI\Bundle\OAuthBundle\HWIOAuthBundle::class => ['all' => true], ]; diff --git a/config/packages/hwi_oauth.yaml b/config/packages/hwi_oauth.yaml new file mode 100644 index 0000000..b451310 --- /dev/null +++ b/config/packages/hwi_oauth.yaml @@ -0,0 +1,8 @@ +hwi_oauth: + # https://github.com/hwi/HWIOAuthBundle/blob/master/docs/2-configuring_resource_owners.md + resource_owners: + facebook: + type: facebook + client_id: '%env(FB_ID)%' + client_secret: '%env(FB_SECRET)%' + scope: "email public_profile" diff --git a/config/routes/hwi_oauth_routing.yaml b/config/routes/hwi_oauth_routing.yaml new file mode 100644 index 0000000..190b994 --- /dev/null +++ b/config/routes/hwi_oauth_routing.yaml @@ -0,0 +1,11 @@ +hwi_oauth_redirect: + resource: "@HWIOAuthBundle/Resources/config/routing/redirect.php" + prefix: /connect + +hwi_oauth_connect: + resource: "@HWIOAuthBundle/Resources/config/routing/connect.php" + prefix: /connect + +hwi_oauth_login: + resource: "@HWIOAuthBundle/Resources/config/routing/login.php" + prefix: /login diff --git a/symfony.lock b/symfony.lock index ff647de..2d176c7 100644 --- a/symfony.lock +++ b/symfony.lock @@ -59,6 +59,19 @@ "migrations/.gitignore" ] }, + "hwi/oauth-bundle": { + "version": "2.5", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "2.0", + "ref": "20154480d558409ad3eb9de3644817c81fad2268" + }, + "files": [ + "config/packages/hwi_oauth.yaml", + "config/routes/hwi_oauth_routing.yaml" + ] + }, "knplabs/knp-menu-bundle": { "version": "v3.7.0" }, From 56b4a4a18a5932dab2cf92c231e58a34dd4c1991 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Tue, 12 May 2026 18:20:05 +0200 Subject: [PATCH 02/13] feat: added and document use of hwi/oathbundle --- README.md | 183 ++++++++++++- config/packages/hwi_oauth.yaml | 24 +- config/packages/security.yaml | 12 + config/routes/hwi_oauth_routing.yaml | 4 - config/services.yaml | 9 + migrations/Version20260512135528.php | 30 +++ src/Controller/User/LoginController.php | 6 +- src/Entity/User/User.php | 44 ++++ src/Event/User/AzureLoginEvent.php | 21 ++ src/Security/OAuth/AzureUserProvider.php | 121 +++++++++ templates/user/login.html.twig | 16 ++ tests/Entity/User/UserTest.php | 47 ++++ .../Security/OAuth/AzureUserProviderTest.php | 249 ++++++++++++++++++ translations/messages.nl.yaml | 4 + 14 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 migrations/Version20260512135528.php create mode 100644 src/Event/User/AzureLoginEvent.php create mode 100644 src/Security/OAuth/AzureUserProvider.php create mode 100644 tests/Security/OAuth/AzureUserProviderTest.php diff --git a/README.md b/README.md index bebf0d9..d01f6fe 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Copy the `EventListener` folder from `src/` into your own project. Or adjust you ### Alter the database -* Copy the migrations from `src/Migrations/` into your own project. +* Copy the migrations from `migrations/` into your own project. * Run the migrations: `symfony console doctrine:migrations:migrate` @@ -87,3 +87,184 @@ If your project does not need registration, you can remove: * `src/Message/User/RegisterUser.php` * `src/MessageHandler/User/RegisterUserHandler.php` * `templates/user/register.html.twig` + +--- + +## Azure SSO (optional) + +This project includes optional Azure Entra ID (SSO) support via `hwi/oauth-bundle`. +Users can log in with their Microsoft account alongside — or instead of — local email/password accounts. + +### Create an application in Azure + +* Go to **[Azure Portal](https://portal.azure.com/)** +* Search for "App registrations" +* Click "New registration" + * Name: the name of the application, e.g. the URL of the web application + * Supported account types: select "Accounts in this organizational directory only (... only - Single tenant)" + * Redirect URI — you will need to add extra URLs later on: + * Platform: Web, URL: `https://.wip/login/check-azure` + * You will be redirected to the newly created app registration + * Note down the **Application (client) ID** and **Directory (tenant) ID** +* Click "Redirect URIs" → "Add URI" and add all required URLs, then save. E.g.: + * `https://.wip/login/check-azure` + * `https://.phpXX.sumocoders.eu/login/check-azure` +* Click "Certificates & Secrets" → "New client secret" + * Description: the URL of the web application + * Expires: 12 months, or as long as you are comfortable with + * Click "Add" + * Note down the **Value** (the secret itself — the Secret ID is not needed) +* Provide the following to your integrator: + * Application (client) ID + * Directory (tenant) ID + * Client secret Value + +Full article: **[Register a Microsoft Entra app and create a service principal](https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-service-principal-portal)** + +### Allow the application to be used + +When this is done, you still need to allow the users to use this application: + +* Go to **[Azure Portal](https://portal.azure.com/)** +* Search for "App registrations" +* Select the newly created application +* Select "Manage → API Permissions" on the left +* Click "Grant admin consent for ..." + +Full article: **[Grant tenant-wide admin consent to an application](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/grant-admin-consent?pivots=portal)** + +### Configure the roles + +* Go to the **[Azure Portal](https://portal.azure.com/)** +* Search for "App registrations" +* Select your application +* Click "Manage → App roles" on the left +* Create a role for each role in your application: + * Display name: a readable label, e.g. "Admin" + * Allowed member types: Both + * Value: the Symfony role name, e.g. `ROLE_ADMIN` + * Enable this app role: yes + +Full article: **[Add app roles to your application and receive them in the token](https://learn.microsoft.com/en-us/azure/active-directory/develop/howto-add-app-roles-in-azure-ad-apps)** + +### Give users a role + +* Go to the **[Azure Portal](https://portal.azure.com/)** +* Search for "Microsoft Entra ID" +* Click "Manage → Enterprise applications" on the left +* Select your created application +* Select "Manage → Users and groups" on the left +* Add users/groups with the correct role + +Full article: **[Assign users and groups to roles](https://learn.microsoft.com/en-us/azure/active-directory/develop/howto-add-app-roles-in-azure-ad-apps#assign-users-and-groups-to-roles)** + +### Configure the application + +#### Install the bundle + +``` +symfony composer require hwi/oauth-bundle +``` + +This will normally register the bundle automatically. If not, add it to `config/bundles.php`: + +```php +HWI\Bundle\OAuthBundle\HWIOAuthBundle::class => ['all' => true], +``` + +#### Copy the files from this project + +* `config/packages/hwi_oauth.yaml` +* `config/routes/hwi_oauth_routing.yaml` +* `src/Security/OAuth/AzureUserProvider.php` +* `src/Event/User/AzureLoginEvent.php` +* The `azure_object_id` parts from `src/Entity/User/User.php`: + `azureObjectId` property, `createFromAzureProfile()`, `linkAzureAccount()`, `unlinkAzureAccount()`, `isAzureUser()`, `getAzureObjectId()`, `syncAzureRoles()` +* The `$azureClientId` and `$sumocodersClientId` constructor arguments and template variables from `src/Controller/User/LoginController.php` +* The "Sign in with Microsoft" buttons from `templates/user/login.html.twig` +* The `migrations/Version20260512135528.php` migration + +#### Add the env variables + +Add the following to your `.env.local` file: + +``` +###> hwi/oauth-bundle ### +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_TENANT_ID= +AZURE_ALLOWED_EMAIL_DOMAIN= # e.g. sumocoders.be — leave empty to allow any domain + +SUMOCODERS_CLIENT_ID= +SUMOCODERS_CLIENT_SECRET= +SUMOCODERS_TENANT_ID= +###< hwi/oauth-bundle ### +``` + +#### Update security.yaml + +Add the `oauth` block to your main firewall and the public access routes: + +```yaml +firewalls: + main: + # ... existing config ... + entry_point: App\Security\CustomAuthenticator + oauth: + resource_owners: + azure: /login/check-azure + sumocoders: /login/check-sumocoders + login_path: /login + failure_path: /login + oauth_user_provider: + service: App\Security\OAuth\AzureUserProvider + +access_control: + - { path: '^/login/check-azure', roles: PUBLIC_ACCESS } + - { path: '^/login/check-sumocoders', roles: PUBLIC_ACCESS } + - { path: '^/connect/', roles: PUBLIC_ACCESS } + # ... existing rules ... +``` + +#### Update services.yaml + +```yaml +services: + App\Security\OAuth\AzureUserProvider: + arguments: + $allowedEmailDomain: '%env(AZURE_ALLOWED_EMAIL_DOMAIN)%' + + App\Controller\User\LoginController: + arguments: + $azureClientId: '%env(AZURE_CLIENT_ID)%' + $sumocodersClientId: '%env(SUMOCODERS_CLIENT_ID)%' +``` + +#### Run the migration + +The `azure_object_id` column is added via a dedicated migration so projects that do not need Azure SSO can skip it: + +``` +symfony console doctrine:migrations:migrate +``` + +### SumoCoders login (optional) + +To allow SumoCoders developers to log in with their `@sumocoders.be` accounts, a second Azure app registration is needed in the SumoCoders tenant. This is separate from the client's app registration. + +Add the following redirect URIs to the SumoCoders app registration in the Azure Portal: + +* `https://.wip/login/check-sumocoders` +* `https://.phpXX.sumocoders.eu/login/check-sumocoders` + +> Replace `` with the project name. + +Add the credentials to `.env.local`: + +``` +SUMOCODERS_CLIENT_ID= +SUMOCODERS_CLIENT_SECRET= +SUMOCODERS_TENANT_ID= +``` + +The "Sign in with Microsoft (SumoCoders)" button appears automatically on the login page when `SUMOCODERS_CLIENT_ID` is set. Leave it empty to hide the button. diff --git a/config/packages/hwi_oauth.yaml b/config/packages/hwi_oauth.yaml index b451310..64a2083 100644 --- a/config/packages/hwi_oauth.yaml +++ b/config/packages/hwi_oauth.yaml @@ -1,8 +1,22 @@ hwi_oauth: # https://github.com/hwi/HWIOAuthBundle/blob/master/docs/2-configuring_resource_owners.md + firewall_names: [main] resource_owners: - facebook: - type: facebook - client_id: '%env(FB_ID)%' - client_secret: '%env(FB_SECRET)%' - scope: "email public_profile" + azure: + type: azure + client_id: '%env(AZURE_CLIENT_ID)%' + client_secret: '%env(AZURE_CLIENT_SECRET)%' + scope: 'openid profile email User.Read' + options: + # HWI substitutes this into the Azure authorize/token URLs via sprintf. + # Use your tenant ID for single-tenant apps, or 'common' for multi-tenant. + application: '%env(AZURE_TENANT_ID)%' + sumocoders: + type: azure + client_id: '%env(SUMOCODERS_CLIENT_ID)%' + client_secret: '%env(SUMOCODERS_CLIENT_SECRET)%' + scope: 'openid profile email User.Read' + options: + # HWI substitutes this into the Azure authorize/token URLs via sprintf. + # Use your tenant ID for single-tenant apps, or 'common' for multi-tenant. + application: '%env(SUMOCODERS_TENANT_ID)%' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 541f002..cbbe897 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -18,6 +18,7 @@ security: lazy: true switch_user: { target_route: user_profile } provider: app_user_provider + entry_point: App\Security\CustomAuthenticator custom_authenticator: App\Security\CustomAuthenticator user_checker: App\Security\UserChecker login_throttling: @@ -33,6 +34,14 @@ security: check_path: 2fa_login_check enable_csrf: true trusted_parameter_name: _trusted + oauth: + resource_owners: + azure: /login/check-azure + sumocoders: /login/check-sumocoders + login_path: /login + failure_path: /login + oauth_user_provider: + service: App\Security\OAuth\AzureUserProvider role_hierarchy: ROLE_ADMIN: [ ROLE_USER, ROLE_ALLOWED_TO_SWITCH ] @@ -42,6 +51,9 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { route: 'login', roles: PUBLIC_ACCESS } + - { path: '^/login/check-azure', roles: PUBLIC_ACCESS } + - { path: '^/login/check-sumocoders', roles: PUBLIC_ACCESS } + - { path: '^/connect/', roles: PUBLIC_ACCESS } - { route: 'user_ajax_password_strength', roles: PUBLIC_ACCESS } - { route: 'user_resend_confirmation', roles: PUBLIC_ACCESS } - { route: 'user_register', roles: PUBLIC_ACCESS } diff --git a/config/routes/hwi_oauth_routing.yaml b/config/routes/hwi_oauth_routing.yaml index 190b994..37dee5e 100644 --- a/config/routes/hwi_oauth_routing.yaml +++ b/config/routes/hwi_oauth_routing.yaml @@ -2,10 +2,6 @@ hwi_oauth_redirect: resource: "@HWIOAuthBundle/Resources/config/routing/redirect.php" prefix: /connect -hwi_oauth_connect: - resource: "@HWIOAuthBundle/Resources/config/routing/connect.php" - prefix: /connect - hwi_oauth_login: resource: "@HWIOAuthBundle/Resources/config/routing/login.php" prefix: /login diff --git a/config/services.yaml b/config/services.yaml index ebc7f8a..9221a81 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -38,3 +38,12 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + + App\Security\OAuth\AzureUserProvider: + arguments: + $allowedEmailDomain: '%env(AZURE_ALLOWED_EMAIL_DOMAIN)%' + + App\Controller\User\LoginController: + arguments: + $azureClientId: '%env(AZURE_CLIENT_ID)%' + $sumocodersClientId: '%env(SUMOCODERS_CLIENT_ID)%' diff --git a/migrations/Version20260512135528.php b/migrations/Version20260512135528.php new file mode 100644 index 0000000..ab58999 --- /dev/null +++ b/migrations/Version20260512135528.php @@ -0,0 +1,30 @@ +getTable('user'); + $table->addColumn('azure_object_id', 'string', ['length' => 36, 'notnull' => false]); + $table->addUniqueIndex(['azure_object_id'], 'UNIQ_USER_AZURE_OID'); + } + + public function down(Schema $schema): void + { + $table = $schema->getTable('user'); + $table->dropIndex('UNIQ_USER_AZURE_OID'); + $table->dropColumn('azure_object_id'); + } +} diff --git a/src/Controller/User/LoginController.php b/src/Controller/User/LoginController.php index 0dbee48..9b876f1 100644 --- a/src/Controller/User/LoginController.php +++ b/src/Controller/User/LoginController.php @@ -13,7 +13,9 @@ class LoginController extends AbstractController { public function __construct( - private readonly AuthenticationUtils $authenticationUtils + private readonly AuthenticationUtils $authenticationUtils, + private readonly string $azureClientId, + private readonly string $sumocodersClientId, ) { } @@ -31,6 +33,8 @@ public function __invoke(): Response 'error' => $this->authenticationUtils->getLastAuthenticationError(), 'last_username' => $this->authenticationUtils->getLastUsername(), 'form' => $form, + 'azure_login_enabled' => $this->azureClientId !== '', + 'sumocoders_login_enabled' => $this->sumocodersClientId !== '', ]); } } diff --git a/src/Entity/User/User.php b/src/Entity/User/User.php index 1ced72a..cde31eb 100644 --- a/src/Entity/User/User.php +++ b/src/Entity/User/User.php @@ -65,6 +65,9 @@ class User implements #[ORM\Column(type: 'integer')] private int $trustedVersion = 0; + #[ORM\Column(type: 'string', length: 36, nullable: true, unique: true)] + private ?string $azureObjectId = null; + /** * @param array $roles */ @@ -83,6 +86,19 @@ public function __construct( $this->passwordRequestedAt = null; } + /** + * @param array $roles + */ + public static function createFromAzureProfile(string $email, string $azureObjectId, array $roles = []): self + { + $user = new self($email, $roles); + $user->enabled = true; + $user->confirmedAt = new DateTime(); + $user->azureObjectId = $azureObjectId; + + return $user; + } + /** * @param array $roles */ @@ -246,6 +262,34 @@ public function isEnabled(): bool return $this->enabled; } + public function getAzureObjectId(): ?string + { + return $this->azureObjectId; + } + + public function isAzureUser(): bool + { + return $this->azureObjectId !== null; + } + + public function linkAzureAccount(string $azureObjectId): void + { + $this->azureObjectId = $azureObjectId; + } + + public function unlinkAzureAccount(): void + { + $this->azureObjectId = null; + } + + /** + * @param string[] $roles + */ + public function syncAzureRoles(array $roles): void + { + $this->roles = $roles; + } + private function generateToken(): string { return bin2hex(random_bytes(32)); diff --git a/src/Event/User/AzureLoginEvent.php b/src/Event/User/AzureLoginEvent.php new file mode 100644 index 0000000..ffb11e2 --- /dev/null +++ b/src/Event/User/AzureLoginEvent.php @@ -0,0 +1,21 @@ +user; + } +} diff --git a/src/Security/OAuth/AzureUserProvider.php b/src/Security/OAuth/AzureUserProvider.php new file mode 100644 index 0000000..99f9d4f --- /dev/null +++ b/src/Security/OAuth/AzureUserProvider.php @@ -0,0 +1,121 @@ +getData(); + $oid = $data['oid'] ?? null; + $email = $response->getEmail(); + $roles = is_array($data['roles'] ?? null) ? $data['roles'] : []; + + if ($oid === null || $email === null) { + throw new AuthenticationException('Azure response is missing required oid or email claim.'); + } + + // 1. Match on azure_object_id — most common path after first login + $user = $this->userRepository->findOneBy(['azureObjectId' => $oid]); + if ($user !== null) { + $user->syncAzureRoles($roles); + + // Keep email in sync in case it changed in Azure + if ($user->getEmail() !== $email) { + $user->update($email, $user->getRoles()); + } + + $this->userRepository->save(); + $this->eventDispatcher->dispatch(new AzureLoginEvent($user)); + + return $user; + } + + // 2. Match on email — existing local user logging in via Azure for the first time + $user = $this->userRepository->findOneBy(['email' => $email]); + if ($user !== null) { + $user->linkAzureAccount($oid); + $user->syncAzureRoles($roles); + $this->userRepository->save(); + $this->eventDispatcher->dispatch(new AzureLoginEvent($user)); + + return $user; + } + + // 3. No match — auto-provision if domain whitelist allows it + $this->assertEmailDomainIsAllowed($email); + + $user = User::createFromAzureProfile($email, $oid, $roles); + $this->userRepository->add($user); + $this->eventDispatcher->dispatch(new AzureLoginEvent($user)); + + return $user; + } + + public function refreshUser(UserInterface $user): UserInterface + { + if (!$user instanceof User) { + throw new UnsupportedUserException( + sprintf('Expected instance of %s, got "%s".', User::class, $user::class) + ); + } + + $refreshedUser = $this->userRepository->find($user->getId()); + if ($refreshedUser === null) { + throw new UserNotFoundException(sprintf('User with id "%d" not found.', $user->getId())); + } + + return $refreshedUser; + } + + public function supportsClass(string $class): bool + { + return $class === User::class || is_subclass_of($class, User::class); + } + + public function loadUserByIdentifier(string $identifier): UserInterface + { + $user = $this->userRepository->findOneBy(['email' => $identifier]); + if ($user === null) { + throw new UserNotFoundException(sprintf('User "%s" not found.', $identifier)); + } + + return $user; + } + + private function assertEmailDomainIsAllowed(string $email): void + { + if ($this->allowedEmailDomain === '') { + return; + } + + $allowedDomain = ltrim($this->allowedEmailDomain, '@'); + if (!str_ends_with(strtolower($email), '@' . strtolower($allowedDomain))) { + throw new AccessDeniedException( + sprintf('Email domain of "%s" is not allowed to log in via Azure.', $email) + ); + } + } +} diff --git a/templates/user/login.html.twig b/templates/user/login.html.twig index df245a1..6da7714 100644 --- a/templates/user/login.html.twig +++ b/templates/user/login.html.twig @@ -26,4 +26,20 @@ {{ form_end(form) }}

{{ 'New to x?'|trans }} {{ 'Sign up'|trans }}

+ + {% if azure_login_enabled or sumocoders_login_enabled %} +
+ {% if azure_login_enabled %} + + + {{ 'Sign in with Microsoft'|trans }} + + {% endif %} + {% if sumocoders_login_enabled %} + + {{ 'Sign in with SumoCoders'|trans }} + + {% endif %} +
+ {% endif %} {% endblock %} diff --git a/tests/Entity/User/UserTest.php b/tests/Entity/User/UserTest.php index 81302a4..ffe4116 100644 --- a/tests/Entity/User/UserTest.php +++ b/tests/Entity/User/UserTest.php @@ -84,4 +84,51 @@ public function testUserIsDisabledAfterDisable(): void $this->assertFalse($user->isEnabled()); } + + public function testNewUserIsNotAnAzureUser(): void + { + $user = new User('user@example.com', []); + + $this->assertFalse($user->isAzureUser()); + $this->assertNull($user->getAzureObjectId()); + } + + public function testLinkAzureAccount(): void + { + $user = new User('user@example.com', []); + $user->linkAzureAccount('azure-oid-123'); + + $this->assertTrue($user->isAzureUser()); + $this->assertSame('azure-oid-123', $user->getAzureObjectId()); + } + + public function testUnlinkAzureAccount(): void + { + $user = new User('user@example.com', []); + $user->linkAzureAccount('azure-oid-123'); + $user->unlinkAzureAccount(); + + $this->assertFalse($user->isAzureUser()); + $this->assertNull($user->getAzureObjectId()); + } + + public function testCreateFromAzureProfileIsEnabledAndConfirmed(): void + { + $user = User::createFromAzureProfile('azure@sumocoders.be', 'azure-oid-123'); + + $this->assertSame('azure@sumocoders.be', $user->getEmail()); + $this->assertSame('azure-oid-123', $user->getAzureObjectId()); + $this->assertTrue($user->isAzureUser()); + $this->assertTrue($user->isEnabled()); + $this->assertTrue($user->isConfirmed()); + $this->assertContains('ROLE_USER', $user->getRoles()); + } + + public function testCreateFromAzureProfileWithCustomRoles(): void + { + $user = User::createFromAzureProfile('azure@sumocoders.be', 'azure-oid-123', ['ROLE_ADMIN']); + + $this->assertContains('ROLE_ADMIN', $user->getRoles()); + $this->assertContains('ROLE_USER', $user->getRoles()); + } } diff --git a/tests/Security/OAuth/AzureUserProviderTest.php b/tests/Security/OAuth/AzureUserProviderTest.php new file mode 100644 index 0000000..6ca9330 --- /dev/null +++ b/tests/Security/OAuth/AzureUserProviderTest.php @@ -0,0 +1,249 @@ +userRepository = $this->createMock(UserRepository::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + } + + private function makeProvider(string $allowedDomain = 'sumocoders.be'): AzureUserProvider + { + return new AzureUserProvider($this->userRepository, $this->eventDispatcher, $allowedDomain); + } + + /** + * @param string[] $roles + */ + private function makeResponse(string $oid, string $email, array $roles = []): UserResponseInterface&MockObject + { + $response = $this->createMock(UserResponseInterface::class); + $response->method('getData')->willReturn(['oid' => $oid, 'roles' => $roles]); + $response->method('getEmail')->willReturn($email); + + return $response; + } + + public function testExistingUserFoundByOidIsReturned(): void + { + $user = User::createFromAzureProfile('user@sumocoders.be', 'oid-abc'); + + $this->userRepository + ->method('findOneBy') + ->willReturnMap([ + [['azureObjectId' => 'oid-abc'], $user], + ]); + + $response = $this->makeResponse('oid-abc', 'user@sumocoders.be'); + + $result = $this->makeProvider()->loadUserByOAuthUserResponse($response); + + $this->assertSame($user, $result); + } + + public function testExistingUserFoundByOidHasEmailUpdatedWhenChanged(): void + { + $user = User::createFromAzureProfile('old@sumocoders.be', 'oid-abc'); + + $this->userRepository + ->method('findOneBy') + ->willReturnMap([ + [['azureObjectId' => 'oid-abc'], $user], + ]); + $this->userRepository->expects($this->once())->method('save'); + + $response = $this->makeResponse('oid-abc', 'new@sumocoders.be'); + + $result = $this->makeProvider()->loadUserByOAuthUserResponse($response); + + $this->assertSame('new@sumocoders.be', $result->getEmail()); + } + + public function testExistingLocalUserFoundByEmailGetsOidLinked(): void + { + $user = new User('user@sumocoders.be', []); + $user->confirm(); + + $this->userRepository + ->method('findOneBy') + ->willReturnMap([ + [['azureObjectId' => 'oid-abc'], null], + [['email' => 'user@sumocoders.be'], $user], + ]); + $this->userRepository->expects($this->once())->method('save'); + + $response = $this->makeResponse('oid-abc', 'user@sumocoders.be'); + + $result = $this->makeProvider()->loadUserByOAuthUserResponse($response); + + $this->assertSame($user, $result); + $this->assertSame('oid-abc', $result->getAzureObjectId()); + } + + public function testNewUserIsAutoProvisionedWhenDomainMatches(): void + { + $this->userRepository + ->method('findOneBy') + ->willReturn(null); + $this->userRepository->expects($this->once())->method('add'); + + $response = $this->makeResponse('oid-new', 'newuser@sumocoders.be'); + + $result = $this->makeProvider('sumocoders.be')->loadUserByOAuthUserResponse($response); + + $this->assertSame('newuser@sumocoders.be', $result->getEmail()); + $this->assertSame('oid-new', $result->getAzureObjectId()); + $this->assertTrue($result->isEnabled()); + $this->assertTrue($result->isConfirmed()); + } + + public function testNewUserIsAutoProvisionedWhenNoDomainRestriction(): void + { + $this->userRepository + ->method('findOneBy') + ->willReturn(null); + $this->userRepository->expects($this->once())->method('add'); + + $response = $this->makeResponse('oid-new', 'anyone@otherdomain.com'); + + $result = $this->makeProvider('')->loadUserByOAuthUserResponse($response); + + $this->assertSame('anyone@otherdomain.com', $result->getEmail()); + } + + public function testNewUserWithWrongDomainIsRejected(): void + { + $this->userRepository + ->method('findOneBy') + ->willReturn(null); + + $response = $this->makeResponse('oid-new', 'attacker@evil.com'); + + $this->expectException(AccessDeniedException::class); + + $this->makeProvider('sumocoders.be')->loadUserByOAuthUserResponse($response); + } + + public function testDomainCheckIsCaseInsensitive(): void + { + $this->userRepository + ->method('findOneBy') + ->willReturn(null); + $this->userRepository->expects($this->once())->method('add'); + + $response = $this->makeResponse('oid-new', 'user@SUMOCODERS.BE'); + + $result = $this->makeProvider('sumocoders.be')->loadUserByOAuthUserResponse($response); + + $this->assertSame('user@SUMOCODERS.BE', $result->getEmail()); + } + + public function testAzureRolesAreSyncedOnEveryLoginByOid(): void + { + $user = User::createFromAzureProfile('user@sumocoders.be', 'oid-abc'); + + $this->userRepository + ->method('findOneBy') + ->willReturnMap([ + [['azureObjectId' => 'oid-abc'], $user], + ]); + + $response = $this->makeResponse('oid-abc', 'user@sumocoders.be', ['ROLE_ADMIN']); + + $result = $this->makeProvider()->loadUserByOAuthUserResponse($response); + + $this->assertContains('ROLE_ADMIN', $result->getRoles()); + $this->assertContains('ROLE_USER', $result->getRoles()); + } + + public function testAzureRolesAreSyncedWhenLocalUserLinksAccount(): void + { + $user = new User('user@sumocoders.be', []); + $user->confirm(); + + $this->userRepository + ->method('findOneBy') + ->willReturnMap([ + [['azureObjectId' => 'oid-abc'], null], + [['email' => 'user@sumocoders.be'], $user], + ]); + + $response = $this->makeResponse('oid-abc', 'user@sumocoders.be', ['ROLE_ADMIN']); + + $result = $this->makeProvider()->loadUserByOAuthUserResponse($response); + + $this->assertContains('ROLE_ADMIN', $result->getRoles()); + } + + public function testAzureRolesAreSyncedForNewUser(): void + { + $this->userRepository + ->method('findOneBy') + ->willReturn(null); + $this->userRepository->expects($this->once())->method('add'); + + $response = $this->makeResponse('oid-new', 'newuser@sumocoders.be', ['ROLE_ADMIN']); + + $result = $this->makeProvider('sumocoders.be')->loadUserByOAuthUserResponse($response); + + $this->assertContains('ROLE_ADMIN', $result->getRoles()); + } + + public function testLoginEventIsDispatchedOnEverySuccessfulLogin(): void + { + $user = User::createFromAzureProfile('user@sumocoders.be', 'oid-abc'); + + $this->userRepository + ->method('findOneBy') + ->willReturnMap([ + [['azureObjectId' => 'oid-abc'], $user], + ]); + + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(AzureLoginEvent::class)); + + $response = $this->makeResponse('oid-abc', 'user@sumocoders.be'); + $this->makeProvider()->loadUserByOAuthUserResponse($response); + } + + public function testEmptyAzureRolesResultInOnlyRoleUser(): void + { + $user = User::createFromAzureProfile('user@sumocoders.be', 'oid-abc', ['ROLE_ADMIN']); + + $this->userRepository + ->method('findOneBy') + ->willReturnMap([ + [['azureObjectId' => 'oid-abc'], $user], + ]); + + // Azure sends no roles this time (e.g. role was revoked) + $response = $this->makeResponse('oid-abc', 'user@sumocoders.be', []); + + $result = $this->makeProvider()->loadUserByOAuthUserResponse($response); + + $this->assertNotContains('ROLE_ADMIN', $result->getRoles()); + $this->assertContains('ROLE_USER', $result->getRoles()); + } +} diff --git a/translations/messages.nl.yaml b/translations/messages.nl.yaml index 244a2c2..5ba83f6 100644 --- a/translations/messages.nl.yaml +++ b/translations/messages.nl.yaml @@ -106,3 +106,7 @@ user.actions.switchback: 'terug naar eigen account' 'Yes': 'Ja' 'No': 'Nee' 'Cancel': 'Annuleer' + +'Sign in with Microsoft': 'Aanmelden met Microsoft' +'Email domain is not allowed to log in via Azure.': 'Je e-maildomein heeft geen toegang via Azure.' +'Sign in with SumoCoders': 'Aanmelden met SumoCoders' From fc0bcfdc6e28d7299007a5394a75c321db1a59e9 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 13:28:41 +0200 Subject: [PATCH 03/13] refactor: remove firewall parameter --- config/packages/hwi_oauth.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/config/packages/hwi_oauth.yaml b/config/packages/hwi_oauth.yaml index 64a2083..ba5ebe8 100644 --- a/config/packages/hwi_oauth.yaml +++ b/config/packages/hwi_oauth.yaml @@ -1,6 +1,5 @@ hwi_oauth: # https://github.com/hwi/HWIOAuthBundle/blob/master/docs/2-configuring_resource_owners.md - firewall_names: [main] resource_owners: azure: type: azure From 1f1ceeea8cdb272ca4a92eec819c02c80e65af0a Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 13:42:14 +0200 Subject: [PATCH 04/13] refactor: use autowire --- config/services.yaml | 8 -------- src/Controller/User/LoginController.php | 5 +++-- src/Security/OAuth/AzureUserProvider.php | 3 ++- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 9221a81..e308b09 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -39,11 +39,3 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones - App\Security\OAuth\AzureUserProvider: - arguments: - $allowedEmailDomain: '%env(AZURE_ALLOWED_EMAIL_DOMAIN)%' - - App\Controller\User\LoginController: - arguments: - $azureClientId: '%env(AZURE_CLIENT_ID)%' - $sumocodersClientId: '%env(SUMOCODERS_CLIENT_ID)%' diff --git a/src/Controller/User/LoginController.php b/src/Controller/User/LoginController.php index 9b876f1..6803d4b 100644 --- a/src/Controller/User/LoginController.php +++ b/src/Controller/User/LoginController.php @@ -5,6 +5,7 @@ use App\Entity\User\User; use App\Form\User\LoginType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; @@ -14,8 +15,8 @@ class LoginController extends AbstractController { public function __construct( private readonly AuthenticationUtils $authenticationUtils, - private readonly string $azureClientId, - private readonly string $sumocodersClientId, + #[Autowire(env: 'AZURE_CLIENT_ID')] private readonly string $azureClientId, + #[Autowire(env: 'SUMOCODERS_CLIENT_ID')] private readonly string $sumocodersClientId, ) { } diff --git a/src/Security/OAuth/AzureUserProvider.php b/src/Security/OAuth/AzureUserProvider.php index 99f9d4f..45ec21e 100644 --- a/src/Security/OAuth/AzureUserProvider.php +++ b/src/Security/OAuth/AzureUserProvider.php @@ -9,6 +9,7 @@ use App\Repository\User\UserRepository; use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface; use HWI\Bundle\OAuthBundle\Security\Core\User\OAuthAwareUserProviderInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; @@ -22,7 +23,7 @@ final class AzureUserProvider implements UserProviderInterface, OAuthAwareUserPr public function __construct( private readonly UserRepository $userRepository, private readonly EventDispatcherInterface $eventDispatcher, - private readonly string $allowedEmailDomain, + #[Autowire(env: 'AZURE_ALLOWED_EMAIL_DOMAIN')] private readonly string $allowedEmailDomain, ) { } From 819a6c3e3a6bbe8b39f6aed12422856f40e08c89 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 13:54:03 +0200 Subject: [PATCH 05/13] refactor: remove email domain check --- src/Security/OAuth/AzureUserProvider.php | 25 +++--------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/Security/OAuth/AzureUserProvider.php b/src/Security/OAuth/AzureUserProvider.php index 45ec21e..0b25012 100644 --- a/src/Security/OAuth/AzureUserProvider.php +++ b/src/Security/OAuth/AzureUserProvider.php @@ -9,8 +9,6 @@ use App\Repository\User\UserRepository; use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface; use HWI\Bundle\OAuthBundle\Security\Core\User\OAuthAwareUserProviderInterface; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; @@ -23,7 +21,6 @@ final class AzureUserProvider implements UserProviderInterface, OAuthAwareUserPr public function __construct( private readonly UserRepository $userRepository, private readonly EventDispatcherInterface $eventDispatcher, - #[Autowire(env: 'AZURE_ALLOWED_EMAIL_DOMAIN')] private readonly string $allowedEmailDomain, ) { } @@ -40,7 +37,7 @@ public function loadUserByOAuthUserResponse(UserResponseInterface $response): Us // 1. Match on azure_object_id — most common path after first login $user = $this->userRepository->findOneBy(['azureObjectId' => $oid]); - if ($user !== null) { + if ($user instanceof User) { $user->syncAzureRoles($roles); // Keep email in sync in case it changed in Azure @@ -56,7 +53,7 @@ public function loadUserByOAuthUserResponse(UserResponseInterface $response): Us // 2. Match on email — existing local user logging in via Azure for the first time $user = $this->userRepository->findOneBy(['email' => $email]); - if ($user !== null) { + if ($user instanceof User) { $user->linkAzureAccount($oid); $user->syncAzureRoles($roles); $this->userRepository->save(); @@ -65,9 +62,7 @@ public function loadUserByOAuthUserResponse(UserResponseInterface $response): Us return $user; } - // 3. No match — auto-provision if domain whitelist allows it - $this->assertEmailDomainIsAllowed($email); - + // 3. No match — auto-provision $user = User::createFromAzureProfile($email, $oid, $roles); $this->userRepository->add($user); $this->eventDispatcher->dispatch(new AzureLoginEvent($user)); @@ -105,18 +100,4 @@ public function loadUserByIdentifier(string $identifier): UserInterface return $user; } - - private function assertEmailDomainIsAllowed(string $email): void - { - if ($this->allowedEmailDomain === '') { - return; - } - - $allowedDomain = ltrim($this->allowedEmailDomain, '@'); - if (!str_ends_with(strtolower($email), '@' . strtolower($allowedDomain))) { - throw new AccessDeniedException( - sprintf('Email domain of "%s" is not allowed to log in via Azure.', $email) - ); - } - } } From 544d7b19b245f2be3cd6939cec00eabdade1a0c0 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 13:56:50 +0200 Subject: [PATCH 06/13] refactor: remove update services.yml section --- README.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/README.md b/README.md index d01f6fe..4fdb622 100644 --- a/README.md +++ b/README.md @@ -226,19 +226,6 @@ access_control: # ... existing rules ... ``` -#### Update services.yaml - -```yaml -services: - App\Security\OAuth\AzureUserProvider: - arguments: - $allowedEmailDomain: '%env(AZURE_ALLOWED_EMAIL_DOMAIN)%' - - App\Controller\User\LoginController: - arguments: - $azureClientId: '%env(AZURE_CLIENT_ID)%' - $sumocodersClientId: '%env(SUMOCODERS_CLIENT_ID)%' -``` #### Run the migration From 4008c983c3d331e600b2b636602299a34ea2e8ae Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 13:58:38 +0200 Subject: [PATCH 07/13] refactor: added auth variables to .env and removed the email variabel from readme --- .env | 9 +++++++-- README.md | 1 - 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 1612b04..4e4acf2 100644 --- a/.env +++ b/.env @@ -61,6 +61,11 @@ ENCRYPTION_KEY="8ea13de9680e2a1441774ec26642fa65a56d8099f44a301f219864b51bbaa925 ###< sumocoders/framework-core-bundle ### ###> hwi/oauth-bundle ### -FB_ID= -FB_SECRET= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_TENANT_ID= + +SUMOCODERS_CLIENT_ID= +SUMOCODERS_CLIENT_SECRET= +SUMOCODERS_TENANT_ID= ###< hwi/oauth-bundle ### diff --git a/README.md b/README.md index 4fdb622..62efce6 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,6 @@ Add the following to your `.env.local` file: AZURE_CLIENT_ID= AZURE_CLIENT_SECRET= AZURE_TENANT_ID= -AZURE_ALLOWED_EMAIL_DOMAIN= # e.g. sumocoders.be — leave empty to allow any domain SUMOCODERS_CLIENT_ID= SUMOCODERS_CLIENT_SECRET= From 89079b09928addd9cec21b274d22f031a201b455 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 14:02:27 +0200 Subject: [PATCH 08/13] refactor: use project.client istead of --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 62efce6..f77ba28 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,12 @@ Users can log in with their Microsoft account alongside — or instead of — lo * Name: the name of the application, e.g. the URL of the web application * Supported account types: select "Accounts in this organizational directory only (... only - Single tenant)" * Redirect URI — you will need to add extra URLs later on: - * Platform: Web, URL: `https://.wip/login/check-azure` + * Platform: Web, URL: `https://project.client.wip/login/check-azure` * You will be redirected to the newly created app registration * Note down the **Application (client) ID** and **Directory (tenant) ID** * Click "Redirect URIs" → "Add URI" and add all required URLs, then save. E.g.: - * `https://.wip/login/check-azure` - * `https://.phpXX.sumocoders.eu/login/check-azure` + * `https://project.client.wip/login/check-azure` + * `https://project.client.phpXX.sumocoders.eu/login/check-azure` * Click "Certificates & Secrets" → "New client secret" * Description: the URL of the web application * Expires: 12 months, or as long as you are comfortable with @@ -240,10 +240,9 @@ To allow SumoCoders developers to log in with their `@sumocoders.be` accounts, a Add the following redirect URIs to the SumoCoders app registration in the Azure Portal: -* `https://.wip/login/check-sumocoders` -* `https://.phpXX.sumocoders.eu/login/check-sumocoders` +* `https://project.client.wip/login/check-sumocoders` +* `https://project.client.phpXX.sumocoders.eu/login/check-sumocoders` -> Replace `` with the project name. Add the credentials to `.env.local`: From d6e4293415f60e1a8fadc4354cacf03b181899d6 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 14:08:35 +0200 Subject: [PATCH 09/13] refactor: insert if to show oauth or form --- templates/user/login.html.twig | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/templates/user/login.html.twig b/templates/user/login.html.twig index 6da7714..df4ebfc 100644 --- a/templates/user/login.html.twig +++ b/templates/user/login.html.twig @@ -7,6 +7,21 @@ {% endif %} + {% if azure_login_enabled or sumocoders_login_enabled %} +
+ {% if azure_login_enabled %} + + + {{ 'Sign in with Microsoft'|trans }} + + {% endif %} + {% if sumocoders_login_enabled %} + + {{ 'Sign in with SumoCoders'|trans }} + + {% endif %} +
+ {% else %} {{ form_start(form) }} {{ form_row(form.email) }}
@@ -26,20 +41,5 @@ {{ form_end(form) }}

{{ 'New to x?'|trans }} {{ 'Sign up'|trans }}

- - {% if azure_login_enabled or sumocoders_login_enabled %} -
- {% if azure_login_enabled %} - - - {{ 'Sign in with Microsoft'|trans }} - - {% endif %} - {% if sumocoders_login_enabled %} - - {{ 'Sign in with SumoCoders'|trans }} - - {% endif %} -
{% endif %} {% endblock %} From 4030b61e4b7c69ba2c3eca712e8de7507e82a498 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 14:29:31 +0200 Subject: [PATCH 10/13] refactor: write migration with sql --- migrations/Version20260512135528.php | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/migrations/Version20260512135528.php b/migrations/Version20260512135528.php index ab58999..a6eb6b7 100644 --- a/migrations/Version20260512135528.php +++ b/migrations/Version20260512135528.php @@ -16,15 +16,26 @@ public function getDescription(): string public function up(Schema $schema): void { - $table = $schema->getTable('user'); - $table->addColumn('azure_object_id', 'string', ['length' => 36, 'notnull' => false]); - $table->addUniqueIndex(['azure_object_id'], 'UNIQ_USER_AZURE_OID'); + $this->addSql(<<<'SQL' + ALTER TABLE user + ADD azure_object_id VARCHAR(36) DEFAULT NULL, + ADD UNIQUE INDEX UNIQ_USER_AZURE_OID (azure_object_id) + SQL + ); } public function down(Schema $schema): void { - $table = $schema->getTable('user'); - $table->dropIndex('UNIQ_USER_AZURE_OID'); - $table->dropColumn('azure_object_id'); + $this->addSql(<<<'SQL' + ALTER TABLE user + DROP INDEX UNIQ_USER_AZURE_OID, + DROP COLUMN azure_object_id + SQL + ); + } + + public function isTransactional(): bool + { + return false; } } From 78cd17896c78fca0ff5aff213365b626dceaca41 Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 15:01:59 +0200 Subject: [PATCH 11/13] fix: remove empty line --- config/services.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/config/services.yaml b/config/services.yaml index e308b09..ebc7f8a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -38,4 +38,3 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones - From 7a1c43bfb42dada5160d0091d35e7de775a22e4a Mon Sep 17 00:00:00 2001 From: Ilias Khatsjiyev Date: Wed, 13 May 2026 15:07:03 +0200 Subject: [PATCH 12/13] refactor: simplify supportClass --- src/Security/OAuth/AzureUserProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Security/OAuth/AzureUserProvider.php b/src/Security/OAuth/AzureUserProvider.php index 0b25012..a257c5f 100644 --- a/src/Security/OAuth/AzureUserProvider.php +++ b/src/Security/OAuth/AzureUserProvider.php @@ -88,7 +88,7 @@ public function refreshUser(UserInterface $user): UserInterface public function supportsClass(string $class): bool { - return $class === User::class || is_subclass_of($class, User::class); + return $class === User::class; } public function loadUserByIdentifier(string $identifier): UserInterface From c07423ab9dbe5875d7a775cc78578f9f9e757755 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Wed, 20 May 2026 09:22:26 +0200 Subject: [PATCH 13/13] feat: update AGENTS.md --- AGENTS.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4315026..c5d3f11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,8 @@ All commands and paths in these steps assume the current working directory is th -1. From the project root, clone the reference repository: `git clone https://github.com/sumocoders/Framework-User-Implementation-Example.git ./temp` +1. From the project root, clone the reference repository: `git clone https://github.com/sumocoders/Framework-User-Implementation-Example.git ./temp`. + And checkout the `HWIOAuthBundle-implementation` branch: `git -C ./temp checkout HWIOAuthBundle-implementation`. 2. Install packages: `symfony composer require 2fa scheb/2fa-backup-code scheb/2fa-totp scheb/2fa-trusted-device endroid/qr-code` - If `Scheb\TwoFactorBundle\SchebTwoFactorBundle::class` is missing from `config/bundles.php`, add it: `Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true]` @@ -61,11 +62,58 @@ All commands and paths in these steps assume the current working directory is th 6. Copy `src/EventListener` from `./temp` into the project's `src/` directory. -7. Copy all files from `./temp/src/Migrations/` into `src/Migrations/`, then run: `symfony console doctrine:migrations:migrate` +7. Copy all files from `./temp/migrations/` into `migrations/`, then run: `symfony console doctrine:migrations:migrate` 8. From the project root, remove the temp folder: `rm -rf ./temp` -9. Ask the user which optional features to remove, then apply: - - No profile page: remove `src/Controller/User/ProfileController.php`, `templates/user/profile.html.twig`, and the profile navigation entry in `templates/user/_profile_navigation.html.twig` - - No registration: remove `src/Controller/User/RegisterController.php`, `src/Message/User/RegisterUser.php`, `src/MessageHandler/User/RegisterUserHandler.php`, `templates/user/register.html.twig` +9. If the user wants Azure Entra ID (SSO) login support, perform all of the following sub-steps; otherwise skip to step 10: + a. Install the bundle: `symfony composer require hwi/oauth-bundle` + - If `HWI\Bundle\OAuthBundle\HWIOAuthBundle::class` is missing from `config/bundles.php`, add it: `HWI\Bundle\OAuthBundle\HWIOAuthBundle::class => ['all' => true]` + b. Copy from `./temp` into the project, preserving paths: + - `config/packages/hwi_oauth.yaml` + - `config/routes/hwi_oauth_routing.yaml` + - `src/Security/OAuth/AzureUserProvider.php` + - `src/Event/User/AzureLoginEvent.php` + - `migrations/Version20260512135528.php` + c. In `src/Entity/User/User.php`, copy from `./temp`: the `azureObjectId` property and the methods `createFromAzureProfile()`, `linkAzureAccount()`, `unlinkAzureAccount()`, `isAzureUser()`, `getAzureObjectId()`, `syncAzureRoles()` + d. In `src/Controller/User/LoginController.php`, copy from `./temp`: the `$azureClientId` and `$sumocodersClientId` constructor arguments and the template variables that pass them to the view + e. In `templates/user/login.html.twig`, copy from `./temp`: the "Sign in with Microsoft" buttons + f. From the project root, remove the temp folder: `rm -rf ./temp` + g. In `config/packages/security.yaml`, add inside the `main` firewall: + ```yaml + entry_point: App\Security\CustomAuthenticator + oauth: + resource_owners: + azure: /login/check-azure + sumocoders: /login/check-sumocoders + login_path: /login + failure_path: /login + oauth_user_provider: + service: App\Security\OAuth\AzureUserProvider + ``` + Add to `access_control` (before existing rules): + ```yaml + - { path: '^/login/check-azure', roles: PUBLIC_ACCESS } + - { path: '^/login/check-sumocoders', roles: PUBLIC_ACCESS } + - { path: '^/connect/', roles: PUBLIC_ACCESS } + ``` + h. Add to `.env.local`: + ``` + ###> hwi/oauth-bundle ### + AZURE_CLIENT_ID= + AZURE_CLIENT_SECRET= + AZURE_TENANT_ID= + + SUMOCODERS_CLIENT_ID= + SUMOCODERS_CLIENT_SECRET= + SUMOCODERS_TENANT_ID= + ###< hwi/oauth-bundle ### + ``` + i. Run: `symfony console doctrine:migrations:migrate` + +10. Ask the user which optional features to remove, then apply: + + **Profile page**: remove `src/Controller/User/ProfileController.php`, `templates/user/profile.html.twig`, and the profile navigation entry in `templates/user/_profile_navigation.html.twig` + + **Registration**: remove `src/Controller/User/RegisterController.php`, `src/Message/User/RegisterUser.php`, `src/MessageHandler/User/RegisterUserHandler.php`, `templates/user/register.html.twig`