From 2405fcc2daf536e225d9457ede6ebe9b120cdc78 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 19 Mar 2026 10:38:19 +0100 Subject: [PATCH] Add SSO E-Cosplay (Keycloak OIDC) and dynamic navbar active state - Install knpuniversity/oauth2-client-bundle and stevenmaguire/oauth2-keycloak - Register KnpUOAuth2ClientBundle in bundles.php - Configure Keycloak OIDC client (realm e-cosplay, auth.esy-web.dev) - Add keycloakId field to User entity with migration - Create KeycloakAuthenticator with group-to-role mapping (/superadmin -> ROLE_ROOT) - Create OAuthController with SSO routes (/connection/sso/login, logout, check) - Add custom_authenticator to security firewall with form_login entry point - Add auth.esy-web.dev to nelmio external_redirects whitelist and CSP form-action - Add SSO button and error flash messages to login page - Make navbar active state dynamic based on current route (desktop + mobile) - Add Keycloak env vars to .env, .env.local, and ansible/env.local.j2 Co-Authored-By: Claude Opus 4.6 (1M context) --- .env | 7 + ansible/env.local.j2 | 4 + composer.json | 2 + composer.lock | 252 +++++++++++++++++++++++- config/bundles.php | 1 + config/packages/knpu_oauth2_client.yaml | 12 ++ config/packages/nelmio_security.yaml | 4 + config/packages/security.yaml | 3 + migrations/Version20260319093607.php | 37 ++++ src/Controller/OAuthController.php | 29 +++ src/Entity/User.php | 15 ++ src/Security/KeycloakAuthenticator.php | 117 +++++++++++ symfony.lock | 9 + templates/base.html.twig | 9 +- templates/security/login.html.twig | 61 ++++++ 15 files changed, 557 insertions(+), 5 deletions(-) create mode 100644 config/packages/knpu_oauth2_client.yaml create mode 100644 migrations/Version20260319093607.php create mode 100644 src/Controller/OAuthController.php create mode 100644 src/Security/KeycloakAuthenticator.php diff --git a/.env b/.env index 180b673..758e9d8 100644 --- a/.env +++ b/.env @@ -47,6 +47,13 @@ STRIPE_WEBHOOK_SECRET= STRIPE_MODE=test SMIME_PASSPHRASE='KLreLnyR07x5h#3$AC' +###> SSO E-Cosplay (Keycloak OIDC) ### +OAUTH_KEYCLOAK_CLIENT_ID=e-ticket +OAUTH_KEYCLOAK_CLIENT_SECRET=changeme +OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev +OAUTH_KEYCLOAK_REALM=e-cosplay +###< SSO E-Cosplay (Keycloak OIDC) ### + ###> s3/minio ### S3_ENDPOINT=http://minio:9000 S3_ACCESS_KEY=e-ticket diff --git a/ansible/env.local.j2 b/ansible/env.local.j2 index db0e682..15d1f54 100644 --- a/ansible/env.local.j2 +++ b/ansible/env.local.j2 @@ -19,3 +19,7 @@ STRIPE_MODE=live SMIME_PASSPHRASE='{{ smime_passphrase }}' MEILISEARCH_URL=http://meilisearch:7700 MEILISEARCH_API_KEY={{ meilisearch_api_key }} +OAUTH_KEYCLOAK_CLIENT_ID=e-ticket +OAUTH_KEYCLOAK_CLIENT_SECRET=1oLwbhJDNVmGH8CES1OdQtzR7dECOlII +OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev +OAUTH_KEYCLOAK_REALM=e-cosplay diff --git a/composer.json b/composer.json index 18a896f..444b74f 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "doctrine/orm": "^3.6", "dompdf/dompdf": "*", "endroid/qr-code-bundle": "*", + "knpuniversity/oauth2-client-bundle": "^2.20", "league/flysystem-aws-s3-v3": "^3.32", "league/flysystem-bundle": "^3.6", "liip/imagine-bundle": "^2.17", @@ -19,6 +20,7 @@ "nelmio/security-bundle": "^3.9", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpdoc-parser": "^2.3", + "stevenmaguire/oauth2-keycloak": "^6.1", "stripe/stripe-php": "*", "symfony/asset": "8.0.*", "symfony/console": "8.0.*", diff --git a/composer.lock b/composer.lock index 5478b64..b614b8b 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": "a708b901e0ade836caa13855beebd9d1", + "content-hash": "ecb55cf346fc28e16c4caec521a016e2", "packages": [ { "name": "aws/aws-crt-php", @@ -1872,6 +1872,69 @@ ], "time": "2025-12-01T22:03:15+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.3", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" + }, + "time": "2026-02-25T22:16:40+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.10.0", @@ -2324,6 +2387,66 @@ }, "time": "2025-11-30T20:12:26+00:00" }, + { + "name": "knpuniversity/oauth2-client-bundle", + "version": "v2.20.2", + "source": { + "type": "git", + "url": "https://github.com/knpuniversity/oauth2-client-bundle.git", + "reference": "9ce4fcea69dbbf4d19ee7368b8d623ec2d73d3c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/9ce4fcea69dbbf4d19ee7368b8d623ec2d73d3c7", + "reference": "9ce4fcea69dbbf4d19ee7368b8d623ec2d73d3c7", + "shasum": "" + }, + "require": { + "league/oauth2-client": "^2.0", + "php": ">=8.1", + "symfony/dependency-injection": "^6.4|^7.3|^8.0", + "symfony/framework-bundle": "^6.4|^7.3|^8.0", + "symfony/http-foundation": "^6.4|^7.3|^8.0", + "symfony/routing": "^6.4|^7.3|^8.0", + "symfony/security-core": "^6.4|^7.3|^8.0", + "symfony/security-http": "^6.4|^7.3|^8.0" + }, + "require-dev": { + "league/oauth2-facebook": "^1.1|^2.0", + "symfony/phpunit-bridge": "^7.3", + "symfony/yaml": "^6.4|^7.3|^8.0" + }, + "suggest": { + "symfony/security-guard": "For integration with Symfony's Guard Security layer" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "KnpU\\OAuth2ClientBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Weaver", + "email": "ryan@symfonycasts.com" + } + ], + "description": "Integration with league/oauth2-client to provide services", + "homepage": "https://symfonycasts.com", + "keywords": [ + "oauth", + "oauth2" + ], + "support": { + "issues": "https://github.com/knpuniversity/oauth2-client-bundle/issues", + "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.20.2" + }, + "time": "2026-02-12T17:07:18+00:00" + }, { "name": "league/flysystem", "version": "3.32.0", @@ -2637,6 +2760,71 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.6.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" + }, + "type": "library", + "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.9.0" + }, + "time": "2025-11-25T22:17:17+00:00" + }, { "name": "liip/imagine-bundle", "version": "2.17.1", @@ -4036,6 +4224,68 @@ }, "time": "2026-03-03T17:31:43+00:00" }, + { + "name": "stevenmaguire/oauth2-keycloak", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/stevenmaguire/oauth2-keycloak.git", + "reference": "459cc58576d37f5823de2a677a7b17667b85ba7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevenmaguire/oauth2-keycloak/zipball/459cc58576d37f5823de2a677a7b17667b85ba7f", + "reference": "459cc58576d37f5823de2a677a7b17667b85ba7f", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^7.0", + "league/oauth2-client": "^2.8", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "~9.6.4", + "squizlabs/php_codesniffer": "~3.7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Stevenmaguire\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steven Maguire", + "email": "stevenmaguire@gmail.com", + "homepage": "https://github.com/stevenmaguire" + } + ], + "description": "Keycloak OAuth 2.0 Client Provider for The PHP League OAuth2-Client", + "keywords": [ + "authorisation", + "authorization", + "client", + "keycloak", + "oauth", + "oauth2" + ], + "support": { + "issues": "https://github.com/stevenmaguire/oauth2-keycloak/issues", + "source": "https://github.com/stevenmaguire/oauth2-keycloak/tree/6.1.0" + }, + "time": "2026-03-03T11:50:29+00:00" + }, { "name": "stripe/stripe-php", "version": "v19.4.1", diff --git a/config/bundles.php b/config/bundles.php index b19f8e5..e87a17a 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -16,4 +16,5 @@ return [ Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true], League\FlysystemBundle\FlysystemBundle::class => ['all' => true], Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true], + KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], ]; diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml new file mode 100644 index 0000000..75bd877 --- /dev/null +++ b/config/packages/knpu_oauth2_client.yaml @@ -0,0 +1,12 @@ +knpu_oauth2_client: + clients: + keycloak: + type: generic + provider_class: Stevenmaguire\OAuth2\Client\Provider\Keycloak + client_id: '%env(OAUTH_KEYCLOAK_CLIENT_ID)%' + client_secret: '%env(OAUTH_KEYCLOAK_CLIENT_SECRET)%' + redirect_route: app_oauth_keycloak_check + redirect_params: {} + provider_options: + authServerUrl: '%env(OAUTH_KEYCLOAK_URL)%' + realm: '%env(OAUTH_KEYCLOAK_REALM)%' diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index 1e036d0..4be6bcb 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -51,6 +51,9 @@ nelmio_security: - 'https://fonts.gstatic.com' object-src: - 'none' + form-action: + - 'self' + - 'https://auth.esy-web.dev' block-all-mixed-content: true permissions_policy: @@ -71,3 +74,4 @@ nelmio_security: - stripe.com - checkout.stripe.com - hooks.stripe.com + - auth.esy-web.dev diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 7fe9bf1..b823e91 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -19,6 +19,9 @@ security: login_path: app_login check_path: app_login enable_csrf: true + custom_authenticators: + - App\Security\KeycloakAuthenticator + entry_point: form_login logout: path: app_logout target: app_home diff --git a/migrations/Version20260319093607.php b/migrations/Version20260319093607.php new file mode 100644 index 0000000..5f36f89 --- /dev/null +++ b/migrations/Version20260319093607.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE messenger_log (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, message_class VARCHAR(255) NOT NULL, message_body TEXT DEFAULT NULL, status VARCHAR(20) NOT NULL, error_message TEXT DEFAULT NULL, stack_trace TEXT DEFAULT NULL, transport_name VARCHAR(255) DEFAULT NULL, retry_count INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, failed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX idx_messenger_log_status ON messenger_log (status)'); + $this->addSql('CREATE INDEX idx_messenger_log_created_at ON messenger_log (created_at)'); + $this->addSql('ALTER TABLE "user" ADD keycloak_id VARCHAR(255) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649491914B1 ON "user" (keycloak_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE messenger_log'); + $this->addSql('DROP INDEX UNIQ_8D93D649491914B1'); + $this->addSql('ALTER TABLE "user" DROP keycloak_id'); + } +} diff --git a/src/Controller/OAuthController.php b/src/Controller/OAuthController.php new file mode 100644 index 0000000..35d4acb --- /dev/null +++ b/src/Controller/OAuthController.php @@ -0,0 +1,29 @@ +getClient('keycloak')->redirect(['openid', 'email', 'profile']); + } + + #[Route('/connection/sso/logout', name: 'app_oauth_keycloak_logout')] + public function logout(): RedirectResponse + { + return $this->redirectToRoute('app_home'); + } + + #[Route('/connection/sso/check', name: 'app_oauth_keycloak_check')] + public function check(): void + { + // Handled by KeycloakAuthenticator + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index d33596d..fa3dc8e 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -34,6 +34,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column] private ?string $password = null; + #[ORM\Column(length: 255, unique: true, nullable: true)] + private ?string $keycloakId = null; + #[ORM\Column] private \DateTimeImmutable $createdAt; @@ -122,6 +125,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this->createdAt; } + public function getKeycloakId(): ?string + { + return $this->keycloakId; + } + + public function setKeycloakId(?string $keycloakId): static + { + $this->keycloakId = $keycloakId; + + return $this; + } + public function eraseCredentials(): void { // Required by UserInterface — no temporary credentials to clear diff --git a/src/Security/KeycloakAuthenticator.php b/src/Security/KeycloakAuthenticator.php new file mode 100644 index 0000000..7a0224c --- /dev/null +++ b/src/Security/KeycloakAuthenticator.php @@ -0,0 +1,117 @@ +attributes->get('_route'); + } + + private const GROUP_ROLE_MAP = [ + '/superadmin' => 'ROLE_ROOT', + ]; + + public function authenticate(Request $request): Passport + { + $client = $this->clientRegistry->getClient('keycloak'); + $accessToken = $this->fetchAccessToken($client); + + return new SelfValidatingPassport( + new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) { + $keycloakUser = $client->fetchUserFromToken($accessToken); + $keycloakId = $keycloakUser->getId(); + $email = $keycloakUser->getEmail(); + $data = $keycloakUser->toArray(); + $roles = $this->mapGroupsToRoles($data['groups'] ?? []); + + $user = $this->em->getRepository(User::class)->findOneBy(['keycloakId' => $keycloakId]); + + if ($user) { + $user->setEmail($email); + $user->setFirstName($data['given_name'] ?? $user->getFirstName()); + $user->setLastName($data['family_name'] ?? $user->getLastName()); + $user->setRoles($roles); + $this->em->flush(); + + return $user; + } + + $existingUser = $this->em->getRepository(User::class)->findOneBy(['email' => $email]); + + if ($existingUser) { + $existingUser->setKeycloakId($keycloakId); + $existingUser->setRoles($roles); + $this->em->flush(); + + return $existingUser; + } + + $newUser = new User(); + $newUser->setKeycloakId($keycloakId); + $newUser->setEmail($email); + $newUser->setFirstName($data['given_name'] ?? ''); + $newUser->setLastName($data['family_name'] ?? ''); + $newUser->setPassword(''); + $newUser->setRoles($roles); + + $this->em->persist($newUser); + $this->em->flush(); + + return $newUser; + }), + ); + } + + /** + * @param list $groups + * + * @return list + */ + private function mapGroupsToRoles(array $groups): array + { + $roles = []; + + foreach ($groups as $group) { + if (isset(self::GROUP_ROLE_MAP[$group])) { + $roles[] = self::GROUP_ROLE_MAP[$group]; + } + } + + return $roles; + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return new RedirectResponse($this->router->generate('app_account')); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $request->getSession()->getFlashBag()->add('error', 'Echec de la connexion SSO E-Cosplay.'); + + return new RedirectResponse($this->router->generate('app_login')); + } +} diff --git a/symfony.lock b/symfony.lock index 8d3c63c..70d8dc3 100644 --- a/symfony.lock +++ b/symfony.lock @@ -50,6 +50,15 @@ ".php-cs-fixer.dist.php" ] }, + "knpuniversity/oauth2-client-bundle": { + "version": "2.20", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.20", + "ref": "1ff300d8c030f55c99219cc55050b97a695af3f6" + } + }, "league/flysystem-bundle": { "version": "3.6", "recipe": { diff --git a/templates/base.html.twig b/templates/base.html.twig index 3d29018..14dfa79 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -82,9 +82,10 @@
@@ -110,9 +111,9 @@