Last active
May 9, 2024 10:57
-
-
Save rimas-kudelis/7f357e0659952f5639c973406be1e791 to your computer and use it in GitHub Desktop.
Sylius FB login
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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()); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: ~ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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