196 lines
7.7 KiB
PHP
196 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Security;
|
|
|
|
use App\Entity\User;
|
|
use App\Repository\UserRepository;
|
|
use App\Security\OAuth2\AuthorizationRequestException;
|
|
use App\Security\OAuth2\EpClient;
|
|
use App\Security\OAuth2\UserinfoUnavailableException;
|
|
use App\Service\AuditLogger;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
|
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\AuthenticationException;
|
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
|
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
|
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\Util\TargetPathTrait;
|
|
|
|
/**
|
|
* Logs someone in from myep's authorization-code callback.
|
|
*
|
|
* ephub reads `/api/userinfo` exactly once, here, and then relies on its own session. The access
|
|
* token lives ten minutes and is never stored or refreshed, because nothing else in this
|
|
* application talks to myep's API.
|
|
*/
|
|
class EpAuthenticator extends AbstractAuthenticator
|
|
{
|
|
use TargetPathTrait;
|
|
|
|
public function __construct(
|
|
private readonly EpClient $client,
|
|
private readonly UserRepository $users,
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly UrlGeneratorInterface $urlGenerator,
|
|
private readonly LoggerInterface $logger,
|
|
private readonly AuditLogger $audit,
|
|
) {
|
|
}
|
|
|
|
public function supports(Request $request): ?bool
|
|
{
|
|
return 'app_auth_check' === $request->attributes->get('_route');
|
|
}
|
|
|
|
public function authenticate(Request $request): Passport
|
|
{
|
|
try {
|
|
$accessToken = $this->client->fetchAccessToken($request);
|
|
} catch (AuthorizationRequestException|IdentityProviderException $e) {
|
|
$this->logger->error('Login via myep failed: no access token', ['exception' => $e]);
|
|
// Audited here rather than from a failure event: this is the only point that still
|
|
// knows why the sign-in did not happen. Downstream there is only the German message
|
|
// the login page shows.
|
|
$this->audit->signInRefused('no_access_token');
|
|
|
|
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
|
}
|
|
|
|
try {
|
|
$userinfo = $this->client->fetchUserinfo($accessToken);
|
|
} catch (UserinfoUnavailableException $e) {
|
|
$this->logger->error('Login via myep failed: userinfo unreadable', ['exception' => $e]);
|
|
$this->audit->signInRefused('userinfo_unreadable');
|
|
|
|
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
|
}
|
|
|
|
// The guide is explicit that profile.communication.email comes from the CRM address
|
|
// record and is NOT necessarily the login email -- one address can hold several
|
|
// contacts. The top-level claim is the identity, and it is what User is keyed on.
|
|
$email = $userinfo['email'] ?? null;
|
|
if (!\is_string($email) || '' === $email) {
|
|
$this->logger->error('Login via myep failed: userinfo carried no email claim');
|
|
$this->audit->signInRefused('no_email_claim');
|
|
|
|
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
|
|
}
|
|
|
|
$roles = Role::eligibleOnly($this->claimedRoles($userinfo));
|
|
if ([] === $roles) {
|
|
$this->logger->info('Login via myep refused: no eligible role', ['email' => $email]);
|
|
$this->audit->signInRefused('no_eligible_role', $email);
|
|
|
|
throw new CustomUserMessageAuthenticationException(
|
|
'Für E&P Platz ist eine Mitarbeiter- oder Administrator-Rolle erforderlich.',
|
|
);
|
|
}
|
|
|
|
$user = $this->createOrUpdate($email, $this->nameFrom($userinfo, $email), $roles);
|
|
|
|
// Resolved here rather than by the user provider: the account may have just been
|
|
// created in this very request, and handing the provider an email would mean a second
|
|
// lookup of a row we already hold.
|
|
return new SelfValidatingPassport(
|
|
new UserBadge($user->getUserIdentifier(), static fn (): User => $user),
|
|
);
|
|
}
|
|
|
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
|
{
|
|
// Whatever they were trying to reach before the firewall intervened.
|
|
$targetPath = $this->getTargetPath($request->getSession(), $firewallName);
|
|
|
|
return new RedirectResponse($targetPath ?? $this->urlGenerator->generate('app_dashboard'));
|
|
}
|
|
|
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
|
{
|
|
$request->getSession()->getFlashBag()->add('error', $exception->getMessageKey());
|
|
|
|
return new RedirectResponse($this->urlGenerator->generate('app_login'));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $userinfo
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function claimedRoles(array $userinfo): array
|
|
{
|
|
$claimed = $userinfo['roles'] ?? [];
|
|
|
|
if (!\is_array($claimed)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter($claimed, \is_string(...)));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $userinfo
|
|
*/
|
|
private function nameFrom(array $userinfo, string $fallback): string
|
|
{
|
|
$profile = $userinfo['profile'] ?? null;
|
|
|
|
if (\is_array($profile)) {
|
|
$name = trim(\sprintf('%s %s', $profile['first_name'] ?? '', $profile['last_name'] ?? ''));
|
|
|
|
if ('' !== $name) {
|
|
return mb_substr($name, 0, 100);
|
|
}
|
|
}
|
|
|
|
// The profile scope may not have been granted, and a nameless account would render as a
|
|
// blank holder on the floor plan -- the email at least identifies somebody.
|
|
return mb_substr($fallback, 0, 100);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $roles
|
|
*/
|
|
private function createOrUpdate(string $email, string $name, array $roles): User
|
|
{
|
|
$user = $this->users->findOneBy(['email' => $email]);
|
|
$isNew = null === $user;
|
|
|
|
if (null === $user) {
|
|
$user = new User($email, $name);
|
|
$this->em->persist($user);
|
|
} else {
|
|
$user->setName($name);
|
|
}
|
|
|
|
// Filtered the same way the claim was, so the comparison below is between two sets of
|
|
// the same shape -- getRoles() would add ROLE_USER to one side and not the other.
|
|
$previousRoles = Role::eligibleOnly($user->getRoles());
|
|
|
|
// myep leads: a role it no longer reports is withdrawn here, not kept because it was
|
|
// granted once.
|
|
$user->setRoles($roles);
|
|
$this->em->flush();
|
|
|
|
// After the flush, so nothing is recorded that a failing write would have undone. Both
|
|
// records show "anonymous" as their actor, correctly: they happen while the person is
|
|
// being authenticated and before any token exists, which is why they name the account.
|
|
if ($isNew) {
|
|
$this->audit->accountProvisioned($user);
|
|
} elseif ($previousRoles !== $roles) {
|
|
$this->audit->rolesChanged($user, $previousRoles, $roles);
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
}
|