Files
myep/src/Security/BpnAuthenticator.php
T
Björn Fromme a097159897 feat: move profile completeness check from login to booking flow entry
- Add profileComplete flag to User entity to avoid API calls
- Set flag during login (BpnAuthenticator) and profile save
- Check flag in IndexController when entering booking flow
- Remove ProfileCompletionSubscriber (no longer needed)

Users with incomplete profiles are only redirected when starting
a new booking, not on every login. Eliminates extra API call by
caching completeness status on the user entity.
2026-01-19 18:55:55 +01:00

138 lines
5.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Security;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
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\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.
*
* 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.
*/
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,
) {
}
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate('app_login');
}
public function authenticate(Request $request): Passport
{
$email = trim($request->request->getString('_username'));
$passwordPlain = trim($request->request->getString('_password'));
// Very lame hashing applied here as required by BPN
$password = md5($passwordPlain);
try {
$response = $this->apiClient->getPersonalData($email, $password);
} catch (ApiClientException $e) {
throw new CustomUserMessageAuthenticationException($e->getMessage());
}
if (false === $response instanceof PersonalData) {
throw new CustomUserMessageAuthenticationException('Der Login ist fehlgeschlagen :(');
}
$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) {
throw new CustomUserMessageAuthenticationException($e->getMessage());
}
$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);
}
$user
->setPassword($encryptedPassword)
->setPersonId($personalData->personId)
->setAddressId($personalData->addressId)
->setRoles($roles)
->setHotelCodes($hotelCodes)
->setLastLoginAt(new \DateTimeImmutable())
->setProfileComplete($this->completenessChecker->isComplete($personalData))
;
$this->entityManager->flush();
return $user;
}
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');
// When redirecting to admin from an HTMX request, force a full page navigation
// to avoid layout issues between frontend (hx-boost) and EasyAdmin
if ($request->headers->has('HX-Request') && str_contains($targetPath, '/admin')) {
return new HxRedirectResponse($targetPath);
}
return new RedirectResponse($targetPath);
}
}