first commit

This commit is contained in:
2025-09-18 10:35:25 +02:00
commit 6af1710c2a
61 changed files with 11486 additions and 0 deletions

0
src/Controller/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class MainController extends AbstractController
{
#[Route('/', name: 'app_home')]
public function home(Request $request): Response
{
return new Response('<h1>Hello world</h1>');
}
}

0
src/Entity/.gitignore vendored Normal file
View File

152
src/Hydra/Client.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
namespace App\Hydra;
use App\Hydra\Exception\InvalidChallengeException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class Client
{
private const int MAX_RETRY = 3;
private const array SLEEP_TIME = [
5,
500,
5000,
];
public function __construct(
private readonly HttpClientInterface $client,
private readonly string $hydraAdminBaseUrl
) {
}
public function fetchLoginRequestInfo(string $loginChallenge): ResponseInterface
{
$response = $this->client->request(
'GET',
$this->hydraAdminBaseUrl.'/oauth2/auth/requests/login',
[
'query' => [
'login_challenge' => $loginChallenge,
],
]
);
switch ($response->getStatusCode()) {
case 404:
throw new InvalidChallengeException();
}
return $response;
}
public function fetchLogoutRequestInfo(string $logoutChallenge): ResponseInterface
{
$response = $this->client->request(
'GET',
$this->hydraAdminBaseUrl.'/oauth2/auth/requests/logout',
[
'query' => [
'logout_challenge' => $logoutChallenge,
],
]
);
switch ($response->getStatusCode()) {
case 404:
throw new InvalidChallengeException();
}
return $response;
}
public function fetchConsentRequestInfo(string $consentChallenge): ResponseInterface
{
$attempt = 0;
while ($attempt < self::MAX_RETRY) {
$response = $this->client->request(
'GET',
$this->hydraAdminBaseUrl.'/oauth2/auth/requests/consent',
[
'query' => [
'consent_challenge' => $consentChallenge,
],
]
);
$status = $response->getStatusCode();
if (503 === $status) {
++$attempt;
usleep(1000 * self::SLEEP_TIME[$attempt] + random_int(1, 5) * 1000);
continue;
}
switch ($status) {
case 404:
throw new InvalidChallengeException();
}
break;
}
if (self::MAX_RETRY === $attempt) {
throw new \Exception(sprintf('Fetch consent a rencontré une erreur %s après %s tentatives', $response->getStatusCode(), self::MAX_RETRY));
}
return $response;
}
public function acceptLoginRequest(string $loginChallenge, array $payload): ResponseInterface
{
$response = $this->client->request(
'PUT',
$this->hydraAdminBaseUrl.'/oauth2/auth/requests/login/accept',
[
'query' => [
'login_challenge' => $loginChallenge,
],
'headers' => [
'Content-Type' => 'application/json',
],
'body' => json_encode($payload),
]
);
return $response;
}
public function acceptConsentRequest(string $consentChallenge, array $payload): ResponseInterface
{
$response = $this->client->request(
'PUT',
$this->hydraAdminBaseUrl.'/oauth2/auth/requests/consent/accept',
[
'query' => [
'consent_challenge' => $consentChallenge,
],
'headers' => [
'Content-Type' => 'application/json',
],
'body' => json_encode($payload),
]
);
return $response;
}
public function acceptLogoutRequest(string $logoutChallenge): ResponseInterface
{
$response = $this->client->request(
'PUT',
$this->hydraAdminBaseUrl.'/oauth2/auth/requests/logout/accept',
[
'query' => [
'logout_challenge' => $logoutChallenge,
],
'headers' => [
'Content-Type' => 'application/json',
],
]
);
return $response;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Hydra\Exception;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
class InvalidChallengeException extends BadRequestException
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Hydra\Exception;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
class InvalidIssuerException extends BadRequestException
{
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Hydra;
use App\Entity\User;
use App\Hydra\Exception\InvalidChallengeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class HydraService extends AbstractController
{
public function __construct(
private readonly Client $client,
private readonly RequestStack $requestStack,
private readonly TokenStorageInterface $tokenStorage,
private readonly string $baseUrl
){
}
public function handleLoginRequest(Request $request): RedirectResponse
{
$challenge = $request->query->get('login_challenge');
// S'il n'y a pas de challenge, on déclenche une bad request
if (empty($challenge)) {
throw new InvalidChallengeException();
}
// Fetch Hydra login request info
$res = $this->client->fetchLoginRequestInfo($challenge);
$loginRequestInfo = $res->toArray();
if (200 !== $res->getStatusCode()) {
$this->requestStack->getSession()->clear();
throw new BadRequestException();
}
// si le challenge est validé par hydra, on le stocke en session pour l'utiliser par la suite et on redirige vers une route interne protégée qui va déclencher l'identification FranceConnect
$this->requestStack->getSession()->set('challenge', $loginRequestInfo['challenge']);
return new RedirectResponse($this->baseUrl.'/connect/login-accept');
}
public function handleConsentRequest(Request $request): RedirectResponse
{
$challenge = $request->query->get('consent_challenge');
if (!$challenge) {
throw new BadRequestException("Le challenge n'est pas disponible");
}
$consentRequestInfo = $this->client->fetchConsentRequestInfo($challenge)->toArray();
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedException('Utilisateur non autorisé.');
}
$consentAcceptResponse = $this->client->acceptConsentRequest($consentRequestInfo['challenge'], [
'grant_scope' => $consentRequestInfo['requested_scope'],
'session' => [
'id_token' => $user->getAttributes(),
],
])->toArray();
return new RedirectResponse($consentAcceptResponse['redirect_to']);
}
public function handleLogoutRequest(Request $request): RedirectResponse
{
$logoutChallenge = $request->get('logout_challenge');
if (empty($logoutChallenge)) {
throw new InvalidChallengeException();
}
$logoutRequestInfo = $this->client->fetchLogoutRequestInfo($logoutChallenge)->toArray();
$logoutAcceptRes = $this->client->acceptLogoutRequest($logoutRequestInfo['challenge'])->toArray();
$this->requestStack->getSession()->clear();
$this->tokenStorage->setToken(null);
return new RedirectResponse($logoutAcceptRes['redirect_to']);
}
}

11
src/Kernel.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

0
src/Repository/.gitignore vendored Normal file
View File