From fe562e5e738e9f57c11a2a48d86be63fa549330e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 18 May 2026 11:28:46 +0200 Subject: [PATCH] feat: local password hashes --- docs/technical-documentation.md | 18 +- src/BusProNet/ApiClient.php | 216 +++++++++-------- .../Account/PersonalDataController.php | 9 +- src/Controller/Api/CrmAttributeController.php | 6 +- src/Controller/Api/UserinfoController.php | 6 +- src/Controller/Booking/DownloadController.php | 5 +- src/Controller/Booking/IndexController.php | 5 +- src/Entity/User.php | 13 +- src/Security/BpnAuthenticator.php | 106 ++++++--- src/Service/BookingEditDataLoader.php | 7 +- src/Service/ParticipantDataPrefiller.php | 8 +- .../ApiClientPersonalDataRequestTest.php | 96 ++++++++ .../Account/PersonalDataControllerTest.php | 18 +- tests/Security/BpnAuthenticatorTest.php | 222 ++++++++++++++++++ .../Service/ParticipantDataPrefillerTest.php | 59 +---- 15 files changed, 541 insertions(+), 253 deletions(-) create mode 100644 tests/BusProNet/ApiClientPersonalDataRequestTest.php create mode 100644 tests/Security/BpnAuthenticatorTest.php diff --git a/docs/technical-documentation.md b/docs/technical-documentation.md index eac7a6b5..51f193c8 100644 --- a/docs/technical-documentation.md +++ b/docs/technical-documentation.md @@ -846,24 +846,22 @@ CompositeCondition::or( **Location:** `src/Security/BpnAuthenticator.php` -Custom authenticator that validates credentials against BusProNet API. +Custom authenticator that validates credentials against the local Symfony password hash. ``` -Login Form → MD5 Hash Password → ApiClient::getPersonalData() +Login Form → Verify local password hash │ - ├─ Valid → Create/Update User Entity - │ Get CRM Attributes (roles, hotel codes) - │ Encrypt password with RSA - │ Store in database + ├─ Valid → Update login timestamp + │ Migrate legacy encrypted password on first success │ └─ Invalid → CustomUserMessageAuthenticationException ``` #### User Entity Password Handling -- Plain password hashed with MD5 for API authentication -- MD5 hash encrypted with RSA before database storage -- Decrypted when needed for subsequent API calls +- Password column stores the Symfony hash after migration +- Existing legacy rows may still contain the old encrypted BPN password until the user logs in once +- BPN customer-data requests use `login=J` plus `email` and address/person identifiers ### 7.2 Encryption @@ -928,7 +926,7 @@ api: | Property | Type | Purpose | |----------|------|---------| | `email` | string (unique) | User identifier | -| `password` | string (nullable) | RSA-encrypted API password | +| `password` | string (nullable) | Symfony password hash, with legacy encrypted value only during migration | | `personId` | int (nullable) | BusProNet person ID | | `addressId` | int (nullable) | BusProNet address ID | | `roles` | array (JSON) | CRM roles | diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 5107a615..e02dec52 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -35,20 +35,20 @@ use Symfony\Component\Serializer\SerializerInterface; class ApiClient { - public const TYPE_NOTIFICATION = 'HINWEIS'; - public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO'; - public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER'; - public const TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN'; - public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT'; - public const TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2'; - public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL'; - public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG'; - public const TYPE_BOOKING = 'BUCHUNG'; - public const TYPE_PRODUCTS = 'PRODUKTE'; - public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN'; - public const TYPE_AGENCIES = 'AGENTUREN'; - public const TYPE_PURCHASE_VOUCHER = 'GUTSCHEINPRUEFUNGEINLOESUNG'; - public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN'; + public const string TYPE_NOTIFICATION = 'HINWEIS'; + public const string TYPE_CUSTOMER_DATA = 'KUNDENKONTO'; + public const string TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER'; + public const string TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN'; + public const string TYPE_AVAILABILITY = 'VERFUEGBARKEIT'; + public const string TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2'; + public const string TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL'; + public const string TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG'; + public const string TYPE_BOOKING = 'BUCHUNG'; + public const string TYPE_PRODUCTS = 'PRODUKTE'; + public const string TYPE_PRODUCT_DATA = 'PRODUKTDATEN'; + public const string TYPE_AGENCIES = 'AGENTUREN'; + public const string TYPE_PURCHASE_VOUCHER = 'GUTSCHEINPRUEFUNGEINLOESUNG'; + public const string TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN'; /** @var array */ private array $config; @@ -73,16 +73,19 @@ class ApiClient /** * @throws ApiClientException */ - public function getPersonalData(string $email, string $password): Notification|PersonalData + public function getPersonalData(string $email, ?int $addressId = null, ?int $personId = null): Notification|PersonalData { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), - 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], - 'art' => 'Adressdaten', - 'email' => $email, - 'passwort' => $password, - ]; + $data = $this->buildCustomerDataRequestData($email, 'Adressdaten', $addressId, $personId); + + return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); + } + + /** + * @throws ApiClientException + */ + public function getPersonalDataWithPassword(string $email, string $password): Notification|PersonalData + { + $data = $this->buildCustomerDataRequestData($email, 'Adressdaten', password: $password); return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } @@ -94,7 +97,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), + 'key' => $this->createKey(static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adresse_Neu', 'adressdaten' => $registrationData->toPayload(), @@ -110,7 +113,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), + 'key' => $this->createKey(static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Passwort_Anfrage', 'email' => $email, @@ -124,21 +127,19 @@ class ApiClient */ public function updatePersonalData( string $email, - string $password, PersonalData $personalData, bool $debug = false, ): Notification|PersonalData { $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), - 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adressdaten_Ändern', - 'email' => $email, - 'passwort' => $password, - 'idadresse' => $personalData->addressId, 'adressdaten' => $personalData->toPayload(), ]; + $data = array_merge( + $this->buildCustomerDataRequestData($email, 'Adressdaten_Ändern', $personalData->addressId, $personalData->personId), + $data + ); + return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug); } @@ -151,7 +152,7 @@ class ApiClient ): RegistrationResponse|Notification { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), + 'key' => $this->createKey(static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adresse_Neu', 'adressdaten' => $personalData->toPayload(false), @@ -164,21 +165,18 @@ class ApiClient /** * @throws ApiClientException */ - public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData + public function updateNewsletterRegistration(string $email, PersonalData $personalData): Notification|PersonalData { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), - 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], + $data = array_merge( + $this->buildCustomerDataRequestData($email, 'Newsletter', $personalData->addressId, $personalData->personId), + [ 'art' => 'Newsletter', - 'email' => $email, - 'passwort' => $password, - 'idadresse' => $personalData->addressId, 'newsletter' => [ 'email' => $email, 'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False', ], - ]; + ] + ); return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } @@ -186,16 +184,9 @@ class ApiClient /** * @throws ApiClientException */ - public function getBookings(string $email, string $password): Notification|BaseData + public function getBookings(string $email, ?int $addressId = null, ?int $personId = null): Notification|BaseData { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), - 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], - 'art' => 'Vorgänge', - 'email' => $email, - 'passwort' => $password, - ]; + $data = $this->buildCustomerDataRequestData($email, 'Vorgänge', $addressId, $personId); return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } @@ -203,17 +194,14 @@ class ApiClient /** * @throws ApiClientException */ - public function getBooking(string $email, string $password, int $id): Notification|Booking + public function getBooking(string $email, int $id, ?int $addressId = null, ?int $personId = null): Notification|Booking { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), - 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], - 'art' => 'Vorgang_Details', - 'email' => $email, - 'passwort' => $password, - 'idbuchung' => $id, - ]; + $data = array_merge( + $this->buildCustomerDataRequestData($email, 'Vorgang_Details', $addressId, $personId), + [ + 'idbuchung' => $id, + ] + ); return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } @@ -227,7 +215,7 @@ class ApiClient $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING_UPDATE), + 'key' => $this->createKey(static::TYPE_BOOKING_UPDATE), 'satz' => ['@typ' => static::TYPE_BOOKING_UPDATE], 'buchungsart' => Constants::BOOKING_TYPE_BOOKING, ...$payload, @@ -255,7 +243,7 @@ class ApiClient $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), + 'key' => $this->createKey(static::TYPE_BOOKING), 'satz' => ['@typ' => static::TYPE_BOOKING], ...$payload, ]; @@ -282,7 +270,7 @@ class ApiClient $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), + 'key' => $this->createKey(static::TYPE_BOOKING), 'satz' => ['@typ' => static::TYPE_BOOKING], ...$payload, ]; @@ -297,7 +285,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_MUTABLE_DATA), + 'key' => $this->createKey(static::TYPE_MUTABLE_DATA), 'satz' => ['@typ' => static::TYPE_MUTABLE_DATA], 'idreise' => $dateId, ]; @@ -312,7 +300,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY), + 'key' => $this->createKey(static::TYPE_AVAILABILITY), 'satz' => ['@typ' => static::TYPE_AVAILABILITY], 'idreise' => $dateId, ]; @@ -327,7 +315,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_EXTENDED), + 'key' => $this->createKey(static::TYPE_AVAILABILITY_EXTENDED), 'satz' => ['@typ' => static::TYPE_AVAILABILITY_EXTENDED], 'idreise' => $dateId, ]; @@ -342,7 +330,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_HOTEL), + 'key' => $this->createKey(static::TYPE_AVAILABILITY_HOTEL), 'satz' => ['@typ' => static::TYPE_AVAILABILITY_HOTEL], 'idreise' => $dateId, 'idpartner' => $hotelId, @@ -355,16 +343,19 @@ class ApiClient /** * @throws ApiClientException */ - public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes + public function getCrmAttributes(string $email, ?int $addressId = null, ?int $personId = null): Notification|CrmAttributes { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), - 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], - 'art' => 'SelektionCRM', - 'email' => $email, - 'passwort' => $password, - ]; + $data = $this->buildCustomerDataRequestData($email, 'SelektionCRM', $addressId, $personId); + + return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); + } + + /** + * @throws ApiClientException + */ + public function getCrmAttributesWithPassword(string $email, string $password): Notification|CrmAttributes + { + $data = $this->buildCustomerDataRequestData($email, 'SelektionCRM', password: $password); return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } @@ -376,7 +367,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], $type), + 'key' => $this->createKey($type), 'satz' => ['@typ' => $type], ]; @@ -386,17 +377,14 @@ class ApiClient /** * @throws ApiClientException */ - public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed + public function getDocuments(string $email, int $bookingId, string $type, ?int $addressId = null, ?int $personId = null): mixed { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), - 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], - 'art' => $type, - 'email' => $email, - 'passwort' => $password, - 'idbuchung' => $bookingId, - ]; + $data = array_merge( + $this->buildCustomerDataRequestData($email, $type, $addressId, $personId), + [ + 'idbuchung' => $bookingId, + ] + ); return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } @@ -408,7 +396,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA), + 'key' => $this->createKey(static::TYPE_PRODUCT_DATA), 'satz' => ['@typ' => static::TYPE_PRODUCT_DATA], 'idprodukt' => $travelId, ]; @@ -432,7 +420,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA), + 'key' => $this->createKey(static::TYPE_PRODUCT_DATA), 'satz' => ['@typ' => static::TYPE_PRODUCT_DATA], 'idprodukt' => $travelId, ]; @@ -447,7 +435,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCTS), + 'key' => $this->createKey(static::TYPE_PRODUCTS), 'satz' => ['@typ' => static::TYPE_PRODUCTS], ]; @@ -468,7 +456,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AGENCIES), + 'key' => $this->createKey(static::TYPE_AGENCIES), 'satz' => ['@typ' => static::TYPE_AGENCIES], ]; @@ -492,7 +480,7 @@ class ApiClient { $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PURCHASE_VOUCHER), + 'key' => $this->createKey(static::TYPE_PURCHASE_VOUCHER), 'satz' => ['@typ' => static::TYPE_PURCHASE_VOUCHER], 'einloesecode' => $redemptionCode, ]; @@ -521,7 +509,7 @@ class ApiClient $data = [ 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PROMO_VOUCHER), + 'key' => $this->createKey(static::TYPE_PROMO_VOUCHER), 'satz' => ['@typ' => static::TYPE_PROMO_VOUCHER], 'aktionsgutschein' => $promoCode, 'idreise' => $travelId, @@ -531,6 +519,36 @@ class ApiClient return $this->sendRequest(static::TYPE_PROMO_VOUCHER, $data); } + /** + * @return array + */ + private function buildCustomerDataRequestData(string $email, string $art, ?int $addressId = null, ?int $personId = null, ?string $password = null): array + { + $data = [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey(static::TYPE_CUSTOMER_DATA), + 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], + 'art' => $art, + 'email' => $email, + 'login' => 'J', + ]; + + if (null !== $addressId) { + $data['idadresse'] = $addressId; + } + + if (null !== $personId) { + $data['idadresseperson'] = $personId; + } + + if (null !== $password) { + $data['passwort'] = $password; + unset($data['login']); + } + + return $data; + } + /** * Sends raw XML to the BPN API with key regeneration and automatic retry. * @@ -580,11 +598,7 @@ class ApiClient throw new ApiClientException('Missing element in XML'); } - $newKey = $this->createKey( - $this->config['bpn_username'], - $this->config['bpn_password'], - $type - ); + $newKey = $this->createKey($type); $keyNode->nodeValue = $newKey; $body = $doc->saveXML(); @@ -800,11 +814,11 @@ class ApiClient return $baseId.'_'.++$this->requestCounter; } - private function createKey(string $username, string $password, string $type): string + private function createKey(string $type): string { $date = (new \DateTimeImmutable())->format('Ymd'); - return md5($username.$password.$date.$type); + return md5($this->config['bpn_username'].$this->config['bpn_password'].$date.$type); } /** diff --git a/src/Controller/Account/PersonalDataController.php b/src/Controller/Account/PersonalDataController.php index 841b328b..2e30b7ee 100644 --- a/src/Controller/Account/PersonalDataController.php +++ b/src/Controller/Account/PersonalDataController.php @@ -13,7 +13,6 @@ use App\Exception\NewsletterProviderException; use App\Form\PersonalDataType; use App\Htmx\HxTrait; use App\Model\NewsletterSubscriptionRequestResult; -use App\Security\Crypt; use App\Service\BookingEditDataLoader; use App\Service\NewsletterManager; use App\Service\ProfileCompletenessChecker; @@ -40,7 +39,6 @@ class PersonalDataController extends AbstractController /** * @param ApiClient $apiClient BusProNet API client for data operations - * @param Crypt $crypt Encryption service for password handling * @param BookingEditDataLoader $dataLoader Data loader for cache invalidation * @param ProfileCompletenessChecker $completenessChecker Profile validation service * @param EntityManagerInterface $entityManager Entity manager for persisting user changes @@ -49,7 +47,6 @@ class PersonalDataController extends AbstractController */ public function __construct( private readonly ApiClient $apiClient, - private readonly Crypt $crypt, private readonly BookingEditDataLoader $dataLoader, private readonly ProfileCompletenessChecker $completenessChecker, private readonly EntityManagerInterface $entityManager, @@ -78,7 +75,6 @@ class PersonalDataController extends AbstractController /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); $personalData = $this->loadPersonalData($user); $newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email); $newsletterPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email); @@ -91,7 +87,7 @@ class PersonalDataController extends AbstractController if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) { try { - $this->apiClient->updatePersonalData($email, $password, $personalData); + $this->apiClient->updatePersonalData($email, $personalData); // Invalidate cached bookings to ensure edit mode shows updated applicant data $this->dataLoader->invalidateUserBookingCaches($user); @@ -183,12 +179,11 @@ class PersonalDataController extends AbstractController private function loadPersonalData(User $user): PersonalData { $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); try { $personalData = $this ->apiClient - ->getPersonalData($email, $password); + ->getPersonalData($email, $user->getAddressId(), $user->getPersonId()); } catch (ApiClientException $e) { $this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden'); diff --git a/src/Controller/Api/CrmAttributeController.php b/src/Controller/Api/CrmAttributeController.php index 14a8f804..af3d8319 100644 --- a/src/Controller/Api/CrmAttributeController.php +++ b/src/Controller/Api/CrmAttributeController.php @@ -6,7 +6,6 @@ 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\Response; @@ -17,7 +16,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('ROLE_OAUTH2_PROFILE')] class CrmAttributeController extends AbstractController { - public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt) + public function __construct(private readonly ApiClient $apiClient) { } @@ -27,10 +26,9 @@ class CrmAttributeController extends AbstractController /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); try { - $data = $this->apiClient->getCrmAttributes($email, $password); + $data = $this->apiClient->getCrmAttributes($email, $user->getAddressId(), $user->getPersonId()); if ($data instanceof Notification) { return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST); diff --git a/src/Controller/Api/UserinfoController.php b/src/Controller/Api/UserinfoController.php index 1e83a076..dcc41cca 100644 --- a/src/Controller/Api/UserinfoController.php +++ b/src/Controller/Api/UserinfoController.php @@ -7,7 +7,6 @@ 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; @@ -18,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('ROLE_OAUTH2_EMAIL')] class UserinfoController extends AbstractController { - public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt) + public function __construct(private readonly ApiClient $apiClient) { } @@ -42,10 +41,9 @@ class UserinfoController extends AbstractController /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); try { - $data = $this->apiClient->getPersonalData($email, $password); + $data = $this->apiClient->getPersonalData($email, $user->getAddressId(), $user->getPersonId()); if ($data instanceof Notification) { return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST); diff --git a/src/Controller/Booking/DownloadController.php b/src/Controller/Booking/DownloadController.php index 6c31253c..b989dfc6 100644 --- a/src/Controller/Booking/DownloadController.php +++ b/src/Controller/Booking/DownloadController.php @@ -6,7 +6,6 @@ use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Model\Notification; use App\Entity\User; -use App\Security\Crypt; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -21,7 +20,6 @@ class DownloadController extends AbstractController { public function __construct( private readonly ApiClient $apiClient, - private readonly Crypt $crypt, private readonly LoggerInterface $logger, ) { } @@ -44,7 +42,6 @@ class DownloadController extends AbstractController /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); $type = match ($fileType) { 'documents' => 'Dokumentdruck', @@ -61,7 +58,7 @@ class DownloadController extends AbstractController try { $file = $this ->apiClient - ->getDocuments($email, $password, $id, $type); + ->getDocuments($email, $id, $type, $user->getAddressId(), $user->getPersonId()); } catch (ApiClientException $e) { $file = null; } diff --git a/src/Controller/Booking/IndexController.php b/src/Controller/Booking/IndexController.php index e51071d0..fbcd3fc1 100644 --- a/src/Controller/Booking/IndexController.php +++ b/src/Controller/Booking/IndexController.php @@ -8,7 +8,6 @@ use App\BusProNet\Model\BaseData; use App\BusProNet\Model\Notification; use App\BusProNet\XmlLoader\TravelLoader; use App\Entity\User; -use App\Security\Crypt; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -21,7 +20,6 @@ class IndexController extends AbstractController public function __construct( private readonly ApiClient $apiClient, private readonly TravelLoader $travelDataLoader, - private readonly Crypt $crypt, private readonly LoggerInterface $logger, ) { } @@ -33,10 +31,9 @@ class IndexController extends AbstractController /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); try { - $bookings = $this->apiClient->getBookings($email, $password); + $bookings = $this->apiClient->getBookings($email, $user->getAddressId(), $user->getPersonId()); } catch (ApiClientException $e) { $this->addFlash('error', 'Buchungen nicht abrufbar'); $bookings = new BaseData([]); diff --git a/src/Entity/User.php b/src/Entity/User.php index 425452d2..1a20a83b 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -93,16 +93,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function getPassword(): ?string { - return base64_decode($this->password); + return $this->password; } public function setPassword(?string $password): static { - $this->password = base64_encode($password); + $this->password = $password; return $this; } + public function hasLocalPasswordHash(): bool + { + if (null === $this->password || '' === $this->password) { + return false; + } + + return null !== password_get_info($this->password)['algo']; + } + /** * @return array */ diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index 9d62d82f..d8d8e710 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -8,6 +8,7 @@ use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ImmediateConnectionCloseException; use App\BusProNet\Exception\TimeoutException; +use App\BusProNet\Model\CrmAttributes; use App\BusProNet\Model\PersonalData; use App\Entity\User; use App\Htmx\HxRedirectResponse; @@ -18,6 +19,7 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; @@ -29,11 +31,11 @@ use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface use Symfony\Component\Security\Http\Util\TargetPathTrait; /** - * Authenticates users against the BPN API. + * Authenticates users against the local Symfony password hash. * - * Validates credentials via BPN's getPersonalData endpoint, creates or updates - * local User entities, and retrieves CRM attributes (roles, hotel codes) for - * authorization. Passwords are stored encrypted with RSA for subsequent API calls. + * Legacy rows still store the old BPN-encrypted password. On first successful + * login, the authenticator migrates that value to a local password hash and + * updates the login timestamp. */ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface { @@ -45,6 +47,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent private readonly EntityManagerInterface $entityManager, private readonly Crypt $crypt, private readonly ProfileCompletenessChecker $completenessChecker, + private readonly UserPasswordHasherInterface $passwordHasher, private readonly LoggerInterface $authLogger, ) { } @@ -59,67 +62,76 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent $email = trim($request->request->getString('_username')); $passwordPlain = trim($request->request->getString('_password')); - // Very lame hashing applied here as required by BPN - $password = md5($passwordPlain); + $userRepository = $this->entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['email' => $email]); - try { - $response = $this->apiClient->getPersonalData($email, $password); - } catch (ApiClientException $e) { - $message = $this->isConnectionError($e) ? - 'Der Server ist momentan nicht erreichbar. Bitte versuche es später erneut.' : $e->getMessage(); - throw new CustomUserMessageAuthenticationException($message); + if (null === $user) { + // First login for this email: prove credentials against BPN, + // then create the local account from the returned customer data. + $user = $this->bootstrapUser($email, $passwordPlain); + } else { + if (true === $user->hasLocalPasswordHash()) { + if (false === $this->passwordHasher->isPasswordValid($user, $passwordPlain)) { + throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); + } + } else { + // Legacy accounts still carry the old encrypted BPN secret as a md5 hash + // in the password column. We validate that old value once, then replace it + // with a local Symfony hash on success. + $legacyPasswordHash = $this->decryptLegacyPassword($user->getPassword()); + + if ($legacyPasswordHash !== md5($passwordPlain)) { + throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); + } + + $user->setPassword($this->passwordHasher->hashPassword($user, $passwordPlain)); + } } - if (false === $response instanceof PersonalData) { - throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); - } + $user->setLastLoginAt(new \DateTimeImmutable()); + $this->entityManager->flush(); $csrfToken = $request->request->getString('_csrf_token'); return new SelfValidatingPassport( - new UserBadge($email, function () use ($email, $password, $response) { - return $this->createOrUpdateLocalUser($email, $password, $response); - }), + new UserBadge($email, fn () => $user), [ new CsrfTokenBadge('authenticate', $csrfToken), ] ); } - private function createOrUpdateLocalUser(string $email, string $password, PersonalData $personalData): User + private function bootstrapUser(string $email, string $passwordPlain): User { try { - $crmAttributes = $this->apiClient->getCrmAttributes($email, $password); + $personalData = $this->apiClient->getPersonalDataWithPassword($email, $passwordPlain); + $crmAttributes = $this->apiClient->getCrmAttributesWithPassword($email, $passwordPlain); } catch (ApiClientException $e) { - $message = $this->isConnectionError($e) ? - 'Der Server ist momentan nicht erreichbar. Bitte versuche es später erneut. :(' : $e->getMessage(); + $message = $this->isConnectionError($e) + ? 'Der Server ist momentan nicht erreichbar. Bitte versuche es später erneut.' + : $e->getMessage(); + throw new CustomUserMessageAuthenticationException($message); } - $roles = $crmAttributes->roles; - $hotelCodes = $crmAttributes->hotelCodes; - - $encryptedPassword = $this->crypt->encrypt($password); - - $userRepository = $this->entityManager->getRepository(User::class); - - if (null === $user = $userRepository->findOneBy(['email' => $email])) { - $user = new User($email); - $this->entityManager->persist($user); + if (false === $personalData instanceof PersonalData || false === $crmAttributes instanceof CrmAttributes) { + throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); } + $user = new User($email); + $this->entityManager->persist($user); + $user - ->setPassword($encryptedPassword) + // Persist the local hash immediately so subsequent logins no longer + // depend on the legacy BPN password format. + ->setPassword($this->passwordHasher->hashPassword($user, $passwordPlain)) ->setPersonId($personalData->personId) ->setAddressId($personalData->addressId) - ->setRoles($roles) - ->setHotelCodes($hotelCodes) - ->setLastLoginAt(new \DateTimeImmutable()) + ->setRoles($crmAttributes->roles) + ->setHotelCodes($crmAttributes->hotelCodes) ->setProfileComplete($this->completenessChecker->isComplete($personalData)) ; - $this->entityManager->flush(); - return $user; } @@ -141,6 +153,24 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent return new RedirectResponse($targetPath); } + private function decryptLegacyPassword(?string $storedPassword): string + { + if (null === $storedPassword || '' === $storedPassword) { + throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); + } + + $ciphertext = base64_decode($storedPassword, true); + if (false === $ciphertext) { + $ciphertext = $storedPassword; + } + + try { + return $this->crypt->decrypt($ciphertext); + } catch (\Throwable) { + throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); + } + } + private function isConnectionError(ApiClientException $e): bool { if ($e instanceof TimeoutException || $e instanceof ImmediateConnectionCloseException) { diff --git a/src/Service/BookingEditDataLoader.php b/src/Service/BookingEditDataLoader.php index 01b15447..6ad72172 100644 --- a/src/Service/BookingEditDataLoader.php +++ b/src/Service/BookingEditDataLoader.php @@ -11,7 +11,6 @@ use App\BusProNet\Model\Notification; use App\BusProNet\XmlLoader\AgencyLoader; use App\Entity\User; use App\Form\Model\BookingDto; -use App\Security\Crypt; use Psr\Cache\InvalidArgumentException; use Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\Cache\ItemInterface; @@ -37,7 +36,6 @@ class BookingEditDataLoader private readonly TravelDataProvider $travelDataService, private readonly BookingEditDraftManager $draftService, private readonly AgencyLoader $agencyLoader, - private readonly Crypt $crypt, private readonly TagAwareCacheInterface $bpnCache, ) { } @@ -179,14 +177,13 @@ class BookingEditDataLoader $cacheKey = $this->getBookingCacheKey($bookingId, $user); $userTag = self::CACHE_TAG_USER_PREFIX.$user->getId(); $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); try { - return $this->bpnCache->get($cacheKey, function (ItemInterface $item) use ($email, $password, $bookingId, $userTag) { + return $this->bpnCache->get($cacheKey, function (ItemInterface $item) use ($email, $bookingId, $userTag, $user) { $item->expiresAfter(300); $item->tag([$userTag]); - return $this->apiClient->getBooking($email, $password, $bookingId); + return $this->apiClient->getBooking($email, $bookingId, $user->getAddressId(), $user->getPersonId()); }); } catch (InvalidArgumentException) { return null; diff --git a/src/Service/ParticipantDataPrefiller.php b/src/Service/ParticipantDataPrefiller.php index 962c23a7..4d825fad 100644 --- a/src/Service/ParticipantDataPrefiller.php +++ b/src/Service/ParticipantDataPrefiller.php @@ -10,7 +10,6 @@ use App\BusProNet\Model\Notification; use App\Entity\User; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; -use App\Security\Crypt; use Carbon\CarbonImmutable; use Psr\Log\LoggerInterface; @@ -27,7 +26,6 @@ class ParticipantDataPrefiller public function __construct( private readonly ApiClient $apiClient, - private readonly Crypt $crypt, private readonly LoggerInterface $logger, ) { } @@ -48,12 +46,10 @@ class ParticipantDataPrefiller public function prefillApplicantFromUser(User $user, ParticipantDto $applicant): ParticipantDto { try { - // Decrypt user password for API authentication - $password = $this->crypt->decrypt($user->getPassword()); $email = $user->getEmail(); // Fetch personal data from API - $result = $this->apiClient->getPersonalData($email, $password); + $result = $this->apiClient->getPersonalData($email, $user->getAddressId(), $user->getPersonId()); // Handle API error response if ($result instanceof Notification) { @@ -90,7 +86,7 @@ class ParticipantDataPrefiller return $applicant; } catch (\Exception $e) { - // Catch any unexpected exceptions (decryption failure, API errors, etc.) + // Catch any unexpected exceptions (API errors, etc.) $this->logger->error('Exception during applicant prepopulation', [ 'error' => $e->getMessage(), 'email' => $user->getEmail(), diff --git a/tests/BusProNet/ApiClientPersonalDataRequestTest.php b/tests/BusProNet/ApiClientPersonalDataRequestTest.php new file mode 100644 index 00000000..d0ce5dfe --- /dev/null +++ b/tests/BusProNet/ApiClientPersonalDataRequestTest.php @@ -0,0 +1,96 @@ +createApiClient(); + $payload = $this->invokeBuildPersonalDataRequestData($apiClient, 'customer@example.com', 'Adressdaten', 123, 456); + $xml = $this->serializePayload($payload); + + self::assertStringContainsString('J', $xml); + self::assertStringNotContainsString('', $xml); + self::assertStringContainsString('customer@example.com', $xml); + self::assertStringContainsString('123', $xml); + self::assertStringContainsString('456', $xml); + } + + public function testPersonalDataRequestOmitsOptionalIdentifiersWhenNotProvided(): void + { + $apiClient = $this->createApiClient(); + $payload = $this->invokeBuildPersonalDataRequestData($apiClient, 'customer@example.com', 'Adressdaten'); + $xml = $this->serializePayload($payload); + + self::assertStringContainsString('J', $xml); + self::assertStringNotContainsString('', $xml); + self::assertStringContainsString('customer@example.com', $xml); + self::assertStringNotContainsString('', $xml); + self::assertStringNotContainsString('', $xml); + } + + /** + * @return array + */ + private function invokeBuildPersonalDataRequestData( + ApiClient $apiClient, + string $email, + string $art, + ?int $addressId = null, + ?int $personId = null + ): array + { + $method = new \ReflectionMethod(ApiClient::class, 'buildCustomerDataRequestData'); + $method->setAccessible(true); + + /** @var array $payload */ + $payload = $method->invoke($apiClient, $email, $art, $addressId, $personId); + + return $payload; + } + + /** + * @param array $payload + */ + private function serializePayload(array $payload): string + { + return (new Serializer([], [new XmlEncoder()]))->serialize($payload, 'xml', [ + XmlEncoder::ROOT_NODE_NAME => 'anfrage', + XmlEncoder::ENCODING => 'UTF-8', + ]); + } + + private function createApiClient(): ApiClient + { + return new ApiClient( + $this->createMock(SerializerInterface::class), + $this->createMock(ApiResponseParser::class), + $this->createMock(FilesystemOperator::class), + $this->createMock(LoggerInterface::class), + $this->createMock(BookingDataProcessor::class), + new RequestStack(), + new RequestIdGenerator(), + [ + 'bpn_username' => 'test-user', + 'bpn_password' => 'test-password', + 'bpn_api_ip' => '127.0.0.1', + 'bpn_api_ports' => [9000], + ], + ); + } +} diff --git a/tests/Controller/Account/PersonalDataControllerTest.php b/tests/Controller/Account/PersonalDataControllerTest.php index 33a63bdd..f5017630 100644 --- a/tests/Controller/Account/PersonalDataControllerTest.php +++ b/tests/Controller/Account/PersonalDataControllerTest.php @@ -9,7 +9,6 @@ use App\BusProNet\Model\PersonalData; use App\Controller\Account\PersonalDataController; use App\Entity\User; use App\Model\NewsletterSubscriptionRequestResult; -use App\Security\Crypt; use App\Service\BookingEditDataLoader; use App\Service\NewsletterManager; use App\Service\ProfileCompletenessChecker; @@ -30,7 +29,7 @@ class PersonalDataControllerTest extends TestCase $controller->apiClient ->expects(self::once()) ->method('getPersonalData') - ->with('customer@example.com', 'secret') + ->with('customer@example.com', null, null) ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->newsletterManager @@ -58,7 +57,7 @@ class PersonalDataControllerTest extends TestCase $controller->apiClient ->expects(self::once()) ->method('getPersonalData') - ->with('customer@example.com', 'secret') + ->with('customer@example.com', null, null) ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->newsletterManager @@ -85,7 +84,7 @@ class PersonalDataControllerTest extends TestCase $controller->apiClient ->expects(self::once()) ->method('getPersonalData') - ->with('customer@example.com', 'secret') + ->with('customer@example.com', null, null) ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->newsletterManager @@ -125,7 +124,7 @@ class PersonalDataControllerTest extends TestCase $controller->apiClient ->expects(self::once()) ->method('getPersonalData') - ->with('customer@example.com', 'secret') + ->with('customer@example.com', null, null) ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->newsletterManager @@ -157,12 +156,6 @@ class PersonalDataControllerTest extends TestCase $apiClient = $this->createMock(ApiClient::class); - $crypt = $this->createMock(Crypt::class); - $crypt - ->method('decrypt') - ->with('secret') - ->willReturn('secret'); - $form = $this->createMock(FormInterface::class); $form ->method('handleRequest') @@ -173,7 +166,6 @@ class PersonalDataControllerTest extends TestCase return new TestablePersonalDataController( $apiClient, - $crypt, $this->createMock(BookingEditDataLoader::class), $this->createMock(ProfileCompletenessChecker::class), $this->createMock(EntityManagerInterface::class), @@ -210,7 +202,6 @@ final class TestablePersonalDataController extends PersonalDataController public function __construct( public readonly ApiClient $apiClient, - Crypt $crypt, BookingEditDataLoader $dataLoader, ProfileCompletenessChecker $completenessChecker, EntityManagerInterface $entityManager, @@ -221,7 +212,6 @@ final class TestablePersonalDataController extends PersonalDataController ) { parent::__construct( $apiClient, - $crypt, $dataLoader, $completenessChecker, $entityManager, diff --git a/tests/Security/BpnAuthenticatorTest.php b/tests/Security/BpnAuthenticatorTest.php new file mode 100644 index 00000000..0815b8cd --- /dev/null +++ b/tests/Security/BpnAuthenticatorTest.php @@ -0,0 +1,222 @@ +setPassword(password_hash('secret', PASSWORD_BCRYPT)); + + $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); + $passwordHasher + ->expects(self::once()) + ->method('isPasswordValid') + ->with($user, 'secret') + ->willReturn(true); + + $authenticator = $this->createAuthenticator( + user: $user, + passwordHasher: $passwordHasher, + ); + + $passport = $authenticator->authenticate($this->createRequest('customer@example.com', 'secret')); + + self::assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + public function testAuthenticateMigratesLegacyPasswordToLocalHashOnSuccess(): void + { + $user = new User('customer@example.com'); + $user->setPassword(base64_encode('legacy-ciphertext')); + + $crypt = $this->createMock(Crypt::class); + $crypt + ->expects(self::once()) + ->method('decrypt') + ->with('legacy-ciphertext') + ->willReturn(md5('secret')); + + $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); + $passwordHasher + ->expects(self::never()) + ->method('isPasswordValid'); + $passwordHasher + ->expects(self::once()) + ->method('hashPassword') + ->with($user, 'secret') + ->willReturn('local-hash'); + + $authenticator = $this->createAuthenticator( + user: $user, + crypt: $crypt, + passwordHasher: $passwordHasher, + ); + + $passport = $authenticator->authenticate($this->createRequest('customer@example.com', 'secret')); + + self::assertInstanceOf(SelfValidatingPassport::class, $passport); + self::assertSame('local-hash', $user->getPassword()); + } + + public function testAuthenticateBootstrapsMissingUserFromBpn(): void + { + $email = 'customer@example.com'; + $password = 'secret'; + + $personalData = new PersonalData(); + $personalData->addressId = 123; + $personalData->personId = 456; + $personalData->firstName = 'Mia'; + $personalData->name = 'Muster'; + $personalData->gender = 'W'; + $personalData->nationality = 'DE'; + $personalData->dateOfBirth = new \DateTimeImmutable('1990-05-15'); + $personalData->communication->email = $email; + $personalData->communication->mobile = '+49123456789'; + $personalData->address->street = 'Main Street 1'; + $personalData->address->postCode = '10115'; + $personalData->address->city = 'Berlin'; + $personalData->address->country = 'DE'; + + $crmAttributes = new CrmAttributes(); + $crmAttributes->roles = ['ROLE_CUSTOMER']; + $crmAttributes->hotelCodes = ['AAA']; + + $apiClient = $this->createMock(ApiClient::class); + $apiClient->expects(self::once()) + ->method('getPersonalDataWithPassword') + ->with($email, $password) + ->willReturn($personalData); + $apiClient->expects(self::once()) + ->method('getCrmAttributesWithPassword') + ->with($email, $password) + ->willReturn($crmAttributes); + + $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); + $passwordHasher + ->expects(self::once()) + ->method('hashPassword') + ->with(self::isInstanceOf(User::class), $password) + ->willReturn('local-hash'); + + $completenessChecker = $this->createMock(ProfileCompletenessChecker::class); + $completenessChecker + ->expects(self::once()) + ->method('isComplete') + ->with($personalData) + ->willReturn(true); + + $persistedUser = null; + $repository = $this->createMock(EntityRepository::class); + $repository + ->expects(self::once()) + ->method('findOneBy') + ->with(['email' => $email]) + ->willReturn(null); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->method('getRepository') + ->willReturn($repository); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (User $user) use (&$persistedUser): bool { + $persistedUser = $user; + + return true; + })); + $entityManager + ->expects(self::once()) + ->method('flush'); + + $authenticator = new BpnAuthenticator( + $this->createMock(UrlGeneratorInterface::class), + $apiClient, + $entityManager, + $this->createMock(Crypt::class), + $completenessChecker, + $passwordHasher, + $this->createMock(LoggerInterface::class), + ); + + $passport = $authenticator->authenticate($this->createRequest($email, $password)); + + self::assertInstanceOf(SelfValidatingPassport::class, $passport); + self::assertInstanceOf(User::class, $persistedUser); + self::assertSame('local-hash', $persistedUser->getPassword()); + self::assertSame(123, $persistedUser->getAddressId()); + self::assertSame(456, $persistedUser->getPersonId()); + self::assertSame(['ROLE_USER', 'ROLE_CUSTOMER'], $persistedUser->getRoles()); + self::assertSame(['AAA'], $persistedUser->getHotelCodes()); + self::assertTrue($persistedUser->isProfileComplete()); + } + + private function createAuthenticator( + ?User $user = null, + ?ApiClient $apiClient = null, + ?Crypt $crypt = null, + ?ProfileCompletenessChecker $completenessChecker = null, + ?UserPasswordHasherInterface $passwordHasher = null, + ): BpnAuthenticator { + $repository = $this->createMock(EntityRepository::class); + $repository + ->expects(self::once()) + ->method('findOneBy') + ->with(self::callback(static function (array $criteria): bool { + return isset($criteria['email']) && is_string($criteria['email']); + })) + ->willReturn($user); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->method('getRepository') + ->with(User::class) + ->willReturn($repository); + $entityManager + ->expects(self::once()) + ->method('flush'); + $entityManager + ->expects(null === $user ? self::once() : self::never()) + ->method('persist'); + + return new BpnAuthenticator( + $this->createMock(UrlGeneratorInterface::class), + $apiClient ?? $this->createMock(ApiClient::class), + $entityManager, + $crypt ?? $this->createMock(Crypt::class), + $completenessChecker ?? $this->createMock(ProfileCompletenessChecker::class), + $passwordHasher ?? $this->createMock(UserPasswordHasherInterface::class), + $this->createMock(LoggerInterface::class), + ); + } + + private function createRequest(string $email, string $password): Request + { + return Request::create('/login', Request::METHOD_POST, [ + '_username' => $email, + '_password' => $password, + '_csrf_token' => 'csrf-token', + ]); + } +} diff --git a/tests/Service/ParticipantDataPrefillerTest.php b/tests/Service/ParticipantDataPrefillerTest.php index 717b601b..3f10383a 100644 --- a/tests/Service/ParticipantDataPrefillerTest.php +++ b/tests/Service/ParticipantDataPrefillerTest.php @@ -12,7 +12,6 @@ use App\BusProNet\Model\PersonalData; use App\Entity\User; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; -use App\Security\Crypt; use App\Service\ParticipantDataPrefiller; use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; @@ -21,19 +20,16 @@ use Psr\Log\LoggerInterface; class ParticipantDataPrefillerTest extends TestCase { private ApiClient $apiClient; - private Crypt $crypt; private LoggerInterface $logger; private ParticipantDataPrefiller $service; protected function setUp(): void { $this->apiClient = $this->createMock(ApiClient::class); - $this->crypt = $this->createMock(Crypt::class); $this->logger = $this->createMock(LoggerInterface::class); $this->service = new ParticipantDataPrefiller( $this->apiClient, - $this->crypt, $this->logger ); } @@ -46,14 +42,9 @@ class ParticipantDataPrefillerTest extends TestCase $personalData = $this->createCompletePersonalData(); - $this->crypt->expects($this->once()) - ->method('decrypt') - ->with('encrypted_password') - ->willReturn('decrypted_password'); - $this->apiClient->expects($this->once()) ->method('getPersonalData') - ->with('test@example.com', 'decrypted_password') + ->with('test@example.com', null, null) ->willReturn($personalData); $result = $this->service->prefillApplicantFromUser($user, $applicant); @@ -94,12 +85,9 @@ class ParticipantDataPrefillerTest extends TestCase $personalData = $this->createPartialPersonalData(); - $this->crypt->expects($this->once()) - ->method('decrypt') - ->willReturn('decrypted_password'); - $this->apiClient->expects($this->once()) ->method('getPersonalData') + ->with('test@example.com', null, null) ->willReturn($personalData); $result = $this->service->prefillApplicantFromUser($user, $applicant); @@ -129,12 +117,9 @@ class ParticipantDataPrefillerTest extends TestCase $notification = new Notification(401, 'Authentication failed'); - $this->crypt->expects($this->once()) - ->method('decrypt') - ->willReturn('decrypted_password'); - $this->apiClient->expects($this->once()) ->method('getPersonalData') + ->with('test@example.com', null, null) ->willReturn($notification); $this->logger->expects($this->once()) @@ -156,46 +141,15 @@ class ParticipantDataPrefillerTest extends TestCase $this->assertNull($result->email); } - public function testPrepopulateApplicantWithDecryptionException(): void - { - $user = $this->createUser('test@example.com', 'encrypted_password'); - $applicant = new ParticipantDto(); - $applicant->index = 0; - - $this->crypt->expects($this->once()) - ->method('decrypt') - ->willThrowException(new \RuntimeException('Decryption failed')); - - $this->apiClient->expects($this->never()) - ->method('getPersonalData'); - - $this->logger->expects($this->once()) - ->method('error') - ->with('Exception during applicant prepopulation', [ - 'error' => 'Decryption failed', - 'email' => 'test@example.com', - ]); - - $result = $this->service->prefillApplicantFromUser($user, $applicant); - - // Should return unchanged applicant - $this->assertNull($result->firstName); - $this->assertNull($result->lastName); - $this->assertNull($result->email); - } - public function testPrepopulateApplicantWithApiException(): void { $user = $this->createUser('test@example.com', 'encrypted_password'); $applicant = new ParticipantDto(); $applicant->index = 0; - $this->crypt->expects($this->once()) - ->method('decrypt') - ->willReturn('decrypted_password'); - $this->apiClient->expects($this->once()) ->method('getPersonalData') + ->with('test@example.com', null, null) ->willThrowException(new \RuntimeException('API connection failed')); $this->logger->expects($this->once()) @@ -223,12 +177,9 @@ class ParticipantDataPrefillerTest extends TestCase $personalData = $this->createCompletePersonalData(); - $this->crypt->expects($this->once()) - ->method('decrypt') - ->willReturn('decrypted_password'); - $this->apiClient->expects($this->once()) ->method('getPersonalData') + ->with('test@example.com', null, null) ->willReturn($personalData); $result = $this->service->prefillApplicantFromUser($user, $applicant);