feat(login): Ajoute l'authentification SSO via Keycloak et Discord

Cette modification implémente l'authentification unique (SSO) via
Keycloak et Discord, permettant aux utilisateurs de se connecter
facilement. Ajoute les trads FR.
```
This commit is contained in:
Serreau Jovann
2026-01-11 14:20:16 +01:00
parent 2deba46584
commit b01ea8b2ab
14 changed files with 555 additions and 4 deletions

9
.env
View File

@@ -81,3 +81,12 @@ STRIPE_SECRET_KEY=sk_test_***
NOTIFUSE_CLIENT_EMAIL=jovann@siteconseil.fr
NOTIFUSE_CLIENT_SECRET=d04zCk3Fa45oOjDWHpAvc1AZxnLdGffOnNWK+Jt2yXf37+FTfuMMHb8flcfPMqLluRR3rvhbr555r6j1DEigrA==
NOTIFUSE_WORKSPACE_ID=ecosplay
KEYCLOAK_URL=https://auth.esy-web.dev
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=ecosplay
KEYCLOAK_CLIENT_SECRET=Cr2WycUH1Vti6xKYMOQSs0etO4tfHj1Q
DISCORD_CLIENT_ID=1392734559099158549
DISCORD_CLIENT_SECRET=upafzLsVMvwX_VC0Jw6Thy7d8lPpQGts

View File

@@ -10,6 +10,7 @@
"ext-iconv": "*",
"ext-libxml": "*",
"ext-zip": "*",
"adam-paterson/oauth2-stripe": "^2.0",
"chillerlan/php-qrcode": ">=5.0.5",
"cocur/slugify": ">=4.6",
"doctrine/dbal": "^3.10.3",
@@ -26,6 +27,7 @@
"imagine/imagine": "^1.5",
"io-developer/php-whois": ">=4.1.10",
"knplabs/knp-paginator-bundle": "^6.9.1",
"knpuniversity/oauth2-client-bundle": "^2.20",
"lasserafn/php-initial-avatar-generator": "^4.5",
"league/flysystem-aws-s3-v3": "^3.30.1",
"league/flysystem-bundle": "^3.6",
@@ -46,6 +48,7 @@
"setasign/fpdi": "^2.6.4",
"spatie/mjml-php": "^1.2.5",
"stancer/stancer": ">=2.0.1",
"stevenmaguire/oauth2-keycloak": "^5.1",
"stripe/stripe-php": "^19.0",
"symfony/amazon-mailer": "7.3.*",
"symfony/asset": "7.3.*",
@@ -82,7 +85,8 @@
"twig/intl-extra": "^3.22.1",
"twig/twig": "^3.22",
"vich/uploader-bundle": "^2.8.1",
"web-auth/webauthn-lib": ">=5.2.2"
"web-auth/webauthn-lib": ">=5.2.2",
"wohali/oauth2-discord-new": "^1.2"
},
"config": {
"allow-plugins": {

247
composer.lock generated
View File

@@ -4,8 +4,67 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ed26fccb39be0438a7def45a9b987dc9",
"content-hash": "403d821dbd4191efa1335b197d907faf",
"packages": [
{
"name": "adam-paterson/oauth2-stripe",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/adam-paterson/oauth2-stripe.git",
"reference": "52a43a58a51dceac5d91c906c6da13f0994951b6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/adam-paterson/oauth2-stripe/zipball/52a43a58a51dceac5d91c906c6da13f0994951b6",
"reference": "52a43a58a51dceac5d91c906c6da13f0994951b6",
"shasum": ""
},
"require": {
"league/oauth2-client": "^2.0",
"php": ">=5.6.0"
},
"require-dev": {
"mockery/mockery": "~0.9",
"phpunit/phpunit": "~4.0",
"scrutinizer/ocular": "^1.1",
"squizlabs/php_codesniffer": "~2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"AdamPaterson\\OAuth2\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Adam Paterson",
"email": "hello@adampaterson.co.uk"
}
],
"description": "Stripe OAuth 2.0 Client Provider for The PHP League OAuth2-Client",
"keywords": [
"Authentication",
"SSO",
"authorization",
"identity",
"idp",
"oauth",
"oauth2",
"single sign on",
"stripe",
"stripe api"
],
"support": {
"issues": "https://github.com/adam-paterson/oauth2-stripe/issues",
"source": "https://github.com/adam-paterson/oauth2-stripe/tree/master"
},
"time": "2018-02-26T09:35:45+00:00"
},
{
"name": "async-aws/core",
"version": "1.27.1",
@@ -4650,6 +4709,66 @@
},
"time": "2025-07-25T07:53:13+00:00"
},
{
"name": "knpuniversity/oauth2-client-bundle",
"version": "v2.20.1",
"source": {
"type": "git",
"url": "https://github.com/knpuniversity/oauth2-client-bundle.git",
"reference": "d59e4dc61484e777b6f19df2efcf8b1bcc03828a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/d59e4dc61484e777b6f19df2efcf8b1bcc03828a",
"reference": "d59e4dc61484e777b6f19df2efcf8b1bcc03828a",
"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.1"
},
"time": "2025-12-04T15:46:43+00:00"
},
{
"name": "lasserafn/php-initial-avatar-generator",
"version": "4.5",
@@ -8437,6 +8556,67 @@
},
"time": "2024-11-15T17:47:59+00:00"
},
{
"name": "stevenmaguire/oauth2-keycloak",
"version": "5.1.0",
"source": {
"type": "git",
"url": "https://github.com/stevenmaguire/oauth2-keycloak.git",
"reference": "1b690b7377dfe7a23e1590373f37e12cf40a6d75"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stevenmaguire/oauth2-keycloak/zipball/1b690b7377dfe7a23e1590373f37e12cf40a6d75",
"reference": "1b690b7377dfe7a23e1590373f37e12cf40a6d75",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^6.0",
"league/oauth2-client": "^2.0",
"php": "~7.2 || ~8.0"
},
"require-dev": {
"mockery/mockery": "~1.5.0",
"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/5.1.0"
},
"time": "2023-10-24T06:10:44+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v19.0.0",
@@ -15230,6 +15410,71 @@
"source": "https://github.com/webmozarts/assert/tree/1.12.1"
},
"time": "2025-10-29T15:56:20+00:00"
},
{
"name": "wohali/oauth2-discord-new",
"version": "1.2.1",
"source": {
"type": "git",
"url": "https://github.com/wohali/oauth2-discord-new.git",
"reference": "2df4d2a882e04c749880797704e4bde8f00ea1d9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/wohali/oauth2-discord-new/zipball/2df4d2a882e04c749880797704e4bde8f00ea1d9",
"reference": "2df4d2a882e04c749880797704e4bde8f00ea1d9",
"shasum": ""
},
"require": {
"ext-json": "*",
"league/oauth2-client": "^2.0",
"php": "^7.2|^8.0"
},
"conflict": {
"team-reflex/oauth2-discord": ">=1.0"
},
"require-dev": {
"mockery/mockery": "~1.3.0",
"php-parallel-lint/php-parallel-lint": "~0.9",
"phpunit/phpunit": "~8.0",
"squizlabs/php_codesniffer": "^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Wohali\\OAuth2\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Joan Touzet",
"email": "code@atypical.net",
"homepage": "https://github.com/wohali"
}
],
"description": "Discord OAuth 2.0 Client Provider for The PHP League OAuth2-Client",
"keywords": [
"authorisation",
"authorization",
"client",
"discord",
"oauth",
"oauth2"
],
"support": {
"issues": "https://github.com/wohali/oauth2-discord-new/issues",
"source": "https://github.com/wohali/oauth2-discord-new/tree/1.2.1"
},
"time": "2022-12-29T18:45:10+00:00"
}
],
"packages-dev": [

View File

@@ -19,4 +19,5 @@ return [
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
PixelOpen\CloudflareTurnstileBundle\PixelOpenCloudflareTurnstileBundle::class => ['all' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,14 @@
knpu_oauth2_client:
clients:
keycloak:
type: keycloak
auth_server_url: '%env(KEYCLOAK_URL)%'
realm: '%env(KEYCLOAK_REALM)%'
client_id: '%env(KEYCLOAK_CLIENT_ID)%'
client_secret: '%env(KEYCLOAK_CLIENT_SECRET)%'
redirect_route: connect_keycloak_check
discord:
type: discord
client_id: '%env(DISCORD_CLIENT_ID)%'
client_secret: '%env(DISCORD_CLIENT_SECRET)%'
redirect_route: connect_discord_check

View File

@@ -27,6 +27,8 @@ security:
entry_point: App\Security\AuthenticationEntryPoint
custom_authenticator:
- App\Security\LoginFormAuthenticator
- App\Security\KeycloakAuthenticator
- App\Security\DiscordAuthenticator
logout:
target: app_logout

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260111130759 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account ALTER password DROP NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE "account" ALTER password SET NOT NULL');
}
}

View File

@@ -0,0 +1,32 @@
<?php
// src/Controller/KeycloakController.php
namespace App\Controller;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class KeycloakController extends AbstractController
{
#[Route('/connect/keycloak', name: 'connect_keycloak_start')]
public function connectActionKeycloak(ClientRegistry $clientRegistry)
{
// Redirige vers Keycloak
return $clientRegistry->getClient('keycloak')->redirect(['openid', 'profile', 'email'], []);
}
#[Route('/connect/discord', name: 'connect_discord_start')]
public function connectActionDiscord(ClientRegistry $clientRegistry)
{
// Redirige vers Keycloak
return $clientRegistry->getClient('discord')->redirect(['openid', 'email','identify'], []);
}
#[Route('/oauth/keycloak', name: 'connect_keycloak_check')]
#[Route('/oauth/discord', name: 'connect_discord_check')]
public function connectCheckAction(Request $request)
{
// Cette méthode reste vide, elle est interceptée par l'Authenticator
}
}

View File

@@ -36,7 +36,7 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
#[ORM\Column(nullable: true)]
private ?string $password = null;
#[ORM\Column(length: 255)]

View File

@@ -0,0 +1,88 @@
<?php
// src/Security/KeycloakAuthenticator.php
namespace App\Security;
use App\Entity\Account;
use App\Entity\User; // Votre entité User
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
use Wohali\OAuth2\Client\Provider\DiscordResourceOwner;
class DiscordAuthenticator extends OAuth2Authenticator
{
use TargetPathTrait;
public function __construct(
private ClientRegistry $clientRegistry,
private EntityManagerInterface $entityManager,
private RouterInterface $router
) {}
public function supports(Request $request): ?bool
{
// S'active uniquement sur la route de check
return $request->attributes->get('_route') === 'connect_discord_check';
}
public function authenticate(Request $request): SelfValidatingPassport
{
$client = $this->clientRegistry->getClient('discord');
$accessToken = $this->fetchAccessToken($client);
return new SelfValidatingPassport(
new UserBadge($accessToken->getToken(), function() use ($accessToken, $client) {
/** @var DiscordResourceOwner $discordUser */
$discordUser = $client->fetchUserFromToken($accessToken);
$email = $discordUser->getEmail();
// 1) Chercher l'utilisateur par son ID Keycloak ou son Email
$user = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $email]);
// 2) Créer l'utilisateur s'il n'existe pas (Inscription automatique)
if (!$user) {
$user = new Account();
$user->setEmail($email);
$user->setUsername($email);
$user->setUuid(Uuid::uuid4()->toString());
$user->setRoles(['ROLE_USER']);
$user->setIsActif(true);
// On peut mapper d'autres champs :
// $user->setFirstName($keycloakUser->toArray()['given_name']);
$this->entityManager->persist($user);
}
// 3) Mettre à jour les rôles ou infos si nécessaire
$this->entityManager->flush();
return $user;
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// Redirige vers la page demandée initialement ou l'accueil
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->router->generate('app_home'));
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
return new Response($message, Response::HTTP_FORBIDDEN);
}
}

View File

@@ -0,0 +1,87 @@
<?php
// src/Security/KeycloakAuthenticator.php
namespace App\Security;
use App\Entity\Account;
use App\Entity\User; // Votre entité User
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class KeycloakAuthenticator extends OAuth2Authenticator
{
use TargetPathTrait;
public function __construct(
private ClientRegistry $clientRegistry,
private EntityManagerInterface $entityManager,
private RouterInterface $router
) {}
public function supports(Request $request): ?bool
{
// S'active uniquement sur la route de check
return $request->attributes->get('_route') === 'connect_keycloak_check';
}
public function authenticate(Request $request): SelfValidatingPassport
{
$client = $this->clientRegistry->getClient('keycloak');
$accessToken = $this->fetchAccessToken($client);
return new SelfValidatingPassport(
new UserBadge($accessToken->getToken(), function() use ($accessToken, $client) {
/** @var \Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner $keycloakUser */
$keycloakUser = $client->fetchUserFromToken($accessToken);
$email = $keycloakUser->getEmail();
// 1) Chercher l'utilisateur par son ID Keycloak ou son Email
$user = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $email]);
// 2) Créer l'utilisateur s'il n'existe pas (Inscription automatique)
if (!$user) {
$user = new Account();
$user->setEmail($email);
$user->setUsername($keycloakUser->toArray()['preferred_username']);
$user->setUuid(Uuid::uuid4()->toString());
$user->setRoles(['ROLE_USER']);
$user->setIsActif(true);
// On peut mapper d'autres champs :
// $user->setFirstName($keycloakUser->toArray()['given_name']);
$this->entityManager->persist($user);
}
// 3) Mettre à jour les rôles ou infos si nécessaire
$this->entityManager->flush();
return $user;
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// Redirige vers la page demandée initialement ou l'accueil
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->router->generate('app_home'));
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
return new Response($message, Response::HTTP_FORBIDDEN);
}
}

View File

@@ -50,6 +50,18 @@
"knplabs/knp-paginator-bundle": {
"version": "v6.8.1"
},
"knpuniversity/oauth2-client-bundle": {
"version": "2.20",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.20",
"ref": "1ff300d8c030f55c99219cc55050b97a695af3f6"
},
"files": [
"config/packages/knpu_oauth2_client.yaml"
]
},
"league/flysystem-bundle": {
"version": "3.4",
"recipe": {

View File

@@ -60,7 +60,7 @@
</div>
{% endfor %}
{# FORMULAIRE #}
{# FORMULAIRE CLASSIQUE #}
<form action="{{ path('app_login') }}" method="post" class="space-y-6">
<input type="hidden" name="remember" value="true">
@@ -103,7 +103,27 @@
<i class="fas fa-chevron-right ml-3 mt-1 group-hover:translate-x-2 transition-transform"></i>
</button>
</form>
{# SÉPARATEUR #}
<div class="relative my-10">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t-4 border-gray-900"></div>
</div>
<div class="relative flex justify-center text-xs uppercase font-black">
<span class="px-4 bg-white text-gray-900">{{ 'common.or'|trans }}</span>
</div>
</div>
{# BOUTON KEYCLOAK (SSO) - Placé en haut pour favoriser le SSO #}
<a href="{{ path('connect_keycloak_start') }}"
class="w-full flex items-center justify-center py-4 px-4 border-4 border-gray-900 text-lg font-black uppercase tracking-widest text-gray-900 bg-yellow-400 shadow-[8px_8px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all">
<i class="fas fa-key mr-3"></i>
{{ 'button.login_keycloak'|trans|default('Connexion Keycloak') }}
</a>
<a href="{{ path('connect_discord_start') }}"
class="mt-2 w-full flex items-center justify-center py-4 px-4 border-4 border-gray-900 text-lg font-black uppercase tracking-widest text-gray-900 bg-yellow-400 shadow-[8px_8px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all">
<i class="fas fa-key mr-3"></i>
{{ 'button.login_discord'|trans|default('Connexion Keycloak') }}
</a>
</div>
</div>
</div>

View File

@@ -1225,3 +1225,8 @@ newsletter:
message: "Ton inscription a été validée avec succès. Bienvenue dans la communauté !"
hint: "Prépare-toi à recevoir le meilleur du Cosplay directement dans ta boîte."
back_button: "C'EST PARTI !"
button:
login_keycloak: 'SSO'
login_discord: 'Discord'
common:
or: 'Ou'