feat: oauth2 login via myep

This commit is contained in:
Björn Fromme
2026-08-12 08:14:13 +02:00
parent 078ef8b9a6
commit 898a7c7505
12 changed files with 384 additions and 2 deletions
+7
View File
@@ -60,6 +60,13 @@ [email protected]
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" 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 ### ###> symfony/mailjet-mailer ###
# MAILER_DSN=mailjet+api://PUBLIC_KEY:[email protected] # MAILER_DSN=mailjet+api://PUBLIC_KEY:[email protected]
# #MAILER_DSN=mailjet+smtp://PUBLIC_KEY:[email protected] # #MAILER_DSN=mailjet+smtp://PUBLIC_KEY:[email protected]
+1
View File
@@ -24,6 +24,7 @@
"knplabs/knp-paginator-bundle": "^6.2", "knplabs/knp-paginator-bundle": "^6.2",
"league/flysystem-bundle": "^3.4", "league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29", "league/flysystem-sftp-v3": "^3.29",
"league/oauth2-client": "^2.9",
"liip/imagine-bundle": "^2.11", "liip/imagine-bundle": "^2.11",
"maennchen/zipstream-php": "^3.1", "maennchen/zipstream-php": "^3.1",
"nelmio/security-bundle": "^3.0", "nelmio/security-bundle": "^3.0",
+1
View File
@@ -20,6 +20,7 @@ security:
lazy: true lazy: true
provider: bpn_user_provider provider: bpn_user_provider
custom_authenticators: custom_authenticators:
- App\Security\MyEpAuthenticator
- App\Security\BpnAuthenticator - App\Security\BpnAuthenticator
user_checker: App\Security\UserChecker user_checker: App\Security\UserChecker
switch_user: switch_user:
+10
View File
@@ -230,3 +230,13 @@ services:
$defaults: $defaults:
from: '%default_email_from%' from: '%default_email_from%'
to: '%default_email_to%' 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)%'
@@ -0,0 +1,32 @@
<?php
namespace App\Controller\Security;
use App\Security\OAuth2\MyEpClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class OAuth2Controller extends AbstractController
{
public function __construct(private readonly MyEpClient $client)
{
}
#[Route('/myep-auth/init', name: 'app_myep_auth_init')]
public function init(Request $request): Response
{
$provider = $this->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
{
}
}
+13
View File
@@ -83,6 +83,19 @@ class Address
return $instance; 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 public function getStreet(): ?string
{ {
return $this->street; return $this->street;
+12
View File
@@ -65,6 +65,18 @@ class Communication
return $instance; 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 public function getPhone(): ?string
{ {
return $this->phone; return $this->phone;
+31
View File
@@ -238,6 +238,37 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
return $instance; 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 public function getId(): ?int
{ {
return $this->id; return $this->id;
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace App\Security;
use App\BusProNet\UserDataHandler;
use App\Entity\Teamer;
use App\Entity\User;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\MyEpClient;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class MyEpAuthenticator extends AbstractAuthenticator
{
private const ELIGIBLE_ROLES = ['ROLE_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'];
use TargetPathTrait;
public function __construct(
private readonly MyEpClient $client,
private readonly UserDataHandler $userDataHandler,
private readonly EntityManagerInterface $entityManager,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger,
) {
}
public function supports(Request $request): ?bool
{
return 'app_myep_auth_check' === $request->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;
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Security\OAuth2;
use Symfony\Component\HttpFoundation\Request;
class AuthorizationRequestException extends \Exception
{
private Request $request;
public function __construct($message, $code, $request)
{
$this->request = $request;
parent::__construct($message, $code);
}
public function getRequest(): Request
{
return $this->request;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Security\OAuth2;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Provider\GenericProvider;
use League\OAuth2\Client\Token\AccessTokenInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class MyEpClient
{
private array $config;
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, array $options)
{
$this->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);
}
}
+5 -2
View File
@@ -45,7 +45,10 @@
</button> </button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form> </form>
<div class="pt-4"> <div class="pt-4 flex space-x-4">
<a href="{{ path('app_myep_auth_init') }}" class="text-sm underline">
Login mit MyE&amp;P
</a>
<a href="{{ path('app_security_password_reset') }}" class="text-sm underline"> <a href="{{ path('app_security_password_reset') }}" class="text-sm underline">
Passwort vergessen? Passwort vergessen?
</a> </a>
@@ -53,4 +56,4 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}