feat: local password hashes
This commit is contained in:
1 parent
08f6633507
commit
fe562e5e73
15 files changed
+541
-253
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user