240 lines
9.4 KiB
PHP
240 lines
9.4 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\Repository\UserRepository;
|
|
use App\Service\UserBootstrapper;
|
|
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\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
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;
|
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
|
|
|
/**
|
|
* Authenticates users against the local Symfony password hash.
|
|
*
|
|
* 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
|
|
{
|
|
use TargetPathTrait;
|
|
|
|
public function __construct(
|
|
private readonly UrlGeneratorInterface $urlGenerator,
|
|
private readonly ApiClient $apiClient,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
private readonly Crypt $crypt,
|
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
|
private readonly ValidatorInterface $validator,
|
|
private readonly UserBootstrapper $userBootstrapper,
|
|
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'));
|
|
|
|
/** @var UserRepository $userRepository */
|
|
$userRepository = $this->entityManager->getRepository(User::class);
|
|
$user = $userRepository->findOneByNormalizedEmail($email);
|
|
|
|
if (null === $user) {
|
|
// Legacy fallback for accounts that were not created locally yet:
|
|
// prove credentials against BPN, then bootstrap the local account.
|
|
$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));
|
|
}
|
|
|
|
$this->refreshBpnSnapshot($user);
|
|
}
|
|
|
|
$user->setLastLoginAt(new \DateTimeImmutable());
|
|
$this->entityManager->flush();
|
|
|
|
$csrfToken = $request->request->getString('_csrf_token');
|
|
|
|
return new SelfValidatingPassport(
|
|
new UserBadge($email, fn () => $user),
|
|
[
|
|
new CsrfTokenBadge('authenticate', $csrfToken),
|
|
]
|
|
);
|
|
}
|
|
|
|
private function bootstrapUser(string $email, string $passwordPlain): User
|
|
{
|
|
try {
|
|
$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();
|
|
|
|
throw new CustomUserMessageAuthenticationException($message);
|
|
}
|
|
|
|
if (!$personalData instanceof PersonalData || !$crmAttributes instanceof CrmAttributes) {
|
|
throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.');
|
|
}
|
|
|
|
$user = new User($email);
|
|
$passwordHash = $this->passwordHasher->hashPassword($user, $passwordPlain);
|
|
$profileComplete = 0 === $this->validator->validate($personalData, null, ['personal_data'])->count();
|
|
$this->userBootstrapper->bootstrap(
|
|
$user,
|
|
$passwordHash,
|
|
$personalData->addressId,
|
|
$personalData->personId,
|
|
$crmAttributes->roles,
|
|
$crmAttributes->hotelCodes,
|
|
$profileComplete,
|
|
);
|
|
$this->entityManager->persist($user);
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function refreshBpnSnapshot(User $user): void
|
|
{
|
|
try {
|
|
$personalData = $this->apiClient->getPersonalData(
|
|
$user->getUserIdentifier(),
|
|
$user->getAddressId(),
|
|
$user->getPersonId(),
|
|
);
|
|
} 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 (!$personalData instanceof PersonalData) {
|
|
throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.');
|
|
}
|
|
|
|
$profileComplete = 0 === $this->validator->validate($personalData, null, ['personal_data'])->count();
|
|
$user
|
|
->setAddressId($personalData->addressId)
|
|
->setPersonId($personalData->personId)
|
|
->setProfileComplete($profileComplete)
|
|
;
|
|
|
|
try {
|
|
$crmAttributes = $this->apiClient->getCrmAttributes(
|
|
$user->getUserIdentifier(),
|
|
$user->getAddressId(),
|
|
$user->getPersonId(),
|
|
);
|
|
} 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 (!$crmAttributes instanceof CrmAttributes) {
|
|
throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.');
|
|
}
|
|
|
|
$user
|
|
->setRoles($crmAttributes->roles)
|
|
->setHotelCodes($crmAttributes->hotelCodes)
|
|
;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
return true;
|
|
}
|
|
|
|
return 'Unable to open socket' === $e->getMessage();
|
|
}
|
|
}
|