⚠️ Warning ⚠️
This article was written 4 years and 11 months ago. The informations it contains may be outdated or no longer relevant.
Api Platform, OAuth & Swagger
Aujourd'hui, nous allons voir comment utiliser une authentification OAuth, pour notre projet API Platform, à travers Swagger UI.
Cela nous permettra d'activer plusieurs services OAuth, tels que Google, Github, ou encore Facebook.
Je pars du principe que si vous arrivez la, vous connaissez un minimum l'utilisation de Symfony et de API-Platform. Je vais donc passer très vite sur l'initialisation.
TOC
- Création d'un projet
- Initialisation de la plateforme
- Création d'un endpoint de test
- Récupérer des crédentials Oauth pour se connecter avec Google
- Authentication
- Un autre OAuth provider : Github
- Plusieurs OAuth Login sur Swagger UI
Création d'un projet
Pour notre projet, nous allons rapidement créer une application symfony avec API-Platform
composer create-project symfony/website-skeleton api-oauth
cd api-oauth
composer req api
symfony local:server:start -d
J'utilise le website-skeleton pour avoir tous les composants Symfony, et ne pas a avoir a faire de multiples
composer require
. L'utilisateur aguerri partira bien évidemment duskeleton
tout cours.
Initialisation de la plateforme
Premierement, nous allons altérer la configuration d'api platform, pour repasser sur swagger v2 plutot que la v3.
# config/packages/api_platform.yml
api_platform:
#...
swagger:
versions: [2]
Cette étape pourra être évité quand cette PR sera mergée et released.
Puis, créons une entité de test :
php bin/console make:entity Book --api-resource
Et une entitée user (sans besoin de hash password )
php bin/console make:user
Et on met à jour la BDD.
php bin/console d:s:u --force
Création d'un endpoint de test
Nous allons créé un endpoint de test qui se contentera de retourner l'utilisateur.
php bin/console make:controller WhoAmIController --no-template
Alterons ce controleur pour le rendre invokable, et pour retourner l'utilisateur courant :
// src/Controller/WhoAmIController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\User\UserInterface;
final class WhoAmIController extends AbstractController
{
public function __invoke(): ?UserInterface
{
return $this->getUser();
}
}
Allons modifier la classe User
pour la définir en tant que ressource ApiPlatform, et ajoutons dedans notre controller.
// src/Entity/User.php
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use App\Controller\WhoAmIController;
/**
* @ApiResource(
* collectionOperations={
* "get", "post",
* "whoami"= {
* "method"="GET",
* "path"="/whoami",
* "controller"=WhoAmIController::class,
* },
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*/
class User implements UserInterface
{
// ...
}
Je rajoute le controleur dans les collectionOperations
, mais il conviendrai mieux dans itemOperations
. Je ne souhaites juste pas m'embêter a devoir enlever le champ id
du swagger pour l'instant.
Si on exécute le endpoint /api/whoami
, on reçoit null
. Parfait. Passons à l'OAuth.
Récupérer des crédentials Oauth pour se connecter avec Google
Notre premier authenticator sera Google. Depuis la console developper, créeons d'abord un "Ecran d'autorisation Oauth". (Il n'est pas nécessaire de renseigner les domaines pour l'instant).
Puis, toujours dans cette console, créeons des identifiants "ID Client Oauth".
Ici, il est important de rajouter une Url de redirection autorisée ainsi en adaptant votre domaine:
https://127.0.0.1:8000/bundles/apiplatform/swagger-ui/oauth2-redirect.html
Puis on stock notre ID et SECRET dans le fichier .env
#.env
# ...
OAUTH_GOOGLE_ID="monid"
OAUTH_GOOGLE_SECRET="monsecret"
Completons la configuration d'API Platform :
# config/packages/api_platform.yaml
api_platform:
# ...
oauth:
enabled: true
clientId: '%env(OAUTH_GOOGLE_ID)%'
clientSecret: '%env(OAUTH_GOOGLE_SECRET)%'
type: 'oauth2'
# The oauth flow grant type.
flow: 'authorizationCode'
# The oauth authentication url.
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth'
# The oauth token url.
tokenUrl: 'https://www.googleapis.com/oauth2/v4/token'
# The oauth scopes.
scopes:
email: 'Allow to retrieve user email'
Nous pouvons maintenant tester l'authorization depuis notre UI Swagger.
Une barre apparait avec un bouton "Authorize" dans la documentation :
Lorsqu'on clique dessus, une popin apparait :
On reclique sur Authorize, un nouvel onglet s'ouvre sur Google, puis nous somme redirigé sur la documentation. L'OAuth s'est bien passé.
Authentication
Nous sommes a présent bien connecté via l'Oauth de google. Pour chaque appel que nous faisons a l'API, un header Authorization: Bearer accessToken
est ajouté.
Mais si nous appelons le endpoint /api/whoami
, il retourne toujours null
.
Voyons comment récupérer notre utilsateur.
Tout d'abord, générons un GuardAuthenticator :
php bin/console make:auth
Lorsque cette commande le demande, choisissez "Empty Authenticator"
Puis récupérons league/oauth2-google
composer require league/oauth2-google
Dans notre services.yaml
, créeons un service pour le google Provider
services:
# ...
League\OAuth2\Client\Provider\Google:
class: League\OAuth2\Client\Provider\Google
arguments:
- {clientId: '%env(OAUTH_GOOGLE_ID)%', clientSecret: '%env(OAUTH_GOOGLE_SECRET)%'}
Et injectons ce service dans notre authenticator :
// src/Security/GoogleOauthAuthenticator.php
<?php
declare(strict_types=1);
namespace App\Security;
use League\OAuth2\Client\Provider\Google;
use Doctrine\ORM\EntityManagerInterface;
//...
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
final class GoogleOauthAuthenticator extends AbstractGuardAuthenticator
{
/**
* @var \League\OAuth2\Client\Provider\Google
*/
private $client;
/**
* @var \Doctrine\ORM\EntityManagerInterface
*/
private $entityManager;
public function __construct(Google $client, EntityManagerInterface $entityManager)
{
$this->client = $client;
$this->entityManager = $entityManager;
}
Completons maintenant les différentes méthodes de notre GoogleOauthAuthenticator
.
La méthode support
permet de vérifier si les conditions sont bien remplies pour qu'on appelle la classe.
Dans notre cas, il s'agit de vérifier la présence du nouvel header authorization
.
public function supports(Request $request): bool
{
return $request->headers->has('authorization');
}
La méthode getCredentials
permet d'extraire les crédentials nécessaire pour la récupération de notre Utilisateur.
public function getCredentials(Request $request)
{
$accessToken = explode(' ', $request->headers->get('authorization'))[1];
return new \League\OAuth2\Client\Token\AccessToken(['access_token' => $accessToken]);
}
Ici, on explose le contenu du header, le token est la deuxième partie.
La méthode getUser
doit retrouver l'utilisateur à partir des crédentials. C'est ici qu'on va utiliser notre nouveau service.
public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
{
/** @var \League\OAuth2\Client\Provider\GoogleUser $googleUser */
$googleUser = $this->client->getResourceOwner($credentials);
$email = $googleUser->getEmail();
try {
$user = $userProvider->loadUserByUsername($email);
} catch (UsernameNotFoundException $exception) {
$user = (new User())->setEmail($email);
$this->entityManager->persist($user);
$this->entityManager->flush();
}
return $user;
}
Enfin, la méthode checkCredential
doit toujours retourner true
, et la methode supportsRememberMe
retournera false
.
public function checkCredentials($credentials, UserInterface $user): bool
{
return true;
}
public function supportsRememberMe(): bool
{
return false;
}
Et c'est tout. Les autres méthodes peuvent rester vide pour l'instant, il conviendra de le remplir correctement le temps venu.
Si dorénavant, nous appelons l'endpoint /api/whoami
, notre utilisateur va être renvoyé.
Un autre OAuth provider : Github
La plupart du temps, nous proposons à nos utilisateurs plusieurs moyen de se connecter. Voyons comment ajouter une deuxieme authentification avec Github OAuth.
Commençons par créer une application OAuth sur Github et ajoutons nos nouveaux credentials dans le fichier .env
.
#.env
#...
OAUTH_GITHUB_ID=monid
OAUTH_GITHUB_SECRET=monsecret
Remplaçons temporairement la configuration d'API Platform pour utiliser Github en OAuth.
#config/packages/api_platform.yaml
api_platform:
# ...
oauth:
enabled: true
clientId: '%env(OAUTH_GITHUB_ID)%'
clientSecret: '%env(OAUTH_GITHUB_ID)%'
type: 'oauth2'
# The oauth flow grant type.
flow: 'authorizationCode'
# The oauth authentication url.
authorizationUrl: 'https://github.com/login/oauth/authorize'
# The oauth token url.
tokenUrl: 'https://github.com/login/oauth/access_token'
# The oauth scopes.
scopes:
'user:email': 'Allow to retrieve user email'
Si nous réessayons le flow OAuth depuis Swagger UI, une erreur survient :
Auth Error TypeError: Failed to fetch
En inspectant les requêtes depuis l'inspecteur web, on se rend compte que l'url pour récupérer l'accessToken ne prend pas en charge les CORS.
Nous devons donc créer un controlleur qui s'occupera de récupérer notre AccessToken.
php bin/console make:controller GithubTokenController --no-template
Et nous allons utiliser le paquet league/oauth2-github
qui fera l'appel pour nous :
composer require league/oauth2-github
De la même manière que pour Google, ajoutons ce provider en tant que service :
#config/services.yaml
services:
# ...
League\OAuth2\Client\Provider\Github:
class: League\OAuth2\Client\Provider\Github
arguments:
- {clientId: '%env(OAUTH_GITHUB_ID)%', clientSecret: '%env(OAUTH_GITHUB_SECRET)%'}
Injectons ce service dans notre controller et renvoyons l'accessToken :
// src/Controller/GithubTokenController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use League\OAuth2\Client\Provider\Github;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
final class GithubTokenController
{
/**
* @Route("/github/token", name="github_token")
*/
public function index(Request $request, Github $client): JsonResponse
{
return new JsonResponse(
$client->getAccessToken('authorization_code', [
'code' => $request->request->get('code'),
])
);
}
}
Et on change la valeur de tokenUrl dans la config d'API Platform par notre nouvelle route :
#config/packages/api_platform.yaml
api_platform:
# ...
oauth:
# ...
tokenUrl: '/github/token'
Si nous réessayons le flow d'authorization sur Swagger UI, tout devrait bien passer maintenant.
Pour gérer l'authentification, nous allons devoir, comme auparavant pour Google, créer un GuardAuthenticator.
php bin/console make:auth
A la création, un avertissement vous préviendra du comportement lorsque nous avons plusieurs Authenticators et vous demandera de choisir l'entry_point par défaut. Choisissez celui nouvellement créé.
Plus d'info sur la documentation.
Le principe est le même que pour celui de Google, il faudra juste injecter le GithubProvider à la place.
Si on essaye d'appeler l'endpoint /api/whoami
, nous obtenons ... une erreur.
{
"type": "https:\/\/tools.ietf.org\/html\/rfc2616#section-10",
"title": "An error occurred",
"detail": "invalid_request"
}
En utilisant le profiler, on peut voir que l'authentification s'est bien passé, nous avons un utilisateur loggé, mais on passe ensuite dans le GoogleOAuthAuthenticator, qui essaye de récupérer les information avec un accessToken qui vient de Github, et on a donc une exception.
Pour palier à ça, nous pouvons faire un try/catch dans les GuardAuthenticators au moment de récuperer les utilisateurs, et retourner null
si l'appel lève une erreur.
// src/Security/GoogleOAuthAuthenticator.php
// ...
public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
{
try {
/** @var \League\OAuth2\Client\Provider\GoogleUser $googleUser */
$googleUser = $this->client->getResourceOwner($credentials);
} catch (\Throwable $throwable) {
return null;
}
//...
return $user;
}
Mais nous pouvons aussi regrouper les deux Guard en un seul.
Pour cela, dans le services.yaml
, ajoutons un tag sur nos OAuth providers :
#config/services.yaml
services:
# ...
League\OAuth2\Client\Provider\Google:
class: League\OAuth2\Client\Provider\Google
arguments:
- {clientId: '%env(OAUTH_GOOGLE_ID)%', clientSecret: '%env(OAUTH_GOOGLE_SECRET)%'}
tags:
- {name: 'oauth_provider'}
League\OAuth2\Client\Provider\Github:
class: League\OAuth2\Client\Provider\Github
arguments:
- {clientId: '%env(OAUTH_GITHUB_ID)%', clientSecret: '%env(OAUTH_GITHUB_SECRET)%'}
tags:
- {name: 'oauth_provider'}
Retirons les guard actuels dans notre security.yaml
#config/packages/security.yaml
security:
# ...
firewalls:
#...
main:
anonymous: lazy
- guard:
- authenticators:
- - App\Security\GithubOAuthAuthenticator
- - App\Security\GoogleOauthAuthenticator
- entry_point: App\Security\GithubOAuthAuthenticator
Et créons un nouveau Guard : ChainOAuthAuthenticator.
// src/Security/ChainOAuthAuthenticator
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
final class ChainOAuthAuthenticator extends AbstractGuardAuthenticator
{
/**
* @var iterable<\League\OAuth2\Client\Provider\AbstractProvider>
*/
private $oauthProviders;
/**
* @var \Doctrine\ORM\EntityManagerInterface
*/
private $entityManager;
public function __construct(iterable $oauthProviders, EntityManagerInterface $entityManager)
{
$this->oauthProviders = $oauthProviders;
$this->entityManager = $entityManager;
}
public function supports(Request $request): bool
{
return $request->headers->has('authorization');
}
public function getCredentials(Request $request): AccessToken
{
$accessToken = explode(' ',$request->headers->get('authorization'))[1];
return new AccessToken(['access_token' => $accessToken]);
}
public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
{
$oauthUser = null;
/** @var \League\OAuth2\Client\Provider\AbstractProvider $oauthProvider */
foreach ($this->oauthProviders as $oauthProvider) {
try {
$oauthUser = $oauthProvider->getResourceOwner($credentials);
break;
} catch (\Throwable $throwable) {
// This provider doesn't support current accessToken
}
}
if (null === $oauthUser) {
// No OAuth user found
return null;
}
$email = $oauthUser->getEmail();
try {
$user = $userProvider->loadUserByUsername($email);
} catch (UsernameNotFoundException $exception) {
$user = (new User())->setEmail($email);
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->entityManager->refresh($user);
}
return $user;
}
public function checkCredentials($credentials, UserInterface $user): bool
{
return true;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
return new JsonResponse($message, JsonResponse::HTTP_FORBIDDEN);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
// No need to redirect, as it's a stateless call.
// Let the request continue to be handled by the controller
return null;
}
public function start(Request $request, AuthenticationException $authException = null)
{
$message = 'Auth header required';
if (null !== $authException) {
$message = strtr($authException->getMessageKey(), $authException->getMessageData());
}
return new JsonResponse($message, JsonResponse::HTTP_UNAUTHORIZED);
}
public function supportsRememberMe(): bool
{
return false;
}
}
Puis modifions le services.yaml
pour injecter automatiquement nos providers taggés.
#config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
iterable $oauthProviders: !tagged_iterator oauth_provider
# ...
Notre ChainOAuthAuthenticator s'occupera donc maintenant de tester notre AccessToken avec les différents OAuthProviders.
Plusieurs OAuth Login sur Swagger UI
Comme vu précedemment, API Platform ne propose pas (encore) la possibilité de rajouter plusieurs OAuth dans sa configuration.
Swagger UI dans sa v2 le supporte, tout comme la v3. Cependant, dans la v2, les champs client_id et client_secret ne s'affichent pas, et nous devons pouvoir les différenciers.
De même, Swagger UI propose une fonction pour initialiser ces champs, mais est limité à un seul OAuth login. Donc si nous en affichons plusieurs, ils utiliseront tous les mêmes valeurs.
Pour afficher nos OAuth login sur le Swagger UI d'API Platform, nous allons donc "désactiver" le support OAuth d'API Platform, et au passage activer la v3 :
# config/packages/api_platform.yaml
api_platform:
# ...
swagger:
versions: [3]
oauth:
enabled: false
Définissons nos flows OAuth dans nos parametres
#config/services.yaml
parameters:
oauths:
github:
type: oauth2
description: OAuth authorization code Grant
flows:
authorizationCode:
tokenUrl: '/github/token'
authorizationUrl: 'https://github.com/login/oauth/authorize'
scopes:
'user:email': 'Allow to retrieve user email'
google:
type: oauth2
description: OAuth authorization code Grant
flows:
authorizationCode:
tokenUrl: 'https://www.googleapis.com/oauth2/v4/token'
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth'
scopes:
'email': 'Allow to retrieve user email'
services:
# ...
Et créeons un SwaggerDecorator, auquel on passe notre définition de flows OAuth
# config/services.yaml
#...
services:
# ...
App\Swagger\OAuthSwaggerDecorator:
decorates: 'api_platform.swagger.normalizer.api_gateway'
arguments: [ '@App\Swagger\OAuthSwaggerDecorator.inner', '%oauths%' ]
autoconfigure: false
// src/Swagger/OAuthSwaggerDecorator.php
<?php
declare(strict_types=1);
namespace App\Swagger;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class OAuthSwaggerDecorator implements NormalizerInterface
{
private $decorated;
/**
* @var array
*/
private $oauths;
public function __construct(NormalizerInterface $decorated, array $oauths)
{
$this->decorated = $decorated;
$this->oauths = $oauths;
}
public function normalize($object, string $format = null, array $context = [])
{
// Retrieve swagger docs
$docs = $this->decorated->normalize($object, $format, $context);
// Add our flows in securitySchemes
$docs['components']['securitySchemes'] = $this->oauths;
$docs['security'] = [array_map(static function($value): array {
return [];
}, $this->oauths)];
return $docs;
}
public function supportsNormalization($data, string $format = null)
{
return $this->decorated->supportsNormalization($data, $format);
}
}
Swagger UI affiche maintenant les deux mode de connection OAuth dans sa popin.
Malheureusement, les champs clientId et clientSecret sont vide. Pour pouvoir appeler nos service, on devra les renseigner.
Comme dis précedemment, Swagger UI ne supporte pas de pouvoir renseigner ces champs lorsqu'on a plusieurs authentifications. Ça arrivera peut-être un jour, suivez cette issue.
En attendant, nous allons tweaker l'interface pour remplir ces champs pour nous.
Pour cela, il va nous falloir faire un override du template SwaggerUi, injecter dedans nos information de client, et enfin ajouter des boutons pour changer à la volée.
Pour l'override du template, créer un fichier templates/bundles/ApiPlatformBundle/SwaggerUi/index.html.twig
:
{% extends '@!ApiPlatform/SwaggerUi/index.html.twig' %}
{% block javascript %}
{{ parent() }}
<script >
var oauths = {{ oauth_providers()|raw }};
</script>
<script src="{{ asset('swagger-oauth.js') }}"></script>
{% endblock %}
La fonction oauth_providers
nous permet de rajouter la configuration de nos providers OAuth.
Ajoutons cette fonction :
php bin/console make:twig-extension OAuthExtension
// src/Twig/OAuthExtension
<?php
declare(strict_types=1);
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
final class OAuthExtension extends AbstractExtension
{
/**
* @var iterable<\League\OAuth2\Client\Provider\AbstractProvider>
*/
private $oauthProviders;
public function __construct(iterable $oauthProviders)
{
$this->oauthProviders = $oauthProviders;
}
public function getFunctions(): array
{
return [
new TwigFunction('oauth_providers', [$this, 'oauthProviders']),
];
}
public function oauthProviders(): string
{
$data = [];
/** @var \League\OAuth2\Client\Provider\AbstractProvider $provider */
foreach ($this->oauthProviders as $provider)
{
$reflectionClass = new \ReflectionClass($provider);
$clientIdProperty = $reflectionClass->getProperty('clientId');
$clientIdProperty->setAccessible(true);
$clientSecretProperty = $reflectionClass->getProperty('clientSecret');
$clientSecretProperty->setAccessible(true);
$clientId = $clientIdProperty->getValue($provider);
$clientSecret = $clientSecretProperty->getValue($provider);
$data[] = [
'name' => strtolower($reflectionClass->getShortName()),
'client_id' => $clientId,
'client_secret' => $clientSecret
];
}
return json_encode($data);
}
}
Ici, on réutilise l'injection des providers taggé oauth_provider
, afin de faire évoluer directement le rendu quand on ajoutera de nouveaux provider.
Maintenant, rajoutons le javascript qui va créer les boutons et appeler la fonction initOauth de swagger :
// public/swagger-oauth.js
'use strict';
window.addEventListener('load', function() {
// Same in init-swagger-ui provided by API Platform
const data = JSON.parse(document.getElementById('swagger-data').innerText);
const ui = SwaggerUIBundle({
spec: data.spec,
dom_id: '#swagger-ui',
validatorUrl: null,
oauth2RedirectUrl: data.oauth.redirectUrl,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset,
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl,
],
layout: 'StandaloneLayout',
});
const authWrapper = document.getElementsByClassName('auth-wrapper')[0];
// Create an info about current credentials
const info = document.createElement("p");
let oauthTxt = "Current OAuth Credentials : ";
info.innerText = oauthTxt + "None";
authWrapper.parentNode.insertBefore(info, authWrapper);
oauths.forEach(function (elem) {
const btn = document.createElement("button");
btn.classList = 'btn';
btn.innerText = 'OAuth ' + elem.name;
btn.onclick = function() {
ui.initOAuth({
clientId: elem.client_id,
clientSecret: elem.client_secret
});
info.innerText = oauthTxt + elem.name;
};
// Add btn near to authorize button
authWrapper.appendChild(btn);
});
});
Voici le rendu :
Au clic sur le bouton OAuth google, les credentials de Google seront préremplis dans la popin.