Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save rimas-kudelis/7f357e0659952f5639c973406be1e791 to your computer and use it in GitHub Desktop.
Save rimas-kudelis/7f357e0659952f5639c973406be1e791 to your computer and use it in GitHub Desktop.
Sylius FB login
<?php
declare(strict_types=1);
namespace App\Security\Authenticator;
use App\Exception\UserAuthenticationException;
use App\Repository\CustomerRepository;
use Psr\Log\LoggerInterface;
use Sylius\Component\User\Model\UserInterface as SyliusUserInterface;
use Sylius\Component\User\Model\UserOAuthInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
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\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
abstract class AbstractExternalTokenAuthenticator extends AbstractAuthenticator
{
public function __construct(
private AuthenticationSuccessHandlerInterface $successHandler,
private AuthenticationFailureHandlerInterface $failureHandler,
private CustomerRepository $customerRepository,
private ?EventDispatcherInterface $eventDispatcher = null,
private ?LoggerInterface $logger = null,
private ?string $loginRoute = null,
) {
}
public function supports(Request $request): bool
{
return $this->loginRoute === $request->getRequestUri() ||
null === $this->loginRoute && null !== $this->getToken($request);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
$this->logger?->info('Authentication request failed.', ['exception' => $exception]);
$response = $this->failureHandler->onAuthenticationFailure($request, $exception);
if (!$response instanceof Response) {
throw new \RuntimeException('Authentication Failure Handler did not return a Response.');
}
return $response;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
$this->logger?->info('User has been authenticated successfully.', ['username' => $token->getUsername()]);
$this->eventDispatcher?->dispatch(
new InteractiveLoginEvent($request, $token),
SecurityEvents::INTERACTIVE_LOGIN,
);
$response = $this->successHandler->onAuthenticationSuccess($request, $token);
if (!$response instanceof Response) {
throw new \RuntimeException('Authentication Success Handler did not return a Response.');
}
return $response;
}
protected function createPassportOrFail(?UserOAuthInterface $oauthUser, string $email): Passport
{
$user = $oauthUser?->getUser();
if (null === $user) {
throw new UserAuthenticationException(
'No user was found for the provided token.',
$this->customerRepository->userExists($email),
Response::HTTP_OK,
);
}
return new Passport(
new UserBadge($user->getEmail()),
new CustomCredentials(
static fn (UserOAuthInterface $oauthUser, UserInterface $user) => // phpcs:ignore
$user instanceof SyliusUserInterface &&
$oauthUser === $user->getOAuthAccount($oauthUser->getProvider()),
$oauthUser,
),
);
}
abstract protected function getToken(Request $request); // phpcs:ignore
}
<?php
declare(strict_types=1);
namespace App\Security\Authenticator;
use App\Exception\UserAuthenticationException;
use App\Repository\CustomerRepository;
use App\Security\FacebookService;
use Psr\Log\LoggerInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
class FacebookOauthTokenAuthenticator extends AbstractExternalTokenAuthenticator
{
private const OAUTH_PROVIDER_NAME = 'facebook';
public function __construct(
private FacebookService $facebookService,
private RepositoryInterface $oauthUserRepository,
AuthenticationSuccessHandlerInterface $successHandler,
AuthenticationFailureHandlerInterface $failureHandler,
CustomerRepository $customerRepository,
?EventDispatcherInterface $eventDispatcher = null,
?LoggerInterface $logger = null,
?string $loginRoute = null,
) {
parent::__construct(
$successHandler,
$failureHandler,
$customerRepository,
$eventDispatcher,
$logger,
$loginRoute,
);
}
public function authenticate(Request $request): Passport
{
$token = $this->getToken($request);
if (!\is_string($token)) {
throw new UserAuthenticationException(
'No Facebook OAuth token was provided, or it is invalid.',
false,
Response::HTTP_BAD_REQUEST,
);
}
$userIdentifier = $this->facebookService->getFacebookUserIdentifier($token);
$oauthUser = $this->oauthUserRepository->findOneBy(
[
'provider' => self::OAUTH_PROVIDER_NAME,
'identifier' => $userIdentifier,
],
);
$userData = $this->facebookService->getFacebookUserData($token);
return $this->createPassportOrFail($oauthUser, $userData['email']);
}
protected function getToken(Request $request): ?string
{
$tokenString = $request->request->get('token');
if (null === $tokenString || '' === $tokenString) {
return null;
}
if (!$this->facebookService->validateAccessToken($tokenString)) {
return null;
}
return $tokenString;
}
}
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\Customer\Customer;
use App\Entity\User\ShopUser;
use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\Component\User\Model\UserOAuthInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class FacebookService implements OAuthAccountAttacherInterface
{
private const PROVIDER_NAME = 'facebook';
public function __construct(
private string $facebookGraphUrl,
private HttpClientInterface $httpClient,
private FactoryInterface $oauthFactory,
) {
}
public function validateAccessToken(string $accessToken): bool
{
$response = $this->httpClient->request(
'GET',
\sprintf(
'%sapp/?access_token=%s',
$this->facebookGraphUrl,
$accessToken,
),
);
return Response::HTTP_OK === $response->getStatusCode();
}
public function getFacebookUserIdentifier(string $accessToken): string
{
$response = $this->httpClient->request(
'GET',
\sprintf(
'%sme?access_token=%s',
$this->facebookGraphUrl,
$accessToken,
),
);
$content = $response->toArray();
return $content['id'];
}
/**
* @return array<string, string>
*/
public function getFacebookUserData(string $accessToken): array
{
return $this->httpClient->request(
'GET',
\sprintf(
'%sv3.1/%s?fields=email,first_name,last_name&access_token=%s',
$this->facebookGraphUrl,
$this->getFacebookUserIdentifier($accessToken),
$accessToken,
),
)->toArray();
}
public function updateUserFromSocialAccount(string $token, ShopUser $user): void
{
$facebookUserData = $this->getFacebookUserData($token);
$user = $this->updateUser($user, $facebookUserData);
$this->updateCustomer($user->getCustomer(), $facebookUserData);
$this->createNewUserOauthOrUpdateExisting($token, $user);
}
public function createNewUserOauthOrUpdateExisting(string $token, ShopUser $user): void
{
$userOAuth = $user->getOAuthAccount(self::PROVIDER_NAME) ?? $this->oauthFactory->createNew();
\assert($userOAuth instanceof UserOAuthInterface);
$facebookUserIdentifier = $this->getFacebookUserIdentifier($token);
$userOAuth->setIdentifier($facebookUserIdentifier);
$userOAuth->setProvider(self::PROVIDER_NAME);
$user->addOAuthAccount($userOAuth);
}
/**
* @param array<string,string> $facebookUserData
*/
private function updateCustomer(?Customer $customer, array $facebookUserData): void
{
$email = $facebookUserData['email'];
$firstName = $facebookUserData['first_name'] ?? '';
$lastName = $facebookUserData['last_name'] ?? '';
$customer?->setFirstName($firstName);
$customer?->setLastName($lastName);
$customer?->setEmail($email);
}
/**
* @param array<string, string> $facebookUserData
*/
private function updateUser(ShopUser $user, array $facebookUserData): ShopUser
{
$email = $facebookUserData['email'];
$user->setEnabled(true);
$user->setUsername($email);
return $user;
}
}
<?php
declare(strict_types=1);
namespace App\Security;
use App\Dto\JwtPayload;
use App\Entity\Customer\Customer;
use App\Entity\User\ShopUser;
use App\Exception\JwtDecodeException;
use App\Exception\RuntimeException;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Psr\Cache\CacheItemInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Sylius\Component\User\Model\UserOAuthInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Webmozart\Assert\Assert;
class JwtProcessor implements OAuthAccountAttacherInterface
{
private const KEY_SET_CACHE_DURATION = 3600;
private string $keySetCacheKey;
/**
* @param string[] $allowedTokenAudiences
* @param string[] $allowedTokenIssuers
*/
public function __construct(
private string $provider,
private array $allowedTokenAudiences,
private array $allowedTokenIssuers,
private string $keySetUrl,
private RepositoryInterface $oauthUserRepository,
private HttpClientInterface $client,
private CacheInterface $cache,
private FactoryInterface $oauthFactory,
) {
$this->keySetCacheKey = sha1($keySetUrl);
}
public function getProvider(): string
{
return $this->provider;
}
public function decodeTokenIfValid(string $encodedToken): JwtPayload
{
$keys = $this->getKeys();
$algorithms = $this->getKeyAlgorithms();
try {
$payload = JWT::decode($encodedToken, $keys, $algorithms);
$this->validatePayload($payload);
return JwtPayload::fromStdClass($payload);
} catch (\Throwable $e) {
throw new JwtDecodeException('Invalid or otherwise unacceptable JWT token.', 0, $e);
}
}
public function findUserOAuthByPayloadIfExists(JwtPayload $token): ?UserOAuthInterface
{
return $this->oauthUserRepository->findOneBy([
'provider' => $this->provider,
'identifier' => $token->getSubject(),
]);
}
/**
* @return array<string, array <string, string>>
*/
public function fetchKeySet(?CacheItemInterface $cacheItem = null): array
{
$response = $this->client->request('GET', $this->keySetUrl);
if (Response::HTTP_OK !== $response->getStatusCode()) {
throw new RuntimeException('Failed to get key set.');
}
if (null !== $cacheItem) {
$cacheItem->expiresAfter(self::KEY_SET_CACHE_DURATION);
}
return $response->toArray();
}
public function updateUserFromSocialAccount(string $token, ShopUser $user): void
{
$payload = $this->decodeTokenIfValid($token);
$user = $this->updateUser($user, $payload);
$this->updateCustomer($user->getCustomer(), $payload);
$this->createNewUserOAuth($user, $payload);
}
public function createNewUserOauthOrUpdateExisting(string $token, ShopUser $user): void
{
$payload = $this->decodeTokenIfValid($token);
$this->createNewUserOAuth($user, $payload);
}
/**
* @return array<string, resource>
*/
private function getKeys(): array
{
return JWK::parseKeySet($this->getKeySet());
}
/**
* @return array<string, array <string, string>>
*/
private function getKeySet(): array
{
return $this->cache->get($this->keySetCacheKey, [$this, 'fetchKeySet']);
}
/**
* @return string[]
*/
private function getKeyAlgorithms(): array
{
$algorithms = [];
$keySet = $this->getKeySet();
Assert::isArray($keySet);
Assert::keyExists($keySet, 'keys');
foreach ($keySet['keys'] as $key) {
Assert::isArray($key);
Assert::keyExists($key, 'alg');
$algorithms[$key['alg']] = $key['alg'];
}
return array_values($algorithms);
}
private function validatePayload(\stdClass $payload): void
{
// Certain things have already been validated at this point. See JWT::decode()
Assert::inArray(
$payload->aud,
$this->allowedTokenAudiences,
'JWT audience mismatch. Expected one of: %2$s. Got: %1$s.',
);
Assert::inArray(
$payload->iss,
$this->allowedTokenIssuers,
'JWT issuer mismatch. Expected one of: %2$s. Got: %1$s.',
);
}
private function updateUser(ShopUser $user, JwtPayload $payload): ShopUser
{
$user->setEnabled(true);
$user->setUsername($payload->getEmail());
return $user;
}
private function createNewUserOAuth(ShopUser $user, JwtPayload $payload): void
{
$userOAuth = $user->getOAuthAccount($this->getProvider()) ?? $this->oauthFactory->createNew();
assert($userOAuth instanceof UserOAuthInterface);
$userOAuth->setIdentifier($payload->getSubject());
$userOAuth->setProvider($this->getProvider());
$user->addOAuthAccount($userOAuth);
}
private function updateCustomer(Customer $customer, JwtPayload $payload): void
{
$customer->setFirstName($payload->getFirstName());
$customer->setLastName($payload->getLastName());
}
}
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User\ShopUser;
interface OAuthAccountAttacherInterface
{
public function updateUserFromSocialAccount(string $token, ShopUser $user): void;
public function createNewUserOauthOrUpdateExisting(string $token, ShopUser $user): void;
}
parameters:
...
app.security.new_api.shop.base_auth_route: "%sylius.security.new_api_shop_route%/authenticate"
app.security.new_api.shop.base_auth_regex: "^%app.security.new_api.shop.base_auth_route%"
app.security.new_api.shop.password_login_route: "%app.security.new_api.shop.base_auth_route%/password"
app.security.new_api.shop.refresh_token_route: "%app.security.new_api.shop.base_auth_route%/refresh-token"
app.security.new_api.shop.facebook_oauth_login_route: "%app.security.new_api.shop.base_auth_route%/facebook-oauth"
app.security.new_api.shop.facebook_jwt_login_route: "%app.security.new_api.shop.base_auth_route%/facebook"
app.security.new_api.shop.google_login_route: "%app.security.new_api.shop.base_auth_route%/google"
services:
# Authenticators are only used by the security layer, so I defined them here instead of in services.yaml
App\Security\Authenticator\ExternalJwtTokenAuthenticator.facebook:
class: App\Security\Authenticator\ExternalJwtTokenAuthenticator
arguments:
$jwtProcessor: '@App\Security\JwtProcessor.facebook'
$successHandler: '@lexik_jwt_authentication.handler.authentication_success'
$failureHandler: '@lexik_jwt_authentication.handler.authentication_failure'
$customerRepository: '@sylius.repository.customer'
$eventDispatcher: '@event_dispatcher'
$logger: '@monolog.logger.security'
$loginRoute: '%app.security.new_api.shop.facebook_jwt_login_route%'
App\Security\Authenticator\ExternalJwtTokenAuthenticator.google:
class: App\Security\Authenticator\ExternalJwtTokenAuthenticator
arguments:
$jwtProcessor: '@App\Security\JwtProcessor.google'
$successHandler: '@lexik_jwt_authentication.handler.authentication_success'
$failureHandler: '@lexik_jwt_authentication.handler.authentication_failure'
$customerRepository: '@sylius.repository.customer'
$eventDispatcher: '@event_dispatcher'
$logger: '@monolog.logger.security'
$loginRoute: '%app.security.new_api.shop.google_login_route%'
App\Security\Authenticator\FacebookOauthTokenAuthenticator:
arguments:
$facebookService: '@App\Security\FacebookService'
$oauthUserRepository: '@sylius.repository.oauth_user'
$successHandler: '@lexik_jwt_authentication.handler.authentication_success'
$failureHandler: '@lexik_jwt_authentication.handler.authentication_failure'
$customerRepository: '@sylius.repository.customer'
$eventDispatcher: '@event_dispatcher'
$logger: '@monolog.logger.security'
$loginRoute: '%app.security.new_api.shop.facebook_oauth_login_route%'
security:
...
firewalls:
...
# Separate firewall for logging in only.
# This allows us to ignore the expired JWT token, if it was sent.
new_api_shop_user_login:
pattern: '%app.security.new_api.shop.base_auth_regex%/.*'
provider: sylius_api_shop_user_provider
stateless: true
refresh_jwt:
check_path: "%app.security.new_api.shop.refresh_token_route%"
json_login:
check_path: "%app.security.new_api.shop.password_login_route%"
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
custom_authenticators:
- App\Security\Authenticator\ExternalJwtTokenAuthenticator.facebook
- App\Security\Authenticator\ExternalJwtTokenAuthenticator.google
- App\Security\Authenticator\FacebookOauthTokenAuthenticator
new_api_shop_user:
pattern: "%sylius.security.new_api_shop_regex%/.*"
provider: sylius_api_shop_user_provider
stateless: true
jwt: ~
services:
...
App\Security\FacebookService:
arguments:
$facebookGraphUrl: 'https://graph.facebook.com/'
$httpClient: '@http_client'
$oauthFactory: '@sylius.factory.oauth_user'
App\Security\JwtProcessor:
autoconfigure: false
App\Security\JwtProcessor.facebook:
class: App\Security\JwtProcessor
arguments:
$provider: 'facebook'
$allowedTokenAudiences: '%env(csv:OAUTH_FACEBOOK_APP_IDS)%'
$allowedTokenIssuers: [ 'https://facebook.com', 'https://www.facebook.com' ]
$keySetUrl: 'https://www.facebook.com/.well-known/oauth/openid/jwks/'
$oauthFactory: '@sylius.factory.oauth_user'
App\Security\JwtProcessor.google:
class: App\Security\JwtProcessor
arguments:
$provider: 'google'
$allowedTokenAudiences: '%env(csv:OAUTH_GOOGLE_CLIENT_IDS)%'
$allowedTokenIssuers: [ 'accounts.google.com', 'https://accounts.google.com' ]
$keySetUrl: 'https://www.googleapis.com/oauth2/v3/certs'
$oauthFactory: '@sylius.factory.oauth_user'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment