<?php
/*
* This file is part of the HWIOAuthBundle package.
*
* (c) Hardware Info <opensource@hardware.info>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace HWI\Bundle\OAuthBundle\Security\Http\Authenticator;
use HWI\Bundle\OAuthBundle\OAuth\ResourceOwnerInterface;
use HWI\Bundle\OAuthBundle\OAuth\State\State;
use HWI\Bundle\OAuthBundle\Security\Core\Authentication\Token\OAuthToken;
use HWI\Bundle\OAuthBundle\Security\Core\Exception\OAuthAwareExceptionInterface;
use HWI\Bundle\OAuthBundle\Security\Core\User\OAuthAwareUserProviderInterface;
use HWI\Bundle\OAuthBundle\Security\Http\Authenticator\Passport\Badge\OAuthTokenBadge;
use HWI\Bundle\OAuthBundle\Security\Http\ResourceOwnerMapInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\AuthenticationServiceException;
use Symfony\Component\Security\Core\Exception\LazyResponseException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Component\Security\Http\HttpUtils;
/**
* @author Vadim Borodavko <vadim.borodavko@gmail.com>
*/
final class OAuthAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface, InteractiveAuthenticatorInterface
{
/**
* @param string[] $checkPaths
*/
public function __construct(
private readonly HttpUtils $httpUtils,
private readonly OAuthAwareUserProviderInterface $userProvider,
private readonly ResourceOwnerMapInterface $resourceOwnerMap,
private readonly array $checkPaths,
private readonly AuthenticationSuccessHandlerInterface $successHandler,
private readonly AuthenticationFailureHandlerInterface $failureHandler,
private readonly HttpKernelInterface $kernel,
private readonly array $options
) {
}
public function supports(Request $request): bool
{
foreach ($this->checkPaths as $checkPath) {
if ($this->httpUtils->checkRequestPath($request, $checkPath)) {
return true;
}
}
return false;
}
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
if ($this->options['use_forward'] ?? false) {
$subRequest = $this->httpUtils->createRequest($request, $this->options['login_path']);
$iterator = $request->query->getIterator();
$subRequest->query->add(iterator_to_array($iterator));
$response = $this->kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
if (200 === $response->getStatusCode()) {
$response->headers->set('X-Status-Code', '401');
}
return $response;
}
return new RedirectResponse($this->httpUtils->generateUri($request, $this->options['login_path']));
}
/**
* @throws AuthenticationException
* @throws LazyResponseException
*/
public function authenticate(Request $request): Passport
{
[$resourceOwner, $checkPath] = $this->resourceOwnerMap->getResourceOwnerByRequest($request);
if (!$resourceOwner instanceof ResourceOwnerInterface) {
throw new AuthenticationException('No resource owner match the request.');
}
if (!$resourceOwner->handles($request)) {
throw new AuthenticationException('No oauth code in the request.');
}
// If resource owner supports only one url authentication, call redirect
if ($request->query->has('authenticated') && $resourceOwner->getOption('auth_with_one_url')) {
$request->attributes->set('service', $resourceOwner->getName());
throw new LazyResponseException(new RedirectResponse(sprintf('%s?code=%s&authenticated=true', $this->httpUtils->generateUri($request, 'hwi_oauth_connect_service'), $request->query->get('code'))));
}
$resourceOwner->isCsrfTokenValid(
$this->extractCsrfTokenFromState($request->get('state'))
);
$accessToken = $resourceOwner->getAccessToken(
$request,
$this->httpUtils->createRequest($request, $checkPath)->getUri()
);
$token = new OAuthToken($accessToken);
$token->setResourceOwnerName($resourceOwner->getName());
$token = $this->refreshToken($token);
$user = $token->getUser();
$userBadge = class_exists(UserBadge::class)
? new UserBadge(
$user ? method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername() : null,
static function () use ($user) { return $user; }
)
: $user;
return new SelfValidatingPassport($userBadge, [new RememberMeBadge(), new OAuthTokenBadge($token)]);
}
/**
* This function can be used for refreshing an expired token
* or for custom "password grant" authenticator, if site owner also owns oauth instance.
*
* @template T of OAuthToken
*
* @param T $token
*
* @return T
*/
public function refreshToken(OAuthToken $token): OAuthToken
{
if (!$token->isExpired() && null !== $token->getUser()) {
return $this->recreateToken($token, $token->getUser());
}
$resourceOwner = $this->resourceOwnerMap->getResourceOwnerByName($token->getResourceOwnerName());
if (!$resourceOwner) {
throw new AuthenticationServiceException('Unknown resource owner set on token: '.$token->getResourceOwnerName());
}
if ($token->isExpired()) {
$expiredToken = $token;
if ($refreshToken = $expiredToken->getRefreshToken()) {
$tokenClass = $expiredToken::class;
$token = new $tokenClass($resourceOwner->refreshAccessToken($refreshToken));
$token->setResourceOwnerName($expiredToken->getResourceOwnerName());
if (!$token->getRefreshToken()) {
$token->setRefreshToken($expiredToken->getRefreshToken());
}
$token->copyPersistentDataFrom($expiredToken);
} else {
// if you cannot refresh token, you do not need to make user_info request to oauth-resource
if (null !== $expiredToken->getUser()) {
return $expiredToken;
}
}
unset($expiredToken);
}
$userResponse = $resourceOwner->getUserInformation($token->getRawToken());
try {
$user = $this->userProvider->loadUserByOAuthUserResponse($userResponse);
} catch (OAuthAwareExceptionInterface $e) {
$e->setToken($token);
$e->setResourceOwnerName($token->getResourceOwnerName());
throw $e;
}
if (!$user instanceof UserInterface) {
throw new AuthenticationServiceException('loadUserByOAuthUserResponse() must return a UserInterface.');
}
return $this->recreateToken($token, $user);
}
/**
* @template T of OAuthToken
*
* @param T $token
* @param ?UserInterface $user
*
* @return T
*/
public function recreateToken(OAuthToken $token, ?UserInterface $user = null): OAuthToken
{
$user = $user instanceof UserInterface ? $user : $token->getUser();
$tokenClass = $token::class;
if ($user) {
$newToken = new $tokenClass(
$token->getRawToken(),
method_exists($user, 'getRoles') ? $user->getRoles() : []
);
$newToken->setUser($user);
} else {
$newToken = new $tokenClass($token->getRawToken());
}
$newToken->setResourceOwnerName($token->getResourceOwnerName());
$newToken->setRefreshToken($token->getRefreshToken());
$newToken->setCreatedAt($token->getCreatedAt());
$newToken->setTokenSecret($token->getTokenSecret());
$newToken->setAttributes($token->getAttributes());
// required for compatibility with Symfony 5.4
if (method_exists($newToken, 'setAuthenticated')) {
$newToken->setAuthenticated((bool) $user, false);
}
$newToken->copyPersistentDataFrom($token);
return $newToken;
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
return $this->createAuthenticatedToken($passport, $firewallName);
}
/**
* @param Passport $passport
*/
public function createAuthenticatedToken($passport, string $firewallName): TokenInterface
{
$badge = $passport->getBadge(OAuthTokenBadge::class);
if ($badge instanceof OAuthTokenBadge) {
return $badge->getToken();
}
throw new \LogicException(sprintf('Given passport must contain instance of "%s".', OAuthTokenBadge::class));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->successHandler->onAuthenticationSuccess($request, $token);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
return $this->failureHandler->onAuthenticationFailure($request, $exception);
}
public function isInteractive(): bool
{
return true;
}
private function extractCsrfTokenFromState(?string $stateParameter): ?string
{
$state = new State($stateParameter);
return $state->getCsrfToken() ?: $stateParameter;
}
}