fix: stop MyE&P from granting roles and matching the wrong account
This commit is contained in:
@@ -5,9 +5,11 @@ namespace App\Security;
|
|||||||
use App\BusProNet\UserDataHandler;
|
use App\BusProNet\UserDataHandler;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
|
||||||
use App\Security\OAuth2\AuthorizationRequestException;
|
use App\Security\OAuth2\AuthorizationRequestException;
|
||||||
use App\Security\OAuth2\MyEpClient;
|
use App\Security\OAuth2\MyEpClient;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Flagception\Manager\FeatureManagerInterface;
|
||||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||||
use Psr\Http\Client\ClientExceptionInterface;
|
use Psr\Http\Client\ClientExceptionInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -26,22 +28,35 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
|||||||
|
|
||||||
class MyEpAuthenticator extends AbstractAuthenticator
|
class MyEpAuthenticator extends AbstractAuthenticator
|
||||||
{
|
{
|
||||||
private const ELIGIBLE_ROLES = ['ROLE_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'];
|
|
||||||
|
|
||||||
use TargetPathTrait;
|
use TargetPathTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles that entitle someone to log in here at all. Anything else MyE&P reports
|
||||||
|
* is dropped rather than stored, so that no role this application assigns a meaning
|
||||||
|
* to can be set from the outside.
|
||||||
|
*/
|
||||||
|
private const ELIGIBLE_ROLES = ['ROLE_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MyEpClient $client,
|
private readonly MyEpClient $client,
|
||||||
private readonly UserDataHandler $userDataHandler,
|
private readonly UserDataHandler $userDataHandler,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly UrlGeneratorInterface $urlGenerator,
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly FeatureManagerInterface $featureManager,
|
||||||
|
private readonly RequiredTeamerCheckRegistry $requiredTeamerCheckRegistry,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function supports(Request $request): ?bool
|
public function supports(Request $request): ?bool
|
||||||
{
|
{
|
||||||
return 'app_myep_auth_check' === $request->attributes->get('_route');
|
if ('app_myep_auth_check' !== $request->attributes->get('_route')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the controller guards the route as well, but the authenticator must not fire
|
||||||
|
// on a disabled feature either: it is what creates and updates accounts
|
||||||
|
return $this->featureManager->isActive('myep_login');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function authenticate(Request $request): Passport
|
public function authenticate(Request $request): Passport
|
||||||
@@ -58,30 +73,44 @@ class MyEpAuthenticator extends AbstractAuthenticator
|
|||||||
try {
|
try {
|
||||||
$provider = $this->client->getProvider();
|
$provider = $this->client->getProvider();
|
||||||
$url = $provider->getResourceOwnerDetailsUrl($accessToken);
|
$url = $provider->getResourceOwnerDetailsUrl($accessToken);
|
||||||
$request = $provider->getAuthenticatedRequest('GET', $url, $accessToken, [
|
$userinfoRequest = $provider->getAuthenticatedRequest('GET', $url, $accessToken, [
|
||||||
'headers' => [
|
'headers' => [
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
$response = $provider->getHttpClient()->sendRequest($request);
|
$response = $provider->getHttpClient()->sendRequest($userinfoRequest);
|
||||||
} catch (ClientExceptionInterface $e) {
|
} catch (ClientExceptionInterface $e) {
|
||||||
$this->logger->error('Login via MyE&P failed due to unexpected userinfo response');
|
$this->logger->error('Login via MyE&P failed due to unexpected userinfo response');
|
||||||
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
|
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
|
||||||
}
|
}
|
||||||
$userinfo = json_decode($response->getBody(), true);
|
|
||||||
$username = $userinfo['email'] ?? null;
|
|
||||||
|
|
||||||
if (null === $username) {
|
$userinfo = json_decode($response->getBody(), true);
|
||||||
|
|
||||||
|
if (false === is_array($userinfo)) {
|
||||||
|
$this->logger->error('Login via MyE&P failed due to unreadable userinfo payload');
|
||||||
|
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === ($userinfo['email'] ?? null)) {
|
||||||
$this->logger->error('Login via MyE&P failed due to missing username claim');
|
$this->logger->error('Login via MyE&P failed due to missing username claim');
|
||||||
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
|
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null !== $this->createOrUpdateUserFromUserinfo($userinfo)) {
|
$user = $this->createOrUpdateUserFromUserinfo($userinfo);
|
||||||
return new SelfValidatingPassport(new UserBadge($username));
|
|
||||||
|
if (null === $user) {
|
||||||
|
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
|
// The user is resolved here rather than by the user provider: the account is
|
||||||
|
// matched on the BusPro ids, which the provider cannot do, and handing it an
|
||||||
|
// email would risk loading a different row than the one just written. The
|
||||||
|
// UserChecker still runs and still refuses deleted, disabled and unapproved
|
||||||
|
// accounts.
|
||||||
|
return new SelfValidatingPassport(
|
||||||
|
new UserBadge($user->getUserIdentifier(), static fn (): User => $user),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
@@ -89,13 +118,26 @@ class MyEpAuthenticator extends AbstractAuthenticator
|
|||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $token->getUser();
|
$user = $token->getUser();
|
||||||
|
|
||||||
|
// recorded here rather than while importing, so that only a login that actually
|
||||||
|
// passed the UserChecker is counted as one
|
||||||
|
$user->setLastLoginAt(new \DateTimeImmutable());
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$this->logger->info('Logged in');
|
$this->logger->info('Logged in');
|
||||||
|
|
||||||
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
|
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
|
||||||
return new RedirectResponse($targetPath);
|
return new RedirectResponse($targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = $this->urlGenerator->generate($user->getDefaultRoute());
|
$route = $user->getDefaultRoute();
|
||||||
|
if ('app_teamer_index' === $route) {
|
||||||
|
$check = $this->requiredTeamerCheckRegistry->getFirstUnresolvedCheck($user);
|
||||||
|
if (null !== $check) {
|
||||||
|
$route = $check->getRouteName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->urlGenerator->generate($route);
|
||||||
|
|
||||||
return new RedirectResponse($url);
|
return new RedirectResponse($url);
|
||||||
}
|
}
|
||||||
@@ -111,57 +153,185 @@ class MyEpAuthenticator extends AbstractAuthenticator
|
|||||||
|
|
||||||
private function createOrUpdateUserFromUserinfo(array $userinfo): ?User
|
private function createOrUpdateUserFromUserinfo(array $userinfo): ?User
|
||||||
{
|
{
|
||||||
// User is expected to have at least one role
|
$claimedRoles = $this->collectClaimedRoles($userinfo);
|
||||||
if (false === isset($userinfo['roles']) || 0 === count($userinfo['roles'])) {
|
|
||||||
// Claim keys only, the payload itself carries the full profile
|
|
||||||
$this->logger->warning('Login via MyE&P failed due to missing roles claim', [
|
|
||||||
'claims' => array_keys($userinfo),
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
if ([] === $claimedRoles) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User is expected to have at least one of the roles teamer, manager, house manager or admin
|
$isTeamer = in_array('ROLE_TEAMER', $claimedRoles, true);
|
||||||
if ([] === array_intersect(self::ELIGIBLE_ROLES, $userinfo['roles'])) {
|
$pendingRoles = $this->userDataHandler->toPendingRoles($claimedRoles);
|
||||||
$this->logger->warning('Login via MyE&P failed due to lack of an eligible role', [
|
|
||||||
'roles' => $userinfo['roles'],
|
$user = $this->findLocalUser($userinfo);
|
||||||
'eligible_roles' => self::ELIGIBLE_ROLES,
|
|
||||||
|
// A deleted account is excluded from every process, and MyE&P must not be able to
|
||||||
|
// undo that: no data is written back, no role is granted or revoked, not even
|
||||||
|
// lastLoginAt is bumped. It is returned untouched so the UserChecker can refuse
|
||||||
|
// the login and say why. Only an admin restores it.
|
||||||
|
if (true === $user?->isDeleted()) {
|
||||||
|
$this->logger->info('Skip MyE&P sync for deleted user', [
|
||||||
|
'user_id' => $user->getId(),
|
||||||
|
'user_email' => $userinfo['email'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is already present in local database
|
// Update existing user, leaving granted roles and hotel codes alone: they are
|
||||||
$user = $this
|
// imported once on creation and managed manually afterwards. Only the
|
||||||
->entityManager
|
// privilege-free markers and ROLE_TEAMER track MyE&P on every login.
|
||||||
->getRepository(User::class)
|
|
||||||
->findOneBy(['email' => $userinfo['email']])
|
|
||||||
;
|
|
||||||
|
|
||||||
// Update existing user's roles and teamer data and return it
|
|
||||||
if (null !== $user) {
|
if (null !== $user) {
|
||||||
$user
|
$this->refreshBusProIds($user, $userinfo);
|
||||||
->setRoles($userinfo['roles'])
|
$this->userDataHandler->refreshPendingRoles($user, $pendingRoles);
|
||||||
->setHotelCodes($userinfo['profile']['hotel_codes'])
|
|
||||||
->setLastLoginAt(new \DateTimeImmutable('now'))
|
if (true === $isTeamer) {
|
||||||
;
|
$this->userDataHandler->grantTeamerRole($user);
|
||||||
|
}
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->createLocalUser($userinfo, $pendingRoles, $isTeamer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles MyE&P grants this person in this application.
|
||||||
|
*
|
||||||
|
* The remote list is filtered down to the eligible roles rather than trusted as it
|
||||||
|
* stands: anything else the identity provider reports is meaningless here at best,
|
||||||
|
* and at worst a role this application assigns a meaning to - ROLE_ADMINISTRATIVE
|
||||||
|
* from the role hierarchy, say - which must never be settable from the outside.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function collectClaimedRoles(array $userinfo): array
|
||||||
|
{
|
||||||
|
$roles = $userinfo['roles'] ?? [];
|
||||||
|
|
||||||
|
// User is expected to have at least one role
|
||||||
|
if (false === is_array($roles) || [] === $roles) {
|
||||||
|
// Claim keys only, the payload itself carries the full profile
|
||||||
|
$this->logger->warning('Login via MyE&P failed due to missing roles claim', [
|
||||||
|
'claims' => array_keys($userinfo),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is expected to have at least one of the roles teamer, manager, house manager or admin
|
||||||
|
$claimedRoles = array_values(array_intersect(self::ELIGIBLE_ROLES, $roles));
|
||||||
|
|
||||||
|
if ([] === $claimedRoles) {
|
||||||
|
$this->logger->warning('Login via MyE&P failed due to lack of an eligible role', [
|
||||||
|
'roles' => $roles,
|
||||||
|
'eligible_roles' => self::ELIGIBLE_ROLES,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $claimedRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the local account on the BusPro ids first and falls back to the email, the
|
||||||
|
* same precedence UserDataHandler::findLocalUser() applies to a BusPro login, so that
|
||||||
|
* the two login paths cannot create two accounts for the same person.
|
||||||
|
*/
|
||||||
|
private function findLocalUser(array $userinfo): ?User
|
||||||
|
{
|
||||||
|
$repository = $this->entityManager->getRepository(User::class);
|
||||||
|
|
||||||
|
$addressId = $userinfo['address_id'] ?? null;
|
||||||
|
$personId = $userinfo['person_id'] ?? null;
|
||||||
|
|
||||||
|
if (null !== $addressId && null !== $personId) {
|
||||||
|
$user = $repository->findOneBy([
|
||||||
|
'busProAddressId' => $addressId,
|
||||||
|
'busProPersonId' => $personId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (null !== $user) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $repository->findBy(['email' => $userinfo['email']]);
|
||||||
|
|
||||||
|
if (1 !== count($users)) {
|
||||||
|
if (1 < count($users)) {
|
||||||
|
$this->logger->warning('Unable to match local user by email: multiple users found', [
|
||||||
|
'email' => $userinfo['email'],
|
||||||
|
'count' => count($users),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $users[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills the BusPro ids on an account matched by email, so the next login matches
|
||||||
|
* on the ids instead. Never called for a deleted user, who must not be written to.
|
||||||
|
*/
|
||||||
|
private function refreshBusProIds(User $user, array $userinfo): void
|
||||||
|
{
|
||||||
|
$addressId = $userinfo['address_id'] ?? null;
|
||||||
|
$personId = $userinfo['person_id'] ?? null;
|
||||||
|
|
||||||
|
if (null === $addressId || null === $personId) {
|
||||||
|
$this->logger->warning('Skip BusPro ID refresh: userinfo missing IDs', [
|
||||||
|
'user_id' => $user->getId(),
|
||||||
|
'user_email' => $user->getEmail(),
|
||||||
|
'bus_pro_address_id' => $addressId,
|
||||||
|
'bus_pro_person_id' => $personId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user
|
||||||
|
->setBusProAddressId((int) $addressId)
|
||||||
|
->setBusProPersonId((int) $personId)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $pendingRoles
|
||||||
|
*/
|
||||||
|
private function createLocalUser(array $userinfo, array $pendingRoles, bool $isTeamer): ?User
|
||||||
|
{
|
||||||
|
$addressId = $userinfo['address_id'] ?? null;
|
||||||
|
$personId = $userinfo['person_id'] ?? null;
|
||||||
|
|
||||||
|
// Both are required columns and there is nothing sensible to fall back to, so an
|
||||||
|
// account is not created at all rather than half-created from a partial payload
|
||||||
|
if (null === $addressId || null === $personId) {
|
||||||
|
$this->logger->warning('Login via MyE&P failed due to missing BusPro ID claims', [
|
||||||
|
'email' => $userinfo['email'],
|
||||||
|
'claims' => array_keys($userinfo),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = is_array($userinfo['profile'] ?? null) ? $userinfo['profile'] : [];
|
||||||
|
$hotelCodes = is_array($profile['hotel_codes'] ?? null) ? $profile['hotel_codes'] : [];
|
||||||
|
|
||||||
$user = new User();
|
$user = new User();
|
||||||
$user
|
$user
|
||||||
->setFirstName($userinfo['profile']['first_name'])
|
->setFirstName($profile['first_name'] ?? null)
|
||||||
->setLastName($userinfo['profile']['last_name'])
|
->setLastName($profile['last_name'] ?? null)
|
||||||
->setEmail($userinfo['profile']['communication']['email'])
|
->setEmail((string) $userinfo['email'])
|
||||||
->setBusProPersonId($userinfo['id'])
|
->setBusProAddressId((int) $addressId)
|
||||||
->setHotelCodes($userinfo['profile']['hotel_codes'])
|
->setBusProPersonId((int) $personId)
|
||||||
->setRoles($userinfo['roles'])
|
->setHotelCodes($hotelCodes)
|
||||||
|
->setRoles([...$pendingRoles, ...($isTeamer ? ['ROLE_TEAMER'] : [])])
|
||||||
;
|
;
|
||||||
|
|
||||||
if (true === in_array('ROLE_TEAMER', $user->getRoles(), true)) {
|
if (true === $isTeamer) {
|
||||||
$teamer = Teamer::fromUserinfo($userinfo);
|
$teamer = Teamer::fromUserinfo($userinfo);
|
||||||
$user->setTeamer($teamer);
|
$user->setTeamer($teamer);
|
||||||
|
|
||||||
@@ -176,6 +346,11 @@ class MyEpAuthenticator extends AbstractAuthenticator
|
|||||||
$this->entityManager->persist($user);
|
$this->entityManager->persist($user);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->logger->info('Create user', [
|
||||||
|
'user_id' => $user->getId(),
|
||||||
|
'user_email' => $user->getEmail(),
|
||||||
|
]);
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Security;
|
||||||
|
|
||||||
|
use App\BusProNet\UserDataHandler;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
|
||||||
|
use App\Security\MyEpAuthenticator;
|
||||||
|
use App\Security\OAuth2\MyEpClient;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\Persistence\ObjectRepository;
|
||||||
|
use Flagception\Manager\FeatureManagerInterface;
|
||||||
|
use GuzzleHttp\Psr7\Request as Psr7Request;
|
||||||
|
use GuzzleHttp\Psr7\Response as Psr7Response;
|
||||||
|
use League\OAuth2\Client\Provider\AbstractProvider;
|
||||||
|
use League\OAuth2\Client\Token\AccessToken;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Http\Client\ClientInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Session;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
|
||||||
|
class MyEpAuthenticatorTest extends TestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface&MockObject $entityManager;
|
||||||
|
private ObjectRepository&MockObject $repository;
|
||||||
|
|
||||||
|
/** @var User[] */
|
||||||
|
private array $persisted = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->persisted = [];
|
||||||
|
$this->repository = $this->createMock(ObjectRepository::class);
|
||||||
|
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$this->entityManager->method('getRepository')->willReturn($this->repository);
|
||||||
|
$this->entityManager
|
||||||
|
->method('persist')
|
||||||
|
->willReturnCallback(function (object $entity): void {
|
||||||
|
if ($entity instanceof User) {
|
||||||
|
$this->persisted[] = $entity;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core of this test case: MyE&P may report an administrative role, but it may
|
||||||
|
* not grant one. Only a super admin turns the marker into the real role.
|
||||||
|
*/
|
||||||
|
public function testAdministrativeRolesAreImportedAsPendingMarkersOnly(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
$user = $this->loadUser($this->createUserinfo(['ROLE_ADMIN', 'ROLE_TEAMER']));
|
||||||
|
|
||||||
|
$this->assertSame([User::PENDING_ROLES['ROLE_ADMIN']], $user->getPendingRoles());
|
||||||
|
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||||
|
$this->assertNotContains('ROLE_ADMIN', $user->getRoles());
|
||||||
|
$this->assertContains('ROLE_TEAMER', $user->getRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownRolesAreNeverStored(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
// ROLE_ADMINISTRATIVE is granted by the role hierarchy and must not be settable
|
||||||
|
// from the outside, ROLE_KUNDE means nothing here
|
||||||
|
$userinfo = $this->createUserinfo(['ROLE_TEAMER', 'ROLE_ADMINISTRATIVE', 'ROLE_KUNDE']);
|
||||||
|
|
||||||
|
$user = $this->loadUser($userinfo);
|
||||||
|
|
||||||
|
$this->assertSame(['ROLE_USER', 'ROLE_TEAMER'], $user->getRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginIsRefusedWithoutAnEligibleRole(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->loadUser($this->createUserinfo(['ROLE_KUNDE']));
|
||||||
|
} finally {
|
||||||
|
$this->assertSame([], $this->persisted, 'no account may be created');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginIsRefusedWithoutAnyRoleClaim(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
$userinfo = $this->createUserinfo([]);
|
||||||
|
unset($userinfo['roles']);
|
||||||
|
|
||||||
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
||||||
|
|
||||||
|
$this->loadUser($userinfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A super admin's manual demotion has to survive the user's next SSO login.
|
||||||
|
*/
|
||||||
|
public function testExistingGrantedRolesAreNeverOverwritten(): void
|
||||||
|
{
|
||||||
|
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
|
||||||
|
$this->repository->method('findOneBy')->willReturn($user);
|
||||||
|
|
||||||
|
$this->loadUser($this->createUserinfo(['ROLE_ADMIN', 'ROLE_MANAGER', 'ROLE_TEAMER']));
|
||||||
|
|
||||||
|
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||||
|
$this->assertSame(
|
||||||
|
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
|
||||||
|
$user->getPendingRoles(),
|
||||||
|
);
|
||||||
|
$this->assertNotContains('ROLE_ADMIN', $user->getRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An already approved role must not be demoted back to a marker on the next login.
|
||||||
|
*/
|
||||||
|
public function testAnAlreadyGrantedAdministrativeRoleIsKept(): void
|
||||||
|
{
|
||||||
|
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER']);
|
||||||
|
$this->repository->method('findOneBy')->willReturn($user);
|
||||||
|
|
||||||
|
$this->loadUser($this->createUserinfo(['ROLE_ADMIN', 'ROLE_TEAMER']));
|
||||||
|
|
||||||
|
$this->assertSame(['ROLE_ADMIN', 'ROLE_TEAMER'], $user->getAssignedRoles());
|
||||||
|
$this->assertSame([], $user->getPendingRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTeamerRoleIsGrantedToAnExistingUser(): void
|
||||||
|
{
|
||||||
|
$user = (new User())->setEmail('[email protected]')->setRoles([User::PENDING_ROLES['ROLE_MANAGER']]);
|
||||||
|
$this->repository->method('findOneBy')->willReturn($user);
|
||||||
|
|
||||||
|
$this->loadUser($this->createUserinfo(['ROLE_TEAMER', 'ROLE_MANAGER']));
|
||||||
|
|
||||||
|
$this->assertContains('ROLE_TEAMER', $user->getAssignedRoles());
|
||||||
|
$this->assertSame([User::PENDING_ROLES['ROLE_MANAGER']], $user->getPendingRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MyE&P must not be able to undo a deletion, in any direction.
|
||||||
|
*/
|
||||||
|
public function testDeletedUserIsReturnedWithoutAnySync(): void
|
||||||
|
{
|
||||||
|
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
|
||||||
|
$user->setHotelCodes(['OLD']);
|
||||||
|
$user->setDeleted();
|
||||||
|
|
||||||
|
$this->repository->method('findOneBy')->willReturn($user);
|
||||||
|
$this->entityManager->expects($this->never())->method('flush');
|
||||||
|
|
||||||
|
$userinfo = $this->createUserinfo(['ROLE_ADMIN', 'ROLE_TEAMER']);
|
||||||
|
$userinfo['profile']['hotel_codes'] = ['NEW'];
|
||||||
|
|
||||||
|
// returned rather than refused, so the UserChecker can explain the deletion
|
||||||
|
$this->assertSame($user, $this->loadUser($userinfo));
|
||||||
|
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||||
|
$this->assertSame([], $user->getPendingRoles());
|
||||||
|
$this->assertSame(['OLD'], $user->getHotelCodes());
|
||||||
|
$this->assertNull($user->getLastLoginAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNewUserIsCreatedWithBothBusProIds(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
$user = $this->loadUser($this->createUserinfo(['ROLE_TEAMER']));
|
||||||
|
|
||||||
|
$this->assertSame(6789, $user->getBusProAddressId());
|
||||||
|
$this->assertSame(12345, $user->getBusProPersonId());
|
||||||
|
$this->assertSame('[email protected]', $user->getEmail());
|
||||||
|
$this->assertSame(['HOTEL'], $user->getHotelCodes());
|
||||||
|
$this->assertNotNull($user->getTeamer());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoAccountIsCreatedWithoutTheBusProIdClaims(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||||
|
unset($userinfo['person_id'], $userinfo['address_id']);
|
||||||
|
|
||||||
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->loadUser($userinfo);
|
||||||
|
} finally {
|
||||||
|
$this->assertSame([], $this->persisted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account a BusPro login created is matched on the id pair, so the two login
|
||||||
|
* paths cannot end up with two rows for the same person.
|
||||||
|
*/
|
||||||
|
public function testExistingUserIsMatchedOnTheBusProIdsRatherThanTheEmail(): void
|
||||||
|
{
|
||||||
|
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
|
||||||
|
|
||||||
|
$this->repository
|
||||||
|
->expects($this->once())
|
||||||
|
->method('findOneBy')
|
||||||
|
->with(['busProAddressId' => 6789, 'busProPersonId' => 12345])
|
||||||
|
->willReturn($user)
|
||||||
|
;
|
||||||
|
$this->repository->expects($this->never())->method('findBy');
|
||||||
|
|
||||||
|
$this->assertSame($user, $this->loadUser($this->createUserinfo(['ROLE_TEAMER'])));
|
||||||
|
$this->assertSame([], $this->persisted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUserMatchedByEmailHasItsBusProIdsBackfilled(): void
|
||||||
|
{
|
||||||
|
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
|
||||||
|
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([$user]);
|
||||||
|
|
||||||
|
$this->loadUser($this->createUserinfo(['ROLE_TEAMER']));
|
||||||
|
|
||||||
|
$this->assertSame(6789, $user->getBusProAddressId());
|
||||||
|
$this->assertSame(12345, $user->getBusProPersonId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAmbiguousEmailNeverCreatesOrPicksAnAccount(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([(new User())->setEmail('[email protected]'), (new User())->setEmail('[email protected]')]);
|
||||||
|
|
||||||
|
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||||
|
unset($userinfo['person_id'], $userinfo['address_id']);
|
||||||
|
|
||||||
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->loadUser($userinfo);
|
||||||
|
} finally {
|
||||||
|
$this->assertSame([], $this->persisted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A partial payload must refuse the login at worst, never fatal.
|
||||||
|
*/
|
||||||
|
public function testPartialProfileDoesNotFail(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||||
|
unset($userinfo['profile']);
|
||||||
|
|
||||||
|
$user = $this->loadUser($userinfo);
|
||||||
|
|
||||||
|
$this->assertSame(['ROLE_USER', 'ROLE_TEAMER'], $user->getRoles());
|
||||||
|
$this->assertSame([], $user->getHotelCodes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDateOfBirthIsReadFromTheProfile(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||||
|
$userinfo['profile']['date_of_birth'] = '1980-04-01';
|
||||||
|
|
||||||
|
$user = $this->loadUser($userinfo);
|
||||||
|
|
||||||
|
$this->assertSame('1980-04-01', $user->getTeamer()?->getDateOfBirth()?->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnparsableDateOfBirthIsIgnoredRatherThanFatal(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findOneBy')->willReturn(null);
|
||||||
|
$this->repository->method('findBy')->willReturn([]);
|
||||||
|
|
||||||
|
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||||
|
$userinfo['profile']['date_of_birth'] = 'not a date';
|
||||||
|
|
||||||
|
$user = $this->loadUser($userinfo);
|
||||||
|
|
||||||
|
$this->assertNull($user->getTeamer()?->getDateOfBirth());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuthenticatorDoesNotSupportTheRouteWhileTheFeatureIsOff(): void
|
||||||
|
{
|
||||||
|
$authenticator = $this->createAuthenticator([], featureActive: false);
|
||||||
|
|
||||||
|
$request = new Request();
|
||||||
|
$request->attributes->set('_route', 'app_myep_auth_check');
|
||||||
|
|
||||||
|
$this->assertFalse($authenticator->supports($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuthenticatorSupportsTheRouteWhileTheFeatureIsOn(): void
|
||||||
|
{
|
||||||
|
$authenticator = $this->createAuthenticator([]);
|
||||||
|
|
||||||
|
$request = new Request();
|
||||||
|
$request->attributes->set('_route', 'app_myep_auth_check');
|
||||||
|
|
||||||
|
$this->assertTrue($authenticator->supports($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
private function createUserinfo(array $roles): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'person_id' => 12345,
|
||||||
|
'address_id' => 6789,
|
||||||
|
'roles' => $roles,
|
||||||
|
'profile' => [
|
||||||
|
'first_name' => 'Erika',
|
||||||
|
'last_name' => 'Musterfrau',
|
||||||
|
'hotel_codes' => ['HOTEL'],
|
||||||
|
'communication' => [
|
||||||
|
'email' => '[email protected]',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadUser(array $userinfo): User
|
||||||
|
{
|
||||||
|
$authenticator = $this->createAuthenticator($userinfo);
|
||||||
|
|
||||||
|
$request = new Request(query: ['code' => 'code', 'state' => 'state']);
|
||||||
|
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||||
|
|
||||||
|
$passport = $authenticator->authenticate($request);
|
||||||
|
|
||||||
|
/** @var UserBadge $badge */
|
||||||
|
$badge = $passport->getBadge(UserBadge::class);
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $badge->getUser();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAuthenticator(array $userinfo, bool $featureActive = true): MyEpAuthenticator
|
||||||
|
{
|
||||||
|
$httpClient = $this->createMock(ClientInterface::class);
|
||||||
|
$httpClient
|
||||||
|
->method('sendRequest')
|
||||||
|
->willReturn(new Psr7Response(200, [], json_encode($userinfo)))
|
||||||
|
;
|
||||||
|
|
||||||
|
$provider = $this->createMock(AbstractProvider::class);
|
||||||
|
$provider->method('getResourceOwnerDetailsUrl')->willReturn('https://my.example.com/api/userinfo');
|
||||||
|
$provider
|
||||||
|
->method('getAuthenticatedRequest')
|
||||||
|
->willReturn(new Psr7Request('GET', 'https://my.example.com/api/userinfo'))
|
||||||
|
;
|
||||||
|
$provider->method('getHttpClient')->willReturn($httpClient);
|
||||||
|
|
||||||
|
$client = $this->createMock(MyEpClient::class);
|
||||||
|
$client->method('fetchAccessToken')->willReturn(new AccessToken(['access_token' => 'token']));
|
||||||
|
$client->method('getProvider')->willReturn($provider);
|
||||||
|
|
||||||
|
$featureManager = $this->createMock(FeatureManagerInterface::class);
|
||||||
|
$featureManager->method('isActive')->willReturn($featureActive);
|
||||||
|
|
||||||
|
return new MyEpAuthenticator(
|
||||||
|
$client,
|
||||||
|
new UserDataHandler($this->entityManager, $this->createMock(LoggerInterface::class)),
|
||||||
|
$this->entityManager,
|
||||||
|
$this->createMock(UrlGeneratorInterface::class),
|
||||||
|
$this->createMock(LoggerInterface::class),
|
||||||
|
$featureManager,
|
||||||
|
$this->createMock(RequiredTeamerCheckRegistry::class),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user