diff --git a/.env b/.env index 76c46ee..453697b 100644 --- a/.env +++ b/.env @@ -60,6 +60,13 @@ APP_DEFAULT_EMAIL_TO=team@ep-reisen.de XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" +MYEP_OAUTH2_CLIENT_ID= +MYEP_OAUTH2_CLIENT_SECRET= +MYEP_OAUTH2_URL_AUTHORIZE=https://my.ep-reisen.de/authorize +MYEP_OAUTH2_URL_ACCESS_TOKEN=https://my.ep-reisen.de/token +MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo +MYEP_OAUTH2_SCOPES=email,id,roles,profile + ###> symfony/mailjet-mailer ### # MAILER_DSN=mailjet+api://PUBLIC_KEY:PRIVATE_KEY@api.mailjet.com # #MAILER_DSN=mailjet+smtp://PUBLIC_KEY:PRIVATE_KEY@in-v3.mailjet.com diff --git a/composer.json b/composer.json index 6e1e7a8..19c35c6 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "knplabs/knp-paginator-bundle": "^6.2", "league/flysystem-bundle": "^3.4", "league/flysystem-sftp-v3": "^3.29", + "league/oauth2-client": "^2.9", "liip/imagine-bundle": "^2.11", "maennchen/zipstream-php": "^3.1", "nelmio/security-bundle": "^3.0", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 7400747..6812335 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -20,6 +20,7 @@ security: lazy: true provider: bpn_user_provider custom_authenticators: + - App\Security\MyEpAuthenticator - App\Security\BpnAuthenticator user_checker: App\Security\UserChecker switch_user: diff --git a/config/services.yaml b/config/services.yaml index 5a125ff..2368909 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -230,3 +230,13 @@ services: $defaults: from: '%default_email_from%' to: '%default_email_to%' + + App\Security\OAuth2\MyEpClient: + arguments: + $options: + myep_oauth2_client_id: '%env(MYEP_OAUTH2_CLIENT_ID)%' + myep_oauth2_client_secret: '%env(MYEP_OAUTH2_CLIENT_SECRET)%' + myep_oauth2_url_authorize: '%env(MYEP_OAUTH2_URL_AUTHORIZE)%' + myep_oauth2_url_access_token: '%env(MYEP_OAUTH2_URL_ACCESS_TOKEN)%' + myep_oauth2_url_resource_owner_details: '%env(MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS)%' + myep_oauth2_scopes: '%env(csv:MYEP_OAUTH2_SCOPES)%' diff --git a/src/Controller/Security/OAuth2Controller.php b/src/Controller/Security/OAuth2Controller.php new file mode 100644 index 0000000..b44e520 --- /dev/null +++ b/src/Controller/Security/OAuth2Controller.php @@ -0,0 +1,32 @@ +client->getProvider(); + $url = $provider->getAuthorizationUrl(); + $state = $provider->getState(); + $request->getSession()->set('oauth2state', $state); + + return $this->redirect($url); + } + + #[Route('/myep-auth/check', name: 'app_myep_auth_check')] + public function check(): void + { + } +} diff --git a/src/Entity/Embeddable/Address.php b/src/Entity/Embeddable/Address.php index 7e4a0bd..bcc4f63 100644 --- a/src/Entity/Embeddable/Address.php +++ b/src/Entity/Embeddable/Address.php @@ -83,6 +83,19 @@ class Address return $instance; } + public static function fromUserinfo(array $userinfo): static + { + $instance = new static(); + $instance + ->setStreet($userinfo['profile']['address']['street'] ?? null) + ->setPostCode($userinfo['profile']['address']['postcode'] ?? null) + ->setCity($userinfo['profile']['address']['city'] ?? null) + ->setCountry($userinfo['profile']['address']['country'] ?? 'DE') + ; + + return $instance; + } + public function getStreet(): ?string { return $this->street; diff --git a/src/Entity/Embeddable/Communication.php b/src/Entity/Embeddable/Communication.php index 2520490..bca3cb3 100644 --- a/src/Entity/Embeddable/Communication.php +++ b/src/Entity/Embeddable/Communication.php @@ -65,6 +65,18 @@ class Communication return $instance; } + public static function fromUserinfo(array $userinfo): static + { + $instance = new static(); + $instance + ->setPhone($userinfo['profile']['communication']['phone'] ?? null) + ->setMobile($userinfo['profile']['communication']['mobile'] ?? null) + ->setEmail($userinfo['profile']['communication']['email'] ?? null) + ; + + return $instance; + } + public function getPhone(): ?string { return $this->phone; diff --git a/src/Entity/Teamer.php b/src/Entity/Teamer.php index 9a32cd4..5bded01 100644 --- a/src/Entity/Teamer.php +++ b/src/Entity/Teamer.php @@ -238,6 +238,37 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa return $instance; } + public static function fromUserinfo(array $userinfo): static + { + $instance = new static(); + + $instance + ->setAcademicTitle($userinfo['profile']['title'] ?? null) + ->setSalutation($userinfo['profile']['salutation'] ?? null) + ->setFirstName($userinfo['profile']['first_name'] ?? null) + ->setLastName($userinfo['profile']['last_name'] ?? null) + ->setGender($userinfo['profile']['gender'] ?? null) + ; + + if (null !== $userinfo['profile']['date_of_birth'] ?? null) { + try { + $dateOfBirth = new \DateTimeImmutable(['profile']['date_of_birth']); + $instance->setDateOfBirth($dateOfBirth); + } catch (\Exception $e) { + } + } + + $address = Address::fromUserinfo($userinfo); + $communication = Communication::fromUserinfo($userinfo); + + $instance + ->setAddress($address) + ->setCommunication($communication) + ; + + return $instance; + } + public function getId(): ?int { return $this->id; diff --git a/src/Security/MyEpAuthenticator.php b/src/Security/MyEpAuthenticator.php new file mode 100644 index 0000000..2349333 --- /dev/null +++ b/src/Security/MyEpAuthenticator.php @@ -0,0 +1,168 @@ +attributes->get('_route'); + } + + public function authenticate(Request $request): Passport + { + try { + $accessToken = $this->client->fetchAccessToken($request); + } catch (AuthorizationRequestException|IdentityProviderException $e) { + throw new CustomUserMessageAuthenticationException('Invalid token'); + } + + try { + $provider = $this->client->getProvider(); + $url = $provider->getResourceOwnerDetailsUrl($accessToken); + $request = $provider->getAuthenticatedRequest('GET', $url, $accessToken, [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + ]); + $response = $provider->getHttpClient()->sendRequest($request); + } catch (ClientExceptionInterface $e) { + $this->logger->error('Login via MyE&P failed due to unexpected userinfo response'); + throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); + } + $userinfo = json_decode($response->getBody(), true); + $username = $userinfo['email'] ?? null; + + if (null === $username) { + $this->logger->error('Login via MyE&P failed due to missing username claim'); + throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); + } + + if (null !== $this->createOrUpdateUserFromUserinfo($userinfo)) { + return new SelfValidatingPassport(new UserBadge($username)); + } + + throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + /** @var User $user */ + $user = $token->getUser(); + + $this->logger->info('Logged in'); + + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + $url = $this->urlGenerator->generate($user->getDefaultRoute()); + + return new RedirectResponse($url); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $request->getSession()->getBag('flashes')->add('error', $exception->getMessage()); + + $redirectUrl = $this->urlGenerator->generate('app_security_login'); + + return new RedirectResponse($redirectUrl); + } + + private function createOrUpdateUserFromUserinfo(array $userinfo): ?User + { + // User is expected to have at least one role + if (false === isset($userinfo['roles']) || 0 === count($userinfo['roles'])) { + return null; + } + + // User is expected to have at least one of the roles teamer, manager, house manager or admin + if ([] === array_intersect(self::ELIGIBLE_ROLES, $userinfo['roles'])) { + return null; + } + + // Check if user is already present in local database + $user = $this + ->entityManager + ->getRepository(User::class) + ->findOneBy(['email' => $userinfo['email']]) + ; + + // Update existing user's roles and teamer data and return it + if (null !== $user) { + $user + ->setRoles($userinfo['roles']) + ->setHotelCodes($userinfo['profile']['hotel_codes']) + ->setLastLoginAt(new \DateTimeImmutable('now')) + ; + + $this->entityManager->flush(); + + return $user; + } + + $user = new User(); + $user + ->setFirstName($userinfo['profile']['first_name']) + ->setLastName($userinfo['profile']['last_name']) + ->setEmail($userinfo['profile']['communication']['email']) + ->setBusProPersonId($userinfo['id']) + ->setHotelCodes($userinfo['profile']['hotel_codes']) + ->setRoles($userinfo['roles']) + ; + + if (true === in_array('ROLE_TEAMER', $user->getRoles(), true)) { + $teamer = Teamer::fromUserinfo($userinfo); + $user->setTeamer($teamer); + + $this->entityManager->persist($teamer); + + $this->logger->info('Create teamer', [ + 'teamer_id' => $teamer->getId(), + 'teamer_name' => (string) $teamer, + ]); + } + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} diff --git a/src/Security/OAuth2/AuthorizationRequestException.php b/src/Security/OAuth2/AuthorizationRequestException.php new file mode 100644 index 0000000..fdb8474 --- /dev/null +++ b/src/Security/OAuth2/AuthorizationRequestException.php @@ -0,0 +1,22 @@ +request = $request; + + parent::__construct($message, $code); + } + + public function getRequest(): Request + { + return $this->request; + } +} \ No newline at end of file diff --git a/src/Security/OAuth2/MyEpClient.php b/src/Security/OAuth2/MyEpClient.php new file mode 100644 index 0000000..c904015 --- /dev/null +++ b/src/Security/OAuth2/MyEpClient.php @@ -0,0 +1,82 @@ +config = $this->resolveConfig($options); + } + + /** + * @throws IdentityProviderException + * @throws AuthorizationRequestException + */ + public function fetchAccessToken(Request $request): AccessTokenInterface + { + if (null === $code = $request->query->get('code')) { + throw new AuthorizationRequestException('Missing code', 400, $request); + } + + $session = $request->getSession(); + + if ( + null === $request->query->get('state') + || $request->query->get('state') !== $session->get('oauth2state') + ) { + $session->remove('oauth2state'); + throw new AuthorizationRequestException('Missing state or mismatch', 400, $request); + } + + $session->remove('oauth2state'); + + return $this->getProvider()->getAccessToken('authorization_code', [ + 'code' => $code, + ]); + } + + public function getProvider(): AbstractProvider + { + $redirectUrl = $this + ->urlGenerator + ->generate('app_myep_auth_check', [], UrlGeneratorInterface::ABSOLUTE_URL) + ; + + return new GenericProvider([ + 'clientId' => $this->config['myep_oauth2_client_id'], + 'clientSecret' => $this->config['myep_oauth2_client_secret'], + 'redirectUri' => $redirectUrl, + 'urlAuthorize' => $this->config['myep_oauth2_url_authorize'], + 'urlAccessToken' => $this->config['myep_oauth2_url_access_token'], + 'urlResourceOwnerDetails' => $this->config['myep_oauth2_url_resource_owner_details'], + 'scopes' => $this->config['myep_oauth2_scopes'], + 'scopeSeparator' => ' ', + ]); + } + + private function resolveConfig(array $options): array + { + $optionsResolver = new OptionsResolver(); + $optionsResolver->setRequired([ + 'myep_oauth2_client_id', + 'myep_oauth2_client_secret', + 'myep_oauth2_url_authorize', + 'myep_oauth2_url_access_token', + 'myep_oauth2_url_resource_owner_details', + 'myep_oauth2_scopes', + ]); + + return $optionsResolver->resolve($options); + } +} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index fec040e..c56bcb8 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -45,7 +45,10 @@ -