urlGenerator->generate('app_login'); } public function authenticate(Request $request): Passport { // One canonical casing per account. BusPro matches an address case-insensitively and so // does the utf8mb4_unicode_ci column, so the casing somebody happens to type must not // become the casing every later BusPro call re-sends. $email = mb_strtolower(trim($request->request->getString('_username'))); $passwordPlain = trim($request->request->getString('_password')); // BPN requires md5, not a real hash $password = md5($passwordPlain); 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 (false === $response instanceof PersonalData) { throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); } $csrfToken = $request->request->getString('_csrf_token'); return new SelfValidatingPassport( new UserBadge($email, function () use ($email, $password, $response) { return $this->createOrUpdateLocalUser($email, $password, $response); }), [ new CsrfTokenBadge('authenticate', $csrfToken), ] ); } private function createOrUpdateLocalUser(string $email, string $password, PersonalData $personalData): User { try { $crmAttributes = $this->apiClient->getCrmAttributes($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); } $encryptedPassword = $this->crypt->encrypt($password); $userRepository = $this->entityManager->getRepository(User::class); $user = $userRepository->findOneBy(['email' => $email]) ?? new User($email); $user // Not redundant next to the constructor: an account created before the address was // normalized still carries the casing of its very first login, and everything // downstream re-sends what is stored rather than what was just typed. ->setEmail($email) ->setPassword($encryptedPassword) ->setPersonId($personalData->personId) ->setAddressId($personalData->addressId) ->setFirstName($personalData->firstName) ->setLastName($personalData->name) ->setLastLoginAt(new \DateTimeImmutable()) ->setProfileComplete($this->completenessChecker->isComplete($personalData)) ; $nominated = $this->syncFromCrm($user, $crmAttributes); // Registered only once it is fully populated: syncFromCrm() logs on a channel that writes // to the database, and an account already managed at that point would be flushed // half-built — which is how a NULL password used to reach the user table. A no-op for an // account that came from the repository. $this->entityManager->persist($user); $this->entityManager->flush(); // After the flush, deliberately: a first login has no id before it, and the transport is // Doctrine-backed, so a message queued ahead of a failing flush would announce a // nomination that was never stored. if ([] !== $nominated) { $this->messageBus->dispatch(new RoleNominationMessage((int) $user->getId(), $nominated)); } return $user; } /** * Writes back what the CRM currently claims: the roles per Role::sync() and the hotel codes * verbatim. Both replace what is stored, which is what makes BusPro the source of truth. * * @return string[] the roles this login newly nominated the account for — the roles * themselves, not their markers, and empty whenever nothing changed */ private function syncFromCrm(User $user, CrmAttributes $crmAttributes): array { $previousRoles = $user->getRoles(); if ([] === $crmAttributes->selectionGroups) { // BusPro always answers with the full attribute tree and expresses membership through // the `auswahl` flag, so an empty one is a degraded payload rather than a revocation. // Syncing it would strip the roles of every user who logs in. $this->authLogger->warning('Skipped the role sync: the BPN CRM response carries no selection groups', [ 'email' => $user->getEmail(), ]); // An existing account keeps everything it has. A brand new one still needs a role, // and an empty claim set is exactly what Role::sync() answers with the fallback. if ([] !== Role::assignedOnly($previousRoles)) { return []; } } $claimedRoles = $crmAttributes->roles; // Not a CRM claim: BusPro has no selection for it, so the account's own address decides. // Passing it in as a claim rather than setting it afterwards is what makes it revocable — // Role::sync() strips every stored role the claim set does not contain. if ($this->employeeDomainMatcher->isEmployee($user->getEmail())) { $claimedRoles[] = Role::EMPLOYEE; } $user ->setRoles(Role::sync($previousRoles, $claimedRoles)) ->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes))) ; // Compared on the full role sets rather than on the markers alone: nominatedFrom() needs // to see ROLE_EMPLOYEE to know whether an EMPLOYEE_ONLY marker counts. $nominated = array_values(array_diff( array_keys(Role::nominatedFrom($user->getRoles())), array_keys(Role::nominatedFrom($previousRoles)), )); if ([] === $nominated) { return []; } // The CRM claims an administrative role for somebody who does not hold it. It grants // nothing until an administrator approves it in /admin/user. $this->authLogger->info('Nominated for administrative roles by the BPN CRM', [ 'email' => $user->getEmail(), 'roles' => $nominated, ]); // Only the newly appeared nominations reach this point, so a repeat login with a // nomination still standing announces nothing. That difference is the whole de-duplication. return $nominated; } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { $this->authLogger->info('Login', [ 'email' => $token->getUserIdentifier(), ]); $targetPath = $this->getTargetPath($request->getSession(), $firewallName) ?? $this->urlGenerator->generate('app_account'); // A full load avoids hx-boost layout issues if ($request->headers->has('HX-Request') && str_contains($targetPath, '/admin')) { return new HxRedirectResponse($targetPath); } return new RedirectResponse($targetPath); } private function isConnectionError(ApiClientException $e): bool { if ($e instanceof TimeoutException || $e instanceof ImmediateConnectionCloseException) { return true; } return 'Unable to open socket' === $e->getMessage(); } }