Nous allons voir comment mettre en place une authentification OAuth 2.0 entre une application Symfony/Api Platform et l’outil de gestion d’authentification Keycloak.
Prérequis
- Docker
- Droit d’édition du fichier hosts
Sources de départ
Pour commencer, on a besoin de télécharger les éléments suivants :
- Le projet API Platform
- Le docker-compose de Keycloak (à renommer en docker-compose.yml)
Modification des dépendances d’Api Platform
Cette article a été mis à jour suite à la mise en ligne de la dernière version d’Api Platform (2.5.7). Cette mise à jour à entrainer une incompatibilié des dépendances avec une librarie utilisée. Pour éviter ce problème, il suffit de modifier api-platform-2.5.7/docker/api/composer.json
et api-platform-2.5.7/docker/api/composer.lock
:
"require": {
[...]
"guzzlehttp/guzzle": "^6.0",
[...]
}
et de supprimer dans api-platform-2.5.7/docker/api/composer.lock
:
"name": "guzzlehttp/guzzle",
"version": "7.0.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "2d9d3c186a6637a43193e66b097c50e4451eaab2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/2d9d3c186a6637a43193e66b097c50e4451eaab2",
"reference": "2d9d3c186a6637a43193e66b097c50e4451eaab2",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^1.0",
"guzzlehttp/psr7": "^1.6.1",
"php": "^7.2.5",
"psr/http-client": "^1.0"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.0",
"ext-curl": "*",
"php-http/client-integration-tests": "dev-phpunit8",
"phpunit/phpunit": "^8.5.5",
"psr/log": "^1.1"
},
"suggest": {
"ext-curl": "Required for CURL handler support",
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.0-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "Guzzle is a PHP HTTP client library",
"homepage": "http://guzzlephp.org/",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"psr-18",
"psr-7",
"rest",
"web service"
],
"time": "2020-06-27T10:33:25+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "v1.3.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646",
"reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646",
"shasum": ""
},
"require": {
"php": ">=5.5.0"
},
"require-dev": {
"phpunit/phpunit": "^4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.4-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"time": "2016-12-20T10:07:11+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "1.6.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "239400de7a173fe9901b9ac7c06497751f00727a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a",
"reference": "239400de7a173fe9901b9ac7c06497751f00727a",
"shasum": ""
},
"require": {
"php": ">=5.4.0",
"psr/http-message": "~1.0",
"ralouphie/getallheaders": "^2.0.5 || ^3.0.0"
},
"provide": {
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"ext-zlib": "*",
"phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8"
},
"suggest": {
"zendframework/zend-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.6-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Schultze",
"homepage": "https://github.com/Tobion"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"time": "2019-07-01T23:21:34+00:00"
},
Mise en place des certificats pour Api Platform
Les certificats sont générés dans api-platform-2.5.7/docker/dev-tls/Dockerfile
. On ajoute les noms de domaines host.docker.internal
et keycloak
dans la liste des noms de domaines certifiés :
mkcert --cert-file localhost.crt --key-file localhost.key localhost host.docker.internal keycloak 127.0.0.1 ::1 mercure; \
Lancement du serveur d’Api Platform
Il faut changer le port du container Vulcain car ce port est également utilisé par le container de Keycloak. Cela se passe dans api-platform-2.5.7/docker-compose.yml
:
vulcain:
[...]
ports:
- target: 443
published: 8444
protocol: tcp
On peut maintenant exécuter les commandes suivantes pour fabriquer, télécharger et instancier les images Docker nécessaires au bon fonctionnement d’Api Platform :
docker-compose build
docker-compose pull
docker-compose up -d
Si vous obtenez l’erreur
temporary error (try again later)
alors il faut ajouternetwork: host
dans toutes les étapesbuild
dudocker-compose
.
Mise en place des certificats pour Keycloak
On va utiliser les certificats créés par Api Platform. Pour cela, dans le même dossier que le docker-compose.yml
de Keycloak, exécutez :
docker cp api-platform-257_dev-tls_1:/certs/ .
Il reste à relier ces certificats au container Docker. Dans le docker-compose.yml
:
keycloak:
[...]
volumes:
- ./certs/localhost.crt:/etc/x509/https/tls.crt
- ./certs/localhost.key:/etc/x509/https/tls.key
Lancement du serveur Keycloak
Avant de lancer le serveur Keycloak, il faut mapper le port https. Dans le docker-compose.yml
:
keycloak:
[...]
ports:
- 8080:8080
- 8443:8443
On peut à présent exécuter les containers :
docker-compose pull
docker-compose up -d
Configuration du réseau Docker
Les containers d’Api Platform et Keycloak ont besoin d’être sur le même réseau Docker afin qu’ils puissent communiquer. On va connecter le réseau de Keycloak dans Api Platform.
Dans api-platform-2.5.7/
docker-compose down
On ajoute dans le docker-compose.yml la configuration du réseau :
[...]
networks:
default:
external:
name: keycloak_default
et on relance
docker-compose up -d
Configuration du hosts
Il faut éditer le fichier hosts
afin d’ajouter certains domaines vers Docker. Pour cela, ouvrez le fichier hosts
de votre système dans un éditeur de texte en administrateur et ajoutez 127.0.0.1 host.docker.internal
et 127.0.0.1 keycloak
.
Configuration de Keycloak
Allez sur https://keycloak:8443 avec votre navigateur.
Une erreur de sécurité apparaît sur votre navigateur car le certificat créé par Api Platform n’est pas reconnu. Pour remédier à ce problème, il faut ajouter /certs/localCA.crt
dans les autorités de confiance du navigateur.
Sur Keycloak, se connecter avec les identifiants :
username: Admin
password: Pa55w0rd
Créer un realm :
name: dev
Créer un client :
Client ID: api-service
Client Protocol: openid-connect
Configurer le client :
Access type: confidential
Service Accounts Enabled: on
Authorization Enabled: on
Root URL: https://host.docker.internal:8444
Valid Redirect URLs: /*
Web Origins: /
Créer un utilisateur et son mot de passe :
Installation des dépendances pour Api Platform
On a besoin de deux dépendances :
knpuniversity/oauth2-client-bundle
qui intègre la gestion de l’OAuth 2.0 dans Symfony. Il est compatible avec un grand nombre de service OAuth 2.0. La liste des clients possibles est disponible sur packagist.stevenmaguire/oauth2-keycloak
, le client Keycloak pour la dépendance précédente.
docker exec -it api-platform-257_php_1 composer require stevenmaguire/oauth2-keycloak
docker exec -it api-platform-257_php_1 composer require knpuniversity/oauth2-client-bundle
Configuration d’Api Platform
Configuration des Trusted Hosts et des CORS Allow Origin
Ces réglages sont disponibles dans le fichier d’environnement /api/.env
. On va ajouter le domaine host.docker.internal
dans Trusted Hosts et CORS Allow Origin :
[...]
TRUSTED_HOSTS='^localhost|host.docker.internal|api$'
[...]
CORS_ALLOW_ORIGIN=^https?://(localhost|host.docker.internal|127\.0\.0\.1)(:[0-9]+)?$
[...]
Authentificateur
Dans un nouveau dossier Security
dans /api/src
, créez le fichier KeycloakAuthenticator.php
:
<?php
namespace App\Security;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\SocialAuthenticator;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class KeycloakAuthenticator extends SocialAuthenticator
{
private $clientRegistry;
private $router;
private $session;
public function __construct(ClientRegistry $clientRegistry, RouterInterface $router, SessionInterface $session)
{
$this->clientRegistry = $clientRegistry;
$this->router = $router;
$this->session = $session;
}
public function start(Request $request, AuthenticationException $authException = null)
{
$path = $request->getPathInfo();
$this->session->set("nextPath", $path);
if ($request->headers->get("Authorization") === null) {
return new RedirectResponse(
'/connect/',
Response::HTTP_TEMPORARY_REDIRECT
);
}
$targetUrl = $this->router->generate('connect_check', ["Authorization" => $request->headers->get("Authorization")]);
return new RedirectResponse(
$targetUrl,
Response::HTTP_TEMPORARY_REDIRECT
);
}
public function supports(Request $request)
{
return $request->attributes->get('_route') === 'connect_check';
}
public function getCredentials(Request $request)
{
if ($request->headers->get("Authorization") === null) {
return $this->fetchAccessToken($this->getClient());
}
$token = str_replace("Bearer ", "", $request->headers->get("Authorization"));
return new AccessToken(["access_token" => $token, "token_type" => "bearer"]);
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
try {
return $userProvider->loadUserByUsername($this->getClient()->fetchUserFromToken($credentials)->getId());
} catch (IdentityProviderException $e) {
throw new UnauthorizedHttpException($e->getMessage());
}
}
private function getClient()
{
return $this->clientRegistry->getClient('keycloak');
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
return new Response($message, Response::HTTP_FORBIDDEN);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey)
{
if ($this->session->has("nextPath")) {
$path = $this->session->get("nextPath");
$this->session->remove("nextPath");
return new RedirectResponse($path, 307);
}
return new RedirectResponse("/");
}
}
Celui-ci contient la classe KeycloakAuthenticator
responsable de la gestion de l’authentification. Cette classe implémente les méthodes abstraites de SocialAuthentificator
:
start
: appelé lors d’un accès à une ressource protégée avec un utilisateur non authentifié et permet de lancer la procédure d’authentification en redirigeant la requête. Ici, on commence par stocker l’URL demandé par l’utilisateur afin de pouvoir le rediriger, une fois l’authentification effectuée. Ensuite, on regarde si la requête contient un token, si oui, il y a une redirection vers la procédure de vérification du token, sinon, il y a une redirection vers la page de login.support
: renvoie vrai si la requête est destinée à l’authentification. Ici,support
renvoie vrai si la route est l’URL de vérification du token.getCredentials
: renvoie le token à partir de la requête. Si la requête contient un token alorsgetCredentials
renvoie ce token sinon, récupère le token auprès de Keycloak.getUser
: renvoie un utilisateur à partir du token.onAuthenticationFailure
: action effectuée quand il y a une erreur lors d’une authentification. Ici, renvoie une réponse403 Forbidden
.onAuthenticationSuccess
: action effectuée quand une authentification réussie. Ici, redirige vers la page stockée en session lors de l’étapestart
.
Prévisualiser(ouvre un nouvel onglet)
Contrôleur
On va implémenter le contrôleur d’authentification AuthController
:
<?php
namespace App\Controller;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
class AuthController extends AbstractController
{
/**
* @Route("/connect", name="connect_start")
*/
public function connectAction(ClientRegistry $clientRegistry) {
return $clientRegistry
->getClient('keycloak')
->redirect(['offline_access']);
}
/**
* @Route("/connect/check", name="connect_check")
*/
public function connectCheckAction() {}
/**
* @Route("/logout", name="logout")
*/
public function logoutAction() {}
}
Ce contrôleur répond à 3 routes :
/connect
: redirige l’utilisateur vers la page d’authentification de Keycloak/connect/check
: vérifie la validé du token. L’implémentation est vide car la requête est interceptée par l’authentificateur./logout
: déconnecte un utilisateur. L’implémentation est vide car la requête est interceptée par le composant de sécurité de Symfony.
Fichier de configuration
Il y a 2 fichiers de configuration à modifier :
knpu_oauth2_client.yaml
: contenant la configuration des clients oauth2.security.yaml
: contenant les réglages de sécurité.
knpu_oauth2_client:
clients:
# configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration
keycloak:
# must be "keycloak" - it activates that type!
type: keycloak
# add and set these environment variables in your .env files
client_id: 'api-service'
client_secret: '9aa10044-48e3-4f3e-89ea-37c4e8a81b58'
# a route name you'll create
redirect_route: connect_check
redirect_params: {}
# Keycloak server URL
auth_server_url: https://keycloak:8443/auth
# Keycloak realm
realm: dev
# whether to check OAuth2 "state": defaults to true
use_state: false
Le client_secret
est disponible dans Keycloak :
security:
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
oauth:
id: knpu.oauth2.user_provider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: lazy
provider: oauth
logout:
path: logout
guard:
authenticators:
- App\Security\KeycloakAuthenticator
Entité
Ils ne reste plus qu’à configurer l’entité afin que seuls les utilisateurs connectés puissent accéder aux données. Pour cela, il faut ajouter attributes={"security"="is_granted('ROLE_USER')"}
dans l’annotation @ApiResource
de l’entité.
[...]
/**
*
* @ApiResource(
* attributes={"security"="is_granted('ROLE_USER')"}
* )
* @ORM\Entity
*/
class Greeting
[...]
Tests
Sur navigateur
On accède à Api Plarform sur https://host.docker.internal:8444. La première fois qu’on se connecte sur Api Platform, on n’est pas authentifié :
On accède maintenant à https://host.docker.internal:8444/greetings (une page nécessitant d’être connectée pour pouvoir y accéder). On est alors redirigé sur Keycloak pour rentrer ses identifiants puis, une fois les éléments de connexion entrés, on est à nouveau redirigé sur greetings, mais cette fois de manière authentifié :
Sur Postman
On créé une requête Get
sur https://host.docker.internal:8444/greetings. Il faut sélectionner une authentification de type OAuth 2.0
avec les données d’authentification dans les Request Headers
. Si on met un faux token on obtient :
Pour obtenir, un token valide, cliquez sur Get New Access Token
:
Avec un token valide, on obtient bien notre réponse :
Conclusion
Nous avons vu comment :
- Ajouter un client dans Keycloak afin de pouvoir authentifier des utilisateurs sur ce dernier.
- Mettre une place et configurer une authentification OAuth 2.0 sur Api Platform.
L’ensemble des sources est disponible sur ce dépôt GitHub : https://github.com/INGENIANCE/Api-Plateform-With-Keycloak
6 commentaires
Cyrille P. · 22 octobre 2021 à 10 h 50 min
Bonjour Rémy. Non désolé, nous n’avons pas refait de poc avec les dernières versions des API.
Remy L · 22 octobre 2021 à 0 h 03 min
Est ce que par hasard vous auriez un exemple à partager avec symfony 5.2, le nouvel authenticator OAuth2Authenticator et api platform 2.6.3 ?
J’ai beaucoup de mal à y arriver.
De plus, je n’arrive pas à ne pas mettre de redirection et juste vérifier le token en php.
Merci ! 😀
Emmanuel L. · 23 décembre 2020 à 18 h 01 min
Bonjour,
J’ai bien le token défini dans $request->headers->get(« Authorization ») lors du checking. Je pense que vous avez un problème de configuration mais je n’ai pas trouvé de réglage dans Keycloak ou Api Platform permettant de choisir comment le token est retourné. Avez-vous changé des réglages dans Keycloak ou dans les fichiers de configuration d’Api Platform ? Obtenez-vous une erreur lorsque vous testez avec Postman (ou un outil similaire) ?
Alexis · 15 décembre 2020 à 17 h 38 min
Bonjour,
J’ai suivi votre tutoriel. J’arrive à rediriger sur KeyCloak pour entrer mes login. KeyCloak me redirige vers la page de checking du token, malheuresement, j’ai une erreur qui me dit que les headers sont vide. Il n’y a en effet rien dans $request->headers->get(« Authorization »).
En revanche, l’url de redirection vers connect/check contient bien ‘code=[le code generé]’. Je me demande donc si il ne cherche pas ce code dans les headers alors qu’il est dans l’url ou qu’il n’y a pas un problème dans le formattage de la réponse de KeyCloak.
Merci pour ce tuto, en ésperant une réponse de votre part.
Emmanuel L. · 8 décembre 2020 à 17 h 43 min
Bonjour,
J’ai essayé de reproduire l’erreur que vous obtenez mais je n’ai pas réussi. Je pense que l’erreur vient des certificats partagés entre Api Platform et Keycloak ou d’un problème avec les DNS Docker. Pouvez-vous m’indiquer quel OS vous utiliser ainsi que la version de Docker ?
J’ai également mis à jour l’article afin d’utiliser la dernière version d’Api Platform. Peut-être que ça réglera votre problème.
EK · 24 septembre 2020 à 12 h 11 min
Bonjour,
Merci pour ce super article! Je souhaiterais utiliser ce tutoriel comme base pour connecter une application php a Keycloak et gérer les tokens.
J’ai dans un premier temps suivi votre tutoriel pour commencer. Mais lorsque j’arrive à l’étape d’ouvrir le lien https://host.docker.internal:8444/greetings, j’obtiens l’erreur suivante:
cURL error 60: SSL: no alternative certificate subject name matches target host name ‘keycloak’ (see https://curl.haxx.se/libcurl/c/libcurl-errors.html)
Pourriez-vous svp m’indiquer une solution pour résoudre ce problème?
Merci d’avance.