<?php
namespace App\Service;
use App\Entity\AnonymousUserInfo;
use App\Entity\Blacklisting;
use App\Entity\GoogleIdentityUserInfo;
use App\Entity\Profile;
use App\Entity\User;
use App\Event\UserRegisteredEvent;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use FOS\UserBundle\Util\TokenGeneratorInterface;
use Google\Service\Oauth2\Userinfo;
use Google_Client;
use Google_Service_Oauth2;
use InvalidArgumentException;
use JanusHercules\DatawarehouseIntegration\Domain\Entity\BusinessEvent;
use JanusHercules\DatawarehouseIntegration\Domain\Service\BusinessEventDomainService;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Throwable;
class GoogleIdentityService
{
public const GOOGLE_CLIENT_CONFIG_PATH = '/config/resources/other-fixtures/google/client_secret_788335927892-ttc3saqioltf5ng0plia8l3635mp3lbr.apps.googleusercontent.com.json';
private LoggerInterface $logger;
private RouterHelperService $routerHelperService;
private EntityManagerInterface $entityManager;
private UserService $userService;
private RegistrationService $registrationService;
private JobseekerProfileService $jobseekerProfileService;
private JoboffererProfileService $joboffererProfileService;
private EventDispatcherInterface $eventDispatcher;
private string $kernelRootDir;
private FileService $fileService;
private AnonymousUserInfoService $anonymousUserInfoService;
private SessionService $sessionService;
private TokenGeneratorInterface $tokenGenerator;
private RequestStack $requestStack;
private Google_Client $client;
private BusinessEventDomainService $businessEventDomainService;
public function __construct(
LoggerInterface $logger,
RouterHelperService $routerHelperService,
EntityManagerInterface $entityManager,
UserService $userService,
RegistrationService $registrationService,
JobseekerProfileService $jobseekerProfileService,
JoboffererProfileService $joboffererProfileService,
EventDispatcherInterface $eventDispatcher,
string $kernelRootDir,
FileService $fileService,
AnonymousUserInfoService $anonymousUserInfoService,
SessionService $sessionService,
TokenGeneratorInterface $tokenGenerator,
RequestStack $requestStack,
Google_Client $client,
BusinessEventDomainService $businessEventDomainService,
private readonly BlacklistingService $blacklistingService
) {
$this->logger = $logger;
$this->routerHelperService = $routerHelperService;
$this->entityManager = $entityManager;
$this->userService = $userService;
$this->registrationService = $registrationService;
$this->jobseekerProfileService = $jobseekerProfileService;
$this->joboffererProfileService = $joboffererProfileService;
$this->eventDispatcher = $eventDispatcher;
$this->kernelRootDir = $kernelRootDir;
$this->fileService = $fileService;
$this->anonymousUserInfoService = $anonymousUserInfoService;
$this->sessionService = $sessionService;
$this->tokenGenerator = $tokenGenerator;
$this->requestStack = $requestStack;
$this->client = $client;
$this->businessEventDomainService = $businessEventDomainService;
}
// User Info is available thanks to our Google Cloud Platform project at
// https://console.cloud.google.com/apis/credentials?project=sigma-crawler-349911
public function getJobseekerUserInfoByOAuth2Code(string $code): ?GoogleIdentityUserInfo
{
try {
$this->configureGoogleClient();
$this->client->setRedirectUri($this->routerHelperService->generate(
'returnurls.google.identity.oauth2.jobseeker',
[],
UrlGeneratorInterface::ABSOLUTE_URL
));
return $this->getUserInfoByOAuth2Code($code);
} catch (Throwable $t) {
$this->logger->error("Error while trying get Google user info for jobseeker by OAuth2 code: {$t->getMessage()}");
return null;
}
}
public function getJoboffererUserInfoByOAuth2Code(string $code): ?GoogleIdentityUserInfo
{
try {
$this->configureGoogleClient();
$this->client->setRedirectUri($this->routerHelperService->generate(
'returnurls.google.identity.oauth2.jobofferer',
[],
UrlGeneratorInterface::ABSOLUTE_URL
));
return $this->getUserInfoByOAuth2Code($code);
} catch (Throwable $t) {
$this->logger->error("Error while trying get Google user info for jobofferer by OAuth2 code: {$t->getMessage()}");
return null;
}
}
public function getLoginUserInfoByOAuth2Code(string $code): ?GoogleIdentityUserInfo
{
try {
$this->configureGoogleClient();
$this->client->setRedirectUri($this->routerHelperService->generate(
'returnurls.google.identity.oauth2.login',
[],
UrlGeneratorInterface::ABSOLUTE_URL
));
return $this->getUserInfoByOAuth2Code($code);
} catch (Throwable $t) {
$this->logger->error("Error while trying get Google user info for login by OAuth2 code: {$t->getMessage()}");
return null;
}
}
public function getJobseekerUserInfoByGsiCredential(string $credential): ?GoogleIdentityUserInfo
{
try {
$this->configureGoogleClient();
$this->client->setRedirectUri($this->routerHelperService->generate(
'returnurls.google.identity.oauth2.jobseeker',
[],
UrlGeneratorInterface::ABSOLUTE_URL
));
return $this->getUserInfoByGsiCredential($credential);
} catch (Throwable $t) {
$this->logger->error("Error while trying to get Google user info for jobseeker by GSI credential: {$t->getMessage()}");
return null;
}
}
public function getJoboffererUserInfoByGsiCredential(string $credential): ?GoogleIdentityUserInfo
{
try {
$this->configureGoogleClient();
$this->client->setRedirectUri($this->routerHelperService->generate(
'returnurls.google.identity.oauth2.jobofferer',
[],
UrlGeneratorInterface::ABSOLUTE_URL
));
return $this->getUserInfoByGsiCredential($credential);
} catch (Throwable $t) {
$this->logger->error("Error while trying to get Google user info for jobofferer by GSI credential: {$t->getMessage()}");
return null;
}
}
public function canBeRegistered(GoogleIdentityUserInfo $userInfo): bool
{
return is_null($this->findUser($userInfo));
}
/** @throws Exception
* @throws \Doctrine\DBAL\Driver\Exception
*/
public function registerJobseekerUser(Request $request, GoogleIdentityUserInfo $userInfo): void
{
if ($this->blacklistingService->isEmailBlacklistedForType(trim(mb_strtolower($userInfo->getEmail())), Blacklisting::BLACKLISTING_TYPE_REGISTRATION)) {
throw new Exception('User is not allowed to register on joboo');
}
$user = $this->registrationService->createRudimentaryUser();
$user->addRole(User::ROLE_NAME_JOBSEEKER);
$user->setEmail($userInfo->getEmail());
$user->setCreatedVia(
$userInfo->getInfoSource() === GoogleIdentityUserInfo::INFO_SOURCE_OAUTH2
? User::CREATED_VIA_GOOGLE_IDENTITY_OAUTH2
: User::CREATED_VIA_GOOGLE_IDENTITY_GSI
);
$user->setConfirmationToken(substr(hash('sha256', $this->tokenGenerator->generateToken()), 0, 20));
$this->entityManager->persist($user);
$jobseekerProfile = $this->jobseekerProfileService->getOrCreateAndGetDefaultProfile($user);
if ($this->sessionService->hasSessionAnonymousUserInfo($this->requestStack->getSession())) {
$anonymousUserInfo = $this->entityManager->find(
AnonymousUserInfo::class,
$this->sessionService->getAnonymousUserInfo($this->requestStack->getSession())
);
$anonymousUserInfo->setToken(urlencode($user->getConfirmationToken()));
$this->entityManager->persist($anonymousUserInfo);
$this->entityManager->flush();
$this->anonymousUserInfoService->prefillJobseekerProfileForm($anonymousUserInfo->getId(), $jobseekerProfile);
}
$jobseekerProfile->setFirstname($userInfo->getGivenName());
$jobseekerProfile->setLastname($userInfo->getFamilyName());
$this->addProfilePictureToProfile($jobseekerProfile, $userInfo->getPicture());
$this->entityManager->persist($jobseekerProfile);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(
new UserRegisteredEvent($user),
UserRegisteredEvent::class
);
// Registering via Google Identity implies account confirmation, because we trust Google that
// there is a real user with a valid email address behind each Google account.
$this->confirm($request, $userInfo);
}
/** @throws Exception */
public function registerJoboffererUser(Request $request, GoogleIdentityUserInfo $userInfo): void
{
if ($this->blacklistingService->isEmailBlacklistedForType(trim(mb_strtolower($userInfo->getEmail())), Blacklisting::BLACKLISTING_TYPE_REGISTRATION)) {
throw new Exception('User is not allowed to register on joboo');
}
$user = $this->registrationService->createRudimentaryUser();
$user->addRole(User::ROLE_NAME_JOBOFFERER);
$user->setEmail($userInfo->getEmail());
$user->setCreatedVia(
$userInfo->getInfoSource() === GoogleIdentityUserInfo::INFO_SOURCE_OAUTH2
? User::CREATED_VIA_GOOGLE_IDENTITY_OAUTH2
: User::CREATED_VIA_GOOGLE_IDENTITY_GSI
);
$user->setConfirmationToken(substr(hash('sha256', $this->tokenGenerator->generateToken()), 0, 20));
$this->entityManager->persist($user);
$joboffererProfile = $this->joboffererProfileService->getOrCreateAndGetDefaultProfile($user);
if ($this->sessionService->hasSessionAnonymousUserInfo($this->requestStack->getSession())) {
$anonymousUserInfo = $this->entityManager->find(
AnonymousUserInfo::class,
$this->sessionService->getAnonymousUserInfo($this->requestStack->getSession())
);
$anonymousUserInfo->setToken(urlencode($user->getConfirmationToken()));
$this->entityManager->persist($anonymousUserInfo);
$this->entityManager->flush();
$this->anonymousUserInfoService->prefillJoboffererProfileForm($anonymousUserInfo->getId(), $joboffererProfile);
}
$joboffererProfile->setFirstname($userInfo->getGivenName());
$joboffererProfile->setLastname($userInfo->getFamilyName());
$this->addProfilePictureToProfile($joboffererProfile, $userInfo->getPicture());
$this->entityManager->persist($joboffererProfile);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(
new UserRegisteredEvent($user),
UserRegisteredEvent::class
);
// Registering via Google Identity implies account confirmation, because we trust Google that
// there is a real user with a valid email address behind each Google account.
$this->confirm($request, $userInfo);
}
/** @throws \Google\Exception */
public function getJobseekerOAuth2TargetUrl(): string
{
$this->configureGoogleClient();
$this->client->setRedirectUri($this->routerHelperService->generate(
'returnurls.google.identity.oauth2.jobseeker',
[],
UrlGeneratorInterface::ABSOLUTE_URL
));
return $this->client->createAuthUrl();
}
/** @throws \Google\Exception */
public function getJoboffererOAuth2TargetUrl(): string
{
$this->configureGoogleClient();
$this->client->setRedirectUri($this->routerHelperService->generate(
'returnurls.google.identity.oauth2.jobofferer',
[],
UrlGeneratorInterface::ABSOLUTE_URL
));
return $this->client->createAuthUrl();
}
/** @throws \Google\Exception */
public function getLoginOAuth2TargetUrl(?User $user): string
{
$this->businessEventDomainService->writeNewEvent(BusinessEvent::EVENT_TYPE_LOG_IN_VIA_GOOGLE_BUTTON_WAS_PRESSED, $user);
$this->configureGoogleClient();
$this->client->setRedirectUri($this->routerHelperService->generate(
'returnurls.google.identity.oauth2.login',
[],
UrlGeneratorInterface::ABSOLUTE_URL
));
return $this->client->createAuthUrl();
}
// This is for the edge case where the user has started the registration "normally" (submitted the initial reg form with 2x mailaddress),
// but then later, before clicking the confirmation link, using the "Register with Google" CTA on the registration page
public function canBeConfirmed(GoogleIdentityUserInfo $userInfo): bool
{
$user = $this->findUser($userInfo);
if (!is_null($user) && !$user->isEnabled()) {
return true;
}
return false;
}
public function confirm(Request $request, GoogleIdentityUserInfo $userInfo): bool
{
if (!$this->canBeConfirmed($userInfo)) {
throw new InvalidArgumentException('Cannot confirm based on Google Identity user info.');
}
$user = $this->findUser($userInfo);
$user->setEnabled(true);
$this->registrationService->handleUserAccountConfirmation($request, $user);
return false;
}
public function canBeLoggedIn(GoogleIdentityUserInfo $userInfo): bool
{
$user = $this->findUser($userInfo);
if (is_null($user) || !$user->isEnabled()) {
return false;
}
return true;
}
public function login(Request $request, GoogleIdentityUserInfo $userInfo): void
{
if (!$this->canBeLoggedIn($userInfo)) {
throw new InvalidArgumentException('Cannot log in based on Google Identity user info.');
}
$user = $this->findUser($userInfo);
$this->userService->login($request, $user);
}
public function handleJobseekerUserInfo(Request $request, GoogleIdentityUserInfo $userInfo): string
{
if ($this->canBeConfirmed($userInfo)) {
$this->confirm($request, $userInfo);
}
if ($this->canBeLoggedIn($userInfo)) {
$this->login($request, $userInfo);
return $this->routerHelperService->generate(
'account.conversations.index_router',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
} elseif ($this->canBeRegistered($userInfo)) {
try {
$this->registerJobseekerUser($request, $userInfo);
} catch (Throwable $t) {
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, "Error while trying to register jobseeker user: '{$t->getMessage()}'.");
}
$this->login($request, $userInfo);
return $this->routerHelperService->generate(
'account.profiles.index',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
} else {
// This can e.g. happen if a user started a "normal" jobseeker registration, but before
// clicking on the activation mail cta, they used the OAuth2 cta on the jobseeker reg page
throw new BadRequestHttpException("Can neither log in nor register user with email {$userInfo->getEmail()}.");
}
}
public function handleJoboffererUserInfo(Request $request, GoogleIdentityUserInfo $userInfo): string
{
if ($this->canBeConfirmed($userInfo)) {
$this->confirm($request, $userInfo);
}
if ($this->canBeLoggedIn($userInfo)) {
$this->login($request, $userInfo);
return $this->routerHelperService->generate(
'account.conversations.index_router',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
} elseif ($this->canBeRegistered($userInfo)) {
try {
$this->registerJoboffererUser($request, $userInfo);
} catch (Throwable $t) {
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, "Error while trying to register jobofferer user: '{$t->getMessage()}'.");
}
$this->login($request, $userInfo);
return $this->routerHelperService->generate(
'account.profiles.index',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
} else {
// This can e.g. happen if a user started a "normal" jobseeker registration, but before
// clicking on the activation mail cta, they used the OAuth2 cta on the jobseeker reg page
throw new BadRequestHttpException("Can neither log in nor register user with email {$userInfo->getEmail()}.");
}
}
private function getUserInfoByOAuth2Code(string $code): ?GoogleIdentityUserInfo
{
try {
$token = $this->client->fetchAccessTokenWithAuthCode($code);
if (isset($token['error'])) {
$this->logger->error("Invalid Google OAuth2 auth code '$code'.");
return null;
}
$oAuth = new Google_Service_Oauth2($this->client);
/** @var Userinfo $userInfo */
$userInfo = $oAuth->userinfo_v2_me->get();
if (is_null($userInfo)) {
$this->logger->error('userinfo_v2_me->get result is null.');
return null;
}
$this->logger->debug('User info is ' . json_encode($userInfo));
return new GoogleIdentityUserInfo(
GoogleIdentityUserInfo::INFO_SOURCE_OAUTH2,
$userInfo->getEmail(),
$userInfo->getGivenName(),
$userInfo->getFamilyName(),
$userInfo->getPicture(),
);
} catch (Throwable $t) {
$this->logger->error("Error while trying get Google user info by OAuth2 code: {$t->getMessage()}");
return null;
}
}
private function getUserInfoByGsiCredential(string $credential): ?GoogleIdentityUserInfo
{
try {
$verifyIdTokenResult = $this->client->verifyIdToken($credential);
if ($verifyIdTokenResult === false) {
$this->logger->error('verify id token result is false.');
return null;
}
if (is_null($verifyIdTokenResult)) {
$this->logger->error('verify id token result is null.');
return null;
}
$this->logger->debug('User info is ' . json_encode($verifyIdTokenResult));
return new GoogleIdentityUserInfo(
GoogleIdentityUserInfo::INFO_SOURCE_GSI,
$verifyIdTokenResult['email'],
array_key_exists('given_name', $verifyIdTokenResult) ? $verifyIdTokenResult['given_name'] : '',
array_key_exists('family_name', $verifyIdTokenResult) ? $verifyIdTokenResult['family_name'] : '',
array_key_exists('picture', $verifyIdTokenResult) ? $verifyIdTokenResult['picture'] : '',
);
} catch (Throwable $t) {
$this->logger->error("Error while trying to get Google user info by GSI credential: {$t->getMessage()}");
return null;
}
}
/** @throws \Google\Exception */
private function configureGoogleClient(): void
{
$this->client = new Google_Client();
$this->client->setAuthConfig($this->kernelRootDir . GoogleIdentityService::GOOGLE_CLIENT_CONFIG_PATH);
$this->client->addScope(['profile', 'email']);
}
private function findUser(GoogleIdentityUserInfo $userInfo): ?User
{
return $this->entityManager->getRepository(User::class)->findOneBy(['emailCanonical' => mb_strtolower($userInfo->getEmail())]);
}
private function addProfilePictureToProfile(Profile $profile, string $url): void
{
$pathname = sys_get_temp_dir() . DIRECTORY_SEPARATOR;
$filename = uniqid() . basename($url); // Adding a uniqe value avoids the edge case that two uploads
// with the same filename are handled at the same time and overwrite each other
$filepath = $pathname . $filename;
if (!copy($url, $filepath)) {
return;
}
$fileNameExtended = $this->fileService->copyLocalFileToGaufretteFilesystem(
$filepath,
'profile_photos_fs'
);
unlink($filepath);
$profile->setPhotoFileName($fileNameExtended);
}
}