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 { try { $accessToken = $this->client->fetchAccessToken($request); } catch (AuthorizationDeniedException $e) { // MyE&P said why it sent no code, so this is not a failure to report as one $this->logger->info('Login via MyE&P was denied', [ 'error' => $e->getError(), 'error_description' => $e->getErrorDescription(), ]); throw new CustomUserMessageAuthenticationException('Login via MyE&P wurde abgebrochen'); } catch (AuthorizationRequestException|IdentityProviderException $e) { $this->logger->error('Login via MyE&P failed due to unobtainable access token', [ 'exception' => $e, ]); throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); } try { $provider = $this->client->getProvider(); $url = $provider->getResourceOwnerDetailsUrl($accessToken); $userinfoRequest = $provider->getAuthenticatedRequest('GET', $url, $accessToken, [ 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], ]); $response = $provider->getHttpClient()->sendRequest($userinfoRequest); } catch (ClientExceptionInterface $e) { $this->logger->error('Login via MyE&P failed due to unexpected userinfo response'); throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); } $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'); throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); } $user = $this->createOrUpdateUserFromUserinfo($userinfo); if (null === $user) { 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 { /** @var User $user */ $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'); if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { return new RedirectResponse($targetPath); } $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); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { $request->getSession()->getBag('flashes')->add('error', $exception->getMessage()); $redirectUrl = $this->urlGenerator->generate('app_security_login'); return new RedirectResponse($redirectUrl); } private function createOrUpdateUserFromUserinfo(array $userinfo): ?User { $claimedRoles = $this->collectClaimedRoles($userinfo); if ([] === $claimedRoles) { return null; } $isTeamer = in_array('ROLE_TEAMER', $claimedRoles, true); $pendingRoles = $this->userDataHandler->toPendingRoles($claimedRoles); $hotelCodes = $this->collectHotelCodes($userinfo); $user = $this->findLocalUser($userinfo); // 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 $user; } // Update existing user. MyE&P leads here exactly as BusPro does on the other login // path - the shared policy in UserDataHandler withdraws what is no longer claimed // and marks administrative claims for approval rather than granting them. if (null !== $user) { $this->refreshBusProIds($user, $userinfo); $this->userDataHandler->syncRoles($user, $claimedRoles); $this->userDataHandler->syncHotelCodes($user, $hotelCodes); $this->entityManager->flush(); return $user; } return $this->createLocalUser($userinfo, $pendingRoles, $isTeamer, $hotelCodes); } /** * 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; } /** * The houses MyE&P reports for this person. Read on both the create and the update * path, since they are led by the identity provider just like the roles are. * * @return string[] */ private function collectHotelCodes(array $userinfo): array { $profile = is_array($userinfo['profile'] ?? null) ? $userinfo['profile'] : []; return is_array($profile['hotel_codes'] ?? null) ? array_values($profile['hotel_codes']) : []; } /** * 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; $email = mb_strtolower(trim($userinfo['email'])); if (null !== $addressId && null !== $personId) { $user = $repository->findOneBy([ 'busProAddressId' => $addressId, 'busProPersonId' => $personId, ]); if (null !== $user) { return $user; } } $users = $repository->findBy(['email' => $email]); if (1 !== count($users)) { if (1 < count($users)) { $this->logger->warning('Unable to match local user by email: multiple users found', [ 'email' => $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 * @param string[] $hotelCodes */ private function createLocalUser(array $userinfo, array $pendingRoles, bool $isTeamer, array $hotelCodes): ?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'] : []; $user = new User(); $user ->setFirstName($profile['first_name'] ?? null) ->setLastName($profile['last_name'] ?? null) ->setEmail((string) $userinfo['email']) ->setBusProAddressId((int) $addressId) ->setBusProPersonId((int) $personId) ->setHotelCodes($hotelCodes) ->setRoles([...$pendingRoles, ...($isTeamer ? ['ROLE_TEAMER'] : [])]) ; if (true === $isTeamer) { $teamer = Teamer::fromUserinfo($userinfo); $user->setTeamer($teamer); $this->entityManager->persist($teamer); $this->logger->info('Create teamer', [ 'teamer_id' => $teamer->getId(), 'teamer_name' => (string) $teamer, ]); } $this->entityManager->persist($user); $this->entityManager->flush(); $this->logger->info('Create user', [ 'user_id' => $user->getId(), 'user_email' => $user->getEmail(), ]); return $user; } }