Files
myep/src/Security/BpnAuthenticator.php
T
frommeandClaude Opus 5 f080d05dd6 fix: normalize the login email case
The login address was only trimmed, so the casing somebody happened to type at
their very first login was frozen into the user row forever — createOrUpdateLocalUser()
never wrote the address back. Everything downstream re-sends the stored address
rather than the one just authenticated with, which also leaked that casing into the
OAuth2 email claim and the log identities.

Harmless in practice, since BusPro matches an address case-insensitively and so does
the utf8mb4_unicode_ci column, but it left User out of step with the newsletter
entities, which have always normalized.

Fold the case once in authenticate(), which covers the BusPro calls, the lookup and a
new account alike, and write the address back on every login so an account created
before this converges instead of staying frozen. No backfill: a row nobody logs into
again is matched case-insensitively either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 10:03:06 +02:00

241 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Security;
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;
use App\Message\RoleNominationMessage;
use App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
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;
/**
* Authenticates users against the BPN API.
*
* The password is kept, RSA-encrypted, because every later BPN call needs it again.
*
* BusPro owns the whole role set bar one, and the hotel codes: both are synced on every login, in
* both directions, so anything the CRM no longer reports is withdrawn here. The exception is
* ROLE_EMPLOYEE, which BusPro has no selection for and which is derived from the account's email
* domain — passed to Role::sync() as a claim, so it is granted and revoked by the same machinery. What the CRM claims is
* not automatically granted, though — Role::sync() turns an administrative claim into a
* nomination that an administrator has to approve in /admin/user, because BusPro backend users
* can edit their own CRM selections and would otherwise make themselves administrators.
*/
class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface
{
use TargetPathTrait;
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly ApiClient $apiClient,
private readonly EntityManagerInterface $entityManager,
private readonly Crypt $crypt,
private readonly ProfileCompletenessChecker $completenessChecker,
private readonly LoggerInterface $authLogger,
private readonly EmployeeDomainMatcher $employeeDomainMatcher,
private readonly MessageBusInterface $messageBus,
) {
}
protected function getLoginUrl(Request $request): string
{
return $this->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();
}
}