Last active
July 12, 2024 08:28
-
-
Save HiroKX/244ff784ed23cb2c545324ac9a58e339 to your computer and use it in GitHub Desktop.
Add Keycloak to a Symfony Project
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# You need both bundles : | |
# composer require knpuniversity/oauth2-client-bundle | |
# composer require stevenmaguire/oauth2-keycloak | |
OAUTH_KEYCLOAK_CLIENT_ID="" | |
OAUTH_KEYCLOAK_CLIENT_SECRET="" | |
OAUTH_KEYCLOAK_URL="(Example : https://my.keycloack:922)" | |
OAUTH_KEYCLOAK_REALM="Realm" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Security; | |
use App\Entity\User; | |
use Doctrine\ORM\EntityManagerInterface; | |
use KnpU\OAuth2ClientBundle\Client\Provider\KeycloakClient; | |
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator; | |
use KnpU\OAuth2ClientBundle\Client\ClientRegistry; | |
use Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner; | |
use Symfony\Component\HttpFoundation\Request; | |
use Symfony\Component\HttpFoundation\RedirectResponse; | |
use Symfony\Component\HttpFoundation\Response; | |
use Symfony\Component\Routing\RouterInterface; | |
use Symfony\Component\Security\Core\Exception\AuthenticationException; | |
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | |
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; | |
use Symfony\Component\Security\Core\User\UserInterface; | |
use Symfony\Component\Security\Core\User\UserProviderInterface; | |
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; | |
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 Symfony\Component\Security\Http\Authenticator\Passport\Passport; | |
class KeycloakAuthenticator extends OAuth2Authenticator implements AuthenticatorInterface | |
{ | |
use TargetPathTrait; | |
public function __construct(private readonly ClientRegistry $clientRegistry, private readonly EntityManagerInterface $entityManager, private readonly RouterInterface $router, private readonly KeycloakUserProviderInterface $userProvider) | |
{} | |
public function supports(Request $request): ?bool | |
{ | |
return $request->attributes->get('_route') === 'connect_keycloak_check'; //The route name must be the name as the one that redirect | |
} | |
public function authenticate(Request $request): Passport | |
{ | |
/** @var KeycloakClient $client */ | |
$client = $this->getKeycloakClient(); | |
$accessToken = $this->fetchAccessToken($client); | |
if (null === $accessToken) { | |
throw new CustomUserMessageAuthenticationException('No access token provided'); | |
} | |
return new SelfValidatingPassport( | |
new UserBadge($accessToken->getToken(), function() use ($accessToken, $client) { | |
return $this->userProvider->loadUserByIdentifier($accessToken); | |
} | |
)); | |
} | |
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response | |
{ | |
$url = $this->router->generate('index'); //IMPORTANT : URI AVAILABLE TO REDIRECT WHEN SUCCESS | |
return new RedirectResponse($url); | |
} | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response | |
{ | |
$message = strtr($exception->getMessageKey(), $exception->getMessageData()); | |
return new Response($message, Response::HTTP_FORBIDDEN); | |
} | |
private function getKeycloakClient() | |
{ | |
return $this->clientRegistry | |
->getClient('keycloak'); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Security; | |
use App\Entity\User; | |
use App\Repository\UserRepository; | |
use Doctrine\ORM\EntityManagerInterface; | |
use KnpU\OAuth2ClientBundle\Client\ClientRegistry; | |
use KnpU\OAuth2ClientBundle\Client\OAuth2ClientInterface; | |
use KnpU\OAuth2ClientBundle\Security\User\OAuthUserProvider; | |
use Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner; | |
use Symfony\Component\Security\Core\Exception\UnsupportedUserException; | |
use Symfony\Component\Security\Core\Exception\UserNotFoundException; | |
use Symfony\Component\Security\Core\User\UserInterface; | |
class KeycloakUserProvider extends OAuthUserProvider implements KeycloakUserProviderInterface | |
{ | |
public function __construct(private readonly UserRepository $userRepository, private readonly ClientRegistry $clientRegistry, private readonly EntityManagerInterface $entityManager) | |
{ | |
} | |
public function loadUserByIdentifier($accessToken): UserInterface | |
{ | |
try { | |
/** @var KeycloakResourceOwner $keycloakUser */ | |
$keycloakUser = $this->getKeycloakClient()->fetchUserFromToken($accessToken); | |
} catch (\UnexpectedValueException $e) { | |
throw new UserNotFoundException(); | |
} | |
$email = $keycloakUser->getEmail(); | |
$existingUser = $this->userRepository->findOneBy(['username' => $email]); | |
if ($existingUser) { | |
$existingUser->setRoles($keycloakUser->toArray()['groups']); | |
$existingUser->setAccessToken($accessToken); | |
$this->entityManager->persist($existingUser); | |
$this->entityManager->flush(); | |
return $existingUser; | |
} | |
$user = new User(); | |
$user->setUsername($keycloakUser->getEmail()); | |
$user->setRoles($keycloakUser->toArray()['groups']); | |
$user->setAccessToken($accessToken); | |
$this->entityManager->persist($user); | |
$this->entityManager->flush(); | |
return $user; | |
} | |
public function refreshUser(UserInterface $user): UserInterface | |
{ | |
if (!$user instanceof User) { | |
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user))); | |
} | |
$accessToken = $user->getAccessToken(); | |
if ($accessToken->hasExpired()) { | |
$accessToken = $this->getKeycloakClient()->getOAuth2Provider()->getAccessToken( | |
'refresh_token', | |
[ | |
'refresh_token' => $accessToken->getRefreshToken(), | |
] | |
); | |
} | |
return $this->loadUserByIdentifier($accessToken); | |
} | |
protected function getKeycloakClient(): OAuth2ClientInterface | |
{ | |
return $this->clientRegistry->getClient('keycloak'); | |
} | |
public function supportsClass($class): bool | |
{ | |
return User::class === $class; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Security; | |
use Symfony\Component\Security\Core\User\UserInterface; | |
interface KeycloakUserProviderInterface | |
{ | |
public function loadUserByIdentifier($accessToken):UserInterface; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#config/packages/ | |
knpu_oauth2_client: | |
clients: | |
keycloak: | |
type: keycloak | |
client_id: '%env(OAUTH_KEYCLOAK_CLIENT_ID)%' | |
client_secret: '%env(OAUTH_KEYCLOAK_CLIENT_SECRET)%' | |
redirect_route: connect_keycloak_check #Route name to verify | |
redirect_params: {} | |
auth_server_url: '%env(OAUTH_KEYCLOAK_URL)%' | |
realm: '%env(OAUTH_KEYCLOAK_REALM)%' | |
use_state: true |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#config/packages/ | |
security: | |
providers: | |
keycloak: | |
id: App\Security\KeycloakUserProvider | |
firewalls: | |
main: | |
lazy: true | |
form_login: | |
provider: keycloak | |
login_path: connect_keycloak_login | |
custom_authenticators: | |
- App\Security\KeycloakAuthenticator | |
logout: | |
path: /logout | |
target: / | |
access_control: | |
- { path: ^/connect/keycloak, roles: PUBLIC_ACCESS } | |
- { path: ^/connect/keycloak/check, roles: IS_AUTHENTICATED } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Controller; | |
use KnpU\OAuth2ClientBundle\Client\ClientRegistry; | |
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |
use Symfony\Component\Routing\Attribute\Route; | |
class SecurityController extends AbstractController | |
{ | |
#[Route('/connect/keycloak', name: 'connect_keycloak_login')] | |
public function connect(ClientRegistry $clientRegistry) | |
{ | |
return $clientRegistry | |
->getClient('keycloak') | |
->redirect(['openid']); | |
} | |
#[Route('/connect/keycloak/check', name: 'connect_keycloak_check')] | |
public function check() | |
{ | |
return $this->render('base.html.twig'); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
//All fields are mandatory | |
namespace App\Entity; | |
use App\Repository\UserRepository; | |
use Doctrine\ORM\Mapping as ORM; | |
use League\OAuth2\Client\Token\AccessToken; | |
use Symfony\Component\Security\Core\User\UserInterface; | |
#[ORM\Entity(repositoryClass: UserRepository::class)] | |
#[ORM\Table(name: '`user`')] | |
class User implements UserInterface | |
{ | |
#[ORM\Id] | |
#[ORM\GeneratedValue] | |
#[ORM\Column] | |
private ?int $id = null; | |
#[ORM\Column(length: 255)] | |
private ?string $username = null; | |
#[ORM\Column] | |
private array $roles = []; | |
private ?AccessToken $accessToken = null; | |
public function getAccessToken(): ?AccessToken | |
{ | |
return $this->accessToken; | |
} | |
public function setAccessToken(?AccessToken $accessToken): void | |
{ | |
$this->accessToken = $accessToken; | |
} | |
public function getId(): ?int | |
{ | |
return $this->id; | |
} | |
public function getUsername(): ?string | |
{ | |
return $this->username; | |
} | |
public function setUsername(string $username): static | |
{ | |
$this->username = $username; | |
return $this; | |
} | |
/** | |
* @see UserInterface | |
*/ | |
public function getRoles(): array | |
{ | |
$roles = $this->roles; | |
// guarantee every user at least has ROLE_USER | |
$roles[] = 'ROLE_USER'; | |
return array_unique($roles); | |
} | |
public function setRoles(array $roles): self | |
{ | |
$this->roles = $roles; | |
return $this; | |
} | |
public function eraseCredentials(): void | |
{ | |
} | |
public function getUserIdentifier(): string | |
{ | |
return (string)$this->username; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment