feat: integrated OAuth2 server

This commit is contained in:
Björn Fromme
2025-04-24 20:28:27 +02:00
parent c418ae6399
commit adbfb49c11
84 changed files with 2542 additions and 312 deletions
+5 -4
View File
@@ -2,7 +2,6 @@
namespace App\BusProNet;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ResponseParserException;
@@ -12,6 +11,8 @@ use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Traits\ApiClientTrait;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Form\Model\BookingData;
use App\Form\Model\RegistrationData;
use League\Flysystem\FilesystemException;
@@ -39,7 +40,7 @@ class ApiClient
private readonly ApiResponseParser $responseParser,
private readonly FilesystemOperator $xmlDump,
private readonly LoggerInterface $logger,
array $options
array $options,
) {
$this->config = $this->resolveOptions($options);
}
@@ -107,7 +108,7 @@ class ApiClient
string $email,
string $password,
PersonalData $personalData,
bool $debug = false
bool $debug = false,
): Notification|PersonalData {
$data = [
'user' => $this->config['bpn_username'],
@@ -333,7 +334,7 @@ class ApiClient
private function dumpXmlToFile(string $type, string $requestId, string $body): void
{
try {
$this->xmlDump->write($requestId . '_' . $type . '.xml', $body);
$this->xmlDump->write($requestId.'_'.$type.'.xml', $body);
} catch (FilesystemException $e) {
}
}
@@ -3,7 +3,6 @@
namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Country;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface;
@@ -12,7 +11,8 @@ use Symfony\Contracts\Cache\ItemInterface;
class CountryDataProvider
{
public function __construct(private readonly ApiClient $apiClient, private readonly CacheInterface $cache)
{}
{
}
public function getAll(): array
{
@@ -39,4 +39,4 @@ class CountryDataProvider
return $this->getAll()[$token] ?? null;
}
}
}
@@ -4,4 +4,4 @@ namespace App\BusProNet\Exception;
class ResponseParserException extends \Exception
{
}
}
@@ -11,9 +11,10 @@ use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
class CountryChoiceLoader implements ChoiceLoaderInterface
{
public function __construct(private readonly CountryDataProvider $countries, private readonly string $property)
{}
{
}
public function loadChoiceList(callable $value = null): ChoiceListInterface
public function loadChoiceList(?callable $value = null): ChoiceListInterface
{
$choices = [];
/** @var Country[] $countries */
@@ -25,16 +26,15 @@ class CountryChoiceLoader implements ChoiceLoaderInterface
}
return new ArrayChoiceList($choices);
}
public function loadChoicesForValues(array $values, callable $value = null): array
public function loadChoicesForValues(array $values, ?callable $value = null): array
{
return $values;
}
public function loadValuesForChoices(array $choices, callable $value = null): array
public function loadValuesForChoices(array $choices, ?callable $value = null): array
{
return $choices;
}
}
}
+3 -2
View File
@@ -13,7 +13,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class CountryType extends AbstractType
{
public function __construct(private readonly CountryDataProvider $countries)
{}
{
}
public function getParent(): string
{
@@ -35,4 +36,4 @@ class CountryType extends AbstractType
},
]);
}
}
}
+1 -1
View File
@@ -16,4 +16,4 @@ class Availability implements \JsonSerializable
'available' => $this->available,
];
}
}
}
+3 -2
View File
@@ -5,7 +5,8 @@ namespace App\BusProNet\Model;
class BaseData
{
public function __construct(private readonly array $items)
{}
{
}
public function getItems(): array
{
@@ -16,4 +17,4 @@ class BaseData
{
return $this->items[$key] ?? null;
}
}
}
+1 -1
View File
@@ -8,4 +8,4 @@ class Country
public ?string $name = null;
public ?string $token = null;
public ?string $nationality = null;
}
}
+1 -1
View File
@@ -12,4 +12,4 @@ class CrmAttributes
public bool $teamer = false;
public ?string $hotelCode = null;
public array $roles = [];
}
}
+1 -1
View File
@@ -17,4 +17,4 @@ class CrmSelection
#[Groups(['api:single'])]
public bool $selected = false;
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ class CrmSelectionGroup
{
/**
* Maps BusPro IDs of CRM selection groups representing
* included services
* included services.
*
* @var array|string[]
*/
public static array $includedServicesMapping = [
+1 -1
View File
@@ -100,4 +100,4 @@ class Error
985 => 'Zustieg mit ID #1# nicht fürs Internet buchbar',
999 => 'Systemfehler: #1#',
];
}
}
+1 -1
View File
@@ -14,4 +14,4 @@ class File
public ?string $filename = null;
public ?string $content = null;
public ?string $mimeType = null;
}
}
+1 -1
View File
@@ -6,4 +6,4 @@ class Guide
{
public ?string $name = null;
public ?string $phone = null;
}
}
+1 -1
View File
@@ -26,4 +26,4 @@ class Hotel
#[Groups(['api:list', 'api:single'])]
public ?string $country = null;
}
}
+1 -1
View File
@@ -21,4 +21,4 @@ class MutableData
public ?string $category = null;
public bool $mutable = false;
public ?\DateTimeImmutable $mutableBefore = null;
}
}
+32
View File
@@ -64,4 +64,36 @@ class PersonalData
'bemerkung' => $this->remarks,
];
}
public function getClaims(): array
{
return [
'email' => $this->communication->email,
'profile' => [
'first_name' => $this->firstName,
'last_name' => $this->name,
'title' => $this->title,
'salutation' => $this->salutation,
'gender' => $this->gender,
'nationality' => $this->nationality,
'address' => [
'street' => $this->address->street,
'postcode' => $this->address->postCode,
'city' => $this->address->city,
'disctrict' => $this->address->district,
'country' => $this->address->country,
],
'communication' => [
'email' => $this->communication->email,
'mobile' => $this->communication->mobile,
'phone' => $this->communication->phone,
'newsletter' => $this->communication->newsletter,
],
'height' => $this->height,
'weight' => $this->weight,
'shoe_size' => $this->shoeSize,
'remarks' => $this->remarks,
],
];
}
}
+1 -1
View File
@@ -32,4 +32,4 @@ class Pickup
#[Groups(['api:booking'])]
public array $mapping = [];
}
}
+1 -1
View File
@@ -63,4 +63,4 @@ class Room
#[Groups(['booking'])]
public array $individualPrice = [];
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ class Service
public ?string $category = null;
#[Groups(['api:single', 'api:list'])]
public ?string $status = null;
public ?string $status = null;
#[Groups(['api:single', 'api:list'])]
public bool $mandatory = false;
+1 -1
View File
@@ -8,4 +8,4 @@ class Surcharge
public array $mapping = [];
public ?float $totalPrice = null;
public array $individualPrice = [];
}
}
@@ -1,6 +1,6 @@
<?php
namespace App\BusProNet;
namespace App\BusProNet\Traits;
use App\BusProNet\Exception\ApiClientException;
@@ -56,7 +56,7 @@ trait ApiClientTrait
private function send($socket, string $data): void
{
// message length is prepended to actual message
$send = sprintf('%010s', strlen($data)) . $data;
$send = sprintf('%010s', strlen($data)).$data;
fwrite($socket, $send);
}
@@ -75,4 +75,4 @@ trait ApiClientTrait
{
@fclose($socket);
}
}
}
@@ -1,6 +1,6 @@
<?php
namespace App\BusProNet;
namespace App\BusProNet\Traits;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
@@ -52,6 +52,7 @@ trait TypeConversionTrait
try {
$date = Carbon::createFromFormat('d.m.Y', $string)->startOfDay();
return $date->toDateTimeImmutable();
} catch (InvalidFormatException $e) {
return null;
@@ -66,6 +67,7 @@ trait TypeConversionTrait
try {
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16))->startOfDay();
return $dateTime->toDateTimeImmutable();
} catch (InvalidFormatException $e) {
return null;
@@ -1,6 +1,6 @@
<?php
namespace App\BusProNet;
namespace App\BusProNet\Traits;
use Symfony\Component\DomCrawler\Crawler;
@@ -15,4 +15,4 @@ trait XmlParserTrait
{
return 0 < $node->count() ? (int) $node->text() : null;
}
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
namespace App\BusProNet\XmlLoader;
use App\BusProNet\TypeConversionTrait;
use App\BusProNet\XmlParserTrait;
use App\BusProNet\Traits\TypeConversionTrait;
use App\BusProNet\Traits\XmlParserTrait;
use League\Flysystem\FilesystemOperator;
use Symfony\Contracts\Cache\CacheInterface;
+1 -1
View File
@@ -24,7 +24,7 @@ class TravelLoader extends AbstractLoader
private readonly HotelLoader $hotelDataLoader,
CacheInterface $cache,
private readonly string $travelInfoBaseUrl,
FileSystemOperator $xmlExport,
FilesystemOperator $xmlExport,
) {
parent::__construct($cache, $xmlExport);
}
+2 -2
View File
@@ -2,7 +2,7 @@
namespace App\BusProNet\XmlParser;
use App\BusProNet\TypeConversionTrait;
use App\BusProNet\Traits\TypeConversionTrait;
use Symfony\Component\DomCrawler\Crawler;
abstract class AbstractParser
@@ -47,4 +47,4 @@ abstract class AbstractParser
return $this->stringToArray($node->text(), $separator);
}
}
}
@@ -61,4 +61,4 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('Unable to parse XML response');
}
}
}
@@ -27,4 +27,4 @@ class AvailabilitiesParser extends AbstractParser
return new BaseData($availabilities);
}
}
}
+1 -1
View File
@@ -38,4 +38,4 @@ class BookingsParser extends AbstractParser
return new BaseData($bookings);
}
}
}
+1 -1
View File
@@ -27,4 +27,4 @@ class CountriesParser
return new BaseData($countries);
}
}
}
@@ -6,7 +6,7 @@ use App\BusProNet\Model\CrmAction;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\CrmSelection;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\TypeConversionTrait;
use App\BusProNet\Traits\TypeConversionTrait;
use Symfony\Component\DomCrawler\Crawler;
class CrmAttributesResponseParser
+2 -3
View File
@@ -37,8 +37,7 @@ class DocumentsParser
});
return new File('reisedokumente.zip', $zip->outputAsString(), 'application/zip');
}
catch (ZipException $e) {
} catch (ZipException $e) {
return null;
}
}
@@ -57,4 +56,4 @@ class DocumentsParser
return [$filename, $pdfData];
}
}
}
@@ -39,4 +39,4 @@ class MutableDataParser extends AbstractParser
return new BaseData($mutableFields);
}
}
}
@@ -14,4 +14,4 @@ class NotificationParser extends AbstractParser
return new Notification($number, $text);
}
}
}
@@ -36,8 +36,8 @@ class PersonalDataParser extends AbstractParser
$address = new Address();
$address->street = $this->getStringOrNullValue($addressNode->filterXPath('//strasse'));
$address->postCode = $this->getStringOrNullValue($addressNode->filterXPath('//plz'));
$address->city = $this->getStringOrNullValue($addressNode->filterXPath('//ort')) ;
$address->country = $this->getStringOrNullValue($addressNode->filterXPath('//land')) ;
$address->city = $this->getStringOrNullValue($addressNode->filterXPath('//ort'));
$address->country = $this->getStringOrNullValue($addressNode->filterXPath('//land'));
$personalData->address = $address;
}
@@ -63,4 +63,4 @@ class PersonalDataParser extends AbstractParser
return $personalData;
}
}
}
+1 -1
View File
@@ -33,4 +33,4 @@ class PickupsParser extends AbstractParser
return $pickups;
}
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ class RoomsParser extends AbstractParser
$result->each(function (Crawler $node) use (&$rooms) {
$room = new Room();
$room->id = (int) $node->attr('idzimmer');;
$room->id = (int) $node->attr('idzimmer');
$room->label = $node->attr('zimmer');
$room->category = $node->attr('kategorie');
$room->boardId = (int) $node->attr('idverpflegung');
@@ -38,4 +38,4 @@ class RoomsParser extends AbstractParser
return $rooms;
}
}
}
+1 -1
View File
@@ -39,4 +39,4 @@ class ServicesParser extends AbstractParser
return $services;
}
}
}
+1 -1
View File
@@ -30,4 +30,4 @@ class SurchargesParser extends AbstractParser
return $surcharges;
}
}
}
+5 -5
View File
@@ -13,8 +13,8 @@ use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:cleanup:xml-dumps',
description: 'Removes XML dumps of BPN requests/responses older than a week')
]
description: 'Removes XML dumps of BPN requests/responses older than a week'
)]
class CleanupXMLDumpsCommand extends Command
{
public function __construct(
@@ -33,11 +33,11 @@ class CleanupXMLDumpsCommand extends Command
$files = $this
->xmlDump
->listContents('.')
->filter(fn(StorageAttributes $attributes) => $attributes->isFile() && $attributes->lastModified() < $maxDate)
->map(fn(StorageAttributes $attributes) => $attributes->path())
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && $attributes->lastModified() < $maxDate)
->map(fn (StorageAttributes $attributes) => $attributes->path())
->toArray();
} catch (FilesystemException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
$output->writeln('<error>'.$e->getMessage().'</error>');
return Command::FAILURE;
}
+3 -1
View File
@@ -6,8 +6,10 @@ use App\BusProNet\DataProvider\CountryDataProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class CountryController extends AbstractController
{
public function __construct(private readonly CountryDataProvider $dataProvider)
@@ -21,4 +23,4 @@ class CountryController extends AbstractController
return $this->json($countries);
}
}
}
@@ -5,28 +5,29 @@ namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
use App\Security\Crypt;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api')]
class CrmAttributesController extends AbstractController
#[IsGranted('ROLE_OAUTH2_PROFILE')]
class CrmAttributeController extends AbstractController
{
public function __construct(private readonly APiClient $apiClient)
public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt)
{
}
#[Route('/crm-attributes', name: 'api_crm_attributes', methods: ['GET'])]
public function index(Request $request): JsonResponse
public function index(): JsonResponse
{
$email = $request->headers->get('x-bpn-email');
$password = $request->headers->get('x-bpn-password');
if (null === $email || null === $password) {
return new JsonResponse(['message' => 'Invalid credentials'], Response::HTTP_BAD_REQUEST);
}
/** @var User $user */
$user = $this->getUser();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
try {
$data = $this->apiClient->getCrmAttributes($email, $password);
@@ -40,4 +41,4 @@ class CrmAttributesController extends AbstractController
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
}
}
}
}
+4 -2
View File
@@ -7,15 +7,17 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class HotelController extends AbstractController
{
public function __construct(private readonly HotelLoader $xmlLoader)
{
}
#[Route('/hotels', name: 'api_hotel_all')]
#[Route('/hotels', name: 'api_hotels_all')]
public function index(): JsonResponse
{
$hotels = $this->xmlLoader->loadAll();
@@ -34,4 +36,4 @@ class HotelController extends AbstractController
return $this->json($hotel, Response::HTTP_OK, [], ['groups' => 'api:single']);
}
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api')]
class PersonalDataController extends AbstractController
{
public function __construct(private readonly APiClient $apiClient)
{
}
#[Route('/personal-data', name: 'api_personal_data', methods: ['GET'])]
public function index(Request $request): JsonResponse
{
$email = $request->headers->get('x-bpn-email');
$password = $request->headers->get('x-bpn-password');
if (null === $email || null === $password) {
return new JsonResponse(['message' => 'Invalid credentials'], Response::HTTP_BAD_REQUEST);
}
try {
$data = $this->apiClient->getPersonalData($email, $password);
if ($data instanceof Notification) {
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
}
return $this->json($data);
} catch (ApiClientException $e) {
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
}
}
}
+5 -3
View File
@@ -7,15 +7,17 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class PickupController extends AbstractController
{
public function __construct(private readonly PickupLoader $xmlLoader)
{
}
#[Route('/pickups', name: 'api_pickup_all')]
#[Route('/pickups', name: 'api_pickups_all')]
public function index(): JsonResponse
{
$pickups = $this->xmlLoader->loadAll();
@@ -23,11 +25,11 @@ class PickupController extends AbstractController
return $this->json($pickups, Response::HTTP_OK, [], ['groups' => 'api:list']);
}
#[Route('/pickups/{id}', name: 'api_pickup_single')]
#[Route('/pickups/{id}', name: 'api_pickups_single')]
public function single(int $id): JsonResponse
{
$pickup = $this->xmlLoader->loadById($id);
return $this->json($pickup, Response::HTTP_OK, [], ['groups' => 'api:list']);
}
}
}
@@ -1,47 +0,0 @@
<?php
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api')]
class ResetPasswordController extends AbstractController
{
public function __construct(private readonly ApiClient $apiClient, private readonly ValidatorInterface $validator)
{
}
#[Route('/reset-password', name: 'api_reset_password', methods: ['POST'])]
public function index(Request $request): Response
{
$payload = json_decode($request->getContent(), true);
$email = $payload['email'] ?? null;
if (null === $email) {
return new JsonResponse(['message' => 'Invalid email'], Response::HTTP_BAD_REQUEST);
}
$violationList = $this->validator->validate($email, [new Email([
'mode' => 'strict',
])]);
if (0 < count($violationList)) {
return new JsonResponse(['message' => 'Invalid email'], Response::HTTP_BAD_REQUEST);
}
try {
$this->apiClient->resetPassword($email);
} catch (ApiClientException $e) {
}
return $this->json(['message' => 'Password reset invoked']);
}
}
+6 -4
View File
@@ -11,16 +11,18 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class TravelController extends AbstractController
{
public function __construct(
private readonly TravelLoader $travelXmlLoader,
private readonly HotelLoader $hotelXmlLoader,
private readonly PickupLoader $pickupXmlLoader,
private readonly TravelLoader $travelXmlLoader,
private readonly HotelLoader $hotelXmlLoader,
private readonly PickupLoader $pickupXmlLoader,
private readonly CacheInterface $cache,
) {
}
@@ -90,4 +92,4 @@ class TravelController extends AbstractController
return $travel;
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Security\Crypt;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_EMAIL')]
class UserinfoController extends AbstractController
{
public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt)
{
}
#[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])]
public function index(): JsonResponse
{
// basic scopes applicable to all authenticated users
$scopes = ['email'];
// extend scopes depending on granted permissions
if ($this->isGranted('ROLE_OAUTH2_PROFILE')) {
$scopes[] = 'profile';
}
/** @var User $user */
$user = $this->getUser();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
try {
$data = $this->apiClient->getPersonalData($email, $password);
if ($data instanceof Notification) {
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
}
// extract userdata for resulting claims
$userData = $this->getClaims($data, $scopes);
return $this->json($userData);
} catch (ApiClientException $e) {
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
}
}
private function getClaims(PersonalData $data, array $scopes): array
{
// get all available claims
$allClaims = $data->getClaims();
$keys = array_keys($allClaims);
$claims = [];
// filter claims by provided scopes
foreach ($scopes as $scope) {
if (false === in_array($scope, $keys)) {
continue;
}
$claims[$scope] = $allClaims[$scope];
}
return $claims;
}
}
@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use function Symfony\Component\String\u;
class DownloadController extends AbstractController
@@ -42,7 +43,7 @@ class DownloadController extends AbstractController
requirements: ['id' => '\d+'],
defaults: ['fileType' => 'invoice']
)]
#[IsGranted("ROLE_USER")]
#[IsGranted('ROLE_USER')]
public function documents(int $id, string $fileType): Response
{
/** @var User $user */
+1 -1
View File
@@ -38,7 +38,7 @@ class EditController extends AbstractController
}
#[Route('/bookings/{id}/edit', name: 'app_booking_edit', requirements: ['id' => '\d+'])]
#[IsGranted("ROLE_USER")]
#[IsGranted('ROLE_USER')]
public function edit(int $id, Request $request): Response
{
/** @var User $user */
+1 -1
View File
@@ -27,7 +27,7 @@ class IndexController extends AbstractController
}
#[Route('/bookings', name: 'app_bookings')]
#[IsGranted("ROLE_USER")]
#[IsGranted('ROLE_USER')]
public function index(Request $request): Response
{
/** @var User $user */
+1 -1
View File
@@ -26,7 +26,7 @@ class RegistrationController extends AbstractController
public function index(Request $request): Response
{
$registrationData = new RegistrationData();
$form = $this->createForm(RegistrationType::class, $registrationData,[
$form = $this->createForm(RegistrationType::class, $registrationData, [
'action' => $this->generateUrl('app_registration'),
]);
$form->handleRequest($request);
+1 -2
View File
@@ -4,7 +4,6 @@ namespace App\Controller;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ResponseParserException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
@@ -38,7 +37,7 @@ class ResetPasswordController extends AbstractController
new Email([
'mode' => 'strict',
'message' => 'Bitte eine gültige E-Mail Adresse angeben',
])
]),
],
])
->getForm()
+14 -3
View File
@@ -3,6 +3,7 @@
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -12,7 +13,7 @@ class SecurityController extends AbstractController
{
#[Route('/', name: 'app_login')]
#[IsGranted('PUBLIC_ACCESS')]
public function login(AuthenticationUtils $authenticationUtils): Response
public function login(AuthenticationUtils $authenticationUtils, Request $request): Response
{
if (null !== $this->getUser()) {
return $this->redirectToRoute('app_personal_data');
@@ -21,6 +22,15 @@ class SecurityController extends AbstractController
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
// flag this request in the session as external OAuth2 request if
// applicable to immediately logout the current user after successful
// authentication (see App\EventListener\AuthorizationCodeListener).
$session = $request->getSession();
$targetPath = $session->get('_security.main.target_path');
if (str_contains($targetPath, '/authorize')) {
$session->set('_oauth2', true);
}
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
@@ -30,5 +40,6 @@ class SecurityController extends AbstractController
#[Route('/logout', name: 'app_logout')]
#[IsGranted('ROLE_USER')]
public function logout(): void
{}
}
{
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\EventListener;
use App\Entity\User;
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')]
class AuthorizationCodeListener
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly RequestStack $requestStack,
private readonly Security $security,
private readonly LoggerInterface $authenticationLogger,
) {
}
/**
* Approves or denies authorization by checking for a current user session and redirects anonymous
* users to login page. We purposely don't check consents since these are irrelevant for our use case.
* We do check though if the current user is allowed to login to the requesting client.
*/
public function onAuthorizationRequestResolve(AuthorizationRequestResolveEvent $event): void
{
$request = $this->requestStack->getMainRequest();
/** @var User $user */
$user = $this->security->getUser();
if (null === $user) {
$response = new RedirectResponse(
$this->urlGenerator->generate('app_login', $request->query->all()),
Response::HTTP_TEMPORARY_REDIRECT
);
$event->setResponse($response);
$this->authenticationLogger->info('Authorization request without session');
} else {
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
// in case this authorization request has been flagged as external in login controller,
// immediately logout the current user (see App\Controller\Core\Security\LoginController).
if (true === $request->getSession()->get('_oauth2', false)) {
$this->security->logout(false);
}
}
}
}
+1 -1
View File
@@ -19,4 +19,4 @@ class RegistrationData
#[Assert\NotBlank(message: 'Bitte angeben')]
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict')]
public ?string $email = null;
}
}
+17 -14
View File
@@ -39,13 +39,13 @@ class ParticipantType extends AbstractType
'label' => 'Vorname',
'attr' => [
'readonly' => false === $personalDataMutable,
]
],
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
'attr' => [
'readonly' => false === $personalDataMutable,
]
],
])
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
@@ -54,7 +54,7 @@ class ParticipantType extends AbstractType
'input' => 'datetime_immutable',
'attr' => [
'readonly' => false === $personalDataMutable,
]
],
])
->add('gender', ChoiceType::class, [
'label' => 'Geschlecht',
@@ -68,7 +68,7 @@ class ParticipantType extends AbstractType
'attr' => [
'readonly' => false === $personalDataMutable,
'style' => false === $personalDataMutable ? 'pointer-events: none' : null,
]
],
])
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
@@ -77,21 +77,21 @@ class ParticipantType extends AbstractType
'attr' => [
'readonly' => false === $personalDataMutable,
'style' => false === $personalDataMutable ? 'pointer-events: none' : null,
]
],
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => false,
'attr' => [
'readonly' => false === $personalDataMutable,
]
],
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
'required' => false,
'attr' => [
'readonly' => false === $personalDataMutable,
]
],
])
->add('height', ChoiceType::class, [
'label' => 'Körpergröße',
@@ -144,7 +144,8 @@ class ParticipantType extends AbstractType
return $service->label;
}
return sprintf('%s (%s&nbsp;€)',
return sprintf(
'%s (%s&nbsp;€)',
$service->label,
number_format($price, 2, ',', '.')
);
@@ -189,8 +190,8 @@ class ParticipantType extends AbstractType
// remove choices only available in booking data and
// which are not mapped to the current participant
if (
Service::SOURCE_TRAVEL === $service->source ||
Service::CATEGORY_ADDITIONAL !== $service->category
Service::SOURCE_TRAVEL === $service->source
|| Service::CATEGORY_ADDITIONAL !== $service->category
) {
return true;
}
@@ -293,7 +294,8 @@ class ParticipantType extends AbstractType
return $pickupLabel;
}
return sprintf('%s %s€',
return sprintf(
'%s %s€',
$pickupLabel,
number_format($price, 2, ',', '.')
);
@@ -308,7 +310,7 @@ class ParticipantType extends AbstractType
},
'attr' => [
'readonly' => false === $options['pickups_mutable'],
]
],
]);
})
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
@@ -331,8 +333,9 @@ class ParticipantType extends AbstractType
// forcibly select mandatory services that potentially have been disabled in PRE_SET_DATA
$mandatoryServices = array_filter($options['selectable_services'], function (Service $service) use ($form) {
$participantIndex = $form->getData()->index;
return true === $service->mandatory ||
(Service::SOURCE_BOOKING === $service->source && in_array($participantIndex, $service->mapping));
return true === $service->mandatory
|| (Service::SOURCE_BOOKING === $service->source && in_array($participantIndex, $service->mapping));
});
if (0 < count($mandatoryServices)) {
-2
View File
@@ -4,8 +4,6 @@ namespace App\Form;
use App\BusProNet\Form\CountryType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
+1 -1
View File
@@ -43,4 +43,4 @@ class RegistrationType extends AbstractType
'anti_xss' => true,
]);
}
}
}
+1 -1
View File
@@ -49,4 +49,4 @@ class MenuBuilder
return $menu;
}
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
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\Core\User\UserInterface;
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;
class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function __construct(private readonly array $apiKeys)
{
}
public function supports(Request $request): ?bool
{
return $request->headers->has('X-BPN-API-KEY');
}
public function authenticate(Request $request): Passport
{
$apiKey = $request->headers->get('X-BPN-API-KEY');
if (null === $apiKey) {
throw new CustomUserMessageAuthenticationException('No API key provided');
}
if (false === in_array($apiKey, $this->apiKeys)) {
throw new CustomUserMessageAuthenticationException('Invalid API key');
}
return new SelfValidatingPassport(
new UserBadge($apiKey, function (string $userIdentifier) use ($apiKey): ?UserInterface {
return new ApiUser($apiKey);
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$data = [
'message' => 'Authentication missing or failed',
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
namespace App\Security;
use Symfony\Component\Security\Core\User\UserInterface;
class ApiUser implements UserInterface
{
public function __construct(private readonly string $apiKey)
{
}
public function getRoles(): array
{
return ['ROLE_USER', 'ROLE_API'];
}
public function eraseCredentials(): void
{
}
public function getUserIdentifier(): string
{
return $this->apiKey;
}
}
+8 -1
View File
@@ -22,9 +22,12 @@ 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\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface
{
use TargetPathTrait;
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly ApiClient $apiClient,
@@ -60,7 +63,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
$csrfToken = $request->request->getString('_csrf_token');
return new SelfValidatingPassport(
new UserBadge($email, function () use ($email, $password, $response, $request) {
new UserBadge($email, function () use ($email, $password, $response) {
return $this->createOrUpdateLocalUser($email, $password, $response->personId, $response->addressId);
}),
[
@@ -106,6 +109,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
'email' => $token->getUserIdentifier(),
]);
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->urlGenerator->generate('app_personal_data'));
}
+4 -4
View File
@@ -13,28 +13,28 @@ class Crypt
public function encrypt(string $message): string
{
$privateKey = PrivateKey::fromFile($this->path . '/private.key');
$privateKey = PrivateKey::fromFile($this->path.'/private.key');
return $privateKey->encrypt($message);
}
public function decrypt(string $message): string
{
$publicKey = PublicKey::fromFile($this->path . '/public.key');
$publicKey = PublicKey::fromFile($this->path.'/public.key');
return $publicKey->decrypt($message);
}
public function sign(string $message): string
{
$privateKey = PrivateKey::fromFile($this->path . '/private.key');
$privateKey = PrivateKey::fromFile($this->path.'/private.key');
return $privateKey->sign($message);
}
public function verify(string $message, string $signature): bool
{
$publicKey = PublicKey::fromFile($this->path . '/public.key');
$publicKey = PublicKey::fromFile($this->path.'/public.key');
return $publicKey->verify($message, $signature);
}
+1 -1
View File
@@ -32,7 +32,7 @@ class AppRuntime implements RuntimeExtensionInterface
$size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
$factor = floor((strlen($bytes) - 1) / 3);
return sprintf("%.{$precision}f", $bytes / (1024 ** $factor)) . @$size[$factor];
return sprintf("%.{$precision}f", $bytes / (1024 ** $factor)).@$size[$factor];
}
public function formatMoney(int $amount): string