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 :

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 ajouter network: host dans toutes les étapes build du docker-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 alors getCredentials 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éponse 403 Forbidden.
  • onAuthenticationSuccess : action effectuée quand une authentification réussie. Ici, redirige vers la page stockée en session lors de l’étape start.

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

Références


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.

Laisser un commentaire

Avatar placeholder

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

Mise en place de l’authentification OAuth 2.0 entre Symfony/Api …

par Emmanuel L. temps de lecture : 9 min
6