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
+6
View File
@@ -56,3 +56,9 @@ SFTP_XML_EXPORT_HOST=
SFTP_XML_EXPORT_PORT=
SFTP_XML_EXPORT_USER=
SFTP_XML_EXPORT_PASSWORD=
###> league/oauth2-server-bundle ###
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/secret/public.key
OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
###< league/oauth2-server-bundle ###
+5
View File
@@ -26,3 +26,8 @@ yarn-error.log
###< symfony/webpack-encore-bundle ###
/http-client.private.env.json
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
+13
View File
@@ -0,0 +1,13 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
;
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
])
->setFinder($finder)
;
+40
View File
@@ -0,0 +1,40 @@
### user profile minimum
GET https://myep-next.ddev.site/api/userinfo
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_email")}}
### user profile full
GET https://myep-next.ddev.site/api/userinfo
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_profile")}}
### user crm attributes
GET https://myep-next.ddev.site/api/crm-attributes
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_profile")}}
### API countries
GET https://myep-next.ddev.site/api/countries
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
### API hotels
GET https://myep-next.ddev.site/api/hotels
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
### API hotel
GET https://myep-next.ddev.site/api/hotels/198924
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
### API pickups
GET https://myep-next.ddev.site/api/pickups
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
### API pickup
GET https://myep-next.ddev.site/api/pickups/1
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
+2
View File
@@ -16,6 +16,7 @@
"knplabs/knp-menu-bundle": "^3.4",
"league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29",
"league/oauth2-server-bundle": "^0.11.0",
"nelexa/zip": "^4.0",
"nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6",
@@ -109,6 +110,7 @@
},
"require-dev": {
"deployer/deployer": "^7.5",
"friendsofphp/php-cs-fixer": "^3.75",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*",
Generated
+1967 -1
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,4 +15,5 @@ return [
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
];
+22
View File
@@ -0,0 +1,22 @@
league_oauth2_server:
authorization_server:
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
private_key_passphrase: null
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
enable_client_credentials_grant: true
enable_auth_code_grant: true
enable_refresh_token_grant: false
enable_password_grant: false
access_token_ttl: PT10M
resource_server:
public_key: '%env(resolve:OAUTH_PUBLIC_KEY)%'
scopes:
available: ['email','profile','api']
default: ['email']
persistence:
doctrine: null
when@test:
league_oauth2_server:
persistence:
in_memory: null
+11
View File
@@ -0,0 +1,11 @@
services:
# Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories)
Psr\Http\Message\RequestFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@nyholm.psr7.psr17_factory'
nyholm.psr7.psr17_factory:
class: Nyholm\Psr7\Factory\Psr17Factory
+7 -5
View File
@@ -12,12 +12,14 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api_token:
pattern: ^/token$
security: false
api:
lazy: true
stateless: true
pattern: ^/api
custom_authenticator:
- App\Security\ApiKeyAuthenticator
security: true
stateless: true
oauth2: true
main:
lazy: true
provider: app_user_provider
@@ -30,7 +32,7 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/api, roles: ROLE_API }
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED }
when@test:
security:
+3
View File
@@ -0,0 +1,3 @@
league_oauth2_server:
resource: '@LeagueOAuth2ServerBundle/config/routes.php'
type: php
-4
View File
@@ -27,10 +27,6 @@ services:
- '../src/Entity/'
- '../src/Kernel.php'
App\Security\ApiKeyAuthenticator:
arguments:
$apiKeys: '%env(csv:API_KEYS)%'
App\BusProNet\ApiClient:
arguments:
$logger: '@monolog.logger.bpn'
+37
View File
@@ -0,0 +1,37 @@
{
"dev": {
"Security": {
"Auth": {
"oauth2_email": {
"Type": "OAuth2",
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://myep-next.ddev.site/authorize",
"Token URL": "https://myep-next.ddev.site/token",
"Redirect URL": "https://myep-team.ddev.site/auth/check",
"Scope": "email"
},
"oauth2_profile": {
"Type": "OAuth2",
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://myep-next.ddev.site/authorize",
"Token URL": "https://myep-next.ddev.site/token",
"Redirect URL": "https://myep-team.ddev.site/auth/check",
"Scope": "email profile"
},
"oauth2_api": {
"Type": "OAuth2",
"Grant Type": "Client Credentials",
"Client ID": "{{oauth2_api_client_id}}",
"Client Secret": "{{oauth2_api_client_secret}}",
"Auth URL": "https://myep-next.ddev.site/authorize",
"Token URL": "https://myep-next.ddev.site/token",
"Scope": "api"
}
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250424152547 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_access_token (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL COMMENT '(DC2Type:oauth2_scope)', revoked TINYINT(1) NOT NULL, INDEX IDX_454D9673C7440455 (client), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_authorization_code (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL COMMENT '(DC2Type:oauth2_scope)', revoked TINYINT(1) NOT NULL, INDEX IDX_509FEF5FC7440455 (client), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_client (identifier VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, secret VARCHAR(128) DEFAULT NULL, redirect_uris TEXT DEFAULT NULL COMMENT '(DC2Type:oauth2_redirect_uri)', grants TEXT DEFAULT NULL COMMENT '(DC2Type:oauth2_grant)', scopes TEXT DEFAULT NULL COMMENT '(DC2Type:oauth2_scope)', active TINYINT(1) NOT NULL, allow_plain_text_pkce TINYINT(1) DEFAULT 0 NOT NULL, PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_refresh_token (identifier CHAR(80) NOT NULL, access_token CHAR(80) DEFAULT NULL, expiry DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', revoked TINYINT(1) NOT NULL, INDEX IDX_4DD90732B6A2DD68 (access_token), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE oauth2_access_token DROP FOREIGN KEY FK_454D9673C7440455
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE oauth2_authorization_code DROP FOREIGN KEY FK_509FEF5FC7440455
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE oauth2_refresh_token DROP FOREIGN KEY FK_4DD90732B6A2DD68
SQL);
$this->addSql(<<<'SQL'
DROP TABLE oauth2_access_token
SQL);
$this->addSql(<<<'SQL'
DROP TABLE oauth2_authorization_code
SQL);
$this->addSql(<<<'SQL'
DROP TABLE oauth2_client
SQL);
$this->addSql(<<<'SQL'
DROP TABLE oauth2_refresh_token
SQL);
}
}
+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
{
@@ -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,15 +26,14 @@ 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;
}
+2 -1
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
{
+2 -1
View File
@@ -5,7 +5,8 @@ namespace App\BusProNet\Model;
class BaseData
{
public function __construct(private readonly array $items)
{}
{
}
public function getItems(): array
{
+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 = [
+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
@@ -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,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);
}
@@ -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;
+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);
}
+1 -1
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
@@ -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
+1 -2
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;
}
}
@@ -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;
}
+1 -1
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');
+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;
}
+2
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)
@@ -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);
+3 -1
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();
@@ -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);
}
}
}
+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 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,7 +25,7 @@ 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);
@@ -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']);
}
}
+5 -3
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,
) {
}
+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()
+13 -2
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);
}
}
}
}
+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;
-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
+37
View File
@@ -26,6 +26,18 @@
"migrations/.gitignore"
]
},
"friendsofphp/php-cs-fixer": {
"version": "3.75",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "be2103eb4a20942e28a6dd87736669b757132435"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"knplabs/knp-menu-bundle": {
"version": "v3.4.2"
},
@@ -42,6 +54,31 @@
"var/storage/.gitignore"
]
},
"league/oauth2-server-bundle": {
"version": "0.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "0.11",
"ref": "80320e8e61b51f6965b83a7df1cc9d40bcc3fb78"
},
"files": [
"config/packages/league_oauth2_server.yaml",
"config/routes/league_oauth2_server.yaml"
]
},
"nyholm/psr7": {
"version": "1.8",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "4a8c0345442dcca1d8a2c65633dcf0285dd5a5a2"
},
"files": [
"config/packages/nyholm_psr7.yaml"
]
},
"phpunit/phpunit": {
"version": "9.6",
"recipe": {
@@ -2,8 +2,8 @@
namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\Model\Hotel;
use App\BusProNet\XmlLoader\HotelLoader;
use PHPUnit\Framework\TestCase;
class HotelDataLoaderTest extends TestCase
@@ -2,8 +2,8 @@
namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\Model\Pickup;
use App\BusProNet\XmlLoader\PickupLoader;
use PHPUnit\Framework\TestCase;
class PickupDataLoaderTest extends TestCase
@@ -2,8 +2,8 @@
namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\TravelLoader;
use PHPUnit\Framework\TestCase;
class TravelDataLoaderTest extends TestCase