diff --git a/.env b/.env index ca27cc6..4e4acf2 100644 --- a/.env +++ b/.env @@ -59,3 +59,13 @@ 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 ### +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_TENANT_ID= + +SUMOCODERS_CLIENT_ID= +SUMOCODERS_CLIENT_SECRET= +SUMOCODERS_TENANT_ID= +###< hwi/oauth-bundle ### 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` diff --git a/README.md b/README.md index 364527e..962521e 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,171 @@ 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` +### 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://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://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 + * 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)** + +#### 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= + +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 ... +``` + + +#### 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://project.client.wip/login/check-sumocoders` +* `https://project.client.phpXX.sumocoders.eu/login/check-sumocoders` + + +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. + ### Cleanup #### Profile page @@ -94,4 +256,4 @@ If your project does not need registration, you can remove: ### AI agent instructions -See [AGENTS.md](AGENTS.md) for step-by-step instructions for AI agents. +See [AGENTS.md](AGENTS.md) for step-by-step instructions for AI agents. \ No newline at end of file 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..ba5ebe8 --- /dev/null +++ b/config/packages/hwi_oauth.yaml @@ -0,0 +1,21 @@ +hwi_oauth: + # https://github.com/hwi/HWIOAuthBundle/blob/master/docs/2-configuring_resource_owners.md + resource_owners: + 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 new file mode 100644 index 0000000..37dee5e --- /dev/null +++ b/config/routes/hwi_oauth_routing.yaml @@ -0,0 +1,7 @@ +hwi_oauth_redirect: + resource: "@HWIOAuthBundle/Resources/config/routing/redirect.php" + prefix: /connect + +hwi_oauth_login: + resource: "@HWIOAuthBundle/Resources/config/routing/login.php" + prefix: /login diff --git a/migrations/Version20260512135528.php b/migrations/Version20260512135528.php new file mode 100644 index 0000000..a6eb6b7 --- /dev/null +++ b/migrations/Version20260512135528.php @@ -0,0 +1,41 @@ +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 + { + $this->addSql(<<<'SQL' + ALTER TABLE user + DROP INDEX UNIQ_USER_AZURE_OID, + DROP COLUMN azure_object_id + SQL + ); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Controller/User/LoginController.php b/src/Controller/User/LoginController.php index d4e8121..6e97b16 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,6 +15,10 @@ final class LoginController extends AbstractController { public function __construct( private readonly AuthenticationUtils $authenticationUtils, + #[Autowire(env: 'AZURE_CLIENT_ID')] + private readonly string $azureClientId, + #[Autowire(env: 'SUMOCODERS_CLIENT_ID')] + private readonly string $sumocodersClientId, ) { } @@ -31,12 +36,14 @@ public function __invoke(): Response ); return $this->render( - 'user/login.html.twig', + 'user/login.html.twig', [ '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 413c0f2..b667489 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..a257c5f --- /dev/null +++ b/src/Security/OAuth/AzureUserProvider.php @@ -0,0 +1,103 @@ +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 instanceof User) { + $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 instanceof User) { + $user->linkAzureAccount($oid); + $user->syncAzureRoles($roles); + $this->userRepository->save(); + $this->eventDispatcher->dispatch(new AzureLoginEvent($user)); + + return $user; + } + + // 3. No match — auto-provision + $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; + } + + 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; + } +} 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" }, diff --git a/templates/user/login.html.twig b/templates/user/login.html.twig index df245a1..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,4 +41,5 @@ {{ form_end(form) }}

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

+ {% 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'