src/App/Service/GoogleIdentityService.php line 332

Open in your IDE?
  1. <?php
  2. namespace App\Service;
  3. use App\Entity\AnonymousUserInfo;
  4. use App\Entity\Blacklisting;
  5. use App\Entity\GoogleIdentityUserInfo;
  6. use App\Entity\Profile;
  7. use App\Entity\User;
  8. use App\Event\UserRegisteredEvent;
  9. use Doctrine\ORM\EntityManagerInterface;
  10. use Exception;
  11. use FOS\UserBundle\Util\TokenGeneratorInterface;
  12. use Google\Service\Oauth2\Userinfo;
  13. use Google_Client;
  14. use Google_Service_Oauth2;
  15. use InvalidArgumentException;
  16. use JanusHercules\DatawarehouseIntegration\Domain\Entity\BusinessEvent;
  17. use JanusHercules\DatawarehouseIntegration\Domain\Service\BusinessEventDomainService;
  18. use Psr\Log\LoggerInterface;
  19. use Symfony\Component\HttpFoundation\Request;
  20. use Symfony\Component\HttpFoundation\RequestStack;
  21. use Symfony\Component\HttpFoundation\Response;
  22. use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
  23. use Symfony\Component\HttpKernel\Exception\HttpException;
  24. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  25. use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  26. use Throwable;
  27. class GoogleIdentityService
  28. {
  29. public const GOOGLE_CLIENT_CONFIG_PATH = '/config/resources/other-fixtures/google/client_secret_788335927892-ttc3saqioltf5ng0plia8l3635mp3lbr.apps.googleusercontent.com.json';
  30. private LoggerInterface $logger;
  31. private RouterHelperService $routerHelperService;
  32. private EntityManagerInterface $entityManager;
  33. private UserService $userService;
  34. private RegistrationService $registrationService;
  35. private JobseekerProfileService $jobseekerProfileService;
  36. private JoboffererProfileService $joboffererProfileService;
  37. private EventDispatcherInterface $eventDispatcher;
  38. private string $kernelRootDir;
  39. private FileService $fileService;
  40. private AnonymousUserInfoService $anonymousUserInfoService;
  41. private SessionService $sessionService;
  42. private TokenGeneratorInterface $tokenGenerator;
  43. private RequestStack $requestStack;
  44. private Google_Client $client;
  45. private BusinessEventDomainService $businessEventDomainService;
  46. public function __construct(
  47. LoggerInterface $logger,
  48. RouterHelperService $routerHelperService,
  49. EntityManagerInterface $entityManager,
  50. UserService $userService,
  51. RegistrationService $registrationService,
  52. JobseekerProfileService $jobseekerProfileService,
  53. JoboffererProfileService $joboffererProfileService,
  54. EventDispatcherInterface $eventDispatcher,
  55. string $kernelRootDir,
  56. FileService $fileService,
  57. AnonymousUserInfoService $anonymousUserInfoService,
  58. SessionService $sessionService,
  59. TokenGeneratorInterface $tokenGenerator,
  60. RequestStack $requestStack,
  61. Google_Client $client,
  62. BusinessEventDomainService $businessEventDomainService,
  63. private readonly BlacklistingService $blacklistingService
  64. ) {
  65. $this->logger = $logger;
  66. $this->routerHelperService = $routerHelperService;
  67. $this->entityManager = $entityManager;
  68. $this->userService = $userService;
  69. $this->registrationService = $registrationService;
  70. $this->jobseekerProfileService = $jobseekerProfileService;
  71. $this->joboffererProfileService = $joboffererProfileService;
  72. $this->eventDispatcher = $eventDispatcher;
  73. $this->kernelRootDir = $kernelRootDir;
  74. $this->fileService = $fileService;
  75. $this->anonymousUserInfoService = $anonymousUserInfoService;
  76. $this->sessionService = $sessionService;
  77. $this->tokenGenerator = $tokenGenerator;
  78. $this->requestStack = $requestStack;
  79. $this->client = $client;
  80. $this->businessEventDomainService = $businessEventDomainService;
  81. }
  82. // User Info is available thanks to our Google Cloud Platform project at
  83. // https://console.cloud.google.com/apis/credentials?project=sigma-crawler-349911
  84. public function getJobseekerUserInfoByOAuth2Code(string $code): ?GoogleIdentityUserInfo
  85. {
  86. try {
  87. $this->configureGoogleClient();
  88. $this->client->setRedirectUri($this->routerHelperService->generate(
  89. 'returnurls.google.identity.oauth2.jobseeker',
  90. [],
  91. UrlGeneratorInterface::ABSOLUTE_URL
  92. ));
  93. return $this->getUserInfoByOAuth2Code($code);
  94. } catch (Throwable $t) {
  95. $this->logger->error("Error while trying get Google user info for jobseeker by OAuth2 code: {$t->getMessage()}");
  96. return null;
  97. }
  98. }
  99. public function getJoboffererUserInfoByOAuth2Code(string $code): ?GoogleIdentityUserInfo
  100. {
  101. try {
  102. $this->configureGoogleClient();
  103. $this->client->setRedirectUri($this->routerHelperService->generate(
  104. 'returnurls.google.identity.oauth2.jobofferer',
  105. [],
  106. UrlGeneratorInterface::ABSOLUTE_URL
  107. ));
  108. return $this->getUserInfoByOAuth2Code($code);
  109. } catch (Throwable $t) {
  110. $this->logger->error("Error while trying get Google user info for jobofferer by OAuth2 code: {$t->getMessage()}");
  111. return null;
  112. }
  113. }
  114. public function getLoginUserInfoByOAuth2Code(string $code): ?GoogleIdentityUserInfo
  115. {
  116. try {
  117. $this->configureGoogleClient();
  118. $this->client->setRedirectUri($this->routerHelperService->generate(
  119. 'returnurls.google.identity.oauth2.login',
  120. [],
  121. UrlGeneratorInterface::ABSOLUTE_URL
  122. ));
  123. return $this->getUserInfoByOAuth2Code($code);
  124. } catch (Throwable $t) {
  125. $this->logger->error("Error while trying get Google user info for login by OAuth2 code: {$t->getMessage()}");
  126. return null;
  127. }
  128. }
  129. public function getJobseekerUserInfoByGsiCredential(string $credential): ?GoogleIdentityUserInfo
  130. {
  131. try {
  132. $this->configureGoogleClient();
  133. $this->client->setRedirectUri($this->routerHelperService->generate(
  134. 'returnurls.google.identity.oauth2.jobseeker',
  135. [],
  136. UrlGeneratorInterface::ABSOLUTE_URL
  137. ));
  138. return $this->getUserInfoByGsiCredential($credential);
  139. } catch (Throwable $t) {
  140. $this->logger->error("Error while trying to get Google user info for jobseeker by GSI credential: {$t->getMessage()}");
  141. return null;
  142. }
  143. }
  144. public function getJoboffererUserInfoByGsiCredential(string $credential): ?GoogleIdentityUserInfo
  145. {
  146. try {
  147. $this->configureGoogleClient();
  148. $this->client->setRedirectUri($this->routerHelperService->generate(
  149. 'returnurls.google.identity.oauth2.jobofferer',
  150. [],
  151. UrlGeneratorInterface::ABSOLUTE_URL
  152. ));
  153. return $this->getUserInfoByGsiCredential($credential);
  154. } catch (Throwable $t) {
  155. $this->logger->error("Error while trying to get Google user info for jobofferer by GSI credential: {$t->getMessage()}");
  156. return null;
  157. }
  158. }
  159. public function canBeRegistered(GoogleIdentityUserInfo $userInfo): bool
  160. {
  161. return is_null($this->findUser($userInfo));
  162. }
  163. /** @throws Exception
  164. * @throws \Doctrine\DBAL\Driver\Exception
  165. */
  166. public function registerJobseekerUser(Request $request, GoogleIdentityUserInfo $userInfo): void
  167. {
  168. if ($this->blacklistingService->isEmailBlacklistedForType(trim(mb_strtolower($userInfo->getEmail())), Blacklisting::BLACKLISTING_TYPE_REGISTRATION)) {
  169. throw new Exception('User is not allowed to register on joboo');
  170. }
  171. $user = $this->registrationService->createRudimentaryUser();
  172. $user->addRole(User::ROLE_NAME_JOBSEEKER);
  173. $user->setEmail($userInfo->getEmail());
  174. $user->setCreatedVia(
  175. $userInfo->getInfoSource() === GoogleIdentityUserInfo::INFO_SOURCE_OAUTH2
  176. ? User::CREATED_VIA_GOOGLE_IDENTITY_OAUTH2
  177. : User::CREATED_VIA_GOOGLE_IDENTITY_GSI
  178. );
  179. $user->setConfirmationToken(substr(hash('sha256', $this->tokenGenerator->generateToken()), 0, 20));
  180. $this->entityManager->persist($user);
  181. $jobseekerProfile = $this->jobseekerProfileService->getOrCreateAndGetDefaultProfile($user);
  182. if ($this->sessionService->hasSessionAnonymousUserInfo($this->requestStack->getSession())) {
  183. $anonymousUserInfo = $this->entityManager->find(
  184. AnonymousUserInfo::class,
  185. $this->sessionService->getAnonymousUserInfo($this->requestStack->getSession())
  186. );
  187. $anonymousUserInfo->setToken(urlencode($user->getConfirmationToken()));
  188. $this->entityManager->persist($anonymousUserInfo);
  189. $this->entityManager->flush();
  190. $this->anonymousUserInfoService->prefillJobseekerProfileForm($anonymousUserInfo->getId(), $jobseekerProfile);
  191. }
  192. $jobseekerProfile->setFirstname($userInfo->getGivenName());
  193. $jobseekerProfile->setLastname($userInfo->getFamilyName());
  194. $this->addProfilePictureToProfile($jobseekerProfile, $userInfo->getPicture());
  195. $this->entityManager->persist($jobseekerProfile);
  196. $this->entityManager->flush();
  197. $this->eventDispatcher->dispatch(
  198. new UserRegisteredEvent($user),
  199. UserRegisteredEvent::class
  200. );
  201. // Registering via Google Identity implies account confirmation, because we trust Google that
  202. // there is a real user with a valid email address behind each Google account.
  203. $this->confirm($request, $userInfo);
  204. }
  205. /** @throws Exception */
  206. public function registerJoboffererUser(Request $request, GoogleIdentityUserInfo $userInfo): void
  207. {
  208. if ($this->blacklistingService->isEmailBlacklistedForType(trim(mb_strtolower($userInfo->getEmail())), Blacklisting::BLACKLISTING_TYPE_REGISTRATION)) {
  209. throw new Exception('User is not allowed to register on joboo');
  210. }
  211. $user = $this->registrationService->createRudimentaryUser();
  212. $user->addRole(User::ROLE_NAME_JOBOFFERER);
  213. $user->setEmail($userInfo->getEmail());
  214. $user->setCreatedVia(
  215. $userInfo->getInfoSource() === GoogleIdentityUserInfo::INFO_SOURCE_OAUTH2
  216. ? User::CREATED_VIA_GOOGLE_IDENTITY_OAUTH2
  217. : User::CREATED_VIA_GOOGLE_IDENTITY_GSI
  218. );
  219. $user->setConfirmationToken(substr(hash('sha256', $this->tokenGenerator->generateToken()), 0, 20));
  220. $this->entityManager->persist($user);
  221. $joboffererProfile = $this->joboffererProfileService->getOrCreateAndGetDefaultProfile($user);
  222. if ($this->sessionService->hasSessionAnonymousUserInfo($this->requestStack->getSession())) {
  223. $anonymousUserInfo = $this->entityManager->find(
  224. AnonymousUserInfo::class,
  225. $this->sessionService->getAnonymousUserInfo($this->requestStack->getSession())
  226. );
  227. $anonymousUserInfo->setToken(urlencode($user->getConfirmationToken()));
  228. $this->entityManager->persist($anonymousUserInfo);
  229. $this->entityManager->flush();
  230. $this->anonymousUserInfoService->prefillJoboffererProfileForm($anonymousUserInfo->getId(), $joboffererProfile);
  231. }
  232. $joboffererProfile->setFirstname($userInfo->getGivenName());
  233. $joboffererProfile->setLastname($userInfo->getFamilyName());
  234. $this->addProfilePictureToProfile($joboffererProfile, $userInfo->getPicture());
  235. $this->entityManager->persist($joboffererProfile);
  236. $this->entityManager->flush();
  237. $this->eventDispatcher->dispatch(
  238. new UserRegisteredEvent($user),
  239. UserRegisteredEvent::class
  240. );
  241. // Registering via Google Identity implies account confirmation, because we trust Google that
  242. // there is a real user with a valid email address behind each Google account.
  243. $this->confirm($request, $userInfo);
  244. }
  245. /** @throws \Google\Exception */
  246. public function getJobseekerOAuth2TargetUrl(): string
  247. {
  248. $this->configureGoogleClient();
  249. $this->client->setRedirectUri($this->routerHelperService->generate(
  250. 'returnurls.google.identity.oauth2.jobseeker',
  251. [],
  252. UrlGeneratorInterface::ABSOLUTE_URL
  253. ));
  254. return $this->client->createAuthUrl();
  255. }
  256. /** @throws \Google\Exception */
  257. public function getJoboffererOAuth2TargetUrl(): string
  258. {
  259. $this->configureGoogleClient();
  260. $this->client->setRedirectUri($this->routerHelperService->generate(
  261. 'returnurls.google.identity.oauth2.jobofferer',
  262. [],
  263. UrlGeneratorInterface::ABSOLUTE_URL
  264. ));
  265. return $this->client->createAuthUrl();
  266. }
  267. /** @throws \Google\Exception */
  268. public function getLoginOAuth2TargetUrl(?User $user): string
  269. {
  270. $this->businessEventDomainService->writeNewEvent(BusinessEvent::EVENT_TYPE_LOG_IN_VIA_GOOGLE_BUTTON_WAS_PRESSED, $user);
  271. $this->configureGoogleClient();
  272. $this->client->setRedirectUri($this->routerHelperService->generate(
  273. 'returnurls.google.identity.oauth2.login',
  274. [],
  275. UrlGeneratorInterface::ABSOLUTE_URL
  276. ));
  277. return $this->client->createAuthUrl();
  278. }
  279. // This is for the edge case where the user has started the registration "normally" (submitted the initial reg form with 2x mailaddress),
  280. // but then later, before clicking the confirmation link, using the "Register with Google" CTA on the registration page
  281. public function canBeConfirmed(GoogleIdentityUserInfo $userInfo): bool
  282. {
  283. $user = $this->findUser($userInfo);
  284. if (!is_null($user) && !$user->isEnabled()) {
  285. return true;
  286. }
  287. return false;
  288. }
  289. public function confirm(Request $request, GoogleIdentityUserInfo $userInfo): bool
  290. {
  291. if (!$this->canBeConfirmed($userInfo)) {
  292. throw new InvalidArgumentException('Cannot confirm based on Google Identity user info.');
  293. }
  294. $user = $this->findUser($userInfo);
  295. $user->setEnabled(true);
  296. $this->registrationService->handleUserAccountConfirmation($request, $user);
  297. return false;
  298. }
  299. public function canBeLoggedIn(GoogleIdentityUserInfo $userInfo): bool
  300. {
  301. $user = $this->findUser($userInfo);
  302. if (is_null($user) || !$user->isEnabled()) {
  303. return false;
  304. }
  305. return true;
  306. }
  307. public function login(Request $request, GoogleIdentityUserInfo $userInfo): void
  308. {
  309. if (!$this->canBeLoggedIn($userInfo)) {
  310. throw new InvalidArgumentException('Cannot log in based on Google Identity user info.');
  311. }
  312. $user = $this->findUser($userInfo);
  313. $this->userService->login($request, $user);
  314. }
  315. public function handleJobseekerUserInfo(Request $request, GoogleIdentityUserInfo $userInfo): string
  316. {
  317. if ($this->canBeConfirmed($userInfo)) {
  318. $this->confirm($request, $userInfo);
  319. }
  320. if ($this->canBeLoggedIn($userInfo)) {
  321. $this->login($request, $userInfo);
  322. return $this->routerHelperService->generate(
  323. 'account.conversations.index_router',
  324. [],
  325. UrlGeneratorInterface::ABSOLUTE_URL
  326. );
  327. } elseif ($this->canBeRegistered($userInfo)) {
  328. try {
  329. $this->registerJobseekerUser($request, $userInfo);
  330. } catch (Throwable $t) {
  331. throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, "Error while trying to register jobseeker user: '{$t->getMessage()}'.");
  332. }
  333. $this->login($request, $userInfo);
  334. return $this->routerHelperService->generate(
  335. 'account.profiles.index',
  336. [],
  337. UrlGeneratorInterface::ABSOLUTE_URL
  338. );
  339. } else {
  340. // This can e.g. happen if a user started a "normal" jobseeker registration, but before
  341. // clicking on the activation mail cta, they used the OAuth2 cta on the jobseeker reg page
  342. throw new BadRequestHttpException("Can neither log in nor register user with email {$userInfo->getEmail()}.");
  343. }
  344. }
  345. public function handleJoboffererUserInfo(Request $request, GoogleIdentityUserInfo $userInfo): string
  346. {
  347. if ($this->canBeConfirmed($userInfo)) {
  348. $this->confirm($request, $userInfo);
  349. }
  350. if ($this->canBeLoggedIn($userInfo)) {
  351. $this->login($request, $userInfo);
  352. return $this->routerHelperService->generate(
  353. 'account.conversations.index_router',
  354. [],
  355. UrlGeneratorInterface::ABSOLUTE_URL
  356. );
  357. } elseif ($this->canBeRegistered($userInfo)) {
  358. try {
  359. $this->registerJoboffererUser($request, $userInfo);
  360. } catch (Throwable $t) {
  361. throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, "Error while trying to register jobofferer user: '{$t->getMessage()}'.");
  362. }
  363. $this->login($request, $userInfo);
  364. return $this->routerHelperService->generate(
  365. 'account.profiles.index',
  366. [],
  367. UrlGeneratorInterface::ABSOLUTE_URL
  368. );
  369. } else {
  370. // This can e.g. happen if a user started a "normal" jobseeker registration, but before
  371. // clicking on the activation mail cta, they used the OAuth2 cta on the jobseeker reg page
  372. throw new BadRequestHttpException("Can neither log in nor register user with email {$userInfo->getEmail()}.");
  373. }
  374. }
  375. private function getUserInfoByOAuth2Code(string $code): ?GoogleIdentityUserInfo
  376. {
  377. try {
  378. $token = $this->client->fetchAccessTokenWithAuthCode($code);
  379. if (isset($token['error'])) {
  380. $this->logger->error("Invalid Google OAuth2 auth code '$code'.");
  381. return null;
  382. }
  383. $oAuth = new Google_Service_Oauth2($this->client);
  384. /** @var Userinfo $userInfo */
  385. $userInfo = $oAuth->userinfo_v2_me->get();
  386. if (is_null($userInfo)) {
  387. $this->logger->error('userinfo_v2_me->get result is null.');
  388. return null;
  389. }
  390. $this->logger->debug('User info is ' . json_encode($userInfo));
  391. return new GoogleIdentityUserInfo(
  392. GoogleIdentityUserInfo::INFO_SOURCE_OAUTH2,
  393. $userInfo->getEmail(),
  394. $userInfo->getGivenName(),
  395. $userInfo->getFamilyName(),
  396. $userInfo->getPicture(),
  397. );
  398. } catch (Throwable $t) {
  399. $this->logger->error("Error while trying get Google user info by OAuth2 code: {$t->getMessage()}");
  400. return null;
  401. }
  402. }
  403. private function getUserInfoByGsiCredential(string $credential): ?GoogleIdentityUserInfo
  404. {
  405. try {
  406. $verifyIdTokenResult = $this->client->verifyIdToken($credential);
  407. if ($verifyIdTokenResult === false) {
  408. $this->logger->error('verify id token result is false.');
  409. return null;
  410. }
  411. if (is_null($verifyIdTokenResult)) {
  412. $this->logger->error('verify id token result is null.');
  413. return null;
  414. }
  415. $this->logger->debug('User info is ' . json_encode($verifyIdTokenResult));
  416. return new GoogleIdentityUserInfo(
  417. GoogleIdentityUserInfo::INFO_SOURCE_GSI,
  418. $verifyIdTokenResult['email'],
  419. array_key_exists('given_name', $verifyIdTokenResult) ? $verifyIdTokenResult['given_name'] : '',
  420. array_key_exists('family_name', $verifyIdTokenResult) ? $verifyIdTokenResult['family_name'] : '',
  421. array_key_exists('picture', $verifyIdTokenResult) ? $verifyIdTokenResult['picture'] : '',
  422. );
  423. } catch (Throwable $t) {
  424. $this->logger->error("Error while trying to get Google user info by GSI credential: {$t->getMessage()}");
  425. return null;
  426. }
  427. }
  428. /** @throws \Google\Exception */
  429. private function configureGoogleClient(): void
  430. {
  431. $this->client = new Google_Client();
  432. $this->client->setAuthConfig($this->kernelRootDir . GoogleIdentityService::GOOGLE_CLIENT_CONFIG_PATH);
  433. $this->client->addScope(['profile', 'email']);
  434. }
  435. private function findUser(GoogleIdentityUserInfo $userInfo): ?User
  436. {
  437. return $this->entityManager->getRepository(User::class)->findOneBy(['emailCanonical' => mb_strtolower($userInfo->getEmail())]);
  438. }
  439. private function addProfilePictureToProfile(Profile $profile, string $url): void
  440. {
  441. $pathname = sys_get_temp_dir() . DIRECTORY_SEPARATOR;
  442. $filename = uniqid() . basename($url); // Adding a uniqe value avoids the edge case that two uploads
  443. // with the same filename are handled at the same time and overwrite each other
  444. $filepath = $pathname . $filename;
  445. if (!copy($url, $filepath)) {
  446. return;
  447. }
  448. $fileNameExtended = $this->fileService->copyLocalFileToGaufretteFilesystem(
  449. $filepath,
  450. 'profile_photos_fs'
  451. );
  452. unlink($filepath);
  453. $profile->setPhotoFileName($fileNameExtended);
  454. }
  455. }