feat: oauth2 login via myep
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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&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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user